From 1607023bdf27acfd3d9d028037eb2e03b49c1a00 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:05:06 +0100 Subject: [PATCH 1/7] fix(v2): start MCP OAuth again on the new query of a restart Ending the old query withdrew its OAuth runs and nothing started them on the new one. A resume with other additional directories no longer starts OAuth on the session that its restart retires. Co-Authored-By: Claude --- docs/acp-v2.md | 4 ++- src/tests/v2.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++ src/v2/agent.ts | 29 +++++++-------------- src/v2/restarts.ts | 16 +++++++++++- src/v2/setup.ts | 18 +++++++++++++ 5 files changed, 106 insertions(+), 22 deletions(-) diff --git a/docs/acp-v2.md b/docs/acp-v2.md index 22f4a70c..3e239fcf 100644 --- a/docs/acp-v2.md +++ b/docs/acp-v2.md @@ -256,7 +256,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`. diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts index 8fae718b..40bebfe9 100644 --- a/src/tests/v2.test.ts +++ b/src/tests/v2.test.ts @@ -142,6 +142,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { applyFlagSettings: fake.applyFlagSettings, rewindFiles: fake.rewindFiles, mcpServerStatus: async () => sdk.mcpServers, + setMcpServers: async () => ({ added: [], removed: [], errors: {} }), // A server that needs OAuth: Claude Code returns its authorization URL. mcpAuthenticate: async () => ({ authUrl: "https://mcp.example.com/authorize", @@ -670,6 +671,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) => { diff --git a/src/v2/agent.ts b/src/v2/agent.ts index 4dbfe8af..47e2b83b 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"; @@ -374,7 +380,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 @@ -843,24 +850,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/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/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); +} From da70e3031a12e705dd00734dbfc121d581bccf26 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:11:52 +0100 Subject: [PATCH 2/7] 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; From 600fb6f15d9994eb22a9cf2f12b7079c3ef709e6 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:14:39 +0100 Subject: [PATCH 3/7] fix(v2): undo a failed child cancel for the subagents it withdrew A session/cancel of a child withdraws the child and the foreground subagents that it waits on. When stopTask failed, only the child was restored, so the nested subagents' later permission requests were aborted silently. The failure now restores each of them and reports it again, through the code that a failed root interrupt uses. Co-Authored-By: Claude --- src/tests/v2.test.ts | 29 +++++++++++++++++++++++++++++ src/v2/tasks.ts | 25 ++++++++++++++++++------- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts index 34a6cd57..c55d1def 100644 --- a/src/tests/v2.test.ts +++ b/src/tests/v2.test.ts @@ -2420,6 +2420,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({ diff --git a/src/v2/tasks.ts b/src/v2/tasks.ts index 5f0bb64b..a1818db6 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); } } From 51a9cbc480c88631aa21dcd2c64234288b65f281 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:17:35 +0100 Subject: [PATCH 4/7] fix(v2): read a cleared-context turn's file-change report from the plan's prompt ExitPlanMode's "Yes, clear context" sends /clear, which resets Claude Code's file history, so the report read from the turn's own prompt was unavailable and missed the plan's changes. The open turn's reports now read the checkpoint of the plan's prompt once it starts. Co-Authored-By: Claude --- docs/acp-v2.md | 4 +- src/tests/v2.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++ src/v2/clear-context.ts | 8 ++-- src/v2/file-changes.ts | 11 +++++ src/v2/session.ts | 2 +- 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/acp-v2.md b/docs/acp-v2.md index 3e239fcf..14970703 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`. diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts index c55d1def..86ee739a 100644 --- a/src/tests/v2.test.ts +++ b/src/tests/v2.test.ts @@ -3347,6 +3347,100 @@ describe("ACP v2 core", () => { expect(reportAt).toBeLessThan(idleAt); }); + 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"), + }); + 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).toHaveBeenCalledExactlyOnceWith(plan.uuid, { dryRun: true }); + expect(reports(updatesOf(client.notifications, sessionId))).toEqual([ + { + version: 1, + requestId: "r-1", + status: "reported", + paths: [changed], + declaredComplete: false, + truncated: false, + }, + ]); + }); + 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) => { 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/session.ts b/src/v2/session.ts index 36d66021..ccbe2969 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -1059,7 +1059,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); } From cf902892d342eabfea0dab218d1f7ca7d0792134 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:18:30 +0100 Subject: [PATCH 5/7] test(v2): cover each unavailable file-change report reason One it.each covers a cancel before and after the prompt's insertion, an error result, a query that ends without a result, and a failure before insertion other than -32800. Co-Authored-By: Claude --- src/tests/v2.test.ts | 86 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts index 86ee739a..daae24f2 100644 --- a/src/tests/v2.test.ts +++ b/src/tests/v2.test.ts @@ -3441,26 +3441,76 @@ describe("ACP v2 core", () => { ]); }); - 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) => { - const { response, claude } = await openSession(agent, air("agentFileChangeReport")); - const answer = agent.request(v2.methods.agent.session.prompt, { - sessionId: response.sessionId, - prompt: PROMPT, - _meta: requestMeta("r-1"), + 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 }; }); - 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 }; - }); - expect(claude.rewindFiles).not.toHaveBeenCalled(); - expect(reports(updatesOf(client.notifications, sessionId))).toEqual([ - { version: 1, requestId: "r-1", status: "unavailable", reason: "cancelled" }, - ]); - }); + 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()], From 05ea8b2a5b3613bfe651d846564e13004a682b2b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:19:24 +0100 Subject: [PATCH 6/7] docs: say that ACP v2 has no hold for background subagents The v2 agent answers session/prompt at insertion and reports background subagents in their parent's background list and their child sessions' states. Co-Authored-By: Claude --- docs/air-extensions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From dc24f8a7bcbce1e93769f4464fa295fb8dce3cdd Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 23:19:24 +0100 Subject: [PATCH 7/7] docs: drop the stale line count of the v2 agent Co-Authored-By: Claude --- docs/acp-v2.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/acp-v2.md b/docs/acp-v2.md index 14970703..9b24b99f 100644 --- a/docs/acp-v2.md +++ b/docs/acp-v2.md @@ -345,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/*`)