mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-13 21:02:08 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e857d59ec |
@@ -143,6 +143,32 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("v2 core with Claude Code",
|
||||
]);
|
||||
}, 180_000);
|
||||
|
||||
it("ends the work once when Claude Code folds a prompt into it", async () => {
|
||||
const sessionId = await newSession("bypassPermissions");
|
||||
await prompt(
|
||||
sessionId,
|
||||
"Use the Bash tool in the foreground (not run_in_background) to run exactly: ping -c 8 127.0.0.1 > /dev/null . Then reply with just: woke",
|
||||
);
|
||||
await until(() =>
|
||||
updates.some(
|
||||
({ update }) =>
|
||||
update.sessionUpdate === "terminal_update" &&
|
||||
/ping/.test((update as v2.TerminalUpdate).command ?? ""),
|
||||
),
|
||||
);
|
||||
// Claude Code takes this prompt in after the command, into the same work.
|
||||
// It sends the prompt's `completed` frame before the work's result.
|
||||
await prompt(sessionId, "Reply with exactly the single word: pong");
|
||||
await until(() => states(sessionId).some((update) => update.state === "idle"));
|
||||
// A turn that the result opened would follow the idle at once.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
expect(states(sessionId).map((update) => update.state)).toEqual(["running", "idle"]);
|
||||
// The idle is the result's: it carries what the work spent.
|
||||
const idle = states(sessionId).at(-1) as { stopReason?: string; usage?: v2.Usage };
|
||||
expect(idle.stopReason).toBe("end_turn");
|
||||
expect(idle.usage?.totalTokens).toBeGreaterThan(0);
|
||||
}, 240_000);
|
||||
|
||||
it("fails a queued prompt with -32800 at a cancel and goes idle with cancelled", async () => {
|
||||
const sessionId = await newSession("bypassPermissions");
|
||||
await prompt(
|
||||
|
||||
@@ -1706,6 +1706,62 @@ describe("ACP v2 core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the turn of another prompt", true],
|
||||
["a cycle that Claude Code started on its own", false],
|
||||
])("ends the work once, at its result, when a prompt is folded into %s", async (_, prompted) => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
const { response, claude } = await openSession(agent);
|
||||
const ask = () =>
|
||||
agent.request(v2.methods.agent.session.prompt, {
|
||||
sessionId: response.sessionId,
|
||||
prompt: PROMPT,
|
||||
});
|
||||
let opener: SDKUserMessage | undefined;
|
||||
if (prompted) {
|
||||
const first = ask();
|
||||
opener = await claude.prompt(0);
|
||||
claude.send(lifecycle(opener, "started"));
|
||||
await first;
|
||||
}
|
||||
claude.send(cycleStart());
|
||||
await vi.waitFor(() => expect(client.states()).toEqual(["running"]));
|
||||
// As the bundled CLI sends it: a folded prompt's echo comes before its
|
||||
// `started`, and its `completed` before the result of the work.
|
||||
const second = ask();
|
||||
const folded = await claude.prompt(prompted ? 1 : 0);
|
||||
claude.send(echo(folded));
|
||||
claude.send(lifecycle(folded, "started"));
|
||||
await second;
|
||||
for (const event of textDelta("msg_pong", "pong")) claude.send(event);
|
||||
claude.send(assistant("msg_pong", [{ type: "text", text: "pong" }]));
|
||||
claude.send(lifecycle(folded, "completed"));
|
||||
claude.send(
|
||||
result({
|
||||
result: "pong",
|
||||
origin: { kind: prompted ? "human" : "task-notification" },
|
||||
user_message_uuids: [...(opener ? [opener.uuid] : []), folded.uuid],
|
||||
}),
|
||||
);
|
||||
if (opener) claude.send(lifecycle(opener, "completed"));
|
||||
claude.send(sessionState("idle"));
|
||||
await vi.waitFor(() => expect(client.states()).toContain("idle"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
expect(client.states()).toEqual(["running", "idle"]);
|
||||
expect(
|
||||
client.sessionUpdates.filter((update) => update.sessionUpdate === "agent_message_chunk"),
|
||||
).toMatchObject([{ messageId: "msg_pong" }]);
|
||||
// The idle carries the result's usage: the result ended the work.
|
||||
expect(client.sessionUpdates.at(-1)).toMatchObject({
|
||||
state: "idle",
|
||||
stopReason: "end_turn",
|
||||
usage: { totalTokens: 6 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a tool call that Claude dropped before it ran as cancelled at the turn's end", async () => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
|
||||
@@ -141,6 +141,14 @@ export class V2Session implements RequestTarget {
|
||||
private foreground = false;
|
||||
/** The prompt whose `started` frame came last in the open turn. */
|
||||
private turnCommand: string | undefined;
|
||||
/**
|
||||
* Whether a cycle of Claude Code runs in the open turn: one started (see
|
||||
* {@link openTurn}) and its result has not come. Claude Code folds a prompt
|
||||
* that starts meanwhile into that cycle.
|
||||
*/
|
||||
private cycleOpen = false;
|
||||
/** The prompt that started the running cycle, if one did. */
|
||||
private cycleCommand: string | undefined;
|
||||
/** The state the client was told last. */
|
||||
private reportedState: ForegroundState = "idle";
|
||||
/** The open permission requests and elicitations of the session. */
|
||||
@@ -1088,9 +1096,13 @@ export class V2Session implements RequestTarget {
|
||||
);
|
||||
return;
|
||||
case "completed":
|
||||
// Comes after the turn's result. A turn that ended without one ends here.
|
||||
// Claude Code sends it after the result for the prompt that started
|
||||
// the cycle, so a cycle that ended without a result ends here. For a
|
||||
// prompt folded into the cycle it sends it before the result, which
|
||||
// then ends the turn. Nothing but Claude Code's order tells the two
|
||||
// apart.
|
||||
this.stopped.promptEnded(uuid);
|
||||
if (uuid === this.turnCommand) this.closeTurn();
|
||||
if (uuid === this.cycleCommand) this.closeTurn();
|
||||
return;
|
||||
default:
|
||||
// `queued`: the prompt waits in Claude Code's queue.
|
||||
@@ -1153,6 +1165,10 @@ export class V2Session implements RequestTarget {
|
||||
if (interrupted) this.cancelledWork = true;
|
||||
// A result with no open turn: a turn that sent nothing before it.
|
||||
this.openTurn(undefined);
|
||||
// The result ends the cycle. A prompt that starts next in the open turn,
|
||||
// such as the `/clear` of a fresh context, starts its own.
|
||||
this.cycleOpen = false;
|
||||
this.cycleCommand = undefined;
|
||||
// A cycle that Claude Code started on its own leaves its Fast mode state
|
||||
// to the next result of a prompt.
|
||||
if (!(message.origin && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind))) {
|
||||
@@ -1309,8 +1325,8 @@ export class V2Session implements RequestTarget {
|
||||
// --- State ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens a turn, if none is open. `command` is the prompt that started it,
|
||||
* if one did.
|
||||
* Opens a turn, if none is open, and starts a cycle in it, if none runs
|
||||
* there. `command` is the prompt that started it, if one did.
|
||||
*/
|
||||
private openTurn(command: string | undefined): void {
|
||||
if (command !== undefined) {
|
||||
@@ -1319,6 +1335,10 @@ export class V2Session implements RequestTarget {
|
||||
this.localCommands.delete(command);
|
||||
if (local) this.turnLocalCommand = { command: local, abort: new AbortController() };
|
||||
}
|
||||
if (!this.cycleOpen) {
|
||||
this.cycleOpen = true;
|
||||
this.cycleCommand = command;
|
||||
}
|
||||
if (this.foreground) return;
|
||||
this.foreground = true;
|
||||
this.lastEnd = undefined;
|
||||
@@ -1356,6 +1376,8 @@ export class V2Session implements RequestTarget {
|
||||
this.output.cancelled = false;
|
||||
this.lastEnd = undefined;
|
||||
this.turnCommand = undefined;
|
||||
this.cycleOpen = false;
|
||||
this.cycleCommand = undefined;
|
||||
this.turnReport.reset();
|
||||
const ownCommandDone = this.ownCommandDone;
|
||||
this.ownCommandDone = undefined;
|
||||
|
||||
Reference in New Issue
Block a user