diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index d0a5bf644a..4cab1a5005 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns( seq: rows.length + 1, timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(), item: event.item, - ...(currentTurnId ? { turnId: currentTurnId } : {}), + ...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}), }); } if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve(); @@ -469,122 +469,145 @@ describe("background Claude subagents", () => { }); }); - test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => { - // A child can stream across the main session's turn boundary. Claude opens the next - // main-session turn with system/init; bare child frames do not open a turn. - queryFactory.mockImplementation(() => - buildQueryMock([ - { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, - { - type: "assistant", - message: { - model: "claude-haiku-4-5", - content: [ - { - type: "tool_use", - id: "toolu_background", - name: "Agent", - input: { - subagent_type: "general-purpose", - description: "Count files here", - prompt: "Run ls and reply with the number of entries.", - run_in_background: true, + test.each(["before", "after"])( + "keeps one logged Task card when child tools start %s the parent finishes", + async (timing) => { + // A child can stream across the main session's turn boundary. Claude opens the next + // main-session turn with system/init; bare child frames do not open a turn. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "assistant", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_background", + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + run_in_background: true, + }, }, - }, - ], + ], + }, }, - }, - { - type: "system", - subtype: "task_started", - task_id: "bg-task", - tool_use_id: "toolu_background", - task_type: "local_agent", - subagent_type: "general-purpose", + { + type: "system", + subtype: "task_started", + task_id: "bg-task", + tool_use_id: "toolu_background", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_background", + content: "Async agent launched successfully.", + }, + ], + }, + }, + ...(timing === "after" + ? [ + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + { + type: "system", + subtype: "init", + session_id: "bg-session", + permissionMode: "default", + }, + ] + : []), + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_ls", + name: "Bash", + input: { command: "ls -1", description: "List files" }, + }, + ], + }, + }, + ...(timing === "before" + ? [ + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + { + type: "system", + subtype: "init", + session_id: "bg-session", + permissionMode: "default", + }, + ] + : []), + { + type: "user", + parent_tool_use_id: "toolu_background", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, + }, + { + type: "system", + subtype: "task_updated", + task_id: "bg-task", + patch: { status: "completed" }, + }, + { + type: "system", + subtype: "task_notification", + task_id: "bg-task", + tool_use_id: "toolu_background", + status: "completed", + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const rows = await collectTimelineAcrossTurns(session, "delegate work", 2); + + const cards = projectTimelineRows({ rows, mode: "projected" }) + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", description: "Count files here", - prompt: "Run ls and reply with the number of entries.", + log: "[Bash] ls -1", }, - { - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "toolu_background", - content: "Async agent launched successfully.", - }, - ], - }, - }, - { - type: "assistant", - parent_tool_use_id: "toolu_background", - message: { - model: "claude-haiku-4-5", - content: [ - { - type: "tool_use", - id: "toolu_ls", - name: "Bash", - input: { command: "ls -1", description: "List files" }, - }, - ], - }, - }, - { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, - { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, - { - type: "user", - parent_tool_use_id: "toolu_background", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], - }, - }, - { - type: "assistant", - parent_tool_use_id: "toolu_background", - message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, - }, - { - type: "system", - subtype: "task_updated", - task_id: "bg-task", - patch: { status: "completed" }, - }, - { - type: "system", - subtype: "task_notification", - task_id: "bg-task", - tool_use_id: "toolu_background", - status: "completed", - }, - { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, - ]), - ); - const session = await new ClaudeAgentClient({ - logger: createTestLogger(), - queryFactory, - resolveBinary: async () => "/test/claude/bin", - }).createSession({ provider: "claude", cwd: process.cwd() }); - const rows = await collectTimelineAcrossTurns(session, "delegate work", 2); - - const cards = projectTimelineRows({ rows, mode: "projected" }) - .map((entry) => entry.item) - .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); - - expect(cards).toHaveLength(1); - expect(cards[0]).toMatchObject({ - status: "completed", - detail: { - type: "sub_agent", - subAgentType: "general-purpose", - description: "Count files here", - log: "[Bash] ls -1", - }, - }); - }); + }); + }, + ); test.each([ { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b12ab706da..2af8983de0 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2250,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession { private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? - this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ?? + this.settledSubagentToolCalls.get(toolUseId)?.input ?? null, // Releases that predate the task protocol announce nothing, so the tracker keeps deriving // identity and status from frames for them. Detecting the capability beats comparing version @@ -2258,13 +2258,11 @@ class ClaudeAgentSession implements AgentSession { isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, // A background child streams its frames after Claude has already answered the call, so its // card is rebuilt from the settled call: a running one would replace the settled, labeled card. - // A card belongs to the turn that answered the call, so frames from a later turn leave it be. + // Late card updates retain the turn that answered the call. needsSyntheticParentToolCard: (toolUseId) => - (this.toolUseCache.has(toolUseId) || - this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) && + (this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), - getSettledParentToolCall: (toolUseId) => - this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item, + getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item, }); private persistedHistory: PersistedTimelineEntry[] = []; private persistedProviderSubagentEvents: Extract< @@ -4490,7 +4488,12 @@ class ClaudeAgentSession implements AgentSession { ), ).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event })); const routedId = canonicalSubagentId ?? parentToolUseId; - return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)]; + const settled = this.settledSubagentToolCalls.get(routedId); + const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => { + if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId; + return event; + }); + return [...runtimeEvents, ...sidechainEvents]; } /** @@ -5145,7 +5148,8 @@ class ClaudeAgentSession implements AgentSession { } private notifySubscribers(event: AgentStreamEvent): void { - const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id; + const turnId = + getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id; const tagged = turnId ? { ...event, turnId } : event; this.logger.trace( { @@ -5621,17 +5625,12 @@ class ClaudeAgentSession implements AgentSession { }); } - private settledSubagentToolCallInCurrentTurn(subagentId: string) { - const settled = this.settledSubagentToolCalls.get(subagentId); - const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; - return settled && settled.turnId === currentTurnId ? settled : undefined; - } - /** A finished child streams no more frames, so its settled call has nothing left to update. */ private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void { for (const observation of observations) { if (observation.kind === "status" && observation.status !== "running") { this.settledSubagentToolCalls.delete(observation.id); + this.sidechainTracker.delete(observation.id); } } } diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index f762557879..a17c1cd58c 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -4,6 +4,40 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; describe("ClaudeSidechainTracker", () => { + it("retains a background child's actions across the parent finishing", () => { + const tracker = new ClaudeSidechainTracker({ + getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }), + isDescriptorOwnedElsewhere: () => true, + }); + const action = (id: string, command: string) => + ({ + type: "assistant", + parent_tool_use_id: "task-1", + message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] }, + }) as unknown as SDKMessage; + tracker.handleMessage(action("ls", "ls -1"), "task-1"); + expect(tracker.finishAll("completed")).toEqual([]); + const events = tracker.handleMessage(action("pwd", "pwd"), "task-1"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }), + }), + }), + ); + tracker.delete("task-1"); + const next = tracker.handleMessage(action("next", "ls"), "task-1"); + expect(next).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + detail: expect.objectContaining({ log: "[Bash] ls" }), + }), + }), + ); + }); + it("uses Claude's native agent name for the provider subagent title", () => { const tracker = new ClaudeSidechainTracker({ getToolInput: () => ({ diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index f054523126..083b82cea6 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -199,7 +199,7 @@ export class ClaudeSidechainTracker { finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { const events: AgentStreamEvent[] = []; if (this.isDescriptorOwnedElsewhere()) { - this.activeSidechains.clear(); + // The task protocol owns child completion; the parent finishing does not end its children. return events; } for (const [id, state] of this.activeSidechains) { diff --git a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts new file mode 100644 index 0000000000..7eba32f22a --- /dev/null +++ b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +test("background cards retain child actions after the parent finishes (#6253)", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-")); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + logger, + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "claude", + model: "haiku", + modeId: "bypassPermissions", + cwd, + title: "Background action log reproduction", + }); + await client.sendMessage( + agent.id, + 'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.', + ); + await client.waitForFinish(agent.id, 180_000); + const atFinish = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + await expect + .poll( + async () => { + const { subagents } = await client.listProviderSubagents(agent.id); + return subagents.length === 2 && subagents.every((child) => child.status === "completed"); + }, + { timeout: 180_000, interval: 1000 }, + ) + .toBe(true); + const { subagents } = await client.listProviderSubagents(agent.id); + const children = await Promise.all( + subagents.map(async (child) => ({ + description: child.description, + timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, { + direction: "tail", + limit: 0, + }), + })), + ); + const final = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const { stdout } = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${daemon.port}`, + "logs", + agent.id, + ], + { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 }, + ); + expect(stdout).toContain("[Bash] ls -1"); + expect(stdout).toContain("[Bash] pwd"); + expect( + atFinish.entries.filter( + (row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent", + ), + ).toHaveLength(2); + const cards = final.entries + .map((row) => row.item) + .filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent"); + expect(cards).toHaveLength(2); + for (const child of children) { + expect( + child.timeline.rows.some( + (row) => row.item.type === "tool_call" && row.item.name === "Bash", + ), + ).toBe(true); + } + for (const card of cards) { + expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 420_000);