Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 4c81db8bca fix(claude): keep a background subagent's card in the turn that settled it
A background child often keeps streaming after the parent's turn ends. Its
frames then rebuilt the settled card in the next turn with an empty log, and
the projection showed it as a second card for the same call. The settled call
now rebuilds the card only within the turn that answered it.
2026-10-06 20:09:49 +02:00
paseo-bot[bot] 3a51db2db2 fix(claude): release a settled subagent call when the child finishes 2026-10-06 18:21:02 +02:00
paseo-bot[bot] 08f376f501 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
2026-10-06 18:11:40 +02:00
3 changed files with 216 additions and 25 deletions
@@ -431,6 +431,142 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
// Claude Code usually ends the launching turn before a background child finishes, so the
// child's later frames land in the next turn.
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.",
run_in_background: true,
},
},
],
},
},
{
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: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
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 rows: AgentTimelineRow[] = [];
let currentTurnId: string | undefined;
let completedTurns = 0;
const bothTurnsCompleted = new Promise<void>((resolve) => {
session.subscribe((event) => {
if (event.type === "turn_started") currentTurnId = event.turnId;
if (event.type === "timeline") {
rows.push({
seq: rows.length + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
item: event.item,
...(currentTurnId ? { turnId: currentTurnId } : {}),
});
}
if (event.type === "turn_completed" && ++completedTurns === 2) resolve();
});
});
await session.startTurn("delegate work");
await bothTurnsCompleted;
await session.close();
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",
log: "[Bash] ls -1",
},
});
});
@@ -2094,18 +2094,37 @@ class ClaudeAgentSession implements AgentSession {
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
readWorkflowResult: readClaudeWorkflowResultFile,
});
/**
* Subagent tool calls Claude has already answered while the child still runs, with the input
* they were launched with and the turn that answered them. 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;
turnId: string | null;
}
>();
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCallInCurrentTurn(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.
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
needsSyntheticParentToolCard: (toolUseId) =>
this.toolUseCache.has(toolUseId) &&
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -2711,6 +2730,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?.();
@@ -4133,6 +4153,7 @@ class ClaudeAgentSession implements AgentSession {
for (const event of foldSubagentObservations(subagentObservations)) {
events.push({ type: "provider_subagent", provider: "claude", event });
}
this.releaseFinishedSubagentToolCalls(subagentObservations);
for (const observation of subagentObservations) {
if (observation.kind !== "declared") continue;
if (!this.taskProtocolSource.needsSyntheticParentToolCard(observation.id)) continue;
@@ -5308,28 +5329,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 +5360,34 @@ class ClaudeAgentSession implements AgentSession {
}
}
private rememberSettledSubagentToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
entry: ToolUseCacheEntry | undefined,
): void {
const subagentId = item ? this.taskProtocolSource.resolveSubagentId(item.callId) : undefined;
if (!item || !subagentId) return;
this.settledSubagentToolCalls.set(subagentId, {
item,
input: entry?.input ?? null,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null,
});
}
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
const settled = this.settledSubagentToolCalls.get(subagentId);
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
return settled && settled.turnId === currentTurnId ? settled : undefined;
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
}
}
}
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 } : {}),