fix: show nothing of a tool that cancelled work asks to run after the cancel was reported (#1334)

Closes #1061.

## Where #1061 stands

The incident in #1061 was tool starts streamed after `session/cancel`
that never got a final status, so a client's ledger of open tool calls
kept them forever. Since that report:

- **#1313** drops streamed text, thinking and tool calls of the stopped
work once the client has been told that the work ended (the cancelled
response on v1, `idle cancelled` on v2), while the window after the
cancel is open.
- **#1327** and **#1221** close the tool calls that streamed before that
report, before it is sent.

So a late streamed tool start no longer reaches the client. This PR adds
regression tests for that, and closes the one path that still produced
such a tool call. #1313 named it as not changed: a tool call that the
stopped work creates outside the stream.

## The remaining path

Example: the user sends a second prompt while the first runs, then
cancels. Claude Code had already taken the second prompt off its queue,
so it still runs it, and its answer writes a file. The consumer drops
the stream and the messages of that answer (#1313). But Claude Code asks
for the Write's permission on its control channel:

- **`canUseTool`** created the tool call (`ensureToolCallEmitted`) and
sent the permission request. AskUserQuestion's elicitation goes the same
way.
- **The v2 PreToolUse preview hook** created the tool call with the
Write's patch.

The tool's result is dropped too, so that tool call never ended: the
ghost of #1061, now for tools that need a permission.

## The fix

- **The consumer records each tool use it reaches,** at its streamed
start or, for one that did not stream, at its complete message, with
whether it dropped it. The condition is the stream's existing one, now
`dropsStoppedWorkOutput`: the client was told that the stopped work
ended, and the window after the cancel is open.
- **`canUseTool` denies a dropped tool use without showing anything:**
no tool call, no permission request, no elicitation. The model gets "The
user cancelled this prompt." The **preview hook** sends nothing for it.
- **The control channel runs ahead of the consumer,** which is why #1313
left this open. While a cancel's window may be open, `canUseTool` and
the hook wait until the consumer reaches the tool use. Claude Code
streams the tool use before it asks, so the consumer is usually there
within a few ticks. Example: the next prompt's tool asks while the
session still looks cancelled, because the consumer has not reached that
prompt's echo yet. The wait sees the consumer reach the echo, which
closes the window, and then the tool use, which it shows, so the request
goes out.
- **The wait is bounded:** 2 s, or until Claude Code withdraws the
request. The stream never shows some tool uses, such as
SandboxNetworkAccess's, and those then go out as before.
- **Without a cancel, nothing changes,** not even timing: the check
answers synchronously and adds no tick before the permission request.

## Evidence

**Tests** (`src/tests/acp-agent.test.ts`, "output after a cancel";
`src/tests/acp-v2.test.ts`):

- An abandoned prompt's Write: nothing reaches the client, and the tool
is denied. Claude Code asks before the consumer reaches the tool use,
between its streamed start and its message, after its message, or for a
tool use that did not stream. The same on v2, with Claude Code running
the registered PreToolUse hooks and then `canUseTool`.
- The next prompt's tool, asked while the window still looks open: its
request goes out once the consumer reaches it.
- A tool use the stream never shows: the wait ends when the request is
withdrawn (before it asked, or while it waits), or after the bound, and
then the request goes out.
- **Mutation checks:** removing each of the 11 rules fails at least one
test. That covers the `canUseTool` check, the hook check, the wait,
waiting instead of denying, the stream record, the message record, the
stream's dropped flag, the abort exit, the aborted-signal guard, the
timeout, and the synchronous answer.

**Live, against Claude Code** (v1 and v2, `default` permission mode,
every update checked against the schema):

- After a cancel, the next prompt's Writes asked for their permissions
at once and ran. Nothing was denied, and no tool call stayed open; on v2
the turn went `idle cancelled`, `running`, `requires_action`, `running`,
`idle end_turn`. No schema violations.
- The abandoned prompt's race did not happen live: Claude Code folded
the queued prompt into the cancelled request, and the cancel stopped
both. #1313 could not reproduce it either. The tests follow the order a
direct CLI probe recorded for #1309.

`npm run check`, build and the full suite (2931 tests) pass, with no
golden changes.

## Not covered

- On CLIs without `request_marker_lists_v1`, the window still lasts
until the next prompt's echo, as before.
- A tool that runs without a permission, in `acceptEdits` or
`bypassPermissions` mode, still runs for an abandoned prompt. Nothing of
it reaches the client, since its output is dropped. Stopping such tools
would need the adapter to block tools it is not asked about, which this
PR does not do.
This commit is contained in:
Ben Brandt
2026-10-14 11:42:35 +02:00
committed by GitHub
parent df21b3fb56
commit ebf8b49fd6
3 changed files with 529 additions and 15 deletions

View File

@@ -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<string>;
/** 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<string, boolean>;
/** What waits for the consumer to reach each tool use (see
* `isStoppedWorkToolUse`). */
toolUseWaiters?: Map<string, Set<() => 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<boolean> {
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<boolean> {
await new Promise<void>((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<void> {
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<string, unknown>;
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);

View File

@@ -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<any>) => AsyncGenerator<any>) {
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<string, unknown>) {
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<string, unknown>) => [
...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<void>();
let asked: Promise<unknown> | undefined;
let previewed: Promise<unknown> | 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<void>();
const asked = Promise.withResolvers<void>();
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<void>();
const asking = Promise.withResolvers<void>();
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<void>();
const { agent, texts } = setup((input) =>

View File

@@ -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<void>();
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<void>();
sdk.interrupt = async () => interrupted.resolve();