mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user