From b3ed7363c6eaa9e889d9f2bf471084da541db385 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:07:57 +0200 Subject: [PATCH] fix(claude): keep a background subagent's card labeled after its child streams (#6067) Claude Code answers a background Agent call right away, then streams the child's frames. Each frame re-emitted the parent's card from the sidechain tracker after the tool-input cache had dropped the call, replacing the settled, labeled card with a bare running "Task". The tracker now emits the parent card only while the parent's tool call is still open. Closes #6059 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.background-subagent.test.ts | 121 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 4 + 2 files changed, 125 insertions(+) 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 a767dc9c2a..630bdbf0a6 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 @@ -314,6 +314,127 @@ describe("background Claude subagents", () => { }); }); + test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => { + // Claude Code answers a background Agent call at once ("Agent launched") and only then streams + // the child's frames, so they arrive after the parent's tool call has its result. + 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.", + }, + }, + ], + }, + }, + { + 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: "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 events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + const rows: AgentTimelineRow[] = events + .filter((event) => event.type === "timeline") + .map((event, index) => ({ + seq: index + 1, + timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(), + item: event.item, + })); + 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", + }, + }); + }); + 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 36f991d019..10048356b1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2092,7 +2092,11 @@ class ClaudeAgentSession implements AgentSession { // identity and status from frames for them. Detecting the capability beats comparing version // strings: it reacts to what this session actually does. isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, + // The parent's tool call owns its card once its result arrives. A background child streams + // its frames after Claude has already answered the call, and re-emitting the card from them + // would replace the settled card with an unlabeled, running one. needsSyntheticParentToolCard: (toolUseId) => + this.toolUseCache.has(toolUseId) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), }); private persistedHistory: PersistedTimelineEntry[] = [];