diff --git a/docs/acp-v2.md b/docs/acp-v2.md index 22f4a70c..9b24b99f 100644 --- a/docs/acp-v2.md +++ b/docs/acp-v2.md @@ -216,7 +216,9 @@ lists: `_meta.jetbrains.air.capabilities` is `["agentFileChangeReport", "recomme `_meta.jetbrains.air.agentFileChangeReportRequest` is valid gets one report. v1 sends it before the prompt's response, at the turn's end. A v2 prompt is answered at its insertion, so the report goes out at the end of the turn that serves the prompt, before that turn's idle: the - session reads the prompt's checkpoint (up to 2 seconds), and the idle waits for it. A turn that + session reads the prompt's checkpoint (up to 2 seconds), and the idle waits for it. A turn whose + plan runs in a fresh context (ExitPlanMode's "Yes, clear context") reads the checkpoint of the + plan's prompt instead: `/clear` reset Claude Code's file history. A turn that ends `cancelled` reports `unavailable` with `cancelled`. Any other turn that ends with an error or without a result reports `unavailable` with `providerError`. A prompt that a cancel answers with -32800 before its insertion reports `cancelled`, and one that fails otherwise `providerError`. @@ -256,7 +258,9 @@ exit codes), which the shared renderer gives only with `air.client`, and the `ki for AskUserQuestion (on the session of the agent that asks), for an MCP server's form, and for the refusal fallback's consent prompt. A URL for an MCP server's URL request and for MCP OAuth. All but MCP OAuth are open requests of their session, like permission requests: a cancel or a - close withdraws them. MCP OAuth starts after the setup response, and a close withdraws it. + close withdraws them. MCP OAuth starts after the setup response, and a close withdraws it. A + restart of the query (`restarts.ts`) withdraws it on the old query and starts it again on the + new one. Without form elicitation, AskUserQuestion is disallowed and the refusal fallback is not declared. An MCP server's request in a mode the client did not advertise, or that the client answers with an error, is answered `cancel`. @@ -341,8 +345,7 @@ exit codes), which the shared renderer gives only with `air.client`, and the `ki that a result of any session on the same backend reported, else a guess from the model's names refined by a background `getContextUsage`, until the session's own result reports it. -About 5,500 lines in total, plus the v2 wire conversions (see "History"). The v2 agent shares -these with the v1 core: +The v2 agent shares these with the v1 core: - the SDK message mapping and tool call rendering (`toAcpNotifications`, `streamEventToAcpNotifications`, `tool-calls/*`) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index f8bb626b..ffa8a910 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -1649,7 +1649,8 @@ The hold protects a client that stops listening at the prompt response. ### Behavior with the capability AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`. -The capability applies to ACP v1 only. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2. +The capability applies to ACP v1 only, and the agent does not advertise it to ACP v2. +ACP v2 has no hold: the v2 agent answers `session/prompt` when Claude Code takes the prompt in, lists a background subagent in its parent session's `background` while it works, and reports each subagent's work in the state of its child session (see [ACP v2](#acp-v2)). - The turn ends at its result with the stop reason of the result, usually `end_turn`. - The subagent updates, permission requests and elicitations go out after the prompt response. diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts index fdcc9cfb..b764f540 100644 --- a/src/tests/v2.test.ts +++ b/src/tests/v2.test.ts @@ -145,6 +145,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { rewindFiles: fake.rewindFiles, mcpServerStatus: async () => sdk.mcpServers, ...(sdk.enableRemoteControl ? { enableRemoteControl: sdk.enableRemoteControl } : {}), + setMcpServers: async () => ({ added: [], removed: [], errors: {} }), // A server that needs OAuth: Claude Code returns its authorization URL. mcpAuthenticate: async () => ({ authUrl: "https://mcp.example.com/authorize", @@ -674,6 +675,66 @@ describe("ACP v2 core", () => { }); }); + describe("MCP OAuth", () => { + const linear = { type: "http" as const, name: "linear", url: "https://mcp.example.com/mcp" }; + /** A client that leaves each OAuth elicitation open until the agent withdraws it. */ + const oauthClient = () => { + const withdrawn: number[] = []; + const client = v2Client({ + answerElicitation: (_, signal) => + new Promise((resolve) => + signal.addEventListener("abort", () => { + withdrawn.push(client.elicitations.length); + resolve({ action: "cancel" }); + }), + ), + }); + return { client, withdrawn }; + }; + const initialize = (agent: v2.ClientContext) => + agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: { elicitation: { url: {} } }, + }); + + it("starts OAuth again on the new query of a restart that withdrew it", async () => { + // The server stays `needs-auth`: the user has not signed in yet. + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + const { client, withdrawn } = oauthClient(); + await client.connect(async (agent) => { + await initialize(agent); + await agent.request(v2.methods.agent.session.new, { cwd, mcpServers: [linear] }); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + await setProvider(agent); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(2)); + // The old query's elicitation was withdrawn before the new one went out. + expect(withdrawn).toEqual([1]); + }); + }); + + it("starts OAuth only on the new query of a resume with other additional directories", async () => { + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + const extra = await mkdtemp(path.join(tempDir, "extra-")); + const { client, withdrawn } = oauthClient(); + await client.connect(async (agent) => { + await initialize(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + mcpServers: [linear], + additionalDirectories: [extra], + }); + await vi.waitFor(() => expect(sdk.processes[0].close).toHaveBeenCalled()); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.elicitations).toHaveLength(1); + expect(withdrawn).toEqual([]); + }); + }); + }); + it("stops a session whose query does not start again under the new route", async () => { const client = v2Client(); await client.connect(async (agent) => { @@ -1858,6 +1919,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) => { @@ -2502,6 +2611,35 @@ describe("ACP v2 core", () => { }); }); + it("lets the subagent that a child waits on ask again when Claude Code refuses to stop the child", async () => { + const client = v2Client({ answerPermission: () => new Promise(() => {}) }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const child = subagent("child", "toolu_launch", { is_backgrounded: true }); + const inner = subagent("inner", "toolu_inner", { parent_task_id: "child" }); + claude.send(cycleStart()); + claude.send(child.launch()); + claude.send(child.started()); + claude.send(inner.launch("toolu_launch")); + claude.send(inner.started()); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "inner")).toContainEqual( + expect.objectContaining({ state: "running" }), + ), + ); + claude.stopTask.mockRejectedValueOnce(new Error("refused")); + await agent.notify(v2.methods.agent.session.cancel, { sessionId: "child" }); + await vi.waitFor(() => expect(claude.stopTask).toHaveBeenCalledWith("child")); + await new Promise((resolve) => setTimeout(resolve, 20)); + // The inner subagent's run goes on: its next request is its own. + void askPermission(claude, "Bash", { command: "make" }, { agentID: "inner" }).catch( + () => {}, + ); + await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(1)); + expect(client.permissionRequests[0].sessionId).toBe("inner"); + }); + }); + it("reports the root running again when the interrupt fails, and lets its subagent ask again", async () => { let answer = (_: v2.RequestPermissionResponse) => {}; const client = v2Client({ @@ -3498,27 +3636,171 @@ describe("ACP v2 core", () => { expect(reportAt).toBeLessThan(idleAt); }); - it("reports a prompt that a cancel ended unavailable, without reading a checkpoint", async () => { - const client = v2Client(); - const { claude, sessionId } = await client.connect(async (agent) => { + it("reads the report of a turn whose plan ran in a fresh context from the plan's prompt", async () => { + const client = v2Client({ + answerPermission: () => ({ + outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" }, + }), + }); + const changed = path.join(await realpath(cwd), "src", "App.ts"); + const { claude, sessionId, plan } = await client.connect(async (agent) => { const { response, claude } = await openSession(agent, air("agentFileChangeReport")); + claude.rewindFiles.mockResolvedValue({ canRewind: true, filesChanged: [changed] }); const answer = agent.request(v2.methods.agent.session.prompt, { sessionId: response.sessionId, prompt: PROMPT, _meta: requestMeta("r-1"), }); - await claude.prompt(0); - await agent.notify(v2.methods.agent.session.cancel, { sessionId: response.sessionId }); - await expect(answer).rejects.toMatchObject({ code: -32800 }); - return { claude, sessionId: response.sessionId }; + claude.send(echo(await claude.prompt(0))); + await answer; + claude.send( + assistant("msg_plan", [ + { + type: "tool_use", + id: "toolu_plan", + name: "ExitPlanMode", + input: { plan: "Do it" }, + }, + ]), + ); + await askPermission( + claude, + "ExitPlanMode", + { plan: "Do it" }, + { toolUseID: "toolu_plan" }, + ); + claude.send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_plan", + content: "User accepted the plan and requested a fresh context", + is_error: true, + }, + ], + }, + tool_result_meta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }], + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + stop_reason: null, + errors: [ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ], + }), + ); + claude.send(sessionState("idle")); + const clear = await claude.prompt(1); + claude.send(echo(clear)); + claude.send({ + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: "session", + }); + claude.send(result({ num_turns: 0, stop_reason: null })); + // `/clear` reset Claude Code's file history: only the plan's prompt has a checkpoint. + const plan = await claude.prompt(2); + claude.send(echo(plan)); + claude.send(result()); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle")); + return { claude, sessionId: response.sessionId, plan }; }); - expect(claude.rewindFiles).not.toHaveBeenCalled(); + expect(claude.rewindFiles).toHaveBeenCalledExactlyOnceWith(plan.uuid, { dryRun: true }); expect(reports(updatesOf(client.notifications, sessionId))).toEqual([ - { version: 1, requestId: "r-1", status: "unavailable", reason: "cancelled" }, + { + version: 1, + requestId: "r-1", + status: "reported", + paths: [changed], + declaredComplete: false, + truncated: false, + }, ]); }); + it.each([ + ["a cancel answered with -32800 before its insertion", false, "cancel", "cancelled"], + ["a cancel stopped after its insertion", true, "cancel", "cancelled"], + ["ended with an error result", true, "error", "providerError"], + ["ended without a result, as the query ended", true, "end", "providerError"], + [ + "failed otherwise before its insertion, as the query ended", + false, + "end", + "providerError", + ], + ] as const)( + "reports a prompt that %s unavailable, without reading a checkpoint", + async (_, insert, end, reason) => { + const client = v2Client(); + const { claude, sessionId } = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, air("agentFileChangeReport")); + const sessionId = response.sessionId; + const answer = agent + .request(v2.methods.agent.session.prompt, { + sessionId, + prompt: PROMPT, + _meta: requestMeta("r-1"), + }) + .catch((error: unknown) => error); + const prompt = await claude.prompt(0); + if (insert) { + claude.send(echo(prompt)); + expect(await answer).toEqual({ messageId: prompt.uuid }); + } + if (end === "cancel") { + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + if (insert) { + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + } + } else if (end === "error") { + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + errors: ["Overloaded"], + }), + ); + claude.send(sessionState("idle")); + } else { + claude.end(new Error("Claude Code exited")); + } + if (!insert) { + expect(await answer).toMatchObject({ code: end === "cancel" ? -32800 : -32603 }); + } + await vi.waitFor(() => + expect(reports(updatesOf(client.notifications, sessionId))).toHaveLength(1), + ); + return { claude, sessionId }; + }); + + expect(claude.rewindFiles).not.toHaveBeenCalled(); + expect(reports(updatesOf(client.notifications, sessionId))).toEqual([ + { version: 1, requestId: "r-1", status: "unavailable", reason }, + ]); + }, + ); + it.each([ ["an AIR client without the capability", air()], ["any other client", {}], diff --git a/src/v2/agent.ts b/src/v2/agent.ts index 8134a2cf..e0bf118f 100644 --- a/src/v2/agent.ts +++ b/src/v2/agent.ts @@ -60,7 +60,13 @@ import { canUseTool, denyingStoppedWork } from "./permissions.js"; import { reportHooks } from "./reports.js"; import { SessionRestarts, type SetupParams } from "./restarts.js"; import { V2Session, type SessionConnection } from "./session.js"; -import { clientOptions, replayEndUpdate, sessionModels, withClientHooks } from "./setup.js"; +import { + afterSetupResponse, + clientOptions, + replayEndUpdate, + sessionModels, + withClientHooks, +} from "./setup.js"; import type { SubagentReplay } from "./subagent-replay.js"; import { SubscriptionGuard } from "./subscription-guard.js"; import { restrictedChild } from "./tasks.js"; @@ -384,7 +390,8 @@ class V2Agent implements AgentHandle, SessionConnection { await session.reports.publishTitle(); for (const notification of session.resumeSnapshot()) this.send(notification); const response = await this.setupFields(session); - afterSetupResponse(session, params); + // A restart for other additional directories retires `session`. + if (!this.restarts.restarting(sessionId)) afterSetupResponse(session, params); // The response ends the replay, and the replayed children's states before // it are history: their current states follow it. Nothing enforces that // the timer runs after the response goes out; it relies on the response @@ -887,24 +894,6 @@ async function validatedSetup( return request; } -/** - * Starts OAuth for the setup request's MCP servers that need it, once the - * setup response is sent: v2 asks for no elicitation before it. The handler - * returns the response right after this call, and the SDK writes it before a - * timer runs. Nothing else orders the two. - */ -function afterSetupResponse( - session: V2Session, - params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest, -): void { - // The setup already rejected servers of an unknown transport. - const names = (params.mcpServers ?? []).flatMap((server) => - typeof server.name === "string" ? [server.name] : [], - ); - if (names.length === 0) return; - setTimeout(() => session.elicitations.authenticateMcpServers(names), 0); -} - function sameDirectories(left: readonly string[], right: readonly string[]): boolean { const normalize = (directories: readonly string[]) => [...new Set(directories.map((directory) => path.resolve(directory)))].sort().join("\n"); diff --git a/src/v2/clear-context.ts b/src/v2/clear-context.ts index 50fd59c5..8413d9be 100644 --- a/src/v2/clear-context.ts +++ b/src/v2/clear-context.ts @@ -149,9 +149,11 @@ export class ClearContext { return this.continuation !== undefined && !this.continuation.planStarted; } - /** Claude Code took in the prompt `uuid`. */ - promptStarted(uuid: string): void { - if (this.continuation?.planUuid === uuid) this.continuation.planStarted = true; + /** Claude Code took in the prompt `uuid`. Returns whether it is the plan's prompt. */ + promptStarted(uuid: string): boolean { + if (this.continuation?.planUuid !== uuid) return false; + this.continuation.planStarted = true; + return true; } /** Whether `uuid` is one of the adapter's prompts for the fresh context: the client gets no `user_message`. */ diff --git a/src/v2/file-changes.ts b/src/v2/file-changes.ts index e76ea440..8e6e7f80 100644 --- a/src/v2/file-changes.ts +++ b/src/v2/file-changes.ts @@ -68,6 +68,17 @@ export class TurnFileChanges { this.turn.push(report); } + /** + * The open turn goes on in a fresh context (`clear-context.ts`), with the + * plan's prompt `planUuid`. `/clear` reset Claude Code's file history, so + * the checkpoints of the turn's own prompts are gone: its reports read the + * plan prompt's checkpoint. v1 sends the plan with the turn's prompt uuid + * instead (`clear-context-coordinator.ts`). + */ + contextCleared(planUuid: string): void { + for (const report of this.turn) report.promptUuid = planUuid; + } + /** A message of the query: a subagent's file-tool edits go into the open turn's reports. */ message(message: SDKMessage): void { if (this.turn.length === 0) return; diff --git a/src/v2/restarts.ts b/src/v2/restarts.ts index b3e3dc5f..2cc05075 100644 --- a/src/v2/restarts.ts +++ b/src/v2/restarts.ts @@ -37,7 +37,9 @@ * (their terminals exit), as when a query ends. The new query starts without * the effort of the setup's `_meta`, and the client's choices are set again * on it (`SessionConfig.restoreFrom`), which then reports its options in a - * `config_option_update`. One restart of a session runs at + * `config_option_update`. Ending the old query withdraws its MCP OAuth + * runs, and the new one starts OAuth for the setup's MCP servers that still + * need it, as a setup does. One restart of a session runs at * a time: a route restart waits for an account restart that runs, as for a * busy session. * @@ -51,6 +53,7 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import type { Logger } from "../acp-agent.js"; import { withoutEffort } from "../session-effort.js"; import type { V2Session } from "./session.js"; +import { afterSetupResponse } from "./setup.js"; /** The request that set the session up, whose options a restart starts with again. */ export type SetupParams = v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest; @@ -150,6 +153,14 @@ export class SessionRestarts { return undefined; } + /** + * Whether a restart runs: its new query starts MCP OAuth once it took + * over, so the setup response of the session it replaces need not. + */ + restarting(sessionId: string): boolean { + return this.swaps.has(sessionId); + } + /** What a request other than a prompt waits for: a restart that runs. */ settled(sessionId: string): Promise | undefined { return this.swaps.get(sessionId)?.catch(() => {}); @@ -293,6 +304,9 @@ export class SessionRestarts { sessionId, update: { sessionUpdate: "config_option_update", configOptions: session.config.options() }, }); + // Closing the old query withdrew its MCP OAuth runs. The servers of a + // resume that came during the restart are tracked by now. + afterSetupResponse(session, this.setups.get(sessionId) ?? params); })().finally(() => { if (this.swaps.get(sessionId) === run) this.swaps.delete(sessionId); if (this.retiring.get(sessionId) === previous) this.retiring.delete(sessionId); diff --git a/src/v2/session.ts b/src/v2/session.ts index ca7b6261..162b9485 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -1066,7 +1066,7 @@ export class V2Session implements RequestTarget { // Claude Code takes its prompts in order: the work that a cancel stopped // has ended, also when the cancel came while it ran nothing. this.tasks.foregroundCancelEnded(); - this.clearContext.promptStarted(uuid); + if (this.clearContext.promptStarted(uuid)) this.fileChanges?.contextCleared(uuid); this.inserted(uuid); this.openTurn(uuid); } diff --git a/src/v2/setup.ts b/src/v2/setup.ts index 9db1c9f3..267da971 100644 --- a/src/v2/setup.ts +++ b/src/v2/setup.ts @@ -27,6 +27,8 @@ import { withCurrentModelInfo, } from "../session-model.js"; import type { SettingsManager } from "../settings.js"; +import type { SetupParams } from "./restarts.js"; +import type { V2Session } from "./session.js"; /** The SDK options a client passes in `_meta.claudeCode.options`. */ export function clientOptions(meta: unknown): Options | undefined { @@ -110,3 +112,19 @@ export function replayEndUpdate( _meta: withAirMeta(undefined, AIR_LOAD_REPLAY_END_KEY, true), }; } + +/** + * Starts OAuth for the setup request's MCP servers that need it, once the + * setup response is sent: v2 asks for no elicitation before it. The handler + * returns the response right after this call, and the SDK writes it before a + * timer runs. Nothing else orders the two. A restart calls it on the new + * query too: closing the old one withdrew the old one's OAuth runs. + */ +export function afterSetupResponse(session: V2Session, params: SetupParams): void { + // The setup already rejected servers of an unknown transport. + const names = (params.mcpServers ?? []).flatMap((server) => + typeof server.name === "string" ? [server.name] : [], + ); + if (names.length === 0) return; + setTimeout(() => session.elicitations.authenticateMcpServers(names), 0); +} 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; diff --git a/src/v2/tasks.ts b/src/v2/tasks.ts index 0557bff4..31e35a00 100644 --- a/src/v2/tasks.ts +++ b/src/v2/tasks.ts @@ -609,13 +609,22 @@ export class SessionTasks { */ foregroundCancelFailed(): void { this.rootCancelling = false; - for (const agentId of this.foregroundCancelled) { + this.cancelFailed(this.foregroundCancelled); + this.foregroundCancelled.clear(); + } + + /** + * A cancel failed to stop the agents `agentIds` that it withdrew: their + * runs go on, and their later requests are theirs again. Each reports + * `running` again, which the withdrawn requests did not. + */ + private cancelFailed(agentIds: Iterable): void { + for (const agentId of agentIds) { this.cancelledAgents.delete(agentId); const child = this.children.get(agentId); if (child?.output) child.output.cancelled = false; if (child) this.reportChild(child); } - this.foregroundCancelled.clear(); } /** @@ -635,17 +644,19 @@ export class SessionTasks { async cancelChild(sessionId: string): Promise { const child = this.children.get(sessionId); if (!child?.announced || !child.working) return; + // The agents that this cancel stops, and not an earlier one still. + const withdrawn: string[] = []; for (const agentId of this.agents.keys()) { - if (this.waitsOn(agentId, sessionId)) this.withdraw(agentId); + if (!this.waitsOn(agentId, sessionId)) continue; + if (!this.cancelledAgents.has(agentId)) withdrawn.push(agentId); + this.withdraw(agentId); } try { await this.host.stopTask(child.sessionId); } catch (error) { // The run ended meanwhile, or Claude Code refused: it reports how it - // ends. A run that goes on reports `running` again, which the - // withdrawn requests did not. - this.cancelledAgents.delete(sessionId); - this.reportChild(child); + // ends, and so do the subagents that it waits on. + this.cancelFailed(withdrawn); this.host.logger.error(`Subagent ${sessionId}: stopping it failed`, error); } }