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 <noreply@anthropic.com>
This commit is contained in:
Ben Brandt
2026-11-09 23:11:52 +01:00
parent 1607023bdf
commit da70e3031a
2 changed files with 74 additions and 8 deletions

View File

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

View File

@@ -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<string, boolean>();
/** The `canUseTool` checks that wait for the session to read their tool use. */
private readonly toolUseWaiters = new Map<string, Set<() => 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;