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 7c0beed569..13dd0c4e4c 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 @@ -436,6 +436,141 @@ describe("background Claude subagents", () => { }); }); + test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => { + // Claude Code usually ends the launching turn before a background child finishes, so the + // child's later frames land in the next 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", + 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.", + }, + ], + }, + }, + { + 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: "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: AgentTimelineRow[] = []; + let currentTurnId: string | undefined; + let completedTurns = 0; + const bothTurnsCompleted = new Promise((resolve) => { + session.subscribe((event) => { + if (event.type === "turn_started") currentTurnId = event.turnId; + if (event.type === "timeline") { + rows.push({ + seq: rows.length + 1, + timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(), + item: event.item, + ...(currentTurnId ? { turnId: currentTurnId } : {}), + }); + } + if (event.type === "turn_completed" && ++completedTurns === 2) resolve(); + }); + }); + await session.startTurn("delegate work"); + await bothTurnsCompleted; + await session.close(); + + 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([ { source: "system task protocol", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 2b5df27933..c251285683 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2096,17 +2096,21 @@ class ClaudeAgentSession implements AgentSession { }); /** * Subagent tool calls Claude has already answered while the child still runs, with the input - * they were launched with. A background child is answered at launch and streams its frames - * afterwards. + * they were launched with and the turn that answered them. A background child is answered at + * launch and streams its frames afterwards. */ private readonly settledSubagentToolCalls = new Map< string, - { item: Extract; input: AgentMetadata | null } + { + item: Extract; + input: AgentMetadata | null; + turnId: string | null; + } >(); private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? - this.settledSubagentToolCalls.get(toolUseId)?.input ?? + this.settledSubagentToolCallInCurrentTurn(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 @@ -2114,10 +2118,13 @@ 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. needsSyntheticParentToolCard: (toolUseId) => - (this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) && + (this.toolUseCache.has(toolUseId) || + this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), - getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item, + getSettledParentToolCall: (toolUseId) => + this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item, }); private persistedHistory: PersistedTimelineEntry[] = []; private persistedProviderSubagentEvents: Extract< @@ -5359,7 +5366,17 @@ class ClaudeAgentSession implements AgentSession { ): void { const subagentId = item ? this.taskProtocolSource.resolveSubagentId(item.callId) : undefined; if (!item || !subagentId) return; - this.settledSubagentToolCalls.set(subagentId, { item, input: entry?.input ?? null }); + this.settledSubagentToolCalls.set(subagentId, { + item, + input: entry?.input ?? null, + turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null, + }); + } + + 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. */