fix: report no running for the session's own request that a cancel withdraws, on ACP v2 (#1346)

## Problem

On ACP v2 the adapter reports a session's foreground work as
`state_update`s: `running`, then `requires_action` while a permission
request or question that blocks the work is open, then `running` again,
and finally `idle` with a stop reason. A request that ends because
`session/cancel` or `session/close` stopped the work is not an answer,
but it still reported `running`. #1337 fixed that for a subagent's child
session and listed the session's own request under "Not covered". That
request did not change, and since #1338 neither did its AskUserQuestion,
which now ends at the abort too.

On `main`, with Claude Code 2.1.296, v2, a Bash permission request that
the client holds open, and then a cancel or a close:

| What stops the work | States the session reported |
| --- | --- |
| `session/cancel` | `running`, `requires_action`, **`running`**, `idle
cancelled` |
| `session/close` | `running`, `requires_action`, **`running`**, `idle
cancelled`, all before the close response |

Claude Code's interrupt withdraws the request, so the SDK aborts the
signal of the `canUseTool` call, the request's `finally` calls
`syncAwaitingUser`, and with no blocking request left it reports
`running`. The result that ends the work comes 2 to 9 ms later in these
runs, so the client shows work that is being stopped as working.

## Cause

`syncAwaitingUser` reported `running` whenever the last blocking request
ended, whatever ended it. A request ends when the user answers it or
when Claude Code withdraws it, and a withdrawal alone does not say
whether the work was stopped. A `PermissionRequest` hook that answers
also makes Claude Code withdraw the request, and the work goes on (9 s
more in a live run), as #1337 found. So "a withdrawn request reports no
`running`" is wrong. The rule that fits is #1337's: no `running` when
the request was withdrawn after a cancel that came while it was open.

## Change

- `cancelTurns` (called for `session/cancel`, and by the teardown of
`session/close`) marks each request that is open, in
`Session.openUserInputs`.
- `withPendingUserInput` takes the request's `signal`. When the request
ends with its signal aborted and the mark set, `syncAwaitingUser` stops
waiting but reports no `running`. The four callers pass their signal:
permission requests, AskUserQuestion, MCP elicitation, and the refusal
fallback prompt. (#1337 counts the parent's cancels and compares the
count when the request ends. Here each open request is already an entry
of `openUserInputs`, so the cancel marks it. Same rule.)
- Everything else is as before:
  - an answered request reports `running`, also after the cancel;
  - a withdrawal without a cancel reports `running`;
- with two requests open, only the last one to end reports, by how that
one ended;
- a request that opens after the cancel is judged on its own, so a hook
that withdraws it still reports `running`.
- A foreground subagent's request blocks the turn, so it behaves like
the turn's own. A background subagent's request does not block it
(`blocksForeground`), so the root's states do not change for it. #1337's
child states are unchanged.
- `ForegroundEvents.running` and `v2Foreground` say when `running` is
not reported.

v1 reports no session state, so nothing changes there. AIR is unchanged:
the scenario golden files do not change.

## Evidence

Live runs against Claude Code 2.1.296 (Sonnet), driving `dist/index.js`
over stdio as a v2 client (`CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`) that
holds the request and ignores `$/cancel_request`, with the session's cwd
in default permission mode. Every update and request validated against
the SDK's v2 JSON schema: 0 violations in all runs. The root's states:

| Run | `main` | This PR |
| --- | --- | --- |
| `session/cancel`, Bash permission request | `running`,
`requires_action`, `running`, `idle cancelled` | `running`,
`requires_action`, `idle cancelled` |
| `session/cancel`, AskUserQuestion | `running`, `requires_action`,
`running`, `idle cancelled` | `running`, `requires_action`, `idle
cancelled` |
| `session/close` | `running`, `requires_action`, `running`, `idle
cancelled` | `running`, `requires_action`, `idle cancelled` |
| `session/cancel`, request of a foreground subagent (#1337's run) |
root as above; child `running`, `requires_action`, `idle cancelled` |
root `running`, `requires_action`, `idle cancelled`; child the same |
| `PermissionRequest` hook allows after 3 s, no cancel | `running`,
`requires_action`, `running`, `idle end_turn` | the same |
| the client answers after 2 s | `running`, `requires_action`,
`running`, `idle end_turn` | the same |
| `kill -9` of Claude Code, no cancel | `running`, `requires_action`,
`running`, `idle error` | the same (see below) |

The hook run is why a withdrawal alone cannot mean "stopped": Claude
Code withdrew the request 3 s in, and the command then ran 9 s more. A
v1 run of the cancel (no state updates there) is identical on both
builds.

Tests, in `acp-v2.test.ts`, through the v2 client app, which validates
every update:

- For each of the four kinds of request (permission request,
AskUserQuestion, an MCP server's elicitation, the refusal fallback
prompt): a `session/cancel` withdraws it, a `session/close` withdraws it
(the harness gets a `close` hook, as the SDK's cleanup of a closed query
aborts open requests), and Claude Code withdraws it without a cancel.
- The user answers after the cancel; a request that opens after the
cancel and is then withdrawn; two requests open together (both withdrawn
by the cancel, and one withdrawn and the other answered, in each order);
a request of a cycle that Claude Code started.
- Subagents: a foreground subagent's request also holds the root (an
assertion added to #1337's test); a background subagent's request
changes nothing of the root's states, alone and next to the turn's own
request.

13 of them fail on `main` (12 new tests and the extended one); the other
8 new tests pass there and guard what must not change.

Removal checks, each run against `acp-v2.test.ts`:

| Removed or changed | Tests that fail |
| --- | --- |
| no rule: the end always reports `running` | 13 |
| the cancel marks no request | 13 |
| a mark is enough, the signal is not required | 2 (answered after the
cancel) |
| the simple rule "any withdrawal means stopped" | 5 (the hook cases,
and the request after the cancel) |
| `session.cancelled` in place of the mark of a cancel that came while
the request was open | 1 (the request after the cancel) |
| sticky: after one request was withdrawn by a cancel, no later request
reports `running` | 1 (answers the one the cancel did not withdraw) |
| permission requests do not pass their signal | 6 |
| AskUserQuestion does not pass its signal | 3 |
| MCP elicitation does not pass its signal | 2 |
| the refusal fallback prompt does not pass its signal | 2 |

`npm run check`, `npm run build` and `npm run test:run` pass (2999 tests
passed, 30 skipped; the scenario golden files do not change).

## Not covered

- **A stream end without a cancel.** If Claude Code exits by itself, the
SDK's cleanup aborts every request before the consumer reads the end of
the stream, and nothing at that moment tells it from a hook's
withdrawal. Live (`kill -9` of the CLI, table above): `running` 2 ms
before the `idle`, and the session is dead right after. Left as it is,
for the reasons #1337 gives for children.
- The rule assumes, like #1337's, that the work reports its end right
after such a withdrawal. A turn always does (the force-cancel backstop
ends a wedged one); a cycle with no active turn has no such backstop.
- Open PRs #1344 and #1345 also change `src/acp-agent.ts`; this branch
merges cleanly with each (checked with `git merge-tree`).
This commit is contained in:
Ben Brandt
2026-10-14 13:22:12 +02:00
committed by GitHub
parent 594aa8c955
commit d528647162
4 changed files with 524 additions and 21 deletions

View File

@@ -881,6 +881,16 @@ type PendingReplay = {
stopped: boolean;
};
/** A permission or elicitation request that awaits user input. */
type OpenUserInput = {
/** The subagent that asked: absent for the main conversation and for a
* request that names no agent. */
agentID?: string;
/** Set by a cancel that comes while the request is open (see
* `withPendingUserInput`). */
cancelled?: boolean;
};
export type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
@@ -1087,13 +1097,11 @@ export type Session = {
/** Whether nested subagent text/thinking is forwarded to the ACP client.
* Enabled by either the ACP capability or the pre-existing SDK option. */
forwardSubagentText: boolean;
/** The ACP permission and elicitation requests that await user input, each
* with the subagent that asked (`agentID`, absent for the main
* conversation and for a request that names no agent). Parallel subagents
* can ask concurrently. Steering stays non-interrupting while any request
* is open; the foreground work awaits the user while one that blocks it is
* open (see {@link blocksForeground}). */
openUserInputs?: Set<{ agentID?: string }>;
/** The ACP permission and elicitation requests that await user input.
* Parallel subagents can ask concurrently. Steering stays non-interrupting
* while any request is open; the foreground work awaits the user while one
* that blocks it is open (see {@link blocksForeground}). */
openUserInputs?: Set<OpenUserInput>;
/** Context window size of the session's current model, carried across
* prompts so mid-stream usage_update notifications report a correct `size`
* before the turn's first result message arrives. Seeded synchronously at
@@ -8018,6 +8026,9 @@ export class ClaudeAcpAgent {
return;
}
session.cancelled = true;
// The interrupt below makes Claude Code withdraw the requests that are open
// now (see `withPendingUserInput`).
for (const input of session.openUserInputs ?? []) input.cancelled = true;
// Before anything awaits, so a model request that the consumer reads in
// the meantime is already judged against these prompts.
session.cancelledPrompts ??= new Set();
@@ -9110,22 +9121,33 @@ export class ClaudeAcpAgent {
* conversation) as awaiting user input for exactly the lifetime of its
* promise (see `Session.openUserInputs`). Steering consults the open
* requests synchronously, so a message arriving while any permission or
* elicitation card is open uses non-interrupting SDK delivery. */
* elicitation card is open uses non-interrupting SDK delivery.
*
* `signal` aborts when Claude Code withdraws the request, which is not an
* answer. Example: the user cancels while a permission request is open.
* Claude Code's interrupt withdraws it as it stops the work, and the work
* ends `idle cancelled` right after. The end of the request then reports
* no `running`: the client would see `requires_action`, `running`, `idle
* cancelled`. A withdrawal without a cancel means that something else
* answered the request in the user's place, such as a `PermissionRequest`
* hook, and the work goes on, so the end reports `running`. */
private async withPendingUserInput<T>(
sessionId: string,
signal: AbortSignal,
request: () => Promise<T>,
agentID?: string,
): Promise<T> {
const session = this.sessions[sessionId];
if (!session) return request();
const input = agentID !== undefined ? { agentID } : {};
const input: OpenUserInput = agentID !== undefined ? { agentID } : {};
(session.openUserInputs ??= new Set()).add(input);
this.syncAwaitingUser(sessionId, session);
try {
return await request();
} finally {
session.openUserInputs.delete(input);
this.syncAwaitingUser(sessionId, session);
const withdrawnByCancel = signal.aborted && input.cancelled === true;
this.syncAwaitingUser(sessionId, session, withdrawnByCancel);
}
}
@@ -9135,8 +9157,13 @@ export class ClaudeAcpAgent {
* and Claude Code can ask for a queued prompt before the consumer activates
* it, so a request is not tied to the work that was running when it
* opened. Called when a request opens or closes, when a turn activates, and
* when a task registers or moves to the background. */
private syncAwaitingUser(sessionId: string, session: Session): void {
* when a task registers or moves to the background.
*
* When the last request ends, the work reports `running` again, unless a
* cancel withdrew that request (`withdrawnByCancel`, see
* {@link withPendingUserInput}): the work is being stopped, and reports its
* end next. */
private syncAwaitingUser(sessionId: string, session: Session, withdrawnByCancel = false): void {
const work = this.foregroundWork.get(sessionId);
if (!work) return;
const waiting = [...(session.openUserInputs ?? [])].some((input) =>
@@ -9145,7 +9172,7 @@ export class ClaudeAcpAgent {
if (waiting === work.awaitingUser) return;
work.awaitingUser = waiting;
if (waiting) work.events.awaitingUser();
else work.events.running();
else if (!withdrawnByCancel) work.events.running();
}
/** Make `owner` the foreground work of the session `sessionId` (see
@@ -9334,6 +9361,7 @@ export class ClaudeAcpAgent {
return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () =>
this.withPendingUserInput(
ownerSessionId,
signal,
() => raceWithAbort(this.client.requestPermission(params, signal), signal),
agentID,
),
@@ -9788,7 +9816,7 @@ export class ClaudeAcpAgent {
}
try {
const response = await this.withPendingUserInput(sessionId, () =>
const response = await this.withPendingUserInput(sessionId, signal, () =>
this.elicitFromClient(createRequest, signal),
);
if (signal.aborted) {
@@ -9838,6 +9866,7 @@ export class ClaudeAcpAgent {
response = await this.withChildAwaitingUser(sessionId, requestSessionId, signal, () =>
this.withPendingUserInput(
sessionId,
signal,
() => this.elicitFromClient(createRequest, signal),
agentID,
),
@@ -9884,7 +9913,7 @@ export class ClaudeAcpAgent {
}
let response: CreateElicitationResponse;
try {
response = await this.withPendingUserInput(sessionId, () =>
response = await this.withPendingUserInput(sessionId, signal, () =>
this.elicitFromClient(refusalFallbackToCreateRequest(prompt, sessionId), signal),
);
} catch (error) {

View File

@@ -48,6 +48,8 @@ const sdk = vi.hoisted(() => ({
): AsyncGenerator<Record<string, unknown>> {},
/** Claude Code receives an interrupt, as `cancel()` sends one. */
interrupt: async (): Promise<void> => {},
/** The query is closed, as `session/close` closes it. */
close: (): void => {},
mcpServerStatus: async (): Promise<Array<{ name: string; status: string }>> => [],
mcpAuthenticate: async (
_serverName: string,
@@ -87,6 +89,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
mcpServerStatus: () => sdk.mcpServerStatus(),
mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName),
interrupt: () => sdk.interrupt(),
close: () => sdk.close(),
});
return Object.assign(messages, controls);
},
@@ -3531,10 +3534,10 @@ describe("ACP v2 requests that subagents make", () => {
});
/** Asks for permission for a Bash call of the subagent `agentID`, and
* resolves once the client answers. */
const askFor = (options: Options, agentID: string) =>
* resolves once the client answers, or Claude Code aborts `signal`. */
const askFor = (options: Options, agentID: string, signal = new AbortController().signal) =>
options.canUseTool!("Bash", { command: "ls" }, {
signal: new AbortController().signal,
signal,
suggestions: [],
toolUseID: `toolu_ls_${agentID}`,
agentID,
@@ -3841,7 +3844,7 @@ describe("ACP v2 requests that subagents make", () => {
]);
});
it("reports no running before idle cancelled for a child's request that a cancel withdraws", async () => {
it("reports no running before idle cancelled for a child's request that a cancel withdraws, and for the turn that waits on it", async () => {
// Claude Code's interrupt withdraws the request, which the client holds and
// would not settle, then it stops the child.
const withdrawal = new AbortController();
@@ -3895,6 +3898,13 @@ describe("ACP v2 requests that subagents make", () => {
expect(
turnTrace(client.sessionUpdates.filter(({ sessionId }) => sessionId === "agent-1")),
).toEqual(["running", "requires_action", "idle cancelled"]);
// The turn waits on the foreground child, so its request holds the root too.
expect(turnTrace(client.sessionUpdates.filter(({ sessionId }) => sessionId === root))).toEqual([
"user_message",
"running",
"requires_action",
"idle cancelled",
]);
expect(
client.sessionUpdates.flatMap(({ sessionId, update }) => {
if (sessionId !== root || update.sessionUpdate !== "subagent_update" || !update.state) {
@@ -3905,6 +3915,465 @@ describe("ACP v2 requests that subagents make", () => {
}),
).toEqual(["running", "requires_action", "idle cancelled"]);
});
it("reports nothing for a background subagent's request that a cancel withdraws", async () => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
const client = v2Client({
answerPermission: () =>
new Promise<never>(() => {}) as unknown as v2.RequestPermissionResponse,
});
scriptTurns(async function* (options) {
yield taskStarted(options, "agent-1", { is_backgrounded: true });
yield assistantText(options, "Exploring.");
await askFor(options, "agent-1", withdrawal.signal);
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") });
await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(1));
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle cancelled"));
await client.authUpdate(1);
});
// The request does not hold the turn, so the cancel changes nothing of the
// turn's states but its end.
expect(turnTrace(client.sessionUpdates)).toEqual([
"user_message",
"running",
"agent_message_chunk",
"idle cancelled",
]);
});
it("reports no running for a turn's request that a cancel withdraws along with a background subagent's", async () => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
const client = v2Client({
answerPermission: () =>
new Promise<never>(() => {}) as unknown as v2.RequestPermissionResponse,
});
scriptTurns(async function* (options) {
yield taskStarted(options, "agent-1", { is_backgrounded: true });
yield assistantText(options, "Exploring.");
await Promise.all([
// The turn's own request, and the subagent's.
options.canUseTool!("Bash", { command: "make" }, {
signal: withdrawal.signal,
suggestions: [],
toolUseID: "toolu_make",
requestId: randomUUID(),
} as Parameters<NonNullable<Options["canUseTool"]>>[2]).catch(() => undefined),
askFor(options, "agent-1", withdrawal.signal),
]);
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") });
await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(2));
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle cancelled"));
await client.authUpdate(1);
});
expect(turnTrace(client.sessionUpdates)).toEqual([
"user_message",
"running",
"agent_message_chunk",
"requires_action",
"idle cancelled",
]);
});
});
// Claude Code's interrupt withdraws each request that is open: the SDK aborts
// the signal of the request's callback. A client that holds the request does
// not settle it, so the withdrawal alone ends it. The work is being stopped
// then and reports idle next, so it reports no running before that. A
// withdrawal without a cancel is something else answering the request, such as
// a `PermissionRequest` hook, and the work goes on.
describe("ACP v2 requests that a cancel withdraws", () => {
/** What Claude Code asks of the user, with the signal that it aborts to withdraw the request. */
type Ask = (options: Options, signal: AbortSignal) => Promise<unknown>;
const askPermission =
(toolUseID = "toolu_ls"): Ask =>
(options, signal) =>
options.canUseTool!("Bash", { command: "ls" }, {
signal,
suggestions: [],
toolUseID,
requestId: randomUUID(),
} as Parameters<NonNullable<Options["canUseTool"]>>[2]);
const askQuestion: Ask = (options, signal) =>
options.canUseTool!(
"AskUserQuestion",
{
questions: [
{
question: "Which package?",
header: "Package",
options: [
{ label: "core", description: "The core package" },
{ label: "cli", description: "The CLI" },
],
multiSelect: false,
},
],
},
{
signal,
suggestions: [],
toolUseID: "toolu_question",
requestId: randomUUID(),
} as Parameters<NonNullable<Options["canUseTool"]>>[2],
);
const askElicitation: Ask = (options, signal) =>
options.onElicitation!(
{
serverName: "linear",
message: "Which team?",
mode: "form",
requestedSchema: { type: "object", properties: { team: { type: "string" } } },
},
{ signal } as Parameters<NonNullable<Options["onElicitation"]>>[1],
);
const askRefusalFallback: Ask = (options, signal) =>
options.onUserDialog!(
{
dialogKind: "refusal_fallback_prompt",
payload: { originalModel: "claude-fable-5", fallbackModel: "claude-opus-4-8" },
},
{ signal } as Parameters<NonNullable<Options["onUserDialog"]>>[1],
);
/** Every kind of request that holds the work, with the tool use it is for, if any. */
const ASKS: Array<[string, Ask, string | undefined]> = [
["a permission request", askPermission(), "toolu_ls"],
["a question", askQuestion, "toolu_question"],
["an MCP server's elicitation", askElicitation, undefined],
["the refusal fallback's consent prompt", askRefusalFallback, undefined],
];
const hold = () => new Promise<never>(() => {});
/** A client that holds each request it gets, as one does that ignores `$/cancel_request`. */
const holdingClient = () =>
v2Client({
answerPermission: hold as unknown as () => v2.RequestPermissionResponse,
answerElicitation: hold,
});
const allowOnce: v2.RequestPermissionResponse = {
outcome: { outcome: "selected", optionId: "allow-once" },
};
const toolCallOf = (request: v2.RequestPermissionRequest) =>
(request.subject as { toolCall?: { toolCallId?: string } }).toolCall?.toolCallId;
/** Resolves once the turn waits for the user. */
const awaitsUser = (client: ReturnType<typeof v2Client>) =>
vi.waitFor(() => expect(turnTrace(client.sessionUpdates).at(-1)).toBe("requires_action"));
/**
* Sends the prompt "go", runs `during`, and returns what the client saw of
* the turn once it is idle.
*/
async function runTurn(
client: ReturnType<typeof v2Client>,
during: (agent: v2.ClientContext, sessionId: string) => Promise<unknown> = async () => {},
): Promise<string[]> {
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent, { elicitation: { form: {} } });
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") });
await during(agent, sessionId);
await vi.waitFor(() =>
expect(turnTrace(client.sessionUpdates).some((state) => state.startsWith("idle"))).toBe(
true,
),
);
await client.authUpdate(1);
});
return turnTrace(client.sessionUpdates);
}
it.each(ASKS)(
"reports no running before idle cancelled for %s that a cancel withdraws",
async (_, ask) => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
scriptTurns(async function* (options) {
await ask(options, withdrawal.signal).catch(() => undefined);
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const client = holdingClient();
const trace = await runTurn(client, async (agent, sessionId) => {
await awaitsUser(client);
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(trace).toEqual(["user_message", "running", "requires_action", "idle cancelled"]);
},
);
it.each(ASKS)(
"reports no running before idle cancelled for %s that a session/close withdraws",
async (_, ask) => {
const withdrawal = new AbortController();
// The SDK's cleanup of a closed query aborts every request that is open.
sdk.close = () => withdrawal.abort();
const closed = Promise.withResolvers<void>();
scriptTurns(async function* (options) {
await ask(options, withdrawal.signal).catch(() => undefined);
// Claude Code sends nothing more once the session is closed.
await closed.promise;
yield idleState(options);
});
const client = holdingClient();
let atClose: string[] = [];
await runTurn(client, async (agent, sessionId) => {
await awaitsUser(client);
await agent.request(v2.methods.agent.session.close, { sessionId });
atClose = turnTrace(client.sessionUpdates);
});
closed.resolve();
expect(atClose).toEqual(["user_message", "running", "requires_action", "idle cancelled"]);
},
);
it.each(ASKS)(
"reports running when Claude Code withdraws %s without a cancel, since the work goes on",
async (_, ask, toolUseId) => {
const withdrawal = new AbortController();
const client = holdingClient();
scriptTurns(async function* (options) {
const asking = ask(options, withdrawal.signal).catch(() => undefined);
await awaitsUser(client);
// A hook answers the request in the user's place.
withdrawal.abort();
await asking;
if (toolUseId) yield toolResultMessage(options, toolUseId, {});
yield assistantText(options, "Done.");
yield result(options);
yield idleState(options);
});
expect(await runTurn(client)).toEqual([
"user_message",
"running",
"requires_action",
"running",
"agent_message_chunk",
"idle end_turn",
]);
},
);
it("reports running when the user answers the request after the cancel", async () => {
const interrupted = Promise.withResolvers<void>();
sdk.interrupt = async () => interrupted.resolve();
const answer = Promise.withResolvers<v2.RequestPermissionResponse>();
const client = v2Client({
answerPermission: () => answer.promise as unknown as v2.RequestPermissionResponse,
});
scriptTurns(async function* (options) {
// Claude Code does not withdraw the request.
const asking = askPermission()(options, new AbortController().signal);
await interrupted.promise;
answer.resolve(allowOnce);
await asking;
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const trace = await runTurn(client, async (agent, sessionId) => {
await awaitsUser(client);
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(trace).toEqual([
"user_message",
"running",
"requires_action",
"running",
"idle cancelled",
]);
});
it("reports running when Claude Code withdraws a request that opened after the cancel", async () => {
// The cancel came before the request opened, so it did not stop that
// request's work: a prompt that Claude Code took up after the cancel can ask
// before the agent has seen its echo.
const interrupted = Promise.withResolvers<void>();
sdk.interrupt = async () => interrupted.resolve();
const withdrawal = new AbortController();
const client = holdingClient();
scriptTurns(async function* (options) {
await interrupted.promise;
yield toolUseMessage(options, "toolu_ls", "Bash", { command: "ls" });
const asking = askPermission()(options, withdrawal.signal).catch(() => undefined);
await awaitsUser(client);
withdrawal.abort();
await asking;
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const trace = await runTurn(client, async (agent, sessionId) => {
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(trace).toEqual([
"user_message",
"running",
"requires_action",
"running",
"idle cancelled",
]);
});
describe("two requests that are open together", () => {
// The work reports once, when the last of them ends, by how that one ended.
const wait = (client: ReturnType<typeof v2Client>) =>
vi.waitFor(() => expect(client.permissionRequests).toHaveLength(2));
it("reports no running when a cancel withdraws both", async () => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
const client = holdingClient();
scriptTurns(async function* (options) {
await Promise.all(
[askPermission("toolu_a"), askPermission("toolu_b")].map((ask) =>
ask(options, withdrawal.signal).catch(() => undefined),
),
);
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const trace = await runTurn(client, async (agent, sessionId) => {
await wait(client);
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(trace).toEqual(["user_message", "running", "requires_action", "idle cancelled"]);
});
it("reports running when the user answers the one that the cancel did not withdraw", async () => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
const answer = Promise.withResolvers<v2.RequestPermissionResponse>();
const client = v2Client({
answerPermission: (request) =>
(toolCallOf(request) === "toolu_b"
? answer.promise
: hold()) as unknown as v2.RequestPermissionResponse,
});
scriptTurns(async function* (options) {
const a = askPermission("toolu_a")(options, withdrawal.signal).catch(() => undefined);
const b = askPermission("toolu_b")(options, new AbortController().signal);
// The cancel withdraws the first, which ends while the second is open.
await a;
answer.resolve(allowOnce);
await b;
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const trace = await runTurn(client, async (agent, sessionId) => {
await wait(client);
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(trace).toEqual([
"user_message",
"running",
"requires_action",
"running",
"idle cancelled",
]);
});
it("reports no running when a cancel withdraws the one that ends last, after the user answered the other", async () => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
const answer = Promise.withResolvers<v2.RequestPermissionResponse>();
const client = v2Client({
answerPermission: (request) =>
(toolCallOf(request) === "toolu_b"
? answer.promise
: hold()) as unknown as v2.RequestPermissionResponse,
});
scriptTurns(async function* (options) {
const a = askPermission("toolu_a")(options, withdrawal.signal).catch(() => undefined);
const b = askPermission("toolu_b")(options, new AbortController().signal);
await b;
await a;
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const trace = await runTurn(client, async (agent, sessionId) => {
await wait(client);
// The first is still open, so the answer changes nothing.
answer.resolve(allowOnce);
await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.openUserInputs?.size).toBe(1));
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(trace).toEqual(["user_message", "running", "requires_action", "idle cancelled"]);
});
});
it("reports no running before idle cancelled for a request of a cycle that Claude Code started, which a cancel withdraws", async () => {
const withdrawal = new AbortController();
sdk.interrupt = async () => withdrawal.abort();
const client = holdingClient();
scriptTurns(async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
// The build finished, and Claude Code wakes the model to report it.
yield assistantText(options, "The build failed.", "msg_followup");
await askQuestion(options, withdrawal.signal).catch(() => undefined);
yield result(options, {
origin: { kind: "task-notification" },
...ABORTED_TOOLS,
});
yield idleState(options);
});
const trace = await runTurn(client, async (agent, sessionId) => {
await vi.waitFor(() =>
expect(turnTrace(client.sessionUpdates)).toEqual([
"user_message",
"running",
"agent_message_chunk",
"idle end_turn",
"running",
"agent_message_chunk",
"requires_action",
]),
);
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle cancelled"));
});
expect(trace.slice(4)).toEqual([
"running",
"agent_message_chunk",
"requires_action",
"idle cancelled",
]);
});
});
// A Bash call that sends its command to the background returns at once, while

View File

@@ -54,7 +54,11 @@ export interface TurnEvents {
* the cycle reports nothing more, and the turn reports from there.
*/
export interface ForegroundEvents {
/** The work started, or works again after it waited for the user. */
/**
* The work started, or works again after it waited for the user. Not after
* a wait that a cancel ended: Claude Code's interrupt withdrew the last
* request, so the work is being stopped, and {@link ended} follows at once.
*/
running(): void;
/**
* The work waits for the user: a permission request or a question is open in

View File

@@ -73,7 +73,8 @@ export function v2Prompt(
*
* - Started: `running`. For a prompt's turn, right after its `user_message`.
* - Waiting on a permission request or a question: `requires_action`, and
* `running` once none is open.
* `running` once none is open, unless a cancel withdrew the last one: the
* work is being stopped then, and its `idle` follows.
* - Ended: `idle` with the stop reason, usage, and `_meta` of the work.
* - Failed: `idle` with the `error` stop reason and the JSON-RPC error that v1
* answers the prompt with, so a client can show its message and handle its