Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 47d3910943 test(claude): name replay notification fixture options 2026-11-07 13:36:44 +00:00
paseo-bot[bot] 2095efb07c 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.
2026-11-07 13:29:44 +00:00
2 changed files with 95 additions and 10 deletions
@@ -607,14 +607,23 @@ 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,
form = "user",
includeToolUseId = true,
}: {
status: string;
form?: string;
includeToolUseId?: boolean;
}): 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 },
@@ -636,7 +645,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
`restores a %s task notification from ${form} when the child stop_reason is null`,
async (status, expected) => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
parentLines: [...backgroundLaunch(), taskNotification({ status, form })],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
@@ -648,9 +657,73 @@ 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, includeToolUseId: 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({ status: "stopped", form: "queue-operation", includeToolUseId: false }),
taskNotification({ status: "stopped", form: "user", includeToolUseId: false }),
taskNotification({ status: "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({ status: "completed" }),
taskNotification({ status: "stopped", form: "user", includeToolUseId: 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({ status: "stopped", form: "user", includeToolUseId: 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")],
parentLines: [...backgroundLaunch(), taskNotification({ status: "stopped" })],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
@@ -659,7 +732,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
test("does not invent an outcome for an unknown notification status", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
parentLines: [...backgroundLaunch(), taskNotification({ status: "unknown" })],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
@@ -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,