From da70e3031a12e705dd00734dbfc121d581bccf26 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:11:52 +0100 Subject: [PATCH] fix(v2): end a cancel's interrupt reach when it can no longer stop anything A cancel of an idle session left the interrupt pending across later turns, so a later aborted result, such as of a remote-control interrupt, reported cancelled. The interrupt now reaches the next result after a cancel of an idle session and the next two after a cancel of open work, and a prompt that the cancel did not abandon ends it. Co-Authored-By: Claude --- src/tests/v2.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ src/v2/stopped-work.ts | 34 +++++++++++++++++++++++------- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts index 40bebfe9..34a6cd57 100644 --- a/src/tests/v2.test.ts +++ b/src/tests/v2.test.ts @@ -1728,6 +1728,54 @@ describe("ACP v2 core", () => { expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "end_turn" }); }); + it.each([ + ["a cycle of Claude Code's own ended", "cycle"], + ["a prompt started", "prompt"], + ])( + "does not read a later aborted result as cancelled by a cancel of an idle session once %s", + async (_, after) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + // Claude Code runs nothing: the interrupt stops nothing and sends nothing. + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + if (after === "cycle") { + // Say, a background task ended: Claude Code starts a cycle after the interrupt. + claude.send(cycleStart()); + claude.send(result()); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + claude.send(cycleStart()); + } else { + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: PROMPT, + }); + claude.send(lifecycle(await claude.prompt(0), "started")); + await answer; + } + // Then an interrupt of Claude Code's remote control stops the work. + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + await vi.waitFor(() => + expect(client.states()).toEqual( + after === "cycle" ? ["running", "idle", "running", "idle"] : ["running", "idle"], + ), + ); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "error" }); + }, + ); + it("sends no permission request of a session after session/close answered", async () => { const client = v2Client(); const decision = await client.connect(async (agent) => { diff --git a/src/v2/stopped-work.ts b/src/v2/stopped-work.ts index c56f3365..cbf14896 100644 --- a/src/v2/stopped-work.ts +++ b/src/v2/stopped-work.ts @@ -13,7 +13,13 @@ * * The interrupt stops whatever Claude Code runs when it arrives, which can be * a cycle that the session reads only after the cancel. That cycle ends with - * an aborted result, which reports `cancelled`. + * an aborted result, which reports `cancelled`. Only the next result can be + * that cycle's after a cancel of an idle session, and the next two after a + * cancel of open work (the open cycle can end before the interrupt arrives). + * A prompt that a cancel did not abandon starting, or an idle that follows no + * result, ends that reach sooner: a later aborted result, such as of an + * interrupt from Claude Code's remote control, reports as it would without + * the cancel. */ import type { SDKAssistantMessage, @@ -50,8 +56,14 @@ export class StoppedWork { private readonly toolUses = new Map(); /** The `canUseTool` checks that wait for the session to read their tool use. */ private readonly toolUseWaiters = new Map void>>(); - /** Whether a cancel's interrupt went out and Claude Code has not shown what it stopped. */ - private interruptPending = false; + /** + * How many of the next results a cancel's interrupt can end, until Claude + * Code shows what it stopped: 2 after a cancel of open work, whose result + * can come before the interrupt arrives and stops the next cycle, and 1 + * after a cancel of an idle session (the cycle that Claude Code runs when + * it arrives). + */ + private interruptReach = 0; /** * Whether a model request started since the last result: a `status: * requesting` frame, which starts each one. @@ -66,12 +78,12 @@ export class StoppedWork { cancelled(pushed: readonly string[], foreground: boolean): void { for (const uuid of pushed) this.abandoned.add(uuid); if (!foreground && this.modelRequestSinceResult) this.dropping = true; - this.interruptPending = true; + this.interruptReach = foreground ? 2 : 1; } /** The interrupt failed: Claude Code stops nothing. */ interruptFailed(): void { - this.interruptPending = false; + this.interruptReach = 0; } /** A `status: requesting` frame, with the prompts that the request serves. */ @@ -93,9 +105,13 @@ export class StoppedWork { if (!this.abandoned.has(uuid)) { // Claude Code takes its prompts in order: the earlier ones ran or were // dropped, and a dead turn's late result came before this prompt started. + // A prompt that a cancel did not abandon went to Claude Code after the + // cancel's interrupt, on the same stdin: the interrupt reached no + // further either. this.abandoned.clear(); this.endDropping(); this.deadTurnResult = false; + this.interruptReach = 0; return false; } if (!foreground) this.drop(uuid); @@ -177,8 +193,10 @@ export class StoppedWork { this.startedSinceResult.clear(); // Claude Code asks for a tool use before the result that ends its turn. this.toolUses.clear(); - const aborted = this.interruptPending && isAbortedResult(result); - if (aborted) this.interruptPending = false; + // An aborted result shows what the interrupt stopped. Another one uses up + // one result of its reach (see `interruptReach`). + const aborted = this.interruptReach > 0 && isAbortedResult(result); + this.interruptReach = aborted ? 0 : Math.max(0, this.interruptReach - 1); const dropped = this.dropping || this.deadTurnResult; this.deadTurnResult = false; this.endDropping(); @@ -190,7 +208,7 @@ export class StoppedWork { * ended, and so has the interrupt's reach. */ idle(): void { - this.interruptPending = false; + this.interruptReach = 0; this.modelRequestSinceResult = false; // A dead turn's result comes before Claude Code goes idle, or never. this.deadTurnResult = false;