mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user