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 630bdbf0a6..7c0beed569 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 @@ -431,6 +431,7 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", + log: "[Bash] ls -1", }, }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index aa60e40462..3cd1b9aa63 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2094,18 +2094,29 @@ class ClaudeAgentSession implements AgentSession { getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, readWorkflowResult: readClaudeWorkflowResultFile, }); + /** + * Subagent tool calls Claude has already answered, with the input they were launched with. A + * background child is answered at launch and streams its frames afterwards. + */ + private readonly settledSubagentToolCalls = new Map< + string, + { item: Extract; input: AgentMetadata | null } + >(); private readonly sidechainTracker = new ClaudeSidechainTracker({ - getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, + getToolInput: (toolUseId) => + this.toolUseCache.get(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 // 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. + // 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. needsSyntheticParentToolCard: (toolUseId) => - this.toolUseCache.has(toolUseId) && + (this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), + getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item, }); private persistedHistory: PersistedTimelineEntry[] = []; private persistedProviderSubagentEvents: Extract< @@ -2711,6 +2722,7 @@ class ClaudeAgentSession implements AgentSession { this.cancelCurrentTurn = null; this.turnState = "idle"; this.sidechainTracker.clear(); + this.settledSubagentToolCalls.clear(); this.taskProtocolSource.reset(); this.input?.end(); this.query?.close?.(); @@ -5308,28 +5320,22 @@ class ClaudeAgentSession implements AgentSession { const { images, text } = splitClaudeToolResultImages(block.content); const output = this.buildToolOutput(text, block, entry); - if (block.is_error) { - this.pushToolCall( - mapClaudeFailedToolCall({ + const settledToolCall = block.is_error + ? mapClaudeFailedToolCall({ name: toolName, callId, input: entry?.input ?? null, output: output ?? null, error: { ...block, content: text }, - }), - items, - ); - } else { - this.pushToolCall( - mapClaudeCompletedToolCall({ + }) + : mapClaudeCompletedToolCall({ name: toolName, callId, input: entry?.input ?? null, output: output ?? null, - }), - items, - ); - } + }); + this.pushToolCall(settledToolCall, items); + this.rememberSettledSubagentToolCall(settledToolCall, entry); for (const image of images) { const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, { @@ -5345,6 +5351,14 @@ class ClaudeAgentSession implements AgentSession { } } + private rememberSettledSubagentToolCall( + item: Extract | null, + entry: ToolUseCacheEntry | undefined, + ): void { + if (!item || !this.taskProtocolSource.resolveSubagentId(item.callId)) return; + this.settledSubagentToolCalls.set(item.callId, { item, input: entry?.input ?? null }); + } + private buildToolOutput( content: unknown, block: ClaudeContentChunk, 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..da6faafa90 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -14,6 +14,8 @@ interface ClaudeContentChunk { [key: string]: unknown; } +type ClaudeToolCallItem = Extract; + interface SubAgentActionEntry { index: number; toolName: string; @@ -60,6 +62,7 @@ export class ClaudeSidechainTracker { private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; private readonly isDescriptorOwnedElsewhere: () => boolean; private readonly needsSyntheticParentToolCard: (toolUseId: string) => boolean; + private readonly getSettledParentToolCall: (toolUseId: string) => ClaudeToolCallItem | undefined; constructor(input: { getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; @@ -70,10 +73,17 @@ export class ClaudeSidechainTracker { */ isDescriptorOwnedElsewhere?: () => boolean; needsSyntheticParentToolCard?: (toolUseId: string) => boolean; + /** + * The parent's tool call once Claude has answered it. A background child streams its frames + * after that answer, so its card keeps the settled status and output and gains the child's + * action log. + */ + getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined; }) { this.getToolInput = input.getToolInput; this.isDescriptorOwnedElsewhere = input.isDescriptorOwnedElsewhere ?? (() => false); this.needsSyntheticParentToolCard = input.needsSyntheticParentToolCard ?? (() => true); + this.getSettledParentToolCall = input.getSettledParentToolCall ?? (() => undefined); } handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] { @@ -115,17 +125,19 @@ export class ClaudeSidechainTracker { return []; } - const toolCall = mapClaudeRunningToolCall({ - name: "Task", - callId: parentToolUseId, - input: null, - output: null, - }); + const toolCall = + this.getSettledParentToolCall(parentToolUseId) ?? + mapClaudeRunningToolCall({ + name: "Task", + callId: parentToolUseId, + input: null, + output: null, + }); if (!toolCall) { return []; } - const detail: Extract["detail"] = { + const detail: ClaudeToolCallItem["detail"] = { type: "sub_agent", ...(state.subAgentType ? { subAgentType: state.subAgentType } : {}), ...(state.description ? { description: state.description } : {}),