Compare commits

..

3 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
Eric Holk
4f474d655b feat: report the turn's rate limit in rate-limit failure errors (#1610)
When a turn fails with `errorKind: "rate_limit"`, the error's `data` now
also carries the turn's latest `rate_limit_event` info as `rateLimit`:

```json
"data": {
  "errorKind": "rate_limit",
  "rateLimit": { "status": "rejected", "rateLimitType": "five_hour", "resetsAt": 1763002200 }
}
```

## Why

ACP clients may want to handle a rate-limit failure better than showing
its message, for example by showing when the limit resets or offering to
continue the turn then. That needs the reset time as data. Today the
adapter forwards `rate_limit_event` info only on a `usage_update`
(`_meta["_claude/rateLimit"]`). A message sent while the account is
already over its limit is refused before its first usage, so no
`usage_update` goes out and the client gets only:

```json
"error": { "code": -32603, "message": "Internal error: You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)", "data": { "errorKind": "rate_limit" } }
```

Without this change, the only way to get the reset time is parsing that
text.

## What changed

- `turnErrorData` in `src/result-end.ts` builds the `data` of the error
that fails a turn. It adds `rateLimit` only for a `rate_limit` failure
in a turn that saw a `rate_limit_event`. Other failures keep their
current data.
- v2: `TurnReport` records the turn's latest `rate_limit_event` info and
clears it on `reset()`. `idleOutcome` passes it to `turnErrorData`.
- v1: the prompt loop tracks `turnRateLimit` alongside
`lastAssistantError`, resets it with the other per-turn scratch state,
and passes it at the three result-failure sites that used
`errorKindData(lastAssistantError)`.

## Testing

- New v1 and v2 tests: a `rate_limit_event` followed by a `rate_limit`
failure puts `rateLimit` in the error data.
- `npm run build`, `npm run check` and `npm run test:run` pass.
- Manually tested with a locally built binary: a client received
`rateLimit` in the error for a message refused at the 5-hour limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 13:31:11 +00:00
11 changed files with 222 additions and 14 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

@@ -8,9 +8,26 @@
import type {
SDKMessage,
SDKMessageOrigin,
SDKRateLimitInfo,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
/**
* The `data` of the error that fails a turn, for clients to dispatch on
* without matching its text: the assistant message's `errorKind`, and for a
* `rate_limit` failure the turn's latest `rate_limit_event` info as
* `rateLimit`, whose `resetsAt` says when the account can run again. A turn
* refused before its first usage sends no `usage_update` to carry that info.
* `undefined` without an error kind.
*/
export function turnErrorData<K extends string>(
errorKind: K | undefined,
rateLimit: SDKRateLimitInfo | undefined,
): { errorKind: K; rateLimit?: SDKRateLimitInfo } | undefined {
if (!errorKind) return undefined;
return errorKind === "rate_limit" && rateLimit ? { errorKind, rateLimit } : { errorKind };
}
export type ResultEnd =
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
/**

View File

@@ -13406,6 +13406,30 @@ describe("stop reason propagation", () => {
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
});
it("adds the turn's rate limit to a rate-limit failure's data", async () => {
const agent = createMockAgent();
const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" };
injectSession(agent, [
{
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: randomUUID(),
session_id: "test-session",
},
createAssistantError("rate_limit"),
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)",
}),
]);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toMatchObject({ data: { errorKind: "rate_limit", rateLimit } });
});
it.each(
[false, true].flatMap((typedFailures) =>
[undefined, "authentication_failed" as const].map((staleError) => ({

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) => {
@@ -2467,6 +2523,29 @@ describe("ACP v2 core", () => {
});
});
it("adds the turn's rate limit to a rate-limit failure's error data", async () => {
const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" };
const client = v2Client();
await client.connect(async (agent) => {
const { claude } = await openSession(agent);
claude.send(cycleStart());
claude.send({
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: "00000000-0000-4000-8000-00000000e002",
session_id: "session",
});
claude.send({ ...assistant("msg_limited", []), error: "rate_limit" });
claude.send(result({ is_error: true, result: "You've hit your 5-hour limit" }));
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
});
expect(client.sessionUpdates.at(-1)).toMatchObject({
stopReason: "error",
error: { data: { errorKind: "rate_limit", rateLimit } },
});
});
it.each([
["a notice when the turn goes on to end", { result: "" }, "end_turn", 1],
["a notice once when the result repeats its text", {}, "end_turn", 1],

View File

@@ -76,6 +76,7 @@ import {
Query,
query,
SDKAssistantMessageError,
SDKRateLimitInfo,
SDKActiveGoalMessage,
SDKMessage,
SDKMessageOrigin,
@@ -421,7 +422,7 @@ import {
providerCacheKeyFor,
readContextWindow,
} from "../context-window.js";
import { isLoginRequiredText, resultEnd } from "../result-end.js";
import { isLoginRequiredText, resultEnd, turnErrorData } from "../result-end.js";
import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js";
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
import { subagentHookOptions } from "../subagent-configuration.js";
@@ -3094,6 +3095,8 @@ export class ClaudeAcpAgent {
let lastAssistantModel: string | null = null;
// A rate_limit_event that came before the turn had any usage to report.
let pendingRateLimit: unknown = null;
// The turn's latest rate_limit_event info, for its failure (see `turnErrorData`).
let turnRateLimit: SDKRateLimitInfo | undefined;
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
// problem, billing), it sets a categorical `error` field on the
// `SDKAssistantMessage` that precedes the final `result` message. We capture
@@ -3461,6 +3464,7 @@ export class ClaudeAcpAgent {
lastAssistantUsage = null;
lastAssistantModel = null;
lastAssistantError = undefined;
turnRateLimit = undefined;
lastAssistantWasUsageLimit = false;
lastAssistantFailureTitle = undefined;
lastRefusalExplanation = null;
@@ -6079,7 +6083,10 @@ export class ClaudeAcpAgent {
}
await failActiveWithSessionFailure(
providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
internalErrorForClient(
turnErrorData(lastAssistantError, turnRateLimit),
ended.error,
),
lastAssistantFailureTitle ?? ended.error,
);
break;
@@ -6139,7 +6146,10 @@ export class ClaudeAcpAgent {
if ("error" in ended) {
await failActiveWithSessionFailure(
providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
internalErrorForClient(
turnErrorData(lastAssistantError, turnRateLimit),
ended.error,
),
lastAssistantFailureTitle ?? ended.error,
);
break;
@@ -6152,7 +6162,10 @@ export class ClaudeAcpAgent {
if ("error" in ended) {
await failActiveWithSessionFailure(
LIMIT_FAILURE_CATEGORY[message.subtype],
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
internalErrorForClient(
turnErrorData(lastAssistantError, turnRateLimit),
ended.error,
),
ended.error,
);
break;
@@ -6941,6 +6954,7 @@ export class ClaudeAcpAgent {
}
case "rate_limit_event": {
session.idleCompaction?.noteRateLimit(message.rate_limit_info);
turnRateLimit = message.rate_limit_info;
// With partial messages on, the SDK sends this before the turn's
// first assistant frame, while there is no usage yet to send it with.
if (lastAssistantTotalUsage === null) {

View File

@@ -12,11 +12,12 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type {
SDKAssistantMessage,
SDKAssistantMessageError,
SDKRateLimitInfo,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js";
import { isSyntheticLoginMessage } from "../message-filters.js";
import { resultEnd } from "../result-end.js";
import { resultEnd, turnErrorData } from "../result-end.js";
import { assistantMessageText, providerFailureCategory } from "../session-failure-extension.js";
import {
getMatchingModelUsage,
@@ -96,7 +97,7 @@ export function idleOutcome(
// authenticate: this login reached its time limit and cannot be
// renewed."
RequestError.authRequired(undefined, ended.loginRequired ? undefined : ended.error)
: RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error),
: RequestError.internalError(turnErrorData(kind, report?.rateLimit), ended.error),
);
}
@@ -118,6 +119,8 @@ function failed(error: RequestError): IdleOutcome {
*/
export class TurnReport {
errorKind: SDKAssistantMessageError | undefined;
/** The turn's latest `rate_limit_event` info (see `turnErrorData`). */
rateLimit: SDKRateLimitInfo | undefined;
/** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */
loginRequired = false;
/** The explanation of the turn's refusal. */
@@ -181,6 +184,7 @@ export class TurnReport {
reset(): void {
this.errorKind = undefined;
this.rateLimit = undefined;
this.loginRequired = false;
this.refusalExplanation = undefined;
this.apiError = undefined;

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. */
@@ -1044,6 +1052,7 @@ export class V2Session implements RequestTarget {
* output (see {@link SessionReports}).
*/
private report(message: SDKMessage): void {
if (message.type === "rate_limit_event") this.turnReport.rateLimit = message.rate_limit_info;
if (message.type === "tool_progress") {
this.output.toolProgress(message);
return;
@@ -1087,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.
@@ -1152,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))) {
@@ -1308,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) {
@@ -1318,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;
@@ -1355,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;