Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 9c2bbac1a0 perf(claude): summarize only the subagent log lines kept after a restart 2026-10-05 13:07:17 +02:00
paseo-bot[bot] 8a4abfedfb fix(claude): keep a subagent card's action log after a daemon restart
Live, the parent's card for a foreground Claude subagent carries the
child's tool calls as its action log, built by the sidechain tracker
as the child's frames stream. After a restart the card is rebuilt from
the transcript, and the replay builder hardcoded an empty log, so the
expanded card lost the child's calls.

The child's transcript on disk holds the same tool_use blocks. The
replay source now reads each root subagent's log from its own
transcript with the tracker's line format, and the replayed card
carries it.
2026-10-05 12:36:03 +02:00
4 changed files with 160 additions and 62 deletions
@@ -700,6 +700,60 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
}, },
); );
test("keeps a foreground subagent card's action log after a restart", async () => {
writeSession({
parentLines: [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files foreground",
prompt: "Run ls and reply with the number of entries.",
run_in_background: false,
},
},
]),
taskToolResult(),
],
meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }),
sidechainLines: [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:49.000Z",
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_child_bash",
name: "Bash",
input: { command: "ls -1 | wc -l", description: "Count entries" },
},
],
},
}),
sidechainEntry({ stopReason: "end_turn" }),
],
});
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({
detail: { type: "sub_agent", log: "[Bash] ls -1 | wc -l" },
});
});
test("replays the subagent's own transcript onto its timeline", async () => { test("replays the subagent's own transcript onto its timeline", async () => {
writeSession({ writeSession({
parentLines: [taskToolUse(), taskToolResult()], parentLines: [taskToolUse(), taskToolResult()],
@@ -303,6 +303,8 @@ interface ClaudeReplayOwnership {
interface ClaudeSubagentCardFacts { interface ClaudeSubagentCardFacts {
title?: string; title?: string;
description?: string; description?: string;
/** The child's tool calls, one line each. Live, the sidechain tracker streams it instead. */
log?: string;
} }
interface PersistedTimelineEntry { interface PersistedTimelineEntry {
@@ -5016,7 +5018,12 @@ class ClaudeAgentSession implements AgentSession {
const replay = { const replay = {
restoredIds: restoredProviderSubagentIds, restoredIds: restoredProviderSubagentIds,
toolOwners: subagentReplay.toolOwners, toolOwners: subagentReplay.toolOwners,
subagentToolCalls: parentFacts.toolCalls, subagentToolCalls: new Map(
[...parentFacts.toolCalls].map(([id, facts]) => {
const log = subagentReplay.actionLogs.get(id);
return [id, log ? { ...facts, log } : facts];
}),
),
}; };
if (observations.length === 0) return replay; if (observations.length === 0) return replay;
@@ -5846,7 +5853,7 @@ function buildClaudeSubagentCardDetail(
type: "sub_agent", type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}), ...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}), ...(facts.description ? { description: facts.description } : {}),
log: "", log: facts.log ?? "",
actions: [], actions: [],
}; };
} }
@@ -55,6 +55,86 @@ function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
); );
} }
function isToolUseChunk(block: ClaudeContentChunk): block is ClaudeContentChunk & { name: string } {
return (
(block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use") &&
typeof block.name === "string"
);
}
function normalizeSubAgentText(value: unknown): string | undefined {
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
}
function deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
input,
output: null,
});
if (!runningToolCall) {
return undefined;
}
const display = buildToolCallDisplayModel({
name: runningToolCall.name,
status: runningToolCall.status,
error: runningToolCall.error,
detail: runningToolCall.detail,
metadata: runningToolCall.metadata,
});
return normalizeSubAgentText(display.summary);
}
/** The action log on the parent's card for a subagent, one line per tool the child called. */
function formatSubAgentActionLog(
actions: readonly { toolName: string; summary?: string }[],
): string {
return actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n");
}
/**
* Rebuild a subagent card's action log from the child's persisted transcript, matching the log
* the live tracker builds from the same tool calls as they stream.
*/
export function readClaudeSubagentActionLog(
entries: readonly { type?: unknown; message?: { content?: unknown } }[],
): string {
const callsById = new Map<string, { toolName: string; input: unknown }>();
for (const entry of entries) {
const content = entry.type === "assistant" ? entry.message?.content : undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) continue;
const toolName = readTrimmedString(block.name);
if (!toolName) continue;
const key = readTrimmedString(block.id) ?? `assistant:${toolName}:${callsById.size}`;
if (callsById.has(key)) continue;
callsById.set(key, { toolName, input: block.input ?? null });
}
}
const actions = [...callsById.values()]
.slice(-MAX_SUB_AGENT_LOG_ENTRIES)
.map(({ toolName, input }) => ({
toolName,
summary: deriveSubAgentActionSummary(toolName, input),
}));
return formatSubAgentActionLog(actions);
}
export class ClaudeSidechainTracker { export class ClaudeSidechainTracker {
private readonly activeSidechains = new Map<string, SubAgentActivityState>(); private readonly activeSidechains = new Map<string, SubAgentActivityState>();
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
@@ -129,11 +209,7 @@ export class ClaudeSidechainTracker {
type: "sub_agent", type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}), ...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}), ...(state.description ? { description: state.description } : {}),
log: state.actions log: formatSubAgentActionLog(state.actions),
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
actions: [], actions: [],
}; };
@@ -298,9 +374,9 @@ export class ClaudeSidechainTracker {
parentToolUseId: string, parentToolUseId: string,
): boolean { ): boolean {
const taskInput = this.getToolInput(parentToolUseId); const taskInput = this.getToolInput(parentToolUseId);
const nextName = this.normalizeSubAgentText(taskInput?.name); const nextName = normalizeSubAgentText(taskInput?.name);
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type); const nextSubAgentType = normalizeSubAgentText(taskInput?.subagent_type);
const nextDescription = this.normalizeSubAgentText(taskInput?.description); const nextDescription = normalizeSubAgentText(taskInput?.description);
let changed = false; let changed = false;
if (nextName && nextName !== state.name) { if (nextName && nextName !== state.name) {
@@ -318,17 +394,6 @@ export class ClaudeSidechainTracker {
return changed; return changed;
} }
private normalizeSubAgentText(value: unknown): string | undefined {
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
}
private extractAssistantMessageActions( private extractAssistantMessageActions(
message: Extract<SDKMessage, { type: "assistant" }>, message: Extract<SDKMessage, { type: "assistant" }>,
): SubAgentActionCandidate[] { ): SubAgentActionCandidate[] {
@@ -338,15 +403,7 @@ export class ClaudeSidechainTracker {
} }
const actions: SubAgentActionCandidate[] = []; const actions: SubAgentActionCandidate[] = [];
for (const block of content) { for (const block of content) {
if ( if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) {
!isClaudeContentChunk(block) ||
!(
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) ||
typeof block.name !== "string"
) {
continue; continue;
} }
const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`; const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`;
@@ -367,15 +424,7 @@ export class ClaudeSidechainTracker {
return []; return [];
} }
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null; const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
if ( if (!block || !isToolUseChunk(block)) {
!block ||
!(
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) ||
typeof block.name !== "string"
) {
return []; return [];
} }
const key = const key =
@@ -420,7 +469,7 @@ export class ClaudeSidechainTracker {
return false; return false;
} }
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input); const summary = deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const existingIndex = state.actionIndexByKey.get(candidate.key); const existingIndex = state.actionIndexByKey.get(candidate.key);
if (existingIndex !== undefined) { if (existingIndex !== undefined) {
@@ -471,24 +520,4 @@ export class ClaudeSidechainTracker {
} }
} }
} }
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
input,
output: null,
});
if (!runningToolCall) {
return undefined;
}
const display = buildToolCallDisplayModel({
name: runningToolCall.name,
status: runningToolCall.status,
error: runningToolCall.error,
detail: runningToolCall.detail,
metadata: runningToolCall.metadata,
});
return this.normalizeSubAgentText(display.summary);
}
} }
@@ -4,6 +4,7 @@ import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js"; import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js"; import { resolveObservedClaudeModelId } from "../models.js";
import { readClaudeSubagentActionLog } from "../sidechain-tracker.js";
import type { SubagentObservation } from "./observation.js"; import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -337,9 +338,15 @@ export function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[]; subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts; parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } { }): {
observations: SubagentObservation[];
toolOwners: ReadonlyMap<string, string>;
/** Root subagent id -> the action log its card in the parent transcript shows. */
actionLogs: ReadonlyMap<string, string>;
} {
const observations: SubagentObservation[] = []; const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>(); const toolOwners = new Map<string, string>();
const actionLogs = new Map<string, string>();
const unresolved = [...input.subagents].sort( const unresolved = [...input.subagents].sort(
(left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1), (left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1),
); );
@@ -360,13 +367,14 @@ export function observeReplaySubagents(input: {
// Only proven descendants may own notifications; ambient sidecars cannot claim them. // Only proven descendants may own notifications; ambient sidecars cannot claim them.
recordReplayToolOwners(toolOwners, subagent.entries, link.id); recordReplayToolOwners(toolOwners, subagent.entries, link.id);
if (!ownerId) actionLogs.set(link.id, readClaudeSubagentActionLog(subagent.entries));
observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId)); observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId));
if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts); if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts);
unresolved.splice(index, 1); unresolved.splice(index, 1);
madeProgress = true; madeProgress = true;
} }
} }
return { observations, toolOwners }; return { observations, toolOwners, actionLogs };
} }
function resolveReplayOwner( function resolveReplayOwner(