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 () => {
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
@@ -303,6 +303,8 @@ interface ClaudeReplayOwnership {
interface ClaudeSubagentCardFacts {
title?: string;
description?: string;
/** The child's tool calls, one line each. Live, the sidechain tracker streams it instead. */
log?: string;
}
interface PersistedTimelineEntry {
@@ -5016,7 +5018,12 @@ class ClaudeAgentSession implements AgentSession {
const replay = {
restoredIds: restoredProviderSubagentIds,
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;
@@ -5846,7 +5853,7 @@ function buildClaudeSubagentCardDetail(
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
log: "",
log: facts.log ?? "",
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 {
private readonly activeSidechains = new Map<string, SubAgentActivityState>();
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
@@ -129,11 +209,7 @@ export class ClaudeSidechainTracker {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
log: state.actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
log: formatSubAgentActionLog(state.actions),
actions: [],
};
@@ -298,9 +374,9 @@ export class ClaudeSidechainTracker {
parentToolUseId: string,
): boolean {
const taskInput = this.getToolInput(parentToolUseId);
const nextName = this.normalizeSubAgentText(taskInput?.name);
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
const nextName = normalizeSubAgentText(taskInput?.name);
const nextSubAgentType = normalizeSubAgentText(taskInput?.subagent_type);
const nextDescription = normalizeSubAgentText(taskInput?.description);
let changed = false;
if (nextName && nextName !== state.name) {
@@ -318,17 +394,6 @@ export class ClaudeSidechainTracker {
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(
message: Extract<SDKMessage, { type: "assistant" }>,
): SubAgentActionCandidate[] {
@@ -338,15 +403,7 @@ export class ClaudeSidechainTracker {
}
const actions: SubAgentActionCandidate[] = [];
for (const block of content) {
if (
!isClaudeContentChunk(block) ||
!(
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) ||
typeof block.name !== "string"
) {
if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) {
continue;
}
const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`;
@@ -367,15 +424,7 @@ export class ClaudeSidechainTracker {
return [];
}
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
if (
!block ||
!(
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) ||
typeof block.name !== "string"
) {
if (!block || !isToolUseChunk(block)) {
return [];
}
const key =
@@ -420,7 +469,7 @@ export class ClaudeSidechainTracker {
return false;
}
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const summary = deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const existingIndex = state.actionIndexByKey.get(candidate.key);
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 type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js";
import { readClaudeSubagentActionLog } from "../sidechain-tracker.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -337,9 +338,15 @@ export function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
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 toolOwners = new Map<string, string>();
const actionLogs = new Map<string, string>();
const unresolved = [...input.subagents].sort(
(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.
recordReplayToolOwners(toolOwners, subagent.entries, link.id);
if (!ownerId) actionLogs.set(link.id, readClaudeSubagentActionLog(subagent.entries));
observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId));
if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts);
unresolved.splice(index, 1);
madeProgress = true;
}
}
return { observations, toolOwners };
return { observations, toolOwners, actionLogs };
}
function resolveReplayOwner(