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.
This commit is contained in:
paseo-bot[bot]
2026-11-07 13:29:44 +00:00
parent 28c01ea1ce
commit 2095efb07c
2 changed files with 78 additions and 7 deletions
@@ -607,14 +607,15 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(statuses).toContain("running");
});
function taskNotification(status: string, form = "user"): string {
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
function taskNotification(status: string, form = "user", includeToolUseId = true): string {
const toolUseTag = includeToolUseId ? `<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n` : "";
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n${toolUseTag}<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
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")],
@@ -6435,10 +6435,9 @@ async function readClaudeReplayParentFacts(
const linksByAgentId = new Map<string, { toolCallId: string }>();
const outcomes: Map<string, { status: ProviderSubagentStatus }> = 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<string, { toolCallId: string }>,
): { 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<string, unknown> | undefined,