mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user