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 91007aa7ce..86709a31fc 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 @@ -607,14 +607,15 @@ describe("ClaudeAgentSession persisted subagent replay", () => { 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`; + function taskNotification(status: string, form = "user", includeToolUseId = true): string { + const toolUseTag = includeToolUseId ? `${TOOL_USE_ID}\n` : ""; + const text = `\n${AGENT_ID}\n${toolUseTag}${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, + ...(includeToolUseId ? { tool_use_id: TOOL_USE_ID } : {}), status, }, "queue-operation": { operation: "enqueue", content: text }, @@ -648,6 +649,64 @@ describe("ClaudeAgentSession persisted subagent replay", () => { ); } + for (const form of ["user", "system", "queue-operation"]) { + test.each([ + ["completed", "completed"], + ["failed", "failed"], + ["stopped", "canceled"], + ["unknown", "running"], + ])(`restores a task-id-only %s notification from ${form}`, async (status, expected) => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification(status, form, false)], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: expected, + }); + }); + } + + test("keeps the latest outcome across task-only and tool-linked notifications", async () => { + writeSession({ + parentLines: [ + ...backgroundLaunch(), + taskNotification("stopped", "queue-operation", false), + taskNotification("stopped", "user", false), + taskNotification("completed"), + ], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" }); + }); + + test("honors a later task-only stopped notification after a tool-linked completion", async () => { + writeSession({ + parentLines: [ + ...backgroundLaunch(), + taskNotification("completed"), + taskNotification("stopped", "user", false), + ], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" }); + }); + + test("does not apply an unrelated task's stopped outcome to a child", async () => { + writeSession({ + parentLines: [ + ...backgroundLaunch(), + taskNotification("stopped", "user", false).replace(AGENT_ID, "unrelated-task"), + ], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" }); + }); + test("prefers a stopped notification over the child's end_turn", async () => { writeSession({ parentLines: [...backgroundLaunch(), taskNotification("stopped")], diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b4bd7e27db..d81a2fad33 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6435,10 +6435,9 @@ async function readClaudeReplayParentFacts( 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 notificationOutcome = readClaudeReplayNotificationOutcome(entry, linksByAgentId); + if (notificationOutcome) { + outcomes.set(notificationOutcome.toolCallId, { status: notificationOutcome.status }); } const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; @@ -6470,6 +6469,19 @@ async function readClaudeReplayParentFacts( }; } +function readClaudeReplayNotificationOutcome( + entry: ClaudeHistoryEntry, + linksByAgentId: ReadonlyMap, +): { toolCallId: string; status: ProviderSubagentStatus } | null { + const notification = readTaskNotificationFromHistoryRecord(entry); + const status = mapTaskStatus(notification?.status ?? undefined); + // Stopped tasks can omit tool-use-id; their launch result already links the native ID. + const toolCallId = + notification?.toolUseId ?? + (notification?.taskId ? linksByAgentId.get(notification.taskId)?.toolCallId : undefined); + return toolCallId && status ? { toolCallId, status } : null; +} + function readClaudeReplayTaskDeclaration( entryType: unknown, block: Record | undefined,