fix: report v2 background tasks without a tool call as their own tool call (#1556)

## Summary

Background tasks that have no tool call or terminal on v2 now get a tool
call of their own. Before, they showed up as a `_claude/task` vendor
item. This follows the spec rule in
agentclientprotocol/agent-client-protocol#2786.

Examples: a workflow, or an MCP task that starts after its tool call
already returned, or a task a hook started.

- `BackgroundWork.adoptTasks` starts a tool call `task_<taskId>` for
each such task. It uses the task description as title, `kind: "other"`,
and `in_progress` or `paused`. A subagent's task goes on the subagent's
stream. The message loop sends these calls before `sync`, so a call is
always announced before the first `background` list names it. The same
path covers the `state_update` snapshot on `session/resume`.
- These calls are listed as `tool_call` items, so `tool_call/cancel`
stops the task. When the task ends they get a final status like any
other listed call (`completed`, `failed`, or `cancelled`).
- The `_claude/task` item type is gone. `BackgroundItem` is now
`terminal | tool_call | subagent`.
- Ambient tasks such as the monitor, and tasks whose own tool call is
still open, are unchanged.

`adoptTasks` is synchronous and returns the notifications, so the loop
only awaits when there is something to send. An extra await on every
pass broke the steering-cancel timing in `acp-agent.test.ts`.

## Compatibility

- ACP v1 clients, including JetBrains AIR's `_session/async_task/*`
extension, are unaffected. Background lists, and therefore this path,
only run when the v2 background sink exists and the session has
`perTaskStopAffordance`. On v1, `adoptTasks` returns `[]`.
- v2 clients that saw `_claude/task` items (v0.100.0 to v0.101.1) now
see `task_<id>` tool calls instead.

## Tests

`acp-v2.test.ts` replaces the `_claude/task` tests. New tests cover:

- starting and listing a call for a task that starts after its tool call
returned
- cancelling such a task with `tool_call/cancel`
- the call being sent before the `session/resume` snapshot
- a subagent's task appearing on the subagent's stream

`npm run check`, `build` and `test:run` pass.
This commit is contained in:
Ben Brandt
2026-11-09 20:40:39 +01:00
committed by GitHub
parent 766fd142f6
commit d098fb08d5
3 changed files with 251 additions and 41 deletions

View File

@@ -5917,7 +5917,9 @@ export class ClaudeAcpAgent {
const pause = messageLoopYielder.maybeYield();
if (pause) await pause;
// The last message may have changed the background work, and its
// updates are sent, so the list follows them.
// updates are sent, so the list follows them. A task that needs a
// tool call of its own gets it first, so the list can name it.
for (const call of session.background.adoptTasks(session)) await sendUpdate(call);
this.backgroundWork.sync(params.sessionId, session, this.foregroundState(params.sessionId));
pendingNext ??= session.query
.next()

View File

@@ -19,9 +19,10 @@
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
* from its tool result until its task ends (`Session.backgroundToolCalls`).
* - Any other task, once its `task_started` came and the tool call that
* started it, if any, returned: a `_claude/task` item with the task's id
* and description. For example, a task whose tool call ended before the
* task joined the level.
* started it, if any, returned: a tool call that the adapter starts for
* it (see `SessionBackgroundWork.adoptTasks`). For example, a task that a
* hook started, or one whose tool call ended before the task joined the
* level.
*
* Only a session whose interrupt spares background tasks reports its lists
* (`Session.reportsBackground`): `session/cancel` must leave listed work
@@ -40,8 +41,7 @@ import { textContent } from "./tool-calls/content.js";
export type BackgroundItem =
| { type: "terminal"; terminalId: string }
| { type: "tool_call"; toolCallId: string }
| { type: "subagent"; sessionId: string }
| { type: "_claude/task"; taskId: string; description: string };
| { type: "subagent"; sessionId: string };
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
export interface BackgroundTerminal {
@@ -58,8 +58,14 @@ export interface BackgroundTerminal {
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
export interface BackgroundToolCall {
toolCallId: string;
/** The tool use of the call, which Claude Code no longer caches after its result. */
toolUse: { name: string; input: unknown };
/**
* The tool use of the call, which Claude Code no longer caches after its
* result. Undefined for a call that the adapter started for a task (see
* `SessionBackgroundWork.adoptTasks`), which has `title` instead.
*/
toolUse?: { name: string; input: unknown };
/** The title of a call that the adapter started for a task: the task's description. */
title?: string;
/** The subagent tool call that made the call, if any. */
parentToolUseId?: string;
paused?: boolean;
@@ -378,6 +384,47 @@ export class SessionBackgroundWork {
return updates;
}
/**
* Starts a tool call for each live task that no terminal or tool call
* stands for yet, and returns the `tool_call`s to send: v2 lists background work only by an entity
* the client knows. Such a task is one that no tool call started, such as
* one a hook started, or one whose tool call returned, with its final
* status, before the task started. The call has the task's description as
* its title, is `in_progress`, and ends with the task (see `taskEnded`). A
* task that an announced subagent session runs gets its call on that
* session's stream. Ambient tasks are housekeeping and get no call.
*
* The agent sends them before each list it publishes, so the call comes
* before the first list that names it, as v2 requires.
*/
adoptTasks(owner: BackgroundOwner): AcpSessionNotification[] {
if (!this.reported) return [];
const started: AcpSessionNotification[] = [];
const children = new Map(
[...(owner.nativeSubagentsByTaskId ?? [])].filter(([, child]) => child.announced),
);
for (const [taskId, task] of this.level) {
if (task.ambient || this.toolCalls.has(taskId) || this.terminals.has(taskId)) continue;
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
// A task joins the level before its `task_started`, and before the tool
// call that starts it returns. Until both, the task is unknown or part
// of that tool call.
const live = owner.liveBackgroundTasks.get(taskId);
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
const parent = task.parentTaskId ? children.get(task.parentTaskId) : undefined;
const toolCall: BackgroundToolCall = {
toolCallId: adoptedToolCallId(taskId),
title: task.description,
...(parent?.parentToolUseId ? { parentToolUseId: parent.parentToolUseId } : {}),
};
this.toolCalls.set(taskId, toolCall);
started.push(adoptedToolCall(this.sessionId, toolCall));
}
return started;
}
/**
* The `background` list of the root session and of each of its announced
* subagent sessions, with what their repeated states need, deeper sessions
@@ -389,7 +436,6 @@ export class SessionBackgroundWork {
const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter(
([, child]) => child.announced,
);
const childByTask = new Map(children);
const childByToolUse = new Map(
children.flatMap(([, child]) =>
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
@@ -418,20 +464,10 @@ export class SessionBackgroundWork {
const session = ownerOf(terminal.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
continue;
}
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed below, as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
// A task joins the level before its `task_started`, and before the tool
// call that starts it returns. Until both, the task is unknown or part
// of that tool call.
const live = owner.liveBackgroundTasks.get(taskId);
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
lists
.get(parent?.sessionId ?? this.sessionId)!
.push({ type: "_claude/task", taskId, description: task.description });
// Any other task is listed by the tool call that `adoptTasks` starts
// for it, or is not background work. A subagent is listed below, as
// its session.
}
// A child is listed in its parent's list while it works in the background,
// and while it is idle with background work of its own. Deeper children
@@ -477,7 +513,9 @@ export class SessionBackgroundWork {
return [...this.toolCalls]
.filter(([, toolCall]) => toolCallIds.includes(toolCall.toolCallId))
.map(([taskId, toolCall]) => {
const update = renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd });
const update = toolCall.toolUse
? renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd })
: adoptedToolCall(this.sessionId, toolCall).update;
const status = toolCall.paused ? V2_PAUSED : "in_progress";
return {
sessionId: this.sessionId,
@@ -615,6 +653,31 @@ function toolCallUpdate(
};
}
/**
* The id of the tool call that the adapter starts for the task `taskId`.
* Claude Code's tool use ids start with `toolu_`, so it names no other call.
*/
function adoptedToolCallId(taskId: string): string {
return `task_${taskId}`;
}
/** The `tool_call` that the adapter starts for a task (see `SessionBackgroundWork.adoptTasks`). */
function adoptedToolCall(sessionId: string, toolCall: BackgroundToolCall): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: toolCall.toolCallId,
title: toolCall.title ?? "Background task",
kind: "other",
status: toolCall.paused ? V2_PAUSED : "in_progress",
...(toolCall.parentToolUseId
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
: {}),
} as AcpSessionNotification["update"],
};
}
/** The latest workflow activity, rendered as one replaceable tool-card body. */
function workflowProgress(progress: WorkflowCardProgress | undefined) {
if (!progress || Object.keys(progress).length === 0) return undefined;

View File

@@ -4524,6 +4524,56 @@ describe("ACP v2 requests that subagents make", () => {
]);
});
it("starts a subagent's task without a tool call on the subagent's stream, and lists it there", async () => {
const workflow = {
task_id: "wf1",
task_type: "local_workflow",
description: "Review",
parent_task_id: "agent-1",
};
scriptTurns(async function* (options) {
yield agentUse(options);
yield level(options, [agent1]);
yield taskStarted(options, "agent-1", { prompt: "Look around", is_backgrounded: true });
yield result(options);
yield idleState(options);
yield level(options, [agent1, workflow]);
yield {
type: "system",
subtype: "task_started",
...workflow,
uuid: randomUUID(),
session_id: options.sessionId,
};
});
const client = v2Client();
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("hi") });
await vi.waitFor(() =>
expect(
client.sessionUpdates
.filter(
({ sessionId, update }) =>
sessionId === "agent-1" && update.sessionUpdate === "state_update",
)
.at(-1)?.update,
).toMatchObject({ background: [{ type: "tool_call", toolCallId: "task_wf1" }] }),
);
});
expect(
client.sessionUpdates.filter(
({ update }) =>
update.sessionUpdate === "tool_call_update" && update.toolCallId === "task_wf1",
)[0],
).toMatchObject({
sessionId: "agent-1",
update: { title: "Review", kind: "other", status: "in_progress" },
});
});
it("keeps an idle subagent listed while its own command runs in the background", async () => {
const childMessage = (options: Options, message: Record<string, unknown>) => ({
...message,
@@ -6349,31 +6399,126 @@ describe("ACP v2 terminal of a command in the background", () => {
]);
});
it("lists a task whose call returned before the task started as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield toolUseMessage(options, "toolu_mcp", "mcp__ci__watch", { job: "build" });
yield toolResultMessage(options, "toolu_mcp", { content: "Watching the build." });
// The call's task joins the level and starts after the call returned,
// so the call ended and cannot be listed.
const task = { task_id: "mcp1", task_type: "mcp_task", description: "Watch the build" };
yield level(options, [task]);
yield taskEvent(options, "task_started", { ...task, tool_use_id: "toolu_mcp" });
yield result(options);
yield idleState(options);
});
const client = await promptUntil("idle end_turn [_claude/task]");
/** The adapter's own call for the task `mcp1`, which an MCP call started
* but which started only after that call returned with its final status. */
async function* lateTaskStarts(options: Options) {
yield toolUseMessage(options, "toolu_mcp", "mcp__ci__watch", { job: "build" });
yield toolResultMessage(options, "toolu_mcp", { content: "Watching the build." });
const task = { task_id: "mcp1", task_type: "mcp_task", description: "Watch the build" };
yield level(options, [task]);
yield taskEvent(options, "task_started", { ...task, tool_use_id: "toolu_mcp" });
yield result(options);
yield idleState(options);
}
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mcp1", description: "Watch the build" }],
it("starts a tool call for a task whose call returned before the task started, and lists it", async () => {
scriptTurns(lateTaskStarts);
const client = await promptUntil("idle end_turn [task_mcp1]");
const updates = client.sessionUpdates.map(({ update }) => update);
const started = updates.findIndex(
(update) =>
update.sessionUpdate === "tool_call_update" && update.toolCallId === "task_mcp1",
);
const listed = updates.findIndex(
(update) =>
update.sessionUpdate === "state_update" &&
JSON.stringify(update).includes('"task_mcp1"'),
);
expect(updates[started]).toMatchObject({
title: "Watch the build",
kind: "other",
status: "in_progress",
});
expect(started).toBeLessThan(listed);
// The call that started the task keeps its final status.
expect(
updates
.filter(
(update) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "toolu_mcp" &&
update.status !== undefined,
)
.map((update) => (update as { status?: unknown }).status)
.at(-1),
).toBe("completed");
});
it("stops such a task on tool_call/cancel, and reports its call cancelled", async () => {
const stopped = Promise.withResolvers<string>();
sdk.stopTask = async (taskId) => stopped.resolve(taskId);
scriptTurns(async function* (options) {
yield* lateTaskStarts(options);
await stopped.promise;
yield taskEvent(options, "task_notification", {
task_id: "mcp1",
tool_use_id: "toolu_mcp",
status: "stopped",
summary: "Stopped",
});
yield level(options, []);
});
const client = await promptUntil(
"idle end_turn [task_mcp1]",
async (agent, sessionId, client) => {
await expect(
agent.request(v2.methods.agent.toolCall.cancel, {
sessionId,
toolCallId: "task_mcp1",
}),
).resolves.toEqual({});
expect(await stopped.promise).toBe("mcp1");
await vi.waitFor(() => expect(backgroundTrace(client)).toContain("idle []"));
},
);
const statuses = client.sessionUpdates.flatMap(({ update }) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "task_mcp1" &&
update.status
? [String(update.status)]
: [],
);
expect(statuses).toEqual(["in_progress", "cancelled"]);
});
it("sends such a task's call before the state snapshot of session/resume", async () => {
scriptTurns(lateTaskStarts);
let resumed: v2.SessionUpdate[] = [];
await promptUntil("idle end_turn [task_mcp1]", async (agent, sessionId, client) => {
const before = client.sessionUpdates.length;
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
resumed = client.sessionUpdates.slice(before).map(({ update }) => update);
});
expect(resumed).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "task_mcp1",
title: "Watch the build",
kind: "other",
status: "in_progress",
}),
{
sessionUpdate: "tool_call_update",
toolCallId: "task_mcp1",
capabilities: { cancel: {} },
},
{
sessionUpdate: "state_update",
state: "idle",
background: [{ type: "tool_call", toolCallId: "task_mcp1" }],
},
]);
});
// No tool call started these, such as tasks that a hook started.
it.each([
{
task: workflow,
trace: ["running []", "running [_claude/task]", "idle end_turn [_claude/task]"],
background: [{ type: "_claude/task", taskId: "wf1", description: "Review" }],
trace: ["running []", "running [task_wf1]", "idle end_turn [task_wf1]"],
background: [{ type: "tool_call", toolCallId: "task_wf1" }],
},
// A watcher Claude Code started by itself is housekeeping.
{
@@ -6382,7 +6527,7 @@ describe("ACP v2 terminal of a command in the background", () => {
background: [],
},
])(
"lists a $task.task_type task without such a call unless it is ambient",
"starts and lists a tool call for a $task.task_type task without such a call unless it is ambient",
async ({ task, trace, background }) => {
scriptTurns(async function* (options) {
yield level(options, [task]);