Compare commits

..

2 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
acp-release-bot[bot]
c5c120dba9 chore(main): release 0.107.0 (#1595)
🤖 I have created a release *beep* *boop*
---


##
[0.107.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.106.0...v0.107.0)
(2026-11-13)


### Features

* report the turn's rate limit in rate-limit failure errors
([#1610](https://github.com/agentclientprotocol/claude-agent-acp/issues/1610))
([4f474d6](4f474d655b))
* report unknown subagent state at a conversation reset and reference
children from tool calls
([#1608](https://github.com/agentclientprotocol/claude-agent-acp/issues/1608))
([4804917](4804917a6f))
* say the cause of an api retry in the AIR warning details
([#1594](https://github.com/agentclientprotocol/claude-agent-acp/issues/1594))
([4f690e1](4f690e129c))


### Bug Fixes

* assume a 1M context window until Claude Code reports it
([#1613](https://github.com/agentclientprotocol/claude-agent-acp/issues/1613))
([9619805](9619805306))
* end the tool calls of a response that Claude Code took back
([#1616](https://github.com/agentclientprotocol/claude-agent-acp/issues/1616))
([21f3e04](21f3e0430b))
* explain why Claude Code turns Fast mode off
([#1602](https://github.com/agentclientprotocol/claude-agent-acp/issues/1602))
([c331e17](c331e178a5))
* follow Claude Code's rule for managed deniedModels in the model picker
([#1615](https://github.com/agentclientprotocol/claude-agent-acp/issues/1615))
([c78bcc7](c78bcc7f41))
* log a managed policy source that did not load
([#1614](https://github.com/agentclientprotocol/claude-agent-acp/issues/1614))
([ca46b41](ca46b412f5))
* report a mid-turn API error notice where the response broke off
([#1599](https://github.com/agentclientprotocol/claude-agent-acp/issues/1599))
([4df6999](4df69996ca))
* say why the login failed in the v2 authRequired error
([#1598](https://github.com/agentclientprotocol/claude-agent-acp/issues/1598))
([ecd066c](ecd066c870))
* update to @anthropic-ai/claude-agent-sdk 0.3.323
([#1612](https://github.com/agentclientprotocol/claude-agent-acp/issues/1612))
([ddee24e](ddee24ef8f))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-13 13:34:19 +00:00
7 changed files with 133 additions and 8 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.106.0"
".": "0.107.0"
}

View File

@@ -1,5 +1,26 @@
# Changelog
## [0.107.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.106.0...v0.107.0) (2026-11-13)
### Features
* report the turn's rate limit in rate-limit failure errors ([#1610](https://github.com/agentclientprotocol/claude-agent-acp/issues/1610)) ([4f474d6](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f474d655b88a075ec7d0124d45cafa0f5565af7))
* report unknown subagent state at a conversation reset and reference children from tool calls ([#1608](https://github.com/agentclientprotocol/claude-agent-acp/issues/1608)) ([4804917](https://github.com/agentclientprotocol/claude-agent-acp/commit/4804917a6fa7050490d1d62c6689e78c231c752a))
* say the cause of an api retry in the AIR warning details ([#1594](https://github.com/agentclientprotocol/claude-agent-acp/issues/1594)) ([4f690e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f690e129c01f22e2c2a87e0e0a3c5b503241f15))
### Bug Fixes
* assume a 1M context window until Claude Code reports it ([#1613](https://github.com/agentclientprotocol/claude-agent-acp/issues/1613)) ([9619805](https://github.com/agentclientprotocol/claude-agent-acp/commit/96198053067cf4d7b7440c52036174ad4f54183e))
* end the tool calls of a response that Claude Code took back ([#1616](https://github.com/agentclientprotocol/claude-agent-acp/issues/1616)) ([21f3e04](https://github.com/agentclientprotocol/claude-agent-acp/commit/21f3e0430b693e6b04750ab3018e1eaf726af173))
* explain why Claude Code turns Fast mode off ([#1602](https://github.com/agentclientprotocol/claude-agent-acp/issues/1602)) ([c331e17](https://github.com/agentclientprotocol/claude-agent-acp/commit/c331e178a5df47448be1a3ed2f71270c3aad2433))
* follow Claude Code's rule for managed deniedModels in the model picker ([#1615](https://github.com/agentclientprotocol/claude-agent-acp/issues/1615)) ([c78bcc7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c78bcc7f417fc8d3da66f51021b43f4528826ec0))
* log a managed policy source that did not load ([#1614](https://github.com/agentclientprotocol/claude-agent-acp/issues/1614)) ([ca46b41](https://github.com/agentclientprotocol/claude-agent-acp/commit/ca46b412f51cdb7b5a303c751f5d48c216700887))
* report a mid-turn API error notice where the response broke off ([#1599](https://github.com/agentclientprotocol/claude-agent-acp/issues/1599)) ([4df6999](https://github.com/agentclientprotocol/claude-agent-acp/commit/4df69996ca74adff6c42fc60e4b9b742f2d3fdf5))
* say why the login failed in the v2 authRequired error ([#1598](https://github.com/agentclientprotocol/claude-agent-acp/issues/1598)) ([ecd066c](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecd066c8700955e3456dd9387e6b8317f024e26c))
* update to @anthropic-ai/claude-agent-sdk 0.3.323 ([#1612](https://github.com/agentclientprotocol/claude-agent-acp/issues/1612)) ([ddee24e](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddee24ef8fd0abc0401fb32d567914030fbf6944))
## [0.106.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.105.0...v0.106.0) (2026-11-10)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.106.0",
"version": "0.107.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.106.0",
"version": "0.107.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.6.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.106.0",
"version": "0.107.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

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;