From 2095efb07c6197bb16e434a03e913a063fe7188b Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:29:44 +0000
Subject: [PATCH] fix(claude): restore stopped background subagents as canceled
Resolve task-only replay notifications through the parent launch identity so stopped children keep their recorded outcome after reopening. Preserve transcript order across task and tool notifications.
---
.../claude/agent.sub-agent-replay.test.ts | 65 ++++++++++++++++++-
.../server/agent/providers/claude/agent.ts | 20 ++++--
2 files changed, 78 insertions(+), 7 deletions(-)
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,