mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user