fix(claude): keep a subagent's card labeled after a daemon restart (#6100)

After a restart the parent's timeline is rebuilt from Claude Code's
transcript, and the Task/Agent call went through the generic tool
mapper, which has no subagent branch, so the card replayed as a bare
"Agent" with detail type "unknown". Live, the card is built from the
task protocol's declaration instead.

Replay already reads each Task call's type and description from the
parent transcript for the subagent track. Label the replayed cards
from those same facts, through the builder the live path uses.

The shared tool-call parser is left alone: a completed sub_agent card
from it would replace the action log a foreground child's card
carries live.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-05 10:10:42 +02:00
committed by GitHub
parent f24bb523e8
commit da9fae172d
2 changed files with 111 additions and 12 deletions
@@ -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()],
@@ -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<T> {
interface ClaudeReplayOwnership {
restoredIds: ReadonlySet<string>;
toolOwners: ReadonlyMap<string, string>;
/** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>;
}
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<ToolCallDetail, { type: "sub_agent" }> {
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<string, ClaudeSubagentCardFacts>,
): 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