mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 03:41:17 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user