diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 0b7e96f73..9c8b26771 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -57,14 +57,18 @@ function taskToolResult(options: { isError?: boolean; mentionAgentId?: boolean } }); } -function sidechainEntry(): string { +function sidechainEntry(options: { agentId?: string; stopReason?: string | null } = {}): string { return JSON.stringify({ type: "assistant", isSidechain: true, - agentId: AGENT_ID, + agentId: options.agentId ?? AGENT_ID, sessionId: "replay-session", timestamp: "2026-07-26T06:27:50.000Z", - message: { role: "assistant", content: [{ type: "text", text: "summary of the docs" }] }, + message: { + role: "assistant", + content: [{ type: "text", text: "summary of the docs" }], + stop_reason: options.stopReason ?? null, + }, }); } @@ -77,24 +81,40 @@ describe("ClaudeAgentSession persisted subagent replay", () => { let cwd: string; let configDir: string; + function writeParentSession(parentLines: string[]): string { + const historyDir = claudeProjectDirSync(cwd, { configDir }); + mkdirSync(historyDir, { recursive: true }); + writeFileSync(path.join(historyDir, "replay-session.jsonl"), parentLines.join("\n")); + return path.join(historyDir, "replay-session", "subagents"); + } + + function writeSubagent(options: { + subagentDir: string; + agentId?: string; + meta?: string | null; + sidechainLines?: string[]; + }): void { + const agentId = options.agentId ?? AGENT_ID; + mkdirSync(options.subagentDir, { recursive: true }); + writeFileSync( + path.join(options.subagentDir, `agent-${agentId}.jsonl`), + (options.sidechainLines ?? [sidechainEntry({ agentId })]).join("\n"), + ); + if (options.meta !== null && options.meta !== undefined) { + writeFileSync(path.join(options.subagentDir, `agent-${agentId}.meta.json`), options.meta); + } + } + function writeSession(options: { parentLines: string[]; meta?: string | null; sidechainLines?: string[]; }): void { - const historyDir = claudeProjectDirSync(cwd, { configDir }); - mkdirSync(historyDir, { recursive: true }); - writeFileSync(path.join(historyDir, "replay-session.jsonl"), options.parentLines.join("\n")); - - const subagentDir = path.join(historyDir, "replay-session", "subagents"); - mkdirSync(subagentDir, { recursive: true }); - writeFileSync( - path.join(subagentDir, `agent-${AGENT_ID}.jsonl`), - (options.sidechainLines ?? [sidechainEntry()]).join("\n"), - ); - if (options.meta !== null && options.meta !== undefined) { - writeFileSync(path.join(subagentDir, `agent-${AGENT_ID}.meta.json`), options.meta); - } + writeSubagent({ + subagentDir: writeParentSession(options.parentLines), + meta: options.meta, + sidechainLines: options.sidechainLines, + }); } async function replayDescriptors(): Promise< @@ -198,6 +218,69 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(statuses).toContain("running"); }); + test("finishes a Task subagent from its own end_turn when the parent outcome is missing", async () => { + writeSession({ + parentLines: [taskToolUse()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: "completed", + }); + }); + + test("does not replay a skill-spawned subagent the parent never named", async () => { + // The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only + // agentType and its id appears nowhere in the parent — it used to replay as running forever, + // and every reopen resurrected it. + writeSession({ + parentLines: [parentEntry([{ type: "text", text: "running /code-review" }])], + meta: JSON.stringify({ agentType: "general-purpose" }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + + expect(upserts(await replayDescriptors())).toEqual([]); + }); + + test("does not accumulate internal workflow agents as replay-only running rows", async () => { + const subagentRoot = writeParentSession([ + parentEntry([ + { + type: "tool_use", + id: "toolu_workflow", + name: "Workflow", + input: { script: "await Promise.all([agent('one'), agent('two'), agent('three')])" }, + }, + ]), + ]); + const workflowDir = path.join(subagentRoot, "workflows", "wf_c240e728-6ea"); + + for (const agentId of ["a17a341a0902f5799", "ae2d01ba2dfa46032", "abc9f20546c326c53"]) { + writeSubagent({ + subagentDir: workflowDir, + agentId, + meta: JSON.stringify({ agentType: "workflow-subagent", spawnDepth: 1 }), + sidechainLines: [sidechainEntry({ agentId, stopReason: "end_turn" })], + }); + } + + expect(upserts(await replayDescriptors())).toEqual([]); + }); + + test("does not replay a subagent whose toolUseId names no Task call in this transcript", async () => { + // A grandchild recorded before spawnDepth existed: the Task call it names was made inside a + // sibling's session, so no tool_result for it can ever reach this parent. + writeSession({ + parentLines: [parentEntry([{ type: "text", text: "no task calls here" }])], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID, agentType: "Explore" }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + + expect(upserts(await replayDescriptors())).toEqual([]); + }); + test.each([ ["truncated json", '{"toolUseId":"toolu_01DgLoPMW9"'], ["not json", "toolUseId: toolu"], diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index e47fe817f..6dceb8d0c 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -99,7 +99,7 @@ describe("observeReplaySubagents", () => { // Claude Code writes every descendant into the same subagents/ directory. A grandchild's // toolUseId names a Task call made inside its parent's session, so nothing here can resolve // it: it would replay as an extra row the live stream never showed, with no Task card and no - // outcome, running forever. One recorded session showed 10 subagents live and 22 on reopen. + // outcome. One recorded session showed 10 subagents live and 22 on reopen. const observations = observeReplaySubagents({ subagents: [ { agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] }, @@ -126,8 +126,7 @@ describe("observeReplaySubagents", () => { expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); }); - it("recovers identity from meta when the parent's Task call is missing", () => { - // The case the legacy scrape cannot handle: no tool_result, so no link and no identity. + it("drops a meta-linked subagent when the parent never declared its Task", () => { const observations = observeReplaySubagents({ subagents: [ { @@ -140,12 +139,7 @@ describe("observeReplaySubagents", () => { convertEntry: () => [], }); - expect(observations[0]).toMatchObject({ - id: TOOL_USE_ID, - toolCallId: TOOL_USE_ID, - title: "Explore", - description: "Find the code", - }); + expect(observations).toEqual([]); }); it("falls back to the scraped link when there is no meta file", () => { @@ -165,14 +159,127 @@ describe("observeReplaySubagents", () => { expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); }); - it("keeps an unlinkable subagent visible under its own id", () => { - const [declared] = observeReplaySubagents({ + it("drops an unlinkable subagent", () => { + const observations = observeReplaySubagents({ subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], parent: emptyParent(), convertEntry: () => [], }); - expect(declared).toMatchObject({ id: AGENT_ID }); - expect(declared).not.toHaveProperty("toolCallId"); + expect(observations).toEqual([]); + }); + + it("drops a terminal subagent the parent has no Task call for", () => { + // A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so + // no tool_result for it can ever reach the parent. Left running, it never stops. + const descriptor = applyToStore( + observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }), + ); + + expect(descriptor).toBeNull(); + }); + + it("drops a terminal skill-spawned subagent that has no link at all", () => { + // A /code-review run: no Task tool_use exists to name it, so its sidecar carries only + // agentType and its id appears nowhere in the parent transcript. + const descriptor = applyToStore( + observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { agentType: "general-purpose" }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }), + ); + + expect(descriptor).toBeNull(); + }); + + it("reports a scraped link's failure as failed", () => { + const descriptor = applyToStore( + observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: { + toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), + outcomesByToolCallId: new Map(), + }, + convertEntry: () => [], + }), + ); + + expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); + }); + + it("drops a non-terminal linkless subagent", () => { + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { agentType: "general-purpose" }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: null }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }); + + expect(observations).toEqual([]); + }); + + it("finishes a terminal Task child when the parent has not recorded its outcome", () => { + const descriptor = applyToStore( + observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }), + ); + + expect(descriptor).toMatchObject({ status: "completed" }); }); it("leaves a subagent with no recorded outcome running", () => { diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 61fc2c54f..99eb8e6c6 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -2,6 +2,7 @@ import { z } from "zod"; 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 type { SubagentObservation } from "./observation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; @@ -152,51 +153,81 @@ export interface ClaudeReplayParentFacts { outcomesByToolCallId: ReadonlyMap; } -interface ResolvedLink { +interface ParentLink { id: string; - toolCallId: string | null; - failed: boolean | null; + toolCallId: string; + /** Terminal status recorded by the parent, or null when its Task has no outcome yet. */ + status: ProviderSubagentStatus | null; } /** - * Resolve the canonical subagent id. + * Resolve a child only when the parent transcript declares the corresponding Task. * - * `meta.toolUseId` is authoritative: it is the same id the live stream keys on. The legacy path - * scrapes `agentId:` out of stringified tool-result content, which both misses the link when the - * parent's tool_result is absent and matches unrelated text — on a real five-subagent session it - * produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback for - * sessions recorded before the meta file existed. + * `meta.toolUseId` is authoritative for identity — it is the same id the live stream keys on. The + * legacy path scrapes `agentId:` out of stringified tool-result content, which both misses the link + * when the parent's tool_result is absent and matches unrelated text — on a real five-subagent + * session it produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback + * for sessions recorded before the meta file existed. + * + * ``` + * meta.toolUseId matches a parent Task? + * ├─ yes → use it and the parent's outcome, when present + * └─ no → a legacy scraped agentId link matches a parent Task? + * ├─ yes → use it and its recorded outcome + * └─ no → drop it; live never declared this child either + * ``` + * + * Claude writes grandchildren and skill-owned sidechains beside direct children. Admitting those + * without a parent Task creates replay-only rows with no identity or lifecycle source. Filtering + * here keeps live and replay on the same declaration boundary. */ -function resolveLink( +function resolveParentLink( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, -): ResolvedLink { +): ParentLink | null { const metaToolUseId = subagent.meta?.toolUseId?.trim(); - if (metaToolUseId) { + if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) { const outcome = parent.outcomesByToolCallId.get(metaToolUseId); + let status: ProviderSubagentStatus | null = null; + if (outcome) status = outcome.failed ? "failed" : "completed"; return { id: metaToolUseId, toolCallId: metaToolUseId, - failed: outcome ? outcome.failed : null, + status, }; } const scraped = parent.linksByAgentId.get(subagent.agentId); - if (scraped) { - return { id: scraped.toolCallId, toolCallId: scraped.toolCallId, failed: scraped.failed }; + if (scraped && parent.toolCalls.has(scraped.toolCallId)) { + return { + id: scraped.toolCallId, + toolCallId: scraped.toolCallId, + status: scraped.failed ? "failed" : "completed", + }; } - // No link at all: keep the subagent visible under its own agent id rather than dropping it. - return { id: subagent.agentId, toolCallId: null, failed: null }; + return null; +} + +/** A final `end_turn` is the child's own completion signal when the parent result is absent. */ +function readChildTerminalStatus( + entries: readonly ClaudeReplayEntry[], +): ProviderSubagentStatus | null { + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]; + if (entry?.type !== "assistant") continue; + return entry.message?.stop_reason === "end_turn" ? "completed" : null; + } + return null; } /** - * The parent's tool input is the same source the live path reads, so it wins; meta.json fills the - * gap when the parent's Task call is missing from the transcript. + * The parent's tool input is the same identity source the live path reads; meta.json contributes + * provider details only after that declaration has admitted the child. */ function declareSubagent( subagent: ClaudeReplaySubagentInput, - link: ResolvedLink, + link: ParentLink, toolCall: { title?: string; description?: string } | undefined, ): SubagentObservation { const title = toolCall?.title ?? subagent.meta?.agentType; @@ -214,7 +245,7 @@ function declareSubagent( function observeSubtitle( subagent: ClaudeReplaySubagentInput, - link: ResolvedLink, + link: ParentLink, title: string | undefined, ): SubagentObservation | null { const runtime = readRuntime(subagent.entries); @@ -244,8 +275,9 @@ function observeSubagent( parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], ): SubagentObservation[] { - const link = resolveLink(subagent, parent); - const toolCall = link.toolCallId ? parent.toolCalls.get(link.toolCallId) : undefined; + const link = resolveParentLink(subagent, parent); + if (!link) return []; + const toolCall = parent.toolCalls.get(link.toolCallId); const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)]; const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType); @@ -263,14 +295,15 @@ function observeSubagent( } } - // A transcript with no recorded outcome is a subagent that never finished — leave it running - // rather than inventing a completion. - if (link.failed !== null) { + // Prefer the parent's result because it carries failure. If that result is missing, the child's + // final end_turn still proves completion. Any other shape remains running. + const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries); + if (terminalStatus) { const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); observations.push({ kind: "status", id: link.id, - status: link.failed ? "failed" : "completed", + status: terminalStatus, ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), }); } @@ -286,9 +319,8 @@ function observeSubagent( * another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session, * so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1 * id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them - * anyway adds rows the live stream never showed, each with no Task card and no recoverable - * outcome, which leaves them running forever. One session recorded 10 subagents live and would - * replay 22. + * anyway adds rows the live stream never showed, each with no Task card and no recoverable outcome. + * One session recorded 10 subagents live and would replay 22. * * Absent depth means a session recorded before the field existed: keep the subagent rather than * lose it, matching how every other missing field here is treated.