diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 73e07e8b..6884f2fc 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -412,6 +412,11 @@ const STRUCTURED_USAGE_TIMEOUT_MS = 5_000; * The teardown wakes the consumer itself, so they end within a few ticks * unless the consumer is wedged. */ const TEARDOWN_TURN_END_TIMEOUT_MS = 5_000; +/** How long `canUseTool` or a hook waits, while a cancel's window may be + * open, for the consumer to reach a tool use in the stream. Claude Code + * streams the tool use before it asks, so the consumer is usually there + * within a few ticks. */ +const TOOL_USE_WAIT_MS = 2_000; /** The number of settled subagents whose parent tool call a session keeps for * a later resume (see `resumableSubagents`). */ const MAX_RESUMABLE_SUBAGENTS = 256; @@ -1145,6 +1150,16 @@ export type Session = { * not here: Claude abandoned it before it ran. Pruned when the call ends; * the oldest entries are dropped. */ dispatchedToolCalls?: Set; + /** The tool uses that the consumer reached in the stream, each with whether + * it dropped it as output of the work a cancel stopped (`true`, see + * `dropsStoppedWorkOutput`). The first sight of a tool use decides: its + * streamed start, or its complete message. `canUseTool` and the v2 preview + * hook read it (see `isStoppedWorkToolUse`). The oldest entries are + * dropped. */ + reachedToolUses?: Map; + /** What waits for the consumer to reach each tool use (see + * `isStoppedWorkToolUse`). */ + toolUseWaiters?: Map void>>; /** For each open tool call that a subagent made: the Agent/Task call that * started that subagent. When a cancel ends such a call, the report names * that parent as the call's other updates do, so that every client form @@ -2580,6 +2595,9 @@ export class ClaudeAcpAgent { * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so * tests can shrink it. */ forceCancelGraceMs: number = DEFAULT_FORCE_CANCEL_GRACE_MS; + /** How long `canUseTool` or a hook waits for the consumer to reach a tool + * use (see `isStoppedWorkToolUse`). Mutable so tests can shrink it. */ + toolUseWaitMs: number = TOOL_USE_WAIT_MS; /** * The connection serves ACP v2 (`src/v2/`). The agent still speaks v1 * types, except for the tool call content that v2 needs and v1 cannot @@ -7131,7 +7149,14 @@ export class ClaudeAcpAgent { // prefix of the text, so the text would be sent again (see // unstreamedRemainder). const streamKey = message.parent_tool_use_id ?? ""; - if (session.cancelled && this.workEndReported(params.sessionId, session)) { + const stoppedWorkOutput = this.dropsStoppedWorkOutput(params.sessionId, session); + if ( + message.event.type === "content_block_start" && + isToolUseBlock(message.event.content_block) + ) { + recordReachedToolUse(session, message.event.content_block.id, stoppedWorkOutput); + } + if (stoppedWorkOutput) { if (message.parent_tool_use_id === null || forwardsSubagentText()) { droppedStreams.add(streamKey); } @@ -7210,8 +7235,17 @@ export class ClaudeAcpAgent { } case "user": case "assistant": { - if (message.type === "assistant") + if (message.type === "assistant") { recordDispatchedToolUses(session, message.message.content); + // A tool use that did not stream is reached here first. + const content = message.message.content; + const stoppedWorkOutput = this.dropsStoppedWorkOutput(params.sessionId, session); + for (const block of Array.isArray(content) ? content : []) { + if (isToolUseBlock(block)) { + recordReachedToolUse(session, block.id, stoppedWorkOutput); + } + } + } // Record the ACP messageId -> SDK uuid mapping for this message // (including replays). The consolidated message carries both ids, so // this is where we learn the uuid the SDK's rewind/resume APIs key on @@ -9056,6 +9090,68 @@ export class ClaudeAcpAgent { return this.foregroundEvents ? !this.foregroundWork.has(sessionId) : !session.activeTurn; } + /** Whether the consumer drops what it reaches now as output of the work a + * cancel stopped: the client has been told that the work ended, and the + * window after the cancel is still open (see Session.cancelled). */ + private dropsStoppedWorkOutput(sessionId: string, session: Session): boolean { + return session.cancelled && this.workEndReported(sessionId, session); + } + + /** + * Whether the tool use `toolUseId`, which reaches `canUseTool` or a hook, is + * output of the work a cancel stopped that the consumer dropped (see + * `dropsStoppedWorkOutput`). Example: Claude Code runs a prompt that the + * cancel abandoned, and it asks to run a command. The consumer drops the + * tool use and its result, so a tool call shown for it would never end, and + * the client would keep it open (issue #1061). + * + * Claude Code calls `canUseTool` and hooks on its control channel, after it + * streamed the tool use, and the consumer can lag behind. So the session can + * still look cancelled while the consumer has the next prompt's echo, which + * ends the window, still ahead of the tool use. While a cancel's window may + * be open, this waits until the consumer reaches the tool use, for at most + * {@link toolUseWaitMs} or until `signal` aborts: the stream never shows + * some tool uses, such as SandboxNetworkAccess's. Without a cancel nothing + * is dropped, so it answers at once. It returns a promise only when it + * waits, so a permission request without a cancel goes out as soon as + * before. + */ + private isStoppedWorkToolUse( + session: Session, + toolUseId: string, + signal?: AbortSignal, + ): boolean | Promise { + const reached = session.reachedToolUses?.get(toolUseId); + if (reached !== undefined) return reached; + if (!session.cancelled || signal?.aborted) return false; + return this.reachedInStream(session, toolUseId, signal); + } + + /** Waits until the consumer reaches the tool use `toolUseId` (see + * `isStoppedWorkToolUse`), then says whether it dropped it. */ + private async reachedInStream( + session: Session, + toolUseId: string, + signal?: AbortSignal, + ): Promise { + await new Promise((resolve) => { + const waiters = (session.toolUseWaiters ??= new Map()); + let waiting = waiters.get(toolUseId); + if (!waiting) waiters.set(toolUseId, (waiting = new Set())); + const done = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + waiting.delete(done); + if (waiting.size === 0 && waiters.get(toolUseId) === waiting) waiters.delete(toolUseId); + resolve(); + }; + const timer = setTimeout(done, this.toolUseWaitMs); + signal?.addEventListener("abort", done, { once: true }); + waiting.add(done); + }); + return session.reachedToolUses?.get(toolUseId) ?? false; + } + /** End the session's model cycle that Claude Code started on its own, if one * runs. */ private endAutonomousCycle( @@ -9235,9 +9331,9 @@ export class ClaudeAcpAgent { * decision, and a failure only leaves the preview out. */ private fileChangePreviewHook(sessionId: string): HookCallback { - return async (input, toolUseID) => { + return async (input, toolUseID, { signal }) => { if (input.hook_event_name === "PreToolUse" && toolUseID) { - await this.previewFileChange(sessionId, input, toolUseID).catch((error) => + await this.previewFileChange(sessionId, input, toolUseID, signal).catch((error) => this.logger.error(`Session ${sessionId}: failed to preview ${toolUseID}: ${error}`), ); } @@ -9249,9 +9345,13 @@ export class ClaudeAcpAgent { sessionId: string, input: PreToolUseHookInput, toolUseID: string, + signal: AbortSignal, ): Promise { const session = this.sessions[sessionId]; if (!session) return; + // As in `canUseTool`: the work a cancel stopped shows nothing. + const stopped = this.isStoppedWorkToolUse(session, toolUseID, signal); + if (stopped instanceof Promise ? await stopped : stopped) return; const toolInput = (input.tool_input ?? {}) as Record; const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2"); if (!content) return; @@ -9321,6 +9421,16 @@ export class ClaudeAcpAgent { message: "Session not found", }; } + // The work a cancel stopped shows the client nothing after it was told + // that the work ended: neither a tool call nor a request. Its tool does + // not run. + const stopped = this.isStoppedWorkToolUse(session, toolUseID, signal); + if (stopped instanceof Promise ? await stopped : stopped) { + this.logger.log( + `Session ${sessionId}: denied ${toolName} ${toolUseID}, which the cancelled work asked for`, + ); + return { behavior: "deny", message: "The user cancelled this prompt." }; + } const { parentToolUseId, toolCallSessionId: permissionSessionId } = this.toolUseOwner( session, @@ -12010,17 +12120,39 @@ function recordDispatchedToolCall(session: Session, toolCallId: string): void { function recordDispatchedToolUses(session: Session, content: unknown): void { if (!Array.isArray(content)) return; for (const block of content) { - if ( - (block?.type === "tool_use" || - block?.type === "server_tool_use" || - block?.type === "mcp_tool_use") && - typeof block.id === "string" - ) { - recordDispatchedToolCall(session, block.id); - } + if (isToolUseBlock(block)) recordDispatchedToolCall(session, block.id); } } +function isToolUseBlock(block: unknown): block is { type: string; id: string } { + const { type, id } = (block ?? {}) as { type?: unknown; id?: unknown }; + return ( + (type === "tool_use" || type === "server_tool_use" || type === "mcp_tool_use") && + typeof id === "string" + ); +} + +const MAX_REACHED_TOOL_USES = 1000; + +/** + * Records that the consumer reached the tool use `toolUseId`, and whether it + * dropped it as output of the work a cancel stopped, then wakes what waits for + * it (see `isStoppedWorkToolUse`). The first record of a tool use stays. + */ +function recordReachedToolUse(session: Session, toolUseId: string, dropped: boolean): void { + const reached = (session.reachedToolUses ??= new Map()); + if (reached.has(toolUseId)) return; + reached.set(toolUseId, dropped); + if (reached.size > MAX_REACHED_TOOL_USES) { + const oldest = reached.keys().next().value; + if (oldest !== undefined) reached.delete(oldest); + } + const waiters = session.toolUseWaiters?.get(toolUseId); + if (!waiters) return; + session.toolUseWaiters?.delete(toolUseId); + for (const wake of waiters) wake(); +} + /** Streamed and permission-surfaced tools can precede the SDK's user echo. */ function recordForegroundToolCall(session: Session, toolCallId: string): void { const turn = session.activeTurn ?? session.turnQueue?.find((queued) => !queued.settled); diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index bb85103a..69924076 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -63,6 +63,7 @@ import { getSessionInfo, getSessionMessages, getSubagentMessages, + type HookCallback, importSessionToStore, listSessions, PermissionUpdate, @@ -18823,24 +18824,101 @@ describe("output after a cancel", () => { ]; } - /** An agent on a session that `makeGenerator` scripts, and the text of every - * agent message chunk its client receives. */ + /** An agent on a session that `makeGenerator` scripts, the text of every + * agent message chunk its client receives, every update, and every + * permission request, which the client allows once. */ function setup(makeGenerator: (input: Pushable) => AsyncGenerator) { const texts: string[] = []; + const updates: SessionNotification["update"][] = []; + const permissionRequests: RequestPermissionRequest[] = []; const agent = new ClaudeAcpAgent( { sessionUpdate: async ({ update }: SessionNotification) => { + updates.push(update); if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") { texts.push(update.content.text); } }, + requestPermission: async (params: RequestPermissionRequest) => { + permissionRequests.push(params); + const option = + params.options.find(({ kind }) => kind === "allow_once") ?? params.options[0]!; + return { outcome: { outcome: "selected", optionId: option.optionId } }; + }, } as unknown as AcpClient, { log: () => {}, error: () => {} }, ); injectGeneratorSession(agent, makeGenerator); - return { agent, texts }; + return { agent, texts, updates, permissionRequests }; } + /** How Claude Code starts to stream an assistant message that runs the tool + * `id`, up to the tool use's start. */ + const toolUseStart = (id: string, name: string) => [ + messageStart(), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id, name, input: {} }, + }), + ]; + /** The whole assistant message that runs the tool `id`, which ends its stream. */ + function toolUseMessage(id: string, name: string, input: Record) { + const message = assistant(""); + return [ + { + ...message, + message: { ...message.message, content: [{ type: "tool_use", id, name, input }] }, + }, + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "message_stop" }), + ]; + } + /** How Claude Code sends an assistant message that runs the tool `id`: it + * streams the tool use's start, then sends the whole message. */ + const toolUse = (id: string, name: string, input: Record) => [ + ...toolUseStart(id, name), + ...toolUseMessage(id, name, input), + ]; + /** The result of the tool `id`. */ + const toolResult = (id: string, content: string, isError = false) => ({ + type: "user", + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: id, content, is_error: isError }], + }, + }); + // No test writes the file. A path under /tmp would read to CodeQL as a + // file created in the temp directory. + const writeInput = { file_path: "/workspace/proj/notes.md", content: "done\n" }; + /** Claude Code's permission check for the Write call `id`, as it calls it. */ + const askToWrite = (agent: ClaudeAcpAgent, id: string) => + agent.canUseTool("test-session")("Write", writeInput, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: id, + } as any); + /** The v2 PreToolUse preview hook for the Write call `id`, as Claude Code calls it. */ + const previewWrite = (agent: ClaudeAcpAgent, id: string) => + ( + agent as unknown as { fileChangePreviewHook(sessionId: string): HookCallback } + ).fileChangePreviewHook("test-session")( + { + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: writeInput, + tool_use_id: id, + session_id: "test-session", + transcript_path: "", + cwd: "/workspace/proj", + } as any, + id, + { signal: new AbortController().signal }, + ); + /** Sends the prompt `text` and waits until its turn is active. Returns the * prompt and the session's consumer, which ends with the scripted stream. */ async function promptActive(agent: ClaudeAcpAgent, text: string) { @@ -18979,6 +19057,185 @@ describe("output after a cancel", () => { expect(texts).toEqual(["Starting the build.", "The build", " passed."]); }); + it.each([ + ["before the consumer reaches it", "before", true], + ["after its streamed start, before its message", "between", true], + ["after its message", "after", true], + ["before its message, for a tool use that did not stream", "before", false], + ] as const)( + "shows nothing of a tool that a prompt the cancel abandoned asks to run, and denies it, when Claude Code asks %s (#1061)", + async (_when, order, streamed) => { + const cancelled = Promise.withResolvers(); + let asked: Promise | undefined; + let previewed: Promise | undefined; + const { agent, texts, updates, permissionRequests } = setup((input) => + (async function* () { + const iter = input[Symbol.asyncIterator](); + const { value: first } = await iter.next(); + yield* startTurn(first); + yield* answer("Starting the build."); + const { value: second } = await iter.next(); + yield lifecycleFrame(second.uuid, "queued"); + await cancelled.promise; + yield abortedResult(first.uuid); + yield lifecycleFrame(first.uuid, "cancelled"); + // Claude Code had already taken the second prompt, so it still runs + // it, and its answer writes a file. It calls the preview hook and + // asks for the permission on its control channel, which can run + // ahead of the consumer. + yield lifecycleFrame(second.uuid, "started"); + yield requesting(second.uuid); + yield userEcho(second); + const ask = () => { + previewed = previewWrite(agent, "toolu_late"); + asked = askToWrite(agent, "toolu_late"); + }; + if (order === "before") ask(); + if (streamed) yield* toolUseStart("toolu_late", "Write"); + if (order === "between") { + // Claude Code can run a tool whose input is complete before it + // sends the whole message. + ask(); + await Promise.all([previewed, asked]); + } + yield* streamed + ? toolUseMessage("toolu_late", "Write", writeInput) + : [toolUseMessage("toolu_late", "Write", writeInput)[0]]; + if (order === "after") ask(); + await Promise.all([previewed, asked]); + yield toolResult("toolu_late", "The user cancelled this prompt.", true); + yield stampedResult(second.uuid); + yield lifecycleFrame(second.uuid, "completed"); + yield idle; + yield requesting(); + yield* followup(); + })(), + ); + const { prompt: first, consumer } = await promptActive(agent, "build it"); + const second = agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "and write it down" }], + }); + await vi.waitFor(() => + expect(agent.sessions["test-session"]?.turnQueue?.length).toBeGreaterThanOrEqual(2), + ); + vi.mocked(agent.sessions["test-session"]!.query.interrupt).mockResolvedValue({ + still_queued: [], + }); + + await agent.cancel({ sessionId: "test-session" }); + cancelled.resolve(); + + await expect(first).resolves.toMatchObject({ stopReason: "cancelled" }); + await expect(second).resolves.toEqual({ stopReason: "cancelled" }); + await consumer; + await expect(asked).resolves.toEqual({ + behavior: "deny", + message: "The user cancelled this prompt.", + }); + // Neither a tool call, which would never end, nor a request. + expect(updates.filter((update) => "toolCallId" in update)).toEqual([]); + expect(permissionRequests).toEqual([]); + expect(agent.sessions["test-session"]!.emittedToolCalls.size).toBe(0); + expect(texts).toEqual(["Starting the build.", "The build", " passed."]); + }, + ); + + it("stops waiting for a tool use that the stream does not show, when Claude Code withdraws the request or the wait runs out", async () => { + const cancelled = Promise.withResolvers(); + const asked = Promise.withResolvers(); + const { agent, permissionRequests } = setup((input) => + (async function* () { + const iter = input[Symbol.asyncIterator](); + const { value: first } = await iter.next(); + yield* startTurn(first); + yield* answer("Starting the build."); + await cancelled.promise; + yield abortedResult(first.uuid); + yield lifecycleFrame(first.uuid, "cancelled"); + // The window stays open while the requests below wait. + await asked.promise; + yield idle; + })(), + ); + const { prompt: first, consumer } = await promptActive(agent, "build it"); + await agent.cancel({ sessionId: "test-session" }); + cancelled.resolve(); + await expect(first).resolves.toMatchObject({ stopReason: "cancelled" }); + + // SandboxNetworkAccess asks under an id that no tool use in the stream has. + const ask = (id: string, signal: AbortSignal) => + agent.canUseTool("test-session")("SandboxNetworkAccess", { host: "example.com" }, { + signal, + suggestions: [], + toolUseID: id, + } as any); + // A withdrawn request stops waiting at once, whether it was withdrawn + // before it asked or while it waits. + agent.toolUseWaitMs = 60_000; + await expect(ask("network-1", AbortSignal.abort())).rejects.toThrow("Tool use aborted"); + const withdrawn = new AbortController(); + const withdrawnAsk = ask("network-2", withdrawn.signal); + withdrawn.abort(); + await expect(withdrawnAsk).rejects.toThrow("Tool use aborted"); + // Once the wait runs out, the request goes out as it would without a cancel. + agent.toolUseWaitMs = 20; + await expect(ask("network-3", new AbortController().signal)).resolves.toMatchObject({ + behavior: "allow", + }); + expect(permissionRequests.map(({ toolCall }) => toolCall.toolCallId)).toEqual(["network-3"]); + asked.resolve(); + await consumer; + }); + + it("asks for the next prompt's tool once the consumer reaches it, when Claude Code asks while the window still looks open", async () => { + const cancelled = Promise.withResolvers(); + const asking = Promise.withResolvers(); + const { agent, updates, permissionRequests } = setup((input) => + (async function* () { + const iter = input[Symbol.asyncIterator](); + const { value: first } = await iter.next(); + yield* startTurn(first); + yield* answer("Starting the build."); + await cancelled.promise; + yield abortedResult(first.uuid); + yield lifecycleFrame(first.uuid, "cancelled"); + const { value: next } = await iter.next(); + // Claude Code asks to run the next prompt's tool before the consumer + // has reached the prompt's echo, which closes the window. + await asking.promise; + yield* startTurn(next); + yield* toolUse("toolu_next", "Write", writeInput); + yield toolResult("toolu_next", "Wrote the file."); + yield stampedResult(next.uuid); + yield lifecycleFrame(next.uuid, "completed"); + yield idle; + })(), + ); + const { prompt: first, consumer } = await promptActive(agent, "build it"); + await agent.cancel({ sessionId: "test-session" }); + cancelled.resolve(); + await expect(first).resolves.toMatchObject({ stopReason: "cancelled" }); + + const next = agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "write it down" }], + }); + expect(agent.sessions["test-session"]!.cancelled).toBe(true); + const asked = askToWrite(agent, "toolu_next"); + asking.resolve(); + + await expect(asked).resolves.toMatchObject({ behavior: "allow" }); + expect(permissionRequests.map(({ toolCall }) => toolCall.toolCallId)).toEqual(["toolu_next"]); + await expect(next).resolves.toMatchObject({ stopReason: "end_turn" }); + await consumer; + expect( + updates.some( + (update) => update.sessionUpdate === "tool_call" && update.toolCallId === "toolu_next", + ), + ).toBe(true); + }); + it("does not let the next prompt take the result of a prompt the cancel abandoned", async () => { const cancelled = Promise.withResolvers(); const { agent, texts } = setup((input) => diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 9178f5f3..13ab8624 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -2715,6 +2715,131 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { ]); }); + it("shows nothing of a tool that a prompt the cancel abandoned asks to run, and denies it (#1061)", async () => { + const interrupted = Promise.withResolvers(); + sdk.interrupt = async () => interrupted.resolve(); + const echo = (options: Options, prompt: { uuid?: string; message: unknown }) => ({ + type: "user", + message: prompt.message, + parent_tool_use_id: null, + uuid: prompt.uuid, + session_id: options.sessionId, + isReplay: true, + }); + const input = { file_path: path.join(cwd, "notes.md"), content: "done\n" }; + let decision: unknown; + sdk.run = async function* (prompts, options) { + const iter = prompts[Symbol.asyncIterator](); + const { value: first } = await iter.next(); + yield markersInit(options); + yield requesting(options, first.uuid!); + yield echo(options, first); + yield assistantText(options, "Starting the build.", "msg_first"); + const { value: second } = await iter.next(); + await interrupted.promise; + yield result(options, { ...ABORTED, user_message_uuids: [first.uuid] }); + // Claude Code had already taken the second prompt, so it still runs it, + // and its answer writes a file. + yield requesting(options, second.uuid!); + yield echo(options, second); + yield stream(options, { + type: "message_start", + message: { id: "msg_second", role: "assistant", content: [], usage: {} }, + }); + yield stream(options, { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_late", name: "Write", input: {} }, + }); + yield { + type: "assistant", + message: { + id: "msg_second", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "tool_use", id: "toolu_late", name: "Write", input }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: options.sessionId, + }; + // Claude Code runs the PreToolUse hooks, then asks for the permission. + for (const matcher of options.hooks?.PreToolUse ?? []) { + for (const hook of matcher.hooks) { + await hook( + { + hook_event_name: "PreToolUse", + session_id: options.sessionId!, + cwd, + tool_name: "Write", + tool_input: input, + tool_use_id: "toolu_late", + } as never, + "toolu_late", + { signal: new AbortController().signal }, + ); + } + } + decision = await options.canUseTool!("Write", input, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "toolu_late", + } as never); + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_late", + content: "The user cancelled this prompt.", + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: options.sessionId, + }; + yield result(options, { user_message_uuids: [second.uuid] }); + yield idleState(options); + while (!(await iter.next()).done); + }; + const client = await promptAndWait(1, async (agent, sessionId, client) => { + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "agent_message_chunk", + ]), + ); + const queued = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("and write it down"), + }); + await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.turnQueue).toHaveLength(2)); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await expect(queued).rejects.toMatchObject({ code: -32800 }); + await vi.waitFor(() => expect(decision).toBeDefined()); + }); + + expect(decision).toEqual({ behavior: "deny", message: "The user cancelled this prompt." }); + // Neither a tool call, which would never end, nor a patch, nor a request. + expect(client.sessionUpdates.filter(({ update }) => "toolCallId" in update)).toEqual([]); + expect(client.permissionRequests).toEqual([]); + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "agent_message_chunk", + "idle cancelled", + ]); + }); + it("reports what the interrupt flushes of a cycle that Claude Code started before idle cancelled", async () => { const interrupted = Promise.withResolvers(); sdk.interrupt = async () => interrupted.resolve();