fix(claude): show a background subagent's action log on its card

Claude Code answers a background Agent call at launch and streams the
child's frames afterwards. Since #6067 the sidechain tracker stopped
emitting the parent card once the call had its result, so the card's
log stayed empty. The tracker now rebuilds the card from the settled
call, keeping its status, output, and label, and adds the child's
action log.

Closes #6157
This commit is contained in:
paseo-bot[bot]
2026-10-06 18:11:40 +02:00
parent 7fd469ae19
commit 08f376f501
3 changed files with 52 additions and 25 deletions
@@ -431,6 +431,7 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
@@ -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<AgentTimelineItem, { type: "tool_call" }>; 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<AgentTimelineItem, { type: "tool_call" }> | 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,
@@ -14,6 +14,8 @@ interface ClaudeContentChunk {
[key: string]: unknown;
}
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
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<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
const detail: ClaudeToolCallItem["detail"] = {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),