mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user