Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Brandt
7e857d59ec fix: end a v2 turn once when Claude Code folds a prompt into it (#1618)
Fixes #1611.

## Problem

On ACP v2, a prompt sent while the session works gets its reply twice.
The client sees:

1. the reply, streamed under the model's `msg_` id
2. `state_update` `idle` with `stopReason: end_turn` and zero usage
3. `state_update` `running`
4. the same reply again, under a UUID
5. `state_update` `idle` with the real usage

This happens whenever Claude Code folds the prompt into running work.
The work can be a cycle that Claude Code started on its own, as in the
issue, or the turn of an earlier prompt.

## Cause

Claude Code sends one result, not two. The order of its frames differs
for a folded prompt (CLI 2.1.323):

| Prompt | Order |
| --- | --- |
| started the work | `result`, then `command_lifecycle` `completed` |
| folded into the work | `command_lifecycle` `completed`, then `result`
|

`V2Session.lifecycle` took every `completed` frame of the last started
prompt as the end of a turn that had no result, and closed the turn. The
result then found no open turn. It opened a new one, saw no streamed
text in it, and sent `result.result` as the reply.

## Fix

The session remembers which prompt started Claude Code's current cycle
(`cycleCommand`). Only that prompt's `completed` frame closes a turn
that had no result. A folded prompt's `completed` frame leaves the turn
open, and the result ends it.

A result ends the cycle, so the next prompt of a turn that stays open
(the `/clear` and the plan prompt of a fresh context) starts its own.

The `cancelled` frame is unchanged: it still closes the turn for the
prompt that started last.

## Tests

- `v2.test.ts`: one `it.each` with the frames in the CLI's order, for a
prompt folded into another prompt's turn and into a cycle that Claude
Code started on its own. Both fail without the fix with `running, idle,
running, idle`.
- `v2-integration.test.ts`: one case against the bundled CLI. It passes
with the fix.
- Live, with the issue's steps: one reply and one `idle` with the real
usage.

## Not covered

- I did not capture what Claude Code sends when a cancel arrives during
work with a folded prompt.
- I observed the frame order on CLI 2.1.323 only.

ACP v1 is not affected: it does not end a turn at a `completed` frame.
2026-11-13 13:48:52 +00:00
3 changed files with 108 additions and 4 deletions

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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;