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 666e701649..9fc82d6de8 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 @@ -422,6 +422,237 @@ describe("ClaudeAgentSession persisted subagent replay", () => { }); }); + describe("a background subagent", () => { + // Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result, + // and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn. + function backgroundLaunch(): string[] { + return [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "probe sleeper", + run_in_background: true, + }, + }, + ]), + 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 }, + }), + ]; + } + + function readStatus(event: { status?: string }): string | undefined { + return event.status; + } + + function interruptedChild(): string[] { + return [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:27:50.000Z", + message: { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }], + stop_reason: "tool_use", + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:28:05.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_child_bash", + content: "The user doesn't want to proceed with this tool use.", + is_error: true, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:28:05.001Z", + message: { + role: "user", + content: [{ type: "text", text: "[Request interrupted by user for tool use]" }], + }, + }), + ]; + } + + test.each([ + ["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })], + ["the legacy scrape", null], + ])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => { + writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: "canceled", + }); + }); + + test("stays running when the child was given a new prompt after the interrupt", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + ...interruptedChild(), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:29:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] }, + }), + ], + }); + + const statuses = upserts(await replayDescriptors()).map(readStatus); + expect(statuses).not.toContain("canceled"); + expect(statuses).toContain("running"); + }); + + test.each(["interrupted", "running"])( + "ignores an API request copy of a %s child's launch result", + async (state) => { + const launch = backgroundLaunch(); + const result = JSON.parse(launch[1]!); + writeSession({ + parentLines: [ + ...launch, + JSON.stringify({ + type: "api-request-blob", + sessionId: "replay-session", + hash: "request-copy", + message: result.message, + }), + ], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: + state === "interrupted" + ? interruptedChild() + : [sidechainEntry({ stopReason: "tool_use" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: state === "interrupted" ? "canceled" : "running", + }); + }, + ); + + test("stays running while the child has not finished", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + + const statuses = upserts(await replayDescriptors()).map(readStatus); + expect(statuses).not.toContain("completed"); + expect(statuses).toContain("running"); + }); + + function taskNotification(status: string, form = "user"): string { + const text = `\n${AGENT_ID}\n${TOOL_USE_ID}\n${status}\nBackground task ended\n`; + const records = { + user: { message: { role: "user", content: [{ type: "text", text }] } }, + system: { + subtype: "task_notification", + task_id: AGENT_ID, + tool_use_id: TOOL_USE_ID, + status, + }, + "queue-operation": { operation: "enqueue", content: text }, + }; + return JSON.stringify({ + type: form, + timestamp: "2026-07-26T06:29:00.000Z", + ...records[form as keyof typeof records], + }); + } + + for (const form of ["user", "system", "queue-operation"]) { + test.each([ + ["completed", "completed"], + ["failed", "failed"], + ["killed", "canceled"], + ["stopped", "canceled"], + ])( + `restores a %s task notification from ${form} when the child stop_reason is null`, + async (status, expected) => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification(status, form)], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: null })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: expected, + }); + }, + ); + } + + test("prefers a stopped notification over the child's end_turn", async () => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification("stopped")], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" }); + }); + + test("does not invent an outcome for an unknown notification status", async () => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification("unknown")], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: null })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" }); + }); + + test("completes from the child's own end_turn", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ 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, diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 6516ff81b7..e2cd9dab8f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -33,7 +33,9 @@ import { mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, readTaskNotificationToolUseIdFromHistoryRecord, + readTaskNotificationFromHistoryRecord, } from "./task-notification-tool-call.js"; +import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js"; import { getClaudeContextWindowMaxTokens, getClaudeModelsWithSettings, @@ -56,6 +58,8 @@ import { type ClaudeHookObservationInput, } from "./subagents/live-source.js"; import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js"; +import { mapTaskStatus } from "./subagents/task-status.js"; +import type { ProviderSubagentStatus } from "../../provider-subagents/store.js"; import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js"; import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js"; import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; @@ -425,7 +429,6 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([ "usage", ]); const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]"; -const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/; const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested."; const STEER_SUPERSEDED_PERMISSION_MESSAGE = "The user answered with a message instead of approving. Their message follows."; @@ -789,11 +792,6 @@ function normalizeClaudeTranscriptText(value: unknown): string | null { return normalized.length > 0 ? normalized : null; } -function isClaudeInterruptPlaceholderText(value: unknown): boolean { - const normalized = normalizeClaudeTranscriptText(value); - return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized); -} - function isClaudeNoResponsePlaceholderText(value: unknown): boolean { return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER; } @@ -6262,33 +6260,34 @@ async function readClaudeReplayParentFacts( entries: Iterable | AsyncIterable, ): Promise { const toolCalls = new Map(); - const linksByAgentId = new Map(); - const outcomes = new Map(); + const linksByAgentId = new Map(); + const outcomes: Map = new Map(); for await (const entry of entries) { + const notification = readTaskNotificationFromHistoryRecord(entry); + const status = mapTaskStatus(notification?.status ?? undefined); + if (notification?.toolUseId && status) { + outcomes.set(notification.toolUseId, { status }); + } const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { const block = toObjectRecord(value); + const declaration = readClaudeReplayTaskDeclaration(entry.type, block); + if (declaration) toolCalls.set(declaration.id, declaration.identity); if ( - block?.type === "tool_use" && - (block.name === "Task" || block.name === "Agent") && - typeof block.id === "string" - ) { - const input = toObjectRecord(block.input); - const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); - const description = readNonEmptyString(input?.description); - toolCalls.set(block.id, { - ...(title ? { title } : {}), - ...(description ? { description } : {}), + entry.type !== "user" || + block?.type !== "tool_result" || + typeof block.tool_use_id !== "string" + ) + continue; + if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched") + outcomes.set(block.tool_use_id, { + status: block.is_error === true ? "failed" : "completed", }); - } - if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; - outcomes.set(block.tool_use_id, { failed: block.is_error === true }); const agentId = readLegacyClaudeSubagentId(block.content); if (agentId) linksByAgentId.set(agentId, { toolCallId: block.tool_use_id, - failed: block.is_error === true, }); } } @@ -6299,6 +6298,29 @@ async function readClaudeReplayParentFacts( }; } +function readClaudeReplayTaskDeclaration( + entryType: unknown, + block: Record | undefined, +): { + id: string; + identity: { title?: string; description?: string }; +} | null { + if ( + entryType !== "assistant" || + block?.type !== "tool_use" || + (block.name !== "Task" && block.name !== "Agent") || + typeof block.id !== "string" + ) + return null; + const input = toObjectRecord(block.input); + const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); + const description = readNonEmptyString(input?.description); + return { + id: block.id, + identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) }, + }; +} + function readLegacyClaudeSubagentId(content: unknown): string | undefined { return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1]; } diff --git a/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts b/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts new file mode 100644 index 0000000000..f74c899234 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts @@ -0,0 +1,6 @@ +const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/; + +/** The text Claude Code records in place of a turn that an interrupt stopped. */ +export function isClaudeInterruptPlaceholderText(value: unknown): boolean { + return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim()); +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 2149fae139..af941aa24b 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -1,7 +1,8 @@ +import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; +import { mapTaskStatus } from "./task-status.js"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentMetadata } from "../../../agent-sdk-types.js"; -import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; import { resolveObservedClaudeModelId } from "../models.js"; import type { SubagentObservation } from "./observation.js"; import { @@ -120,28 +121,6 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } -/** - * The descriptor has no paused or pending state, and neither is terminal, so both read as - * running. `killed` is a cancellation, not a failure — the child was stopped, it did not error. - */ -function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined { - switch (status) { - case "pending": - case "running": - case "paused": - return "running"; - case "completed": - return "completed"; - case "failed": - return "failed"; - case "killed": - case "stopped": - return "canceled"; - default: - return undefined; - } -} - export interface ClaudeTaskProtocolSourceInput { /** * The parent's Task tool input, by tool_use id. 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 f5bbf52422..3bde4b813b 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 @@ -36,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts { ], ]), linksByAgentId: new Map(), - outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]), }; } @@ -117,7 +117,7 @@ describe("observeReplaySubagents", () => { [nestedToolUseId, { title: "Explore", description: "Nested audit" }], ]), linksByAgentId: new Map(), - outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]), }, }, { @@ -173,8 +173,8 @@ describe("observeReplaySubagents", () => { it("falls back to the scraped link when there is no meta file", async () => { const parent: ClaudeReplayParentFacts = { toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), - linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), - outcomesByToolCallId: new Map(), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]), }; const [declared, ...rest] = ( @@ -263,8 +263,8 @@ describe("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(), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]), }, convertEntry: () => [], }) 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 2a3ca74846..02cbb783f0 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 @@ -3,6 +3,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 { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js"; import { resolveObservedClaudeModelId } from "../models.js"; import type { SubagentObservation } from "./observation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; @@ -139,9 +140,12 @@ export interface ClaudeReplayParentFacts { /** Task `tool_use` id -> identity declared in the parent's tool input. */ toolCalls: ReadonlyMap; /** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */ - linksByAgentId: ReadonlyMap; - /** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */ - outcomesByToolCallId: ReadonlyMap; + linksByAgentId: ReadonlyMap; + /** + * Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has + * none: its immediate result only says the child started. + */ + outcomesByToolCallId: ReadonlyMap; } interface ParentLink { @@ -177,27 +181,14 @@ function resolveParentLink( parent: ClaudeReplayParentFacts, ): ParentLink | null { const metaToolUseId = subagent.meta?.toolUseId?.trim(); - 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, - status, - }; - } + const toolCallId = + metaToolUseId && parent.toolCalls.has(metaToolUseId) + ? metaToolUseId + : parent.linksByAgentId.get(subagent.agentId)?.toolCallId; + if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null; - const scraped = parent.linksByAgentId.get(subagent.agentId); - if (scraped && parent.toolCalls.has(scraped.toolCallId)) { - return { - id: scraped.toolCallId, - toolCallId: scraped.toolCallId, - status: scraped.failed ? "failed" : "completed", - }; - } - - return null; + const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null; + return { id: toolCallId, toolCallId, status }; } /** @@ -276,9 +267,7 @@ async function observeSubagent( lastTimestamp = timestamp; Object.assign(runtime, readRuntime(entry)); usage = readUsage(entry) ?? usage; - if (entry.type === "assistant") { - childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; - } + childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus); recordReplayToolOwners(toolOwners, entry, link.id); for (const item of await convertEntry(entry)) { timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); @@ -308,6 +297,30 @@ async function observeSubagent( return observations; } +/** Advance the child's fallback outcome without retaining transcript entries. */ +function readChildTerminalStatus( + entry: ClaudeReplayEntry, + previous: ProviderSubagentStatus | null, +): ProviderSubagentStatus | null { + if (entry.type === "assistant") { + return entry.message?.stop_reason === "end_turn" ? "completed" : null; + } + if (entry.type !== "user") return previous; + const texts = readUserTexts(entry); + if (texts.length === 0) return previous; + return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null; +} + +/** Tool results carry no user text and do not change the child's terminal status. */ +function readUserTexts(entry: ClaudeReplayEntry): string[] { + const content = entry.message?.content; + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + return content.flatMap((block: { type?: unknown; text?: unknown } | null) => + block?.type === "text" && typeof block.text === "string" ? [block.text] : [], + ); +} + function recordReplayToolOwners( owners: Map, entry: ClaudeReplayEntry, diff --git a/packages/server/src/server/agent/providers/claude/subagents/task-status.ts b/packages/server/src/server/agent/providers/claude/subagents/task-status.ts new file mode 100644 index 0000000000..a488ce12d7 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/task-status.ts @@ -0,0 +1,23 @@ +import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; + +/** + * The descriptor has no paused or pending state, and neither is terminal, so both read as + * running. `killed` is a cancellation, not a failure — the child was stopped, it did not error. + */ +export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined { + switch (status) { + case "pending": + case "running": + case "paused": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + case "killed": + case "stopped": + return "canceled"; + default: + return undefined; + } +} diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index c8e38bf918..76ad89d8ef 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -407,19 +407,21 @@ export function mapTaskNotificationSystemRecordToToolCall( } export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null { + return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null; +} + +export function readTaskNotificationFromHistoryRecord( + record: unknown, +): TaskNotificationEnvelope | null { const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record); - if (!parsedRecord.success) { - return null; - } + if (!parsedRecord.success) return null; if (parsedRecord.data.type === "user" && parsedRecord.data.message) { - return ( - parseTaskNotificationFromUserContent({ - content: parsedRecord.data.message.content, - messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id, - })?.toolUseId ?? null - ); + return parseTaskNotificationFromUserContent({ + content: parsedRecord.data.message.content, + messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id, + }); } - return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null; + return parseTaskNotificationFromSystemRecord(record); } export function coerceTaskNotificationHistoryRecordToSystemMessage(