fix: close the tool calls of a background subagent that the SDK reports stopped after a cancel (#1336)

## Problem

After `session/cancel`, the tool call of a **background** subagent's
command stays `pending` for good when no turn is left to settle.

Example, live (Claude Code 2.1.296, Sonnet): a background subagent runs
`python3 -c "import time; time.sleep(60)"`, and the client cancels while
it runs. Either the session is idle, or v1 holds the prompt open for the
subagent and the cancel settles it at once. Claude Code's interrupt
stops the subagent: the SDK reports `task_updated` `killed` and
`task_notification` `stopped`, and the RFD form reports the child `idle`
`cancelled`. The command's call is never closed, in the root session or
in the child's.

## Cause

#1327 closes a cancelled turn's open tool calls when the turn settles
(`closeCancelledToolCalls`), and leaves a background subagent's calls
open, because the interrupt may not stop it. Here the turn is already
settled (the cancel settled the held turn before the interrupt) or there
is none, and the stop comes after that. Nothing closes the calls then.

Claude Code does send the command's interrupted result. It comes after
the stop reports, so it is output after the work's end, and the
`session.cancelled` check of the user/assistant case drops it, as it
drops everything after that. The raw stream, in the order Claude Code
sends it:

```
task_updated      agent patch killed
task_notification agent stopped
task_notification the command's task stopped
user (ptu=agent)  tool_result for the command, is_error     <- dropped
```

## Change

- **A subagent's stop closes its open tool calls**
(`closeStoppedSubagentToolCalls`), at `task_notification` `stopped` and
at `task_updated` `killed`, after `finishTask` has reported the child
idle. It closes the calls whose recorded parent is that subagent's Agent
call (`Session.toolCallParents`) with the cancelled report that #1327
sends, naming the subagent so that every client form routes it like the
call's other updates.
- **Only where nothing else will end the calls**: a cancel is in effect
(`session.cancelled`), and the client was told that the work ended
(`workEndReported`: the prompt response on v1, `idle` on v2). While the
work runs, a stopped subagent's calls get Claude Code's own result or
`closeCancelledToolCalls` at the work's end, as before.
- The loop of `closeCancelledToolCalls` is now `closeOpenToolCalls`,
which takes the filter for the calls: the turn's closing, named
`closedAtCancel`, skips a running background subagent's calls, and this
closing takes one subagent's. These hunks are the same as in #1339,
which closes the calls of a cycle that Claude Code started, so that the
PRs merge in any order.
- README: one sentence on when such a call ends.

Nothing is closed twice: a closing forgets the call before it reports
it, so a later closing, and the result that Claude Code sends after the
stop, find nothing. The cancel settles a held turn and closes its calls
before it interrupts Claude Code, so the stop reports always come after
that closing.

## Per client form

- **v1 without subagents:** the call goes to the root session, after the
response when the cancel settled a held turn.
- **RFD:** the call ends in the child's session right after the child's
`idle` `cancelled`, as #1327 does at a turn's settle.
- **AIR:** the cancel already ended every child, so the runtime drops
the report (`ignoring late update for terminal subagent`). The call's
state is released, which it was not before.
- **v2:** as RFD. The root reports nothing, since it had no work.

## Evidence

**Tests** (10 new):

- `incomplete-tools.test.ts`: for a held turn and for no turn, the
command ends once with the cancelled report, after the cancel's
response, and the other subagent's command stays open; each of the two
reports ends it alone; a subagent that completed after the cancel is
left alone; one that stopped without a cancel, and one that stopped
while the work still runs, get Claude Code's result, not a made-up
report.
- `acp-agent.test.ts`: the RFD form (the child's session, after its
idle, after the response) and AIR (nothing reaches the ended child).
- `acp-v2.test.ts`: the child's command ends `cancelled` in its session
after its `idle cancelled`, and the root reports nothing more.

**Removal checks.** Each of these fails at least one test: no closing at
the notification (2), none at the patch (1), closing without a cancel
(1), before the work's end is reported (2), at any status of the
notification (1), of the patch (1), every subagent's calls (4), calls of
another parent (7), the turn's closing no longer skipping a running
background subagent (7), the report without the subagent's name (7), and
the closing before the child reports idle (2).

**Live**, 60 s command in a background subagent, cancel 4 s after its
permission request was answered, every update validated against the
SDK's JSON schema (0 violations):

| client | cancel while | `main` | this PR |
| --- | --- | --- | --- |
| v1, no capabilities | the held turn waits for the subagent |
`pending`, response `cancelled` | `failed` 4 ms after the response |
| v1, no capabilities | no turn runs | `pending` | `failed` after the
SDK's first stop report |
| v1, RFD | no turn runs | `pending` in the child's session | `failed`
in the child's session, after its `idle` `cancelled` |
| v1, RFD | the held turn | not run on `main` | `failed` in the child's
session, after the response |
| v1, AIR | the held turn | not closed | report dropped by the runtime,
state released |
| v2 | root idle | `pending` in the child's session | `cancelled` in the
child's session, after its `idle cancelled` |

`npm run check`, `npm run build` and `npm run test:run` (2934 tests)
pass. No golden file changes. With #1335 and #1339 merged into it, in
any of the six orders, the branch merges without conflicts and all 2948
tests pass.

## For the maintainer

- **Closing, not delivering.** Claude Code 2.1.296 sends the result, so
delivering it after the work's end would also end the call, with Claude
Code's text. I closed instead: 2.1.293 sent none for a subagent's call
(#1327), the result comes after the response in the window #1313 closes,
and closing works for both. The cost is the made-up text ("The turn was
cancelled before this tool call returned a result.") in place of Claude
Code's.

## Not covered

- A background subagent that the interrupt does not stop keeps running,
and its calls stay open. Its output after a cancel is dropped until the
next prompt, as before.
- A subagent that completes after a cancel: Claude Code's results for
its calls were dropped, and the calls stay open. Nothing says they were
cancelled, so none is reported. Delivering those results would end them.
- A subagent that a SendMessage resumed: its calls carry the id of the
call that launched it, while the registry holds the SendMessage id.
`runsInBackground` has the same limit.
- The calls of a cycle that Claude Code started on its own are #1339.
This commit is contained in:
Ben Brandt
2026-10-14 11:45:36 +02:00
committed by GitHub
parent 764f339c3e
commit 9a1b90fb9d
5 changed files with 520 additions and 18 deletions

View File

@@ -55,7 +55,9 @@ Task subagent as its own child session, following the [subagents RFD](https://gi
withdrawn. The adapter ends no child at a cancel itself, so a child that keeps running keeps its
open requests and its open tool calls. A tool call of a stopped child that Claude Code reports no
result for, such as the command it was running, ends in the child's session before the cancelled
prompt resolves. A child that still runs when Claude Code exits is `idle` with no stop reason.
prompt resolves. When Claude Code stops a background child after that, or while no prompt runs,
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
exits is `idle` with no stop reason.
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
guessing its parent.
- A replay restores each child under its live session id, from its own transcript: its prompt and

View File

@@ -4900,6 +4900,35 @@ export class ClaudeAcpAgent {
task.parentToolUseId === agentToolUseId,
);
/** Ends the open tool calls that `include` accepts, with the report of a call
* that a cancel stopped and sent no result for. `include` gets the Agent/Task
* call of the subagent that made the tool call, if a subagent did. */
const closeOpenToolCalls = async (
include: (parentToolUseId: string | undefined) => boolean,
): Promise<void> => {
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
for (const toolCallId of [...session.emittedToolCalls]) {
const parentToolUseId = session.toolCallParents?.get(toolCallId);
if (!include(parentToolUseId)) continue;
const report = renderer.unfinished({
toolCallId,
ran: session.dispatchedToolCalls?.has(toolCallId) === true,
cancelled: true,
});
// Named as the call's other updates are: the subagent runtime routes the
// report with them, to the child session, or to none that has ended.
if (parentToolUseId !== undefined) stampParentToolUseId(report, parentToolUseId);
await closeToolCall(toolCallId, report);
}
};
/** Whether the closing at the end of the work a cancel stopped takes a tool
* call that the subagent started by the Agent call `parentToolUseId` made
* (undefined for a call of the main conversation): not one of a running
* background subagent, which the cancel may not have stopped. */
const closedAtCancel = (parentToolUseId: string | undefined): boolean =>
parentToolUseId === undefined || !runsInBackground(parentToolUseId);
/** Closes the tool calls that the work a cancel stopped left open, as that
* work ends. Claude Code's interrupt ends each running tool of the main
* conversation with an error result, which `sendInterruptedToolResults`
@@ -4912,28 +4941,33 @@ export class ClaudeAcpAgent {
* A foreground subagent ends with the work that waits on it. A background
* subagent keeps its tool calls open until the SDK reports its end, which
* Claude Code 2.1.293 does for the ones it stops, before the turn's result
* (see `NativeSubagentRuntime.parentCancelled`).
* (see `NativeSubagentRuntime.parentCancelled`). When the SDK reports it
* only after the work ended, `closeStoppedSubagentToolCalls` closes them.
* Runs once per turn: a second call, from the other settling path, waits
* for the first (see `Turn.closingCancelledToolCalls`). */
const closeCancelledToolCalls = (turn: Turn): Promise<void> =>
(turn.closingCancelledToolCalls ??= (async () => {
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
for (const toolCallId of [...session.emittedToolCalls]) {
const parentToolUseId = session.toolCallParents?.get(toolCallId);
if (parentToolUseId !== undefined && runsInBackground(parentToolUseId)) continue;
const report = renderer.unfinished({
toolCallId,
ran: session.dispatchedToolCalls?.has(toolCallId) === true,
cancelled: true,
});
// Named as the call's other updates are: the subagent runtime routes the
// report with them, to the child session, or to none that has ended.
if (parentToolUseId !== undefined) stampParentToolUseId(report, parentToolUseId);
await closeToolCall(toolCallId, report);
}
})());
(turn.closingCancelledToolCalls ??= closeOpenToolCalls(closedAtCancel));
session.closeCancelledToolCalls = closeCancelledToolCalls;
/** Closes the tool calls of the subagent that the Agent call `agentToolUseId`
* started, once the SDK reports the subagent stopped, if a cancel stopped it
* after the work that waited on it ended. Nothing else ends them then.
* Example: the cancel settles a turn that v1 holds for a background
* subagent, so `closeCancelledToolCalls` leaves the subagent's command
* open, since the subagent runs. Claude Code's interrupt then stops the
* subagent. Claude Code 2.1.296 sends the command's interrupted result
* after that, but the result is dropped like all output after the work's
* end (see the `session.cancelled` check of the user/assistant case).
* While the work still runs, the calls of a stopped subagent get Claude
* Code's result, or `closeCancelledToolCalls` at the work's end. */
const closeStoppedSubagentToolCalls = async (
agentToolUseId: string | null | undefined,
): Promise<void> => {
if (!agentToolUseId || !session.cancelled) return;
if (!this.workEndReported(params.sessionId, session)) return;
await closeOpenToolCalls((parentToolUseId) => parentToolUseId === agentToolUseId);
};
/** Delivers the results that the work a cancel stopped sends for tool calls
* that the client holds open. Claude Code's interrupt ends each running tool
* of the main conversation with an error result, before the result that
@@ -6068,6 +6102,9 @@ export class ClaudeAcpAgent {
droppedStreams.delete(message.tool_use_id);
}
await endBackgroundTerminal(message.task_id);
if (message.status === "stopped") {
await closeStoppedSubagentToolCalls(message.tool_use_id);
}
settleLiveTask(message.task_id);
break;
case "task_updated":
@@ -6110,6 +6147,9 @@ export class ClaudeAcpAgent {
droppedStreams.delete(parentToolUseId);
}
await endBackgroundTerminal(message.task_id);
if (message.patch.status === "killed") {
await closeStoppedSubagentToolCalls(parentToolUseId);
}
settleLiveTask(message.task_id);
} else if (
message.patch.status === "running" ||

View File

@@ -7384,6 +7384,124 @@ describe("subagent permission attribution (issue #851)", () => {
}
});
it("closes a background child's tool call in its session when the SDK reports the stop after the cancelled response", async () => {
const log: string[] = [];
const updates: AcpSessionNotification[] = [];
const logError = vi.fn();
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: AcpSessionNotification) => {
updates.push(update);
if (update.update.sessionUpdate === "subagent_update") {
log.push(`${update.update.sessionId} ${JSON.stringify(update.update.state)}`);
}
if (update.update.sessionUpdate === "tool_call_update" && update.update.status) {
log.push(
`tool @${update.sessionId} ${update.update.toolCallId} ${update.update.status}`,
);
}
},
} as unknown as AcpClient,
{ log: () => {}, error: logError },
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities,
});
const responded = Promise.withResolvers<void>();
injectGeneratorSession(agent, (input) =>
(async function* () {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield rootToolUse("toolu_agent", "Agent", {
description: "Run a slow command",
subagent_type: "general-purpose",
prompt: "Run it",
run_in_background: true,
});
yield {
...taskStarted("agent-42", "toolu_agent"),
subagent_type: "general-purpose",
is_backgrounded: true,
};
// Claude Code answers a background Agent call when the subagent launches.
yield {
type: "user",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_agent",
content: "Async agent launched",
},
],
},
};
yield {
...rootToolUse("toolu_child", "Bash", { command: "make" }),
parent_tool_use_id: "toolu_agent",
};
// The turn's result comes while the subagent runs, so v1 holds the turn
// for it. The cancel settles the held turn, which leaves the subagent's
// command open, since the subagent runs.
yield successResult();
await agent.cancel({ sessionId: "test-session" });
await responded.promise;
// Claude Code's interrupt stops the subagent only now, and sends the
// command's interrupted result after that.
yield taskUpdated({ status: "killed" });
yield taskNotification("toolu_agent", "stopped", { summary: "stopped" });
yield {
type: "user",
parent_tool_use_id: "toolu_agent",
uuid: randomUUID(),
session_id: "test-session",
tool_result_meta: [{ id: "toolu_child", non_execution_kind: "user-rejected" }],
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_child",
is_error: true,
content: "The user doesn't want to proceed with this tool use.",
},
],
},
};
})(),
);
await expect(
agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] })
.then((response) => {
log.push(`prompt ${response.stopReason}`);
responded.resolve();
return response;
}),
).resolves.toMatchObject({ stopReason: "cancelled" });
await agent.sessions["test-session"]?.consumer;
// The command ends in the child's session, after the child reported idle.
expect(log).toEqual([
'agent-42 {"state":"running"}',
"tool @test-session toolu_agent completed",
"prompt cancelled",
'agent-42 {"state":"idle","stopReason":"cancelled"}',
"tool @agent-42 toolu_child failed",
]);
expect(agent.sessions["test-session"]?.emittedToolCalls.size).toBe(0);
expect(logError).not.toHaveBeenCalled();
for (const payload of updates) {
expect(validateRecorded({ kind: "sessionUpdate", payload }, new Set())).toEqual([]);
}
});
it("adds the SDK's error to a failure that a notification reported first", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
@@ -7524,6 +7642,74 @@ describe("subagent permission attribution (issue #851)", () => {
).toBe(false);
});
it("releases a background child's tool call without a report when the SDK reports the stop after the cancel (AIR)", async () => {
const updates: AcpSessionNotification[] = [];
const logs: string[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: AcpSessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: (message: string) => logs.push(message), error: () => {} },
);
await agent.initialize({ protocolVersion: 1, clientCapabilities: AIR_NATIVE_SUBAGENTS });
const responded = Promise.withResolvers<void>();
injectGeneratorSession(agent, (input) =>
(async function* () {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield rootToolUse("toolu_agent", "Agent", { description: "Explore", prompt: "Look" });
yield {
...taskStarted("agent-42", "toolu_agent"),
subagent_type: "Explore",
is_backgrounded: true,
};
yield {
...rootToolUse("toolu_child", "Bash", { command: "make" }),
parent_tool_use_id: "toolu_agent",
};
yield successResult();
await agent.cancel({ sessionId: "test-session" });
await responded.promise;
yield {
type: "system",
subtype: "task_notification",
task_id: "agent-42",
tool_use_id: "toolu_agent",
status: "stopped",
output_file: "",
summary: "stopped",
uuid: randomUUID(),
session_id: "test-session",
};
})(),
);
await agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] })
.finally(() => responded.resolve());
await agent.sessions["test-session"]?.consumer;
// AIR's draft ended the child at the cancel, so the closing report for its
// command is dropped with every later update of the child.
const childEnded = updates.findIndex(
({ update }) =>
update.sessionUpdate === "subagent_state_update" &&
update.subagentSessionId === "agent-42" &&
update.state === "cancelled",
);
expect(childEnded).toBeGreaterThanOrEqual(0);
expect(
updates.slice(childEnded + 1).filter(({ sessionId }) => sessionId === "agent-42"),
).toEqual([]);
expect(logs).toContain(
"Session test-session: ignoring late update for terminal subagent agent-42",
);
// The call is not kept open here, though.
expect(agent.sessions["test-session"]?.emittedToolCalls.size).toBe(0);
});
it("buffers child output until spawn and drops duplicates and late updates", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(

View File

@@ -1722,6 +1722,107 @@ describe("ACP v2 prompts", () => {
expect(updates[at]).toMatchObject({ update: { status: "completed" } });
expect(at).toBeLessThan(idleAt(root, updates));
});
it("ends the tool call of a background subagent as cancelled when the SDK reports it stopped while the root is idle", async () => {
const interrupted = Promise.withResolvers<void>();
sdk.interrupt = async () => interrupted.resolve();
const subagentTask = (
options: Options,
subtype: string,
fields: Record<string, unknown>,
) => ({
type: "system",
subtype,
task_id: "agent-1",
tool_use_id: "toolu_agent",
uuid: randomUUID(),
session_id: options.sessionId,
...fields,
});
scriptTurns(async function* (options) {
yield toolUseMessage(options, "toolu_agent", "Agent", {
description: "Run a slow command",
subagent_type: "general-purpose",
prompt: "Run it",
run_in_background: true,
});
yield subagentTask(options, "task_started", {
description: "Run a slow command",
subagent_type: "general-purpose",
prompt: "Run it",
is_backgrounded: true,
});
// Claude Code answers a background Agent call when the subagent launches.
yield toolResultMessage(options, "toolu_agent", {});
yield toolUseMessage(options, "toolu_child", "Bash", { command: "make" }, "toolu_agent");
yield result(options);
yield idleState(options);
await interrupted.promise;
// The interrupt stops the subagent while nothing else runs. Claude Code
// sends the interrupted result of its command after that.
yield subagentTask(options, "task_updated", { patch: { status: "killed" } });
yield subagentTask(options, "task_notification", {
status: "stopped",
output_file: "",
summary: "stopped",
});
yield {
...toolResultMessage(options, "toolu_child", { is_error: true }, "user-rejected"),
parent_tool_use_id: "toolu_agent",
};
yield idleState(options);
});
const client = v2Client();
let root = "";
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
({ sessionId: root } = await agent.request(v2.methods.agent.session.new, { cwd }));
await agent.request(v2.methods.agent.session.prompt, {
sessionId: root,
prompt: text("go"),
});
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
await agent.notify(v2.methods.agent.session.cancel, { sessionId: root });
await vi.waitFor(() =>
expect(
client
.updates("tool_call_update")
.filter((update) => update.toolCallId === "toolu_child")
.map((update) => update.status),
).toContain("cancelled"),
);
await client.authUpdate(1);
});
const updates = client.sessionUpdates as unknown as Seen[];
// The child reported idle first, and then its command ended in its session.
const childIdle = updates.findIndex(
({ sessionId, update }) =>
sessionId === "agent-1" &&
update.sessionUpdate === "state_update" &&
update.state === "idle" &&
update.stopReason === "cancelled",
);
expect(childIdle).toBeGreaterThanOrEqual(0);
expect(updates[lastStatusAt(updates, "toolu_child")]).toMatchObject({
sessionId: "agent-1",
update: { status: "cancelled" },
});
expect(lastStatusAt(updates, "toolu_child")).toBeGreaterThan(childIdle);
// Claude Code's result for the command was dropped, so only one report ended it.
expect(
updates.filter(
({ update }) => update.toolCallId === "toolu_child" && update.status === "cancelled",
),
).toHaveLength(1);
// The cancel found no work to end: the root reported nothing after its idle.
expect(turnTrace(updates.filter(({ sessionId }) => sessionId === root) as never)).toEqual([
"user_message",
"running",
"idle end_turn",
]);
});
});
it("reports a tool call that Claude dropped before it ran as cancelled", async () => {

View File

@@ -774,6 +774,114 @@ describe("tool calls of a cancelled turn", () => {
});
});
describe("tool calls of a background subagent that a cancel stops", () => {
/** The reports that end the tool call `id`. */
const ends = (updates: any[], id: string) =>
updates.filter(
(u) => u.toolCallId === id && ["completed", "failed", "cancelled"].includes(u.status),
);
const textOf = (update: any): string =>
(update.content ?? []).map((item: any) => item.content?.text).join("");
/**
* Runs the fixture until two background subagents each run a command, and
* the cancel (if any) came. `settlement` is what the cancel finds: a turn
* that v1 holds open for the subagents, which the cancel settles at once, or
* no turn, because it ended earlier. Then the SDK reports how the first
* subagent ended (`reports`). Returns the updates that its reports caused.
*/
async function endFirstSubagent(
settlement: "held turn" | "no turn",
options: { cancel?: boolean; reports?: SubagentEndReports } = {},
) {
const { cancel = true, reports = "both" } = options;
const reported = Promise.withResolvers<void>();
const session = createTestSession((input) =>
backgroundSubagentsEndMessages(input, settlement, reported.promise, reports),
);
const { agent, updates } = session;
const first = session.prompt();
await vi.waitFor(() => {
const state = agent.sessions[sessionId];
expect(state.emittedToolCalls.has("bg-child-2")).toBe(true);
if (settlement === "held turn") expect(state.activeTurn?.deferredSettle).toBeDefined();
});
if (cancel) await agent.cancel({ sessionId });
if (settlement === "held turn" && cancel) {
await expect(first).resolves.toMatchObject({ stopReason: "cancelled" });
}
const before = updates.length;
reported.resolve();
await agent.sessions[sessionId].consumer;
return { ...session, before, afterReports: updates.slice(before) };
}
it.each([
["held turn", "both"],
["no turn", "both"],
// Each report alone ends the subagent, and the SDK can send either.
["no turn", "update only"],
["no turn", "notification only"],
] as const)(
"closes the command of a subagent that the SDK reports stopped after the cancel (%s, %s)",
async (settlement, reports) => {
const { updates, agent, logError, before, afterReports } = await endFirstSubagent(
settlement,
{ reports },
);
// The cancel left the command open, since its subagent ran. The SDK then
// stopped the subagent, and Claude Code's result for the command came after
// the work's end, which is dropped like all output after it.
expect(ends(updates.slice(0, before), "bg-child-1")).toEqual([]);
const [end, ...more] = ends(afterReports, "bg-child-1");
expect(more).toEqual([]);
expect(end).toMatchObject({
status: "failed",
_meta: { claudeCode: { parentToolUseId: "bg-agent-1" } },
});
expect(textOf(end)).toBe("The turn was cancelled before this tool call returned a result.");
expect(logError).not.toHaveBeenCalled();
// The other subagent runs on, and so does its command.
expect(ends(updates, "bg-child-2")).toEqual([]);
expect([...agent.sessions[sessionId].emittedToolCalls]).toEqual(["bg-child-2"]);
expect([...(agent.sessions[sessionId].toolCallParents ?? [])]).toEqual([
["bg-child-2", "bg-agent-2"],
]);
},
);
it("leaves the command of a subagent that completed after the cancel as it is", async () => {
const { afterReports } = await endFirstSubagent("no turn", { reports: "completed" });
// The subagent was not stopped, so nothing says that its command was cancelled.
expect(ends(afterReports, "bg-child-1")).toEqual([]);
});
it("leaves a subagent that stopped without a cancel to Claude Code's result", async () => {
const { updates, logError } = await endFirstSubagent("no turn", { cancel: false });
// For example, the model stops the subagent. Its command gets Claude Code's
// own result, and no report is made up for it first.
const [end, ...more] = ends(updates, "bg-child-1");
expect(more).toEqual([]);
expect(textOf(end)).toContain("doesn't want to proceed with this tool use");
expect(logError).not.toHaveBeenCalled();
});
it("leaves the command of a subagent that stopped before the work's end to Claude Code's result", async () => {
const { prompt, updates, logError } = createTestSession(stoppedMidTurnSubagentMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "cancelled" });
// The turn still runs, so the result of the command reaches the client.
const [end, ...more] = ends(updates, "bg-child-1");
expect(more).toEqual([]);
expect(textOf(end)).toContain("doesn't want to proceed with this tool use");
expect(logError).not.toHaveBeenCalled();
});
});
function toolStart(id = toolCallId, parent: string | null = null, name = "Bash") {
return {
type: "stream_event",
@@ -1174,6 +1282,71 @@ async function* backgroundSubagentMessages(input: Pushable<any>, agent: ClaudeAc
yield abortedResult();
}
/** The reports with which the SDK ends a subagent: a patch and a notification, or one of them. */
type SubagentEndReports = "both" | "update only" | "notification only" | "completed";
/**
* Two background subagents each run a command. With a "held turn", the turn's
* result came while they run, so v1 holds the turn for them; with "no turn",
* it ended before they started. After `reported`, the SDK reports how the first
* subagent ended, and Claude Code's result for its command.
*/
async function* backgroundSubagentsEndMessages(
input: Pushable<any>,
settlement: "held turn" | "no turn",
reported: Promise<void>,
reports: SubagentEndReports,
) {
yield* echoNextPrompt(input);
if (settlement === "no turn") yield successfulResultMessage();
for (const n of [1, 2]) {
yield toolStart(`bg-agent-${n}`, null, "Agent");
yield toolUse(`bg-agent-${n}`, null, "Agent", { description: "Work", prompt: "Work" });
yield taskStarted(`agent-bg-${n}`, `bg-agent-${n}`, "general-purpose", true);
yield toolResult(`bg-agent-${n}`);
yield toolStart(`bg-child-${n}`, `bg-agent-${n}`);
yield toolUse(`bg-child-${n}`, `bg-agent-${n}`);
}
if (settlement === "held turn") yield successfulResultMessage();
await reported;
const done = { type: "system", session_id: sessionId, task_id: "agent-bg-1" };
if (reports === "completed") {
yield { ...done, subtype: "task_updated", patch: { status: "completed" } };
yield {
...done,
subtype: "task_notification",
tool_use_id: "bg-agent-1",
status: "completed",
summary: "done",
output_file: "",
};
} else {
if (reports !== "notification only") {
yield { ...done, subtype: "task_updated", patch: { status: "killed" } };
}
if (reports !== "update only") yield taskStopped("agent-bg-1", "bg-agent-1");
// Claude Code's result for the command comes after the stop.
yield interruptedResult("bg-child-1", "bg-agent-1");
}
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
/** A background subagent stops while the cancelled turn still runs, and Claude Code sends the result of its command. */
async function* stoppedMidTurnSubagentMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
yield* echoNextPrompt(input);
yield toolStart("bg-agent-1", null, "Agent");
yield toolUse("bg-agent-1", null, "Agent", { description: "Work", prompt: "Work" });
yield taskStarted("agent-bg-1", "bg-agent-1", "general-purpose", true);
yield toolResult("bg-agent-1");
yield toolStart("bg-child-1", "bg-agent-1");
yield toolUse("bg-child-1", "bg-agent-1");
await agent.cancel({ sessionId });
yield taskStopped("agent-bg-1", "bg-agent-1");
yield interruptedResult("bg-child-1", "bg-agent-1");
yield interruptNote;
yield abortedResult();
}
/** A turn that the SDK never answers after the cancel, with a tool that ran and one that did not. */
async function* wedgedToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);