Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Brandt
1e986b526d refactor: give each session one owner of its background work (#1517)
Follow-up to #1510, #1511 and #1512. Clients receive exactly what they
did before.

## Why

The v2 background work grew as about 14 free functions taking `(session,
sessionId, …)` and reaching into six `Session` fields. Four of them it
kept itself: the task set, terminals, tool calls, and
`reportsBackground`. Three belong to other modules:
`liveBackgroundTasks`, the subagent map, and `toolUseCache`. The repo
guidelines ask for one module that owns the state.

## What changed

- **One owner per session.** `SessionBackgroundWork`, stored on
`Session.background`, owns the task set, terminals, tool calls, output
tails, and whether the client gets the lists. It has one method per
event: a Bash result, tool results, a task's end or pause, a new task
set, and a closed query.
- **The reporter only filters.** The object builds each session's
report, so `BackgroundWorkReporter` only drops unchanged lists and
forwards the rest.
- **No reference back to the session.** A recreated session (sign-out
respawn, provider change) gets a new `Session` under the same id, so an
old consumer must not reach the new one. Callers pass in the state other
modules own.
- **No `toolUseCache` dependency.** Whether a task's launching call has
returned used to be judged by the call still being in `toolUseCache`,
which Claude Code prunes at the tool's result. The object now records
returned calls itself, capped at 1024 like the other per-session sets.
- **Stricter typing on the output path.** Typing the terminals map
properly exposed that the parsed `outputFilePath` was loosely typed, so
it is now checked to be a string.
- **Test doubles.** The hand-built `Session` doubles in
`acp-agent.test.ts` and `session-doubles.ts` now carry the new required
field.

## Tests

- The existing suite passes unchanged.
- One new v2 test covers a task whose `task_started` arrives after its
call returned, so it is listed as `_claude/task`. A first version of
this refactor recorded "returned" per task and silently missed that
case. The test fails against that version, and against listing tasks
before their call returns.

`npm run check`, `npm run build` and `npm run test:run` pass (3556
passed, 30 skipped).
2026-11-02 19:52:25 +01:00
5 changed files with 470 additions and 465 deletions

View File

@@ -165,23 +165,11 @@ import {
clientSupportsAsyncTasks,
} from "./async-tasks.js";
import {
backgroundLevel,
type BackgroundLevel,
type BackgroundSink,
type BackgroundTerminal,
backgroundToolCallPause,
backgroundToolCallStates,
type BackgroundTaskEnd,
type BackgroundToolCall,
BackgroundWorkReporter,
cancelBackgroundWork,
droppedTasks,
endBackgroundWork,
finishBackgroundTerminalOutput,
followBackgroundTerminalOutput,
registerBackgroundToolCalls,
takeBackgroundTerminalExit,
takeBackgroundToolCallEnd,
SessionBackgroundWork,
} from "./background-work.js";
import {
AUTH_STATUS_PROBE_TIMEOUT_MS,
@@ -1479,30 +1467,17 @@ export type Session = {
* to stop each one itself. A client that can cancel one subagent gets the
* declaration, but it has no control for a workflow unless it is AIR with
* `asyncTasks` (`_session/async_task/stop`), or gets the session's
* background work (`reportsBackground`, with `tool_call/cancel`), which
* background work (`background.reported`, with `tool_call/cancel`), which
* `session/cancel` must leave running. So for any other such client the
* adapter stops the workflows after the interrupt, as the interrupt did
* before. */
stopsWorkflowsOnCancel?: boolean;
/** The display terminals of the commands that went to the background and
* still run, by the id of the command's task: the Bash tool call whose
* terminal it is, and the subagent tool call that ran it, if any. The
* terminal exits when the task ends, not at the tool result (see
* `withRunningTerminal`). Only for a client that renders terminals and is
* not AIR, which shows the command through its async tasks. */
backgroundTerminals?: Map<string, BackgroundTerminal>;
/** The tool calls whose tasks went on in the background and still run, by
* task id: a Monitor, a Workflow, or a backgrounded MCP call. The tool call
* stays `in_progress` (or `paused`) after its result, until its task ends
* (see `withRunningToolCall`). Only while `reportsBackground`. */
backgroundToolCalls?: Map<string, BackgroundToolCall>;
/** Whether the client gets the session's background work (see
* `background-work.ts`). Only when Claude Code's interrupt spares
* background tasks, as `perTaskStopAffordance` makes it. */
reportsBackground?: boolean;
/** Claude Code's live background task set, from its last
* `background_tasks_changed`. Per CLI process, as is a session. */
backgroundLevel?: BackgroundLevel;
/** The tasks that Claude Code runs in the background for the session: the
* terminals of commands, kept for a client that renders terminals and is
* not AIR (AIR shows a command through its async tasks), and, when the
* client gets the session's background work, the tool calls of tasks and
* the `background` lists. Per CLI process, as is a session. */
background: SessionBackgroundWork;
/** The parent tool call of each subagent that a settle bookend removed from
* `liveBackgroundTasks`. The SDK can resume such a subagent under the same
* agent id without a new `task_started` (a running `task_updated` patch or
@@ -5420,17 +5395,10 @@ export class ClaudeAcpAgent {
this.syncAwaitingUser(params.sessionId, session);
};
/** Ends the display terminal of a command that went to the background, and
* gives a tool call whose task went on in the background its final
* status, once the task ends (see `Session.backgroundTerminals` and
* `Session.backgroundToolCalls`). */
const endBackgroundTask = async (taskId: string, end: BackgroundTaskEnd) => {
await finishBackgroundTerminalOutput(session, taskId);
const exit = takeBackgroundTerminalExit(session, params.sessionId, taskId);
if (exit) await sendUpdate(exit);
const toolCallEnd = takeBackgroundToolCallEnd(session, params.sessionId, taskId, end);
if (toolCallEnd) await sendUpdate(toolCallEnd);
};
/** Ends the terminal and the tool call of a task that ran in the
* background, once the task ends (see `Session.background`). */
const endBackgroundTask = (taskId: string, end: BackgroundTaskEnd) =>
session.background.taskEnded(taskId, end, sendUpdate);
/** Settles a task. Its request may still be open, and an agent without a
* record no longer blocks the foreground work, so that is checked again. */
@@ -6935,23 +6903,13 @@ export class ClaudeAcpAgent {
}
settleLiveTask(message.task_id);
} else if (message.patch.status === "paused") {
const pause = backgroundToolCallPause(
session,
params.sessionId,
message.task_id,
true,
);
const pause = session.background.taskPaused(message.task_id, true);
if (pause) await sendUpdate(pause);
} else if (
message.patch.status === "running" ||
message.patch.status === "pending"
) {
const resume = backgroundToolCallPause(
session,
params.sessionId,
message.task_id,
false,
);
const resume = session.background.taskPaused(message.task_id, false);
if (resume) await sendUpdate(resume);
// The SDK can resume a finished subagent under the same
// agent id without a new task_started.
@@ -7122,13 +7080,7 @@ export class ClaudeAcpAgent {
break;
case "background_tasks_changed": {
await asyncTasks.backgroundTasksChanged(message.tasks);
if (session.reportsBackground) {
const level = backgroundLevel(message.tasks);
for (const taskId of droppedTasks(session, level)) {
await endBackgroundTask(taskId, undefined);
}
session.backgroundLevel = level;
}
await session.background.levelChanged(message.tasks, sendUpdate);
// A level signal: the full live background-task set on every
// membership change, with REPLACE semantics. Used only to
// reconcile `liveBackgroundTasks` — dropping (or, for
@@ -8558,18 +8510,18 @@ export class ClaudeAcpAgent {
session.liveBackgroundTasks.has(backgroundBashTask.taskId)
) {
runningTerminal = terminalId;
(session.backgroundTerminals ??= new Map()).set(backgroundBashTask.taskId, {
session.background.terminalStarted(backgroundBashTask.taskId, {
toolCallId: terminalId,
...(message.parent_tool_use_id
? { parentToolUseId: message.parent_tool_use_id }
: {}),
...(backgroundBashTask.outputFilePath
...(typeof backgroundBashTask.outputFilePath === "string"
? { outputFilePath: backgroundBashTask.outputFilePath }
: {}),
});
runningTerminalTask = backgroundBashTask.taskId;
}
runningToolCalls = registerBackgroundToolCalls(
runningToolCalls = session.background.toolResults(
session,
content,
toolUseResult,
@@ -8654,17 +8606,13 @@ export class ClaudeAcpAgent {
// After the result's own updates: the command's output replaces
// the placeholder that they gave its terminal.
if (runningTerminalTask) {
followBackgroundTerminalOutput(
session,
params.sessionId,
runningTerminalTask,
(notification) =>
sendUpdate(notification).catch((error) => {
this.logger.error(
`Session ${params.sessionId}: failed to send background command output:`,
error,
);
}),
session.background.followOutput(runningTerminalTask, (notification) =>
sendUpdate(notification).catch((error) => {
this.logger.error(
`Session ${params.sessionId}: failed to send background command output:`,
error,
);
}),
);
}
break;
@@ -9383,7 +9331,7 @@ export class ClaudeAcpAgent {
// Its list row now shows what the registry and the transcript tell.
this.sessionIndex.onOwnSessionChanged(sessionId);
// Its background tasks end with the query's process.
for (const exit of endBackgroundWork(session, sessionId)) {
for (const exit of session.background.queryClosed()) {
this.client.sessionUpdate(exit).catch((error) => {
this.logger.error(`Session ${sessionId}: failed to end a background terminal:`, error);
});
@@ -9494,7 +9442,7 @@ export class ClaudeAcpAgent {
/** Stops a command running in the background (v2 `terminal/cancel`, see
* {@link cancelBackgroundWork}). */
cancelBackgroundTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
return cancelBackgroundWork(this.sessions, params.sessionId, {
return cancelBackgroundWork(Object.values(this.sessions), params.sessionId, {
terminalId: params.terminalId,
});
}
@@ -9502,7 +9450,7 @@ export class ClaudeAcpAgent {
/** Stops the task of a tool call running in the background (v2
* `tool_call/cancel`, see {@link cancelBackgroundWork}). */
cancelBackgroundToolCall(params: { sessionId: string; toolCallId: string }): Promise<void> {
return cancelBackgroundWork(this.sessions, params.sessionId, {
return cancelBackgroundWork(Object.values(this.sessions), params.sessionId, {
toolCallId: params.toolCallId,
});
}
@@ -9517,7 +9465,7 @@ export class ClaudeAcpAgent {
if (!session) return;
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
try {
for (const update of backgroundToolCallStates(session, sessionId, toolCallIds, renderer)) {
for (const update of session.background.toolCallStates(toolCallIds, renderer, session.cwd)) {
await this.client.sessionUpdate(update);
}
} catch (error) {
@@ -12751,7 +12699,10 @@ export class ClaudeAcpAgent {
emittedToolCalls: new Set(),
eagerToolCallSessions: new Map(),
liveBackgroundTasks: new Map(),
reportsBackground: this.backgroundWork.enabled && options.perTaskStopAffordance === true,
background: new SessionBackgroundWork(
sessionId,
this.backgroundWork.enabled && options.perTaskStopAffordance === true,
),
stopsWorkflowsOnCancel:
options.perTaskStopAffordance === true &&
!this.backgroundWork.enabled &&

View File

@@ -91,227 +91,425 @@ export interface BackgroundSink {
forget(sessionId: string): void;
}
/** The state of a session that its lists are built from. */
export interface BackgroundWorkSession {
reportsBackground?: boolean;
backgroundLevel?: BackgroundLevel;
backgroundTerminals?: Map<string, BackgroundTerminal>;
backgroundToolCalls?: Map<string, BackgroundToolCall>;
/**
* The state of a session that other modules own, and that the background
* work reads: Claude Code's tasks as the consumer tracks them, the subagent
* sessions, and the query that stops a task.
*/
export interface BackgroundOwner {
liveBackgroundTasks: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
liveBackgroundTasks?: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
/** The tool uses whose result has not come yet, by id. */
toolUseCache?: Record<string, unknown>;
query: { stopTask(taskId: string): Promise<void> };
}
/** A task of a `background_tasks_changed` payload. */
export type LevelTask = {
task_id: string;
task_type: string;
description: string;
parent_task_id?: string;
ambient?: boolean;
};
/** The most returned tool calls that {@link SessionBackgroundWork} remembers. */
const MAX_RETURNED_CALLS = 1024;
/** The tools whose structured result names the task that the call started. */
const TASK_TOOLS = new Set(["Monitor", "Workflow"]);
/**
* Registers each tool call of a tool result message whose task goes on in
* the background: a `Monitor` or `Workflow` call, whose structured result
* names its task, or a call that a live task names as its tool use, such as
* a backgrounded MCP call. The task must be in the level: one that ended
* already, or that Claude Code marks `ambient`, leaves its call as it is.
* Returns the ids of the calls, whose result then reports `in_progress` (see
* `withRunningToolCall`).
* The background work of one root session, and of its subagent sessions: the
* terminals and tool calls of the tasks that run in the background, and what
* the client is told of them.
*
* Terminals are kept for every client that renders them, as before the
* background lists. The rest is kept only when `reported`.
*/
export function registerBackgroundToolCalls(
session: BackgroundWorkSession,
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
parentToolUseId: string | null,
): Set<string> {
const registered = new Set<string>();
if (!session.reportsBackground || !Array.isArray(content)) return registered;
const results = content.filter(
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
typeof block === "object" &&
block !== null &&
block.type === "tool_result" &&
typeof block.tool_use_id === "string" &&
block.is_error !== true,
);
const structuredTaskId =
results.length === 1 &&
typeof toolUseResult === "object" &&
toolUseResult !== null &&
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
? (toolUseResult as { taskId: string }).taskId
: undefined;
for (const { tool_use_id: toolCallId } of results) {
const toolUse = toolUses[toolCallId];
if (!toolUse) continue;
const taskId =
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
[...(session.liveBackgroundTasks ?? [])].find(
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
)?.[0];
const task = taskId === undefined ? undefined : session.backgroundLevel?.get(taskId);
if (
!task ||
task.ambient ||
task.taskType === "local_bash" ||
task.taskType === "local_agent"
) {
continue;
}
(session.backgroundToolCalls ??= new Map()).set(taskId!, {
toolCallId,
toolUse,
...(parentToolUseId ? { parentToolUseId } : {}),
});
registered.add(toolCallId);
export class SessionBackgroundWork {
/** Claude Code's live background task set, from its last `background_tasks_changed`. */
private level: BackgroundLevel = new Map();
/** The terminals of the commands that went to the background, by task id. */
private readonly terminals = new Map<string, BackgroundTerminal>();
/** The tool calls whose tasks went on in the background, by task id. */
private readonly toolCalls = new Map<string, BackgroundToolCall>();
/**
* The tool calls that returned, oldest first. A task that a call started
* is part of that call until it returns, and the foreground work waits on
* it. The task's `task_started` can come after the call returned. Bounded
* by {@link MAX_RETURNED_CALLS}: a task joins the level around the time its
* call returns.
*/
private readonly returned = new Set<string>();
/**
* `reported`: whether the client gets the lists. Only when Claude Code's
* interrupt spares background tasks, as `perTaskStopAffordance` makes it.
*/
constructor(
private readonly sessionId: string,
readonly reported: boolean,
) {}
/**
* Keeps the display terminal of the command `taskId` running after its Bash
* result, until its task ends (see `withRunningTerminal`).
*/
terminalStarted(taskId: string, terminal: BackgroundTerminal): void {
this.terminals.set(taskId, terminal);
}
return registered;
}
/** The level of a `background_tasks_changed` payload. */
export function backgroundLevel(
tasks: {
task_id: string;
task_type: string;
description: string;
parent_task_id?: string;
ambient?: boolean;
}[],
): BackgroundLevel {
return new Map(
tasks.map((task) => [
task.task_id,
{
ambient: task.ambient === true,
taskType: task.task_type,
description: task.description,
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
},
]),
);
}
/**
* Reads what a tool result message tells of the background tasks:
*
* - The returned calls no longer hold the tasks that they started.
* - Each call whose task goes on in the background is registered: a
* `Monitor` or `Workflow` call, whose structured result names its task,
* or a call that a live task names as its tool use, such as a
* backgrounded MCP call. The task must be in the level: one that ended
* already, or that Claude Code marks `ambient`, leaves its call as it is.
*
* Returns the ids of the registered calls, whose result then reports
* `in_progress` (see `withRunningToolCall`).
*/
toolResults(
owner: BackgroundOwner,
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
parentToolUseId: string | null,
): Set<string> {
const registered = new Set<string>();
if (!this.reported || !Array.isArray(content)) return registered;
const results = content.filter(
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
typeof block === "object" &&
block !== null &&
block.type === "tool_result" &&
typeof block.tool_use_id === "string",
);
for (const { tool_use_id: toolCallId } of results) {
this.returned.add(toolCallId);
if (this.returned.size > MAX_RETURNED_CALLS) {
this.returned.delete(this.returned.values().next().value!);
}
}
const succeeded = results.filter((block) => block.is_error !== true);
const structuredTaskId =
succeeded.length === 1 &&
typeof toolUseResult === "object" &&
toolUseResult !== null &&
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
? (toolUseResult as { taskId: string }).taskId
: undefined;
for (const { tool_use_id: toolCallId } of succeeded) {
const toolUse = toolUses[toolCallId];
if (!toolUse) continue;
const taskId =
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
[...owner.liveBackgroundTasks].find(
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
)?.[0];
const task = taskId === undefined ? undefined : this.level.get(taskId);
if (
!task ||
task.ambient ||
task.taskType === "local_bash" ||
task.taskType === "local_agent"
) {
continue;
}
this.toolCalls.set(taskId!, {
toolCallId,
toolUse,
...(parentToolUseId ? { parentToolUseId } : {}),
});
registered.add(toolCallId);
}
return registered;
}
/**
* The `background` list of the root session `rootSessionId` and of each of
* its announced subagent sessions, by session id, deeper sessions first and
* the root last. Each list is empty while
* the level has not been reported: Claude Code sends none before the first
* background task starts.
*/
export function backgroundLists(
session: BackgroundWorkSession,
rootSessionId: string,
): Map<string, BackgroundItem[]> {
const children = [...(session.nativeSubagentsByTaskId ?? [])].filter(
([, child]) => child.announced,
);
const childByTask = new Map(children);
const childByToolUse = new Map(
children.flatMap(([, child]) =>
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
),
);
const lists = new Map<string, BackgroundItem[]>([
[rootSessionId, []],
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
]);
const level: BackgroundLevel = session.backgroundLevel ?? new Map();
for (const [taskId, task] of level) {
if (task.ambient) continue;
/**
* Starts reading the output of the background command `taskId` into its
* terminal, when the client gets the lists and the Bash result named the
* command's output file. `send` sends an update of the terminal's tool
* call, routed as the call's other updates. Called once the Bash result's
* own updates are sent: the first output replaces the placeholder that they
* gave the terminal.
*/
followOutput(taskId: string, send: Send): void {
const terminal = this.terminals.get(taskId);
if (!this.reported || !terminal?.outputFilePath || terminal.output) return;
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
send(terminalOutputUpdate(this.sessionId, terminal, output)),
);
}
/**
* The task `taskId` ended. Reads the rest of its command's output, then
* exits its terminal, and gives a tool call whose task it was its final
* status: `cancelled` for a task that Claude Code stopped, and `completed`
* for one that ended without saying how, such as one that the level
* dropped: it no longer runs, and reported no failure. Claude Code reports
* no exit code for a command, and `completed` does not mean 0: Claude Code
* accepts some non-zero codes as a success, such as grep's 1.
*/
async taskEnded(taskId: string, end: BackgroundTaskEnd, send: Send): Promise<void> {
const terminal = this.terminals.get(taskId);
if (terminal) {
await terminal.output?.finish();
this.terminals.delete(taskId);
await send(terminalExit(this.sessionId, terminal));
}
const toolCall = this.toolCalls.get(taskId);
if (toolCall) {
this.toolCalls.delete(taskId);
await send(
toolCallStatusUpdate(
this.sessionId,
toolCall,
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
),
);
}
}
/**
* The update that reports the background tool call of the task `taskId` as
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
* pauses a workflow this way.
*/
taskPaused(taskId: string, paused: boolean): AcpSessionNotification | undefined {
const toolCall = this.toolCalls.get(taskId);
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
toolCall.paused = paused;
return toolCallStatusUpdate(this.sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
}
/**
* Takes Claude Code's new live background task set. The terminals and tool
* calls whose tasks it no longer holds end first, before a list drops them,
* as v2 requires. A terminal or tool call is registered at its tool result,
* after its task joined the level, so a later level without the task means
* that it ended. Claude Code usually sends a task's end before that level,
* which ends them first, but the SDK does not promise that order.
*/
async levelChanged(tasks: LevelTask[], send: Send): Promise<void> {
if (!this.reported) return;
const level: BackgroundLevel = new Map(
tasks.map((task) => [
task.task_id,
{
ambient: task.ambient === true,
taskType: task.task_type,
description: task.description,
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
},
]),
);
for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) {
if (!level.has(taskId)) await this.taskEnded(taskId, undefined, send);
}
this.level = level;
}
/**
* The query closed: Claude Code's background tasks end with its process.
* Stops reading output, and returns the updates that exit the root
* session's terminals and cancel its tool calls. The level empties, so the
* next lists are empty. A subagent's command or tool call is left as it
* was: it is on the child's stream, which the closed query's routing owned.
*/
queryClosed(): AcpSessionNotification[] {
for (const terminal of this.terminals.values()) terminal.output?.stop();
if (!this.reported) return [];
const updates: AcpSessionNotification[] = [];
for (const [taskId, terminal] of this.terminals) {
if (terminal.parentToolUseId) continue;
this.terminals.delete(taskId);
updates.push(terminalExit(this.sessionId, terminal));
}
for (const [taskId, toolCall] of this.toolCalls) {
if (toolCall.parentToolUseId) continue;
this.toolCalls.delete(taskId);
updates.push(toolCallStatusUpdate(this.sessionId, toolCall, V2_CANCELLED));
}
this.level = new Map();
this.returned.clear();
return updates;
}
/**
* The `background` list of the root session and of each of its announced
* subagent sessions, with what their repeated states need, deeper sessions
* first and the root last: a child's own list goes out before its parent's
* list drops it. Each list is empty while the level has not been reported:
* Claude Code sends none before the first background task starts.
*/
reports(owner: BackgroundOwner, rootState: WorkStateName): BackgroundReport[] {
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] : [],
),
);
const lists = new Map<string, BackgroundItem[]>([
[this.sessionId, []],
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
]);
// A subagent's command or tool call runs on the child's stream.
const ownerOf = (parentToolUseId: string | undefined) =>
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : rootSessionId;
const terminal = session.backgroundTerminals?.get(taskId);
if (terminal) {
const owner = ownerOf(terminal.parentToolUseId);
if (owner) lists.get(owner)!.push({ type: "terminal", terminalId: terminal.toolCallId });
continue;
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
for (const [taskId, task] of this.level) {
if (task.ambient) continue;
const terminal = this.terminals.get(taskId);
if (terminal) {
const session = ownerOf(terminal.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
continue;
}
const toolCall = this.toolCalls.get(taskId);
if (toolCall) {
const session = ownerOf(toolCall.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.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 });
}
const toolCall = session.backgroundToolCalls?.get(taskId);
if (toolCall) {
const owner = ownerOf(toolCall.parentToolUseId);
if (owner) lists.get(owner)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
continue;
// 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
// first, so a child's list is whole before its parent's reads it.
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
const depth = (child: NativeSubagent): number => {
const parent = sessions.get(child.parentSessionId);
return parent ? depth(parent) + 1 : 0;
};
const ordered = children.sort(([, a], [, b]) => depth(b) - depth(a));
for (const [taskId, child] of ordered) {
const task = this.level.get(taskId);
const working =
child.workState !== undefined &&
child.workState !== "idle" &&
task !== undefined &&
!task.ambient;
if (working || lists.get(child.sessionId)!.length > 0) {
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
}
}
// 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, which the foreground work waits on. Claude Code drops a
// tool use from its cache at the tool's result.
const live = session.liveBackgroundTasks?.get(taskId);
if (!live || (live.parentToolUseId && session.toolUseCache?.[live.parentToolUseId])) {
continue;
}
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
lists
.get(parent?.sessionId ?? rootSessionId)!
.push({ type: "_claude/task", taskId, description: task.description });
return [
...ordered.map(([, child]) => ({
sessionId: child.sessionId,
items: lists.get(child.sessionId)!,
state: workStateName(child.workState),
parentSessionId: child.parentSessionId,
})),
{ sessionId: this.sessionId, items: lists.get(this.sessionId)!, state: rootState },
];
}
// 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
// first, so a child's list is whole before its parent's reads it.
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
const depth = (child: NativeSubagent): number => {
const parent = sessions.get(child.parentSessionId);
return parent ? depth(parent) + 1 : 0;
};
for (const [taskId, child] of children.sort(([, a], [, b]) => depth(b) - depth(a))) {
const task = level.get(taskId);
const working =
child.workState !== undefined &&
child.workState !== "idle" &&
task !== undefined &&
!task.ambient;
if (working || lists.get(child.sessionId)!.length > 0) {
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
}
/**
* The current state of each background tool call in `toolCallIds`: the
* call as `renderer` shows its tool use, with its status now (see
* `ClaudeAcpAgent.publishBackgroundToolCalls`).
*/
toolCallStates(
toolCallIds: string[],
renderer: AcpToolCallRenderer,
cwd: string,
): AcpSessionNotification[] {
return [...this.toolCalls.values()]
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
.map((toolCall) => {
const update = renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd });
const status = toolCall.paused ? V2_PAUSED : "in_progress";
return {
sessionId: this.sessionId,
update: { ...update, status } as AcpSessionNotification["update"],
};
});
}
/**
* The task of the terminal or tool call `target` that the session
* `sessionId` lists, or undefined when it lists no such item.
*/
listedTask(
owner: BackgroundOwner,
sessionId: string,
target: BackgroundTarget,
): string | undefined {
if (!this.reported) return undefined;
const listed = this.reports(owner, "idle")
.find((report) => report.sessionId === sessionId)
?.items.some((item) =>
"terminalId" in target
? item.type === "terminal" && item.terminalId === target.terminalId
: item.type === "tool_call" && item.toolCallId === target.toolCallId,
);
if (!listed) return undefined;
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
const entries = "terminalId" in target ? this.terminals : this.toolCalls;
return [...entries].find(([, entry]) => entry.toolCallId === id)?.[0];
}
// In the same order, the root last: a child's own list goes out before its
// parent's list drops it.
return new Map([
...children.map(([, child]): [string, BackgroundItem[]] => [
child.sessionId,
lists.get(child.sessionId)!,
]),
[rootSessionId, lists.get(rootSessionId)!],
]);
}
/**
* The tasks of the terminals and tool calls that `level` does not hold:
* Claude Code no longer runs them, so they end before a list drops them, as
* v2 requires. A terminal or tool call is registered at its tool result,
* after its task joined the level, so a later level without the task means
* that it ended. Claude Code usually sends a task's end before that level,
* which ends them first, but the SDK does not promise that order.
*/
export function droppedTasks(session: BackgroundWorkSession, level: BackgroundLevel): string[] {
return [
...(session.backgroundTerminals?.keys() ?? []),
...(session.backgroundToolCalls?.keys() ?? []),
].filter((taskId) => !level.has(taskId));
}
type Send = (notification: AcpSessionNotification) => Promise<void>;
/** A terminal or tool call that `terminal/cancel` or `tool_call/cancel` names. */
export type BackgroundTarget = { terminalId: string } | { toolCallId: string };
/**
* Starts reading the output of the background command `taskId` into its
* terminal, when the session reports its background work and the Bash result
* named the command's output file. `send` sends an update of the terminal's
* tool call, routed as the call's other updates. Called once the Bash
* result's own updates are sent: the first output replaces the placeholder
* that they gave the terminal.
* Stops the task of a terminal or tool call that the session `sessionId`
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
* root sessions: a subagent session's list is built with its root's. The
* terminal exits, or the tool call gets its final status, when Claude Code
* reports the task stopped.
*/
export function followBackgroundTerminalOutput(
session: BackgroundWorkSession,
export async function cancelBackgroundWork(
sessions: Array<BackgroundOwner & { background?: SessionBackgroundWork }>,
sessionId: string,
taskId: string,
send: (notification: AcpSessionNotification) => Promise<void>,
): void {
const terminal = session.backgroundTerminals?.get(taskId);
if (!session.reportsBackground || !terminal?.outputFilePath || terminal.output) return;
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
send(terminalOutputUpdate(sessionId, terminal, output)),
);
target: BackgroundTarget,
): Promise<void> {
const notCancellable = () =>
"terminalId" in target
? RequestError.invalidParams(
{ sessionId, terminalId: target.terminalId },
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
)
: RequestError.invalidParams(
{ sessionId, toolCallId: target.toolCallId },
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
);
for (const session of sessions) {
const taskId = session.background?.listedTask(session, sessionId, target);
if (taskId === undefined) continue;
try {
await session.query.stopTask(taskId);
} catch (error) {
// The task ended meanwhile: it has ended on the client too, or is about to.
if (session.background?.listedTask(session, sessionId, target) === undefined) {
throw notCancellable();
}
throw error;
}
return;
}
throw notCancellable();
}
/** The update that gives the terminal of `terminal` the text `output`. */
@@ -336,34 +534,8 @@ function terminalOutputUpdate(
};
}
/**
* Reads the rest of the output of the background command `taskId` into its
* terminal: the command ended, and its output goes before its exit, as v2
* asks.
*/
export async function finishBackgroundTerminalOutput(
session: BackgroundWorkSession,
taskId: string,
): Promise<void> {
await session.backgroundTerminals?.get(taskId)?.output?.finish();
}
/**
* Removes the terminal of the background command `taskId` of the session
* `sessionId`, and returns the update that exits it, or undefined when the
* command has no terminal. Claude Code reports no exit code for it, and
* `completed` does not mean 0: Claude Code accepts some non-zero codes as a
* success, such as grep's 1.
*/
export function takeBackgroundTerminalExit(
session: BackgroundWorkSession,
sessionId: string,
taskId: string,
): AcpSessionNotification | undefined {
const terminal = session.backgroundTerminals?.get(taskId);
if (!terminal) return undefined;
terminal.output?.stop();
session.backgroundTerminals!.delete(taskId);
/** The update that exits the terminal of `terminal`, with no exit code. */
function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessionNotification {
return {
sessionId,
update: {
@@ -379,69 +551,6 @@ export function takeBackgroundTerminalExit(
};
}
/**
* Removes the background tool call of the task `taskId` of the session
* `sessionId`, and returns the update that gives it its final status, or
* undefined when the task has no such call. A task that Claude Code stopped
* is `cancelled`. One that ended without saying how, such as one that the
* level dropped, is `completed`: it no longer runs, and reported no failure.
*/
export function takeBackgroundToolCallEnd(
session: BackgroundWorkSession,
sessionId: string,
taskId: string,
end: BackgroundTaskEnd,
): AcpSessionNotification | undefined {
const toolCall = session.backgroundToolCalls?.get(taskId);
if (!toolCall) return undefined;
session.backgroundToolCalls!.delete(taskId);
return toolCallStatusUpdate(
sessionId,
toolCall,
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
);
}
/**
* The update that reports the background tool call of the task `taskId` as
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
* pauses a workflow this way.
*/
export function backgroundToolCallPause(
session: BackgroundWorkSession,
sessionId: string,
taskId: string,
paused: boolean,
): AcpSessionNotification | undefined {
const toolCall = session.backgroundToolCalls?.get(taskId);
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
toolCall.paused = paused;
return toolCallStatusUpdate(sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
}
/**
* The current state of each background tool call in `toolCallIds` of the
* root session `sessionId`: the call as `renderer` shows its tool use, with
* its status now (see `ClaudeAcpAgent.publishBackgroundToolCalls`).
*/
export function backgroundToolCallStates(
session: BackgroundWorkSession & { cwd: string },
sessionId: string,
toolCallIds: string[],
renderer: AcpToolCallRenderer,
): AcpSessionNotification[] {
return [...(session.backgroundToolCalls?.values() ?? [])]
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
.map((toolCall) => {
const update = renderer.toolCall(
{ id: toolCall.toolCallId, ...toolCall.toolUse },
{ cwd: session.cwd },
);
const status = toolCall.paused ? V2_PAUSED : "in_progress";
return { sessionId, update: { ...update, status } as AcpSessionNotification["update"] };
});
}
function toolCallStatusUpdate(
sessionId: string,
toolCall: BackgroundToolCall,
@@ -462,82 +571,6 @@ function toolCallStatusUpdate(
};
}
/**
* Ends the background work of a session whose query closed: Claude Code's
* background tasks end with its process. Returns the updates that exit the
* root session's terminals and cancel its tool calls, and empties the level,
* so the next lists are empty. A subagent's command or tool call is left as
* it was: it is on the child's stream, which the closed query's routing
* owned.
*/
export function endBackgroundWork(
session: BackgroundWorkSession,
sessionId: string,
): AcpSessionNotification[] {
if (!session.reportsBackground) return [];
// No command writes output any more.
for (const terminal of session.backgroundTerminals?.values() ?? []) terminal.output?.stop();
const exits = [...(session.backgroundTerminals ?? [])].flatMap(([taskId, terminal]) => {
const exit = terminal.parentToolUseId
? undefined
: takeBackgroundTerminalExit(session, sessionId, taskId);
return exit ? [exit] : [];
});
const ends = [...(session.backgroundToolCalls ?? [])].flatMap(([taskId, toolCall]) => {
const end = toolCall.parentToolUseId
? undefined
: takeBackgroundToolCallEnd(session, sessionId, taskId, "stopped");
return end ? [end] : [];
});
session.backgroundLevel = undefined;
return [...exits, ...ends];
}
/**
* Stops the task of a terminal or tool call that the session `sessionId`
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
* root sessions, by id: a subagent session's list is built with its root's.
* The terminal exits, or the tool call gets its final status, when Claude
* Code reports the task stopped.
*/
export async function cancelBackgroundWork(
sessions: Record<string, BackgroundWorkSession>,
sessionId: string,
target: { terminalId: string } | { toolCallId: string },
): Promise<void> {
const notCancellable = () =>
"terminalId" in target
? RequestError.invalidParams(
{ sessionId, terminalId: target.terminalId },
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
)
: RequestError.invalidParams(
{ sessionId, toolCallId: target.toolCallId },
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
);
const isTarget = (item: BackgroundItem) =>
"terminalId" in target
? item.type === "terminal" && item.terminalId === target.terminalId
: item.type === "tool_call" && item.toolCallId === target.toolCallId;
const listed = (root: string, session: BackgroundWorkSession) =>
session.reportsBackground === true &&
(backgroundLists(session, root).get(sessionId)?.some(isTarget) ?? false);
const owner = Object.entries(sessions).find(([root, session]) => listed(root, session));
if (!owner) throw notCancellable();
const [root, session] = owner;
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
const taskId = [
...(("terminalId" in target ? session.backgroundTerminals : session.backgroundToolCalls) ?? []),
].find(([, entry]) => entry.toolCallId === id)![0];
try {
await session.query.stopTask(taskId);
} catch (error) {
// The task ended meanwhile: it has ended on the client too, or is about to.
if (!listed(root, session)) throw notCancellable();
throw error;
}
}
/**
* Publishes the lists of each session that reports them, each time one
* changes. The sink sends them to the client synchronously, so a list follows
@@ -559,25 +592,17 @@ export class BackgroundWorkReporter {
* first call for a session publishes its list, also when it is empty.
* `rootState` is the root session's state now.
*/
sync(rootSessionId: string, session: BackgroundWorkSession, rootState: WorkStateName): void {
if (!this.sink || !session.reportsBackground) return;
const children = new Map(
[...(session.nativeSubagentsByTaskId?.values() ?? [])].map((child) => [
child.sessionId,
child,
]),
);
for (const [sessionId, items] of backgroundLists(session, rootSessionId)) {
const json = JSON.stringify(items);
if (this.published.get(sessionId)?.json === json) continue;
this.published.set(sessionId, { json, root: rootSessionId });
const child = children.get(sessionId);
this.sink.changed({
sessionId,
items,
state: child ? workStateName(child.workState) : rootState,
...(child ? { parentSessionId: child.parentSessionId } : {}),
});
sync(
rootSessionId: string,
session: BackgroundOwner & { background?: SessionBackgroundWork },
rootState: WorkStateName,
): void {
if (!this.sink || !session.background?.reported) return;
for (const report of session.background.reports(session, rootState)) {
const json = JSON.stringify(report.items);
if (this.published.get(report.sessionId)?.json === json) continue;
this.published.set(report.sessionId, { json, root: rootSessionId });
this.sink.changed(report);
}
}

View File

@@ -56,6 +56,7 @@ import {
type StreamedToolInputCache,
} from "../acp-agent.js";
import { SessionTitles } from "../session-titles.js";
import { SessionBackgroundWork } from "../background-work.js";
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
import { Pushable } from "../utils.js";
import { heldRequest, initializeClient, makeMockQuery } from "./helpers.js";
@@ -5201,6 +5202,7 @@ describe("permission request cancellation", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -14998,6 +15000,7 @@ describe("session/close", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -15120,6 +15123,7 @@ describe("session/delete", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -15283,6 +15287,7 @@ describe("getOrCreateSession param change detection", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -19764,6 +19769,7 @@ describe("post-error recovery", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork("test-session", false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -28199,6 +28205,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork("test-session", false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -30107,6 +30114,7 @@ describe("agent selection config option", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),

View File

@@ -5982,6 +5982,25 @@ 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]");
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mcp1", description: "Watch the build" }],
});
});
it("lists a task without such a call as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield level(options, [monitor]);

View File

@@ -7,6 +7,7 @@
import { vi } from "vitest";
import { randomUUID } from "crypto";
import { SessionBackgroundWork } from "../background-work.js";
import { SessionTitles } from "../session-titles.js";
/** Stand-in agent for a `SessionTitles` whose test doesn't care about titles:
@@ -91,6 +92,7 @@ export function mockSessionState(
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),