fix: list Monitor calls in the session's background work (#1538)

Claude Code marks every live-update watcher `ambient`, "requested or
auto-started" per the SDK's `background_tasks_changed` docs, so the task
of a `Monitor` call the model made is ambient too. `background-work.ts`
left out every ambient task, so a running Monitor was never listed:

- its tool call went straight to `completed` instead of staying
`in_progress` until its task ends;
- a client couldn't stop it with `tool_call/cancel`;
- a client deciding whether a session is quiet, for example before
restarting or releasing it, saw no background work while the Monitor
kept starting turns.

Seen live in Delta against 0.101.0: a Monitor watching a loop kept
waking the agent every 20 seconds while every `state_update` reported
`background: []`.

## Change

- `toolResults` registers a task a tool call started even when it is
ambient, so the call stays `in_progress` and advertises `cancel`.
- The list builder lists a registered call's task before skipping
ambient tasks. Ambient tasks without a call of their own, such as
watchers Claude Code starts by itself, and ambient terminals stay out.

## Tests

- The Monitor fixture in `acp-v2.test.ts` now carries `ambient: true`,
as the CLI sends it. Without the fix, the four existing Monitor tests
fail with it.
- The `_claude/task` test is now an `it.each`: a non-ambient task
without a call is listed, and an ambient watcher without a call is not.
- `npm run check`, `npm run build`, `npm run test:run` pass. Not run:
`npm run test:integration` against the real CLI.
This commit is contained in:
Ben Brandt
2026-11-06 12:12:58 +01:00
committed by GitHub
parent 85aec26ef9
commit ba162346c9
2 changed files with 48 additions and 34 deletions

View File

@@ -5,8 +5,9 @@
* "Background work").
*
* The lists are built from Claude Code's live background task set
* (`background_tasks_changed`), without the tasks it marks `ambient`, which
* are housekeeping that it started on its own. The root session and each
* (`background_tasks_changed`). Claude Code marks `ambient` its own
* housekeeping and every live-update watcher; those are left out, except a
* task that a tool call started, such as a `Monitor` call's. The root session and each
* announced subagent session (the subagents RFD form) has its own list. Each
* task is listed once, in the list of the session that runs it:
*
@@ -166,7 +167,11 @@ export class SessionBackgroundWork {
* `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.
* already leaves its call as it is. Claude Code marks every live-update
* watcher `ambient`, including the task of a `Monitor` call; a call
* started it, so it is work the client can see and stop, and is
* registered all the same. Ambient tasks without a call of their own,
* such as watchers Claude Code starts by itself, are never registered.
*
* Returns the ids of the registered calls, whose result then reports
* `in_progress` (see `withRunningToolCall`).
@@ -210,12 +215,7 @@ export class SessionBackgroundWork {
([, 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"
) {
if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") {
continue;
}
this.toolCalls.set(taskId!, {
@@ -363,6 +363,15 @@ export class SessionBackgroundWork {
const ownerOf = (parentToolUseId: string | undefined) =>
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
for (const [taskId, task] of this.level) {
// A registered call's task is listed even when ambient, as a `Monitor`
// call's is (see `toolResults`).
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;
}
if (task.ambient) continue;
const terminal = this.terminals.get(taskId);
if (terminal) {
@@ -371,13 +380,6 @@ export class SessionBackgroundWork {
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;

View File

@@ -5879,10 +5879,13 @@ describe("ACP v2 terminal of a command in the background", () => {
});
describe("a task that a tool call started", () => {
// Claude Code marks every live-update watcher ambient, a requested
// Monitor's task included.
const monitor = {
task_id: "mon1",
task_type: "local_monitor",
description: "Watch the logs",
ambient: true,
};
const workflow = { task_id: "wf1", task_type: "local_workflow", description: "Review" };
/** A Monitor call whose task `mon1` runs on after the call returns. The
@@ -6107,25 +6110,34 @@ describe("ACP v2 terminal of a command in the background", () => {
});
});
it("lists a task without such a call as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield level(options, [monitor]);
// No tool call started it, such as a task that a hook started.
yield taskEvent(options, "task_started", monitor);
yield result(options);
yield idleState(options);
});
const client = await promptUntil("idle end_turn [_claude/task]");
// 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" }],
},
// A watcher Claude Code started by itself is housekeeping.
{
task: monitor,
trace: ["running []", "idle end_turn []"],
background: [],
},
])(
"lists 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]);
yield taskEvent(options, "task_started", task);
yield result(options);
yield idleState(options);
});
const client = await promptUntil(trace.at(-1)!);
expect(backgroundTrace(client)).toEqual([
"running []",
"running [_claude/task]",
"idle end_turn [_claude/task]",
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mon1", description: "Watch the logs" }],
});
});
expect(backgroundTrace(client)).toEqual(trace);
expect(client.updates("state_update").at(-1)).toMatchObject({ background });
},
);
});
it("rejects terminal/cancel with -32602 when the command ends before Claude Code stops it", async () => {