diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index b6c8eea188..8701ab87ec 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -634,6 +634,72 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(events[0]).toMatchObject({ id: TOOL_USE_ID }); }); + test.each(["Agent", "Task"] as const)( + "labels a background %s call's card with its type and task after a restart", + async (toolName) => { + writeSession({ + parentLines: [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: toolName, + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ]), + JSON.stringify({ + type: "user", + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:48.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: TOOL_USE_ID, + content: [ + { + type: "text", + text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`, + }, + ], + }, + ], + }, + toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID }, + }), + ], + meta: JSON.stringify({ + agentType: "general-purpose", + description: "Count files here", + toolUseId: TOOL_USE_ID, + spawnDepth: 1, + requestShape: "background", + }), + }); + + const cards = (await replayEvents()).flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === TOOL_USE_ID + ? [event.item] + : [], + ); + expect(cards.at(-1)).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }, + ); + test("replays the subagent's own transcript onto its timeline", async () => { writeSession({ parentLines: [taskToolUse(), taskToolResult()], diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 7b16c030a4..aa60e40462 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -130,6 +130,7 @@ import { type ProviderCatalog, type ProviderRefreshContext, type ResolveAgentDefaultModeInput, + type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -295,6 +296,13 @@ interface AsyncMessageInput { interface ClaudeReplayOwnership { restoredIds: ReadonlySet; toolOwners: ReadonlyMap; + /** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */ + subagentToolCalls: ReadonlyMap; +} + +interface ClaudeSubagentCardFacts { + title?: string; + description?: string; } interface PersistedTimelineEntry { @@ -4249,16 +4257,7 @@ class ClaudeAgentSession implements AgentSession { return { type: "timeline", provider: "claude", - item: { - ...toolCall, - detail: { - type: "sub_agent", - ...(declaration.title ? { subAgentType: declaration.title } : {}), - ...(declaration.description ? { description: declaration.description } : {}), - log: "", - actions: [], - }, - }, + item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) }, }; } @@ -4977,6 +4976,7 @@ class ClaudeAgentSession implements AgentSession { const sidechainEntries = [parentContent, ...sidechains.contents] .flatMap(parseClaudeHistoryRecords) .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); + const parentFacts = readClaudeReplayParentFacts(parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. @@ -4987,7 +4987,7 @@ class ClaudeAgentSession implements AgentSession { entries, parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]), })), - parent: readClaudeReplayParentFacts(parentEntries), + parent: parentFacts, convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), }); const observations = [ @@ -5016,6 +5016,7 @@ class ClaudeAgentSession implements AgentSession { const replay = { restoredIds: restoredProviderSubagentIds, toolOwners: subagentReplay.toolOwners, + subagentToolCalls: parentFacts.toolCalls, }; if (observations.length === 0) return replay; @@ -5082,7 +5083,12 @@ class ClaudeAgentSession implements AgentSession { return; } const taskSnapshot = this.taskState.observe(entry); - const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)]; + const items = [ + ...(taskSnapshot ? [taskSnapshot] : []), + ...this.convertHistoryEntry(entry).map((item) => + labelReplayedSubagentCard(item, replay.subagentToolCalls), + ), + ]; const isVisibleUserEntry = entry.type === "user" && typeof entry.uuid === "string" && @@ -5832,6 +5838,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { return entries; } +/** The parent's card for a subagent, labeled with its type and task. */ +function buildClaudeSubagentCardDetail( + facts: ClaudeSubagentCardFacts, +): Extract { + return { + type: "sub_agent", + ...(facts.title ? { subAgentType: facts.title } : {}), + ...(facts.description ? { description: facts.description } : {}), + log: "", + actions: [], + }; +} + +/** + * Live, a Task call's card is built from the task protocol's declaration. Replay has no task + * protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts. + */ +function labelReplayedSubagentCard( + item: AgentTimelineItem, + subagentToolCalls: ReadonlyMap, +): AgentTimelineItem { + if (item.type !== "tool_call") return item; + const facts = subagentToolCalls.get(item.callId); + if (!facts) return item; + return { ...item, detail: buildClaudeSubagentCardDetail(facts) }; +} + /** * Collect what the parent transcript knows about its Task calls, in the shape the shared replay * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before