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 8701ab87ec..3ae70a4158 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 @@ -700,6 +700,60 @@ describe("ClaudeAgentSession persisted subagent replay", () => { }, ); + test("keeps a foreground subagent card's action log after a restart", async () => { + writeSession({ + parentLines: [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files foreground", + prompt: "Run ls and reply with the number of entries.", + run_in_background: false, + }, + }, + ]), + taskToolResult(), + ], + meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:49.000Z", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_child_bash", + name: "Bash", + input: { command: "ls -1 | wc -l", description: "Count entries" }, + }, + ], + }, + }), + sidechainEntry({ stopReason: "end_turn" }), + ], + }); + + 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({ + detail: { type: "sub_agent", log: "[Bash] ls -1 | wc -l" }, + }); + }); + 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 aa60e40462..5c46237c66 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -303,6 +303,8 @@ interface ClaudeReplayOwnership { interface ClaudeSubagentCardFacts { title?: string; description?: string; + /** The child's tool calls, one line each. Live, the sidechain tracker streams it instead. */ + log?: string; } interface PersistedTimelineEntry { @@ -5016,7 +5018,12 @@ class ClaudeAgentSession implements AgentSession { const replay = { restoredIds: restoredProviderSubagentIds, toolOwners: subagentReplay.toolOwners, - subagentToolCalls: parentFacts.toolCalls, + subagentToolCalls: new Map( + [...parentFacts.toolCalls].map(([id, facts]) => { + const log = subagentReplay.actionLogs.get(id); + return [id, log ? { ...facts, log } : facts]; + }), + ), }; if (observations.length === 0) return replay; @@ -5846,7 +5853,7 @@ function buildClaudeSubagentCardDetail( type: "sub_agent", ...(facts.title ? { subAgentType: facts.title } : {}), ...(facts.description ? { description: facts.description } : {}), - log: "", + log: facts.log ?? "", actions: [], }; } 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 6973d1db5d..f53bfdcbc2 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -55,6 +55,81 @@ function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk { ); } +function isToolUseChunk(block: ClaudeContentChunk): block is ClaudeContentChunk & { name: string } { + return ( + (block.type === "tool_use" || + block.type === "mcp_tool_use" || + block.type === "server_tool_use") && + typeof block.name === "string" + ); +} + +function normalizeSubAgentText(value: unknown): string | undefined { + const normalized = readTrimmedString(value)?.replace(/\s+/g, " "); + if (!normalized) { + return undefined; + } + if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) { + return normalized; + } + return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`; +} + +function deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined { + const runningToolCall = mapClaudeRunningToolCall({ + name: toolName, + callId: `sub-agent-summary-${toolName}`, + input, + output: null, + }); + if (!runningToolCall) { + return undefined; + } + const display = buildToolCallDisplayModel({ + name: runningToolCall.name, + status: runningToolCall.status, + error: runningToolCall.error, + detail: runningToolCall.detail, + metadata: runningToolCall.metadata, + }); + return normalizeSubAgentText(display.summary); +} + +/** The action log on the parent's card for a subagent, one line per tool the child called. */ +function formatSubAgentActionLog( + actions: readonly { toolName: string; summary?: string }[], +): string { + return actions + .map((action) => + action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, + ) + .join("\n"); +} + +/** + * Rebuild a subagent card's action log from the child's persisted transcript, matching the log + * the live tracker builds from the same tool calls as they stream. + */ +export function readClaudeSubagentActionLog( + entries: readonly { type?: unknown; message?: { content?: unknown } }[], +): string { + const actionsById = new Map(); + for (const entry of entries) { + const content = entry.type === "assistant" ? entry.message?.content : undefined; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) continue; + const toolName = readTrimmedString(block.name); + if (!toolName) continue; + const key = readTrimmedString(block.id) ?? `assistant:${toolName}:${actionsById.size}`; + if (actionsById.has(key)) continue; + const summary = deriveSubAgentActionSummary(toolName, block.input ?? null); + actionsById.set(key, { toolName, ...(summary ? { summary } : {}) }); + } + } + return formatSubAgentActionLog([...actionsById.values()].slice(-MAX_SUB_AGENT_LOG_ENTRIES)); +} + export class ClaudeSidechainTracker { private readonly activeSidechains = new Map(); private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; @@ -129,11 +204,7 @@ export class ClaudeSidechainTracker { type: "sub_agent", ...(state.subAgentType ? { subAgentType: state.subAgentType } : {}), ...(state.description ? { description: state.description } : {}), - log: state.actions - .map((action) => - action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, - ) - .join("\n"), + log: formatSubAgentActionLog(state.actions), actions: [], }; @@ -298,9 +369,9 @@ export class ClaudeSidechainTracker { parentToolUseId: string, ): boolean { const taskInput = this.getToolInput(parentToolUseId); - const nextName = this.normalizeSubAgentText(taskInput?.name); - const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type); - const nextDescription = this.normalizeSubAgentText(taskInput?.description); + const nextName = normalizeSubAgentText(taskInput?.name); + const nextSubAgentType = normalizeSubAgentText(taskInput?.subagent_type); + const nextDescription = normalizeSubAgentText(taskInput?.description); let changed = false; if (nextName && nextName !== state.name) { @@ -318,17 +389,6 @@ export class ClaudeSidechainTracker { return changed; } - private normalizeSubAgentText(value: unknown): string | undefined { - const normalized = readTrimmedString(value)?.replace(/\s+/g, " "); - if (!normalized) { - return undefined; - } - if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) { - return normalized; - } - return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`; - } - private extractAssistantMessageActions( message: Extract, ): SubAgentActionCandidate[] { @@ -338,15 +398,7 @@ export class ClaudeSidechainTracker { } const actions: SubAgentActionCandidate[] = []; for (const block of content) { - if ( - !isClaudeContentChunk(block) || - !( - block.type === "tool_use" || - block.type === "mcp_tool_use" || - block.type === "server_tool_use" - ) || - typeof block.name !== "string" - ) { + if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) { continue; } const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`; @@ -367,15 +419,7 @@ export class ClaudeSidechainTracker { return []; } const block = isClaudeContentChunk(event.content_block) ? event.content_block : null; - if ( - !block || - !( - block.type === "tool_use" || - block.type === "mcp_tool_use" || - block.type === "server_tool_use" - ) || - typeof block.name !== "string" - ) { + if (!block || !isToolUseChunk(block)) { return []; } const key = @@ -420,7 +464,7 @@ export class ClaudeSidechainTracker { return false; } - const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input); + const summary = deriveSubAgentActionSummary(normalizedToolName, candidate.input); const existingIndex = state.actionIndexByKey.get(candidate.key); if (existingIndex !== undefined) { @@ -471,24 +515,4 @@ export class ClaudeSidechainTracker { } } } - - private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined { - const runningToolCall = mapClaudeRunningToolCall({ - name: toolName, - callId: `sub-agent-summary-${toolName}`, - input, - output: null, - }); - if (!runningToolCall) { - return undefined; - } - const display = buildToolCallDisplayModel({ - name: runningToolCall.name, - status: runningToolCall.status, - error: runningToolCall.error, - detail: runningToolCall.detail, - metadata: runningToolCall.metadata, - }); - return this.normalizeSubAgentText(display.summary); - } } diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 372cbc01b2..fb014834a6 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -4,6 +4,7 @@ import type { AgentTimelineItem } from "../../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js"; import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; import { resolveObservedClaudeModelId } from "../models.js"; +import { readClaudeSubagentActionLog } from "../sidechain-tracker.js"; import type { SubagentObservation } from "./observation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; @@ -337,9 +338,15 @@ export function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap } { +}): { + observations: SubagentObservation[]; + toolOwners: ReadonlyMap; + /** Root subagent id -> the action log its card in the parent transcript shows. */ + actionLogs: ReadonlyMap; +} { const observations: SubagentObservation[] = []; const toolOwners = new Map(); + const actionLogs = new Map(); const unresolved = [...input.subagents].sort( (left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1), ); @@ -360,13 +367,14 @@ export function observeReplaySubagents(input: { // Only proven descendants may own notifications; ambient sidecars cannot claim them. recordReplayToolOwners(toolOwners, subagent.entries, link.id); + if (!ownerId) actionLogs.set(link.id, readClaudeSubagentActionLog(subagent.entries)); observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId)); if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts); unresolved.splice(index, 1); madeProgress = true; } } - return { observations, toolOwners }; + return { observations, toolOwners, actionLogs }; } function resolveReplayOwner(