Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] c30d967cd9 Keep a background Claude subagent running when it got a new prompt after an interrupt 2026-10-06 22:11:00 +02:00
paseo-bot[bot] 8353a93f71 Keep an interrupted background Claude subagent canceled after a daemon restart
Replay read a background Agent call's immediate async-launch result as the
child's outcome, so every background subagent restored as completed. Skip that
result, and read a child transcript that ends on the interrupt placeholder as
canceled, which is what the live path reported.
2026-10-06 21:58:09 +02:00
5 changed files with 202 additions and 36 deletions
@@ -419,6 +419,150 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
});
});
describe("a background subagent", () => {
// Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result,
// and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn.
function backgroundLaunch(): string[] {
return [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "probe sleeper",
run_in_background: true,
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
];
}
function readStatus(event: { status?: string }): string | undefined {
return event.status;
}
function interruptedChild(): string[] {
return [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:27:50.000Z",
message: {
role: "assistant",
content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }],
stop_reason: "tool_use",
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_child_bash",
content: "The user doesn't want to proceed with this tool use.",
is_error: true,
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.001Z",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
}),
];
}
test.each([
["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })],
["the legacy scrape", null],
])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => {
writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() });
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: "canceled",
});
});
test("stays running when the child was given a new prompt after the interrupt", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
...interruptedChild(),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:29:00.000Z",
message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] },
}),
],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("canceled");
expect(statuses).toContain("running");
});
test("stays running while the child has not finished", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("completed");
expect(statuses).toContain("running");
});
test("completes from the child's own end_turn", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
});
});
test("does not replay a skill-spawned subagent the parent never named", async () => {
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
@@ -30,6 +30,7 @@ import {
mapTaskNotificationUserContentToToolCall,
readTaskNotificationToolUseIdFromHistoryRecord,
} from "./task-notification-tool-call.js";
import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js";
import {
findClaudeModel,
getClaudeModelsWithSettings,
@@ -382,7 +383,6 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([
"usage",
]);
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
const STEER_SUPERSEDED_PERMISSION_MESSAGE =
"The user answered with a message instead of approving. Their message follows.";
@@ -732,11 +732,6 @@ function normalizeClaudeTranscriptText(value: unknown): string | null {
return normalized.length > 0 ? normalized : null;
}
function isClaudeInterruptPlaceholderText(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized);
}
function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
@@ -5901,8 +5896,10 @@ function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): Claud
// Read outcomes straight off the tool_result blocks rather than reusing the agentId scrape,
// so a subagent linked through its meta sidecar still gets a status when the scrape missed it.
// A background launch's result only says the child started, so it is not an outcome.
const outcomesByToolCallId = new Map<string, { failed: boolean }>();
for (const entry of parentEntries) {
if (isClaudeAsyncLaunchResult(entry)) continue;
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -5920,6 +5917,11 @@ function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): Claud
};
}
/** Claude Code answers a background Agent call at once, before the child has done anything. */
function isClaudeAsyncLaunchResult(entry: ClaudeHistoryEntry): boolean {
return toObjectRecord(entry.toolUseResult)?.status === "async_launched";
}
interface ClaudeSidechainHistory {
contents: string[];
workflowContents: string[];
@@ -6044,8 +6046,8 @@ function readClaudeHistoricalSubagentToolCalls(
function readClaudeHistoricalSubagentToolResults(
entries: ClaudeHistoryEntry[],
): Map<string, { toolCallId: string; failed: boolean }> {
const results = new Map<string, { toolCallId: string; failed: boolean }>();
): Map<string, { toolCallId: string }> {
const results = new Map<string, { toolCallId: string }>();
for (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
@@ -6054,7 +6056,7 @@ function readClaudeHistoricalSubagentToolResults(
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content));
if (!match?.[1]) continue;
results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true });
results.set(match[1], { toolCallId: block.tool_use_id });
}
}
return results;
@@ -0,0 +1,6 @@
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
/** The text Claude Code records in place of a turn that an interrupt stopped. */
export function isClaudeInterruptPlaceholderText(value: unknown): boolean {
return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim());
}
@@ -159,8 +159,8 @@ describe("observeReplaySubagents", () => {
it("falls back to the scraped link when there is no meta file", () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed: false }]]),
};
const [declared, ...rest] = observeReplaySubagents({
@@ -240,8 +240,8 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed: true }]]),
},
convertEntry: () => [],
}).observations,
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -150,8 +151,11 @@ export interface ClaudeReplayParentFacts {
/** Task `tool_use` id -> identity declared in the parent's tool input. */
toolCalls: ReadonlyMap<string, { title?: string; description?: string }>;
/** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */
linksByAgentId: ReadonlyMap<string, { toolCallId: string; failed: boolean }>;
/** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */
linksByAgentId: ReadonlyMap<string, { toolCallId: string }>;
/**
* Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has
* none: its immediate result only says the child started.
*/
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
}
@@ -188,41 +192,51 @@ function resolveParentLink(
parent: ClaudeReplayParentFacts,
): ParentLink | null {
const metaToolUseId = subagent.meta?.toolUseId?.trim();
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
let status: ProviderSubagentStatus | null = null;
if (outcome) status = outcome.failed ? "failed" : "completed";
return {
id: metaToolUseId,
toolCallId: metaToolUseId,
status,
};
}
const toolCallId =
metaToolUseId && parent.toolCalls.has(metaToolUseId)
? metaToolUseId
: parent.linksByAgentId.get(subagent.agentId)?.toolCallId;
if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null;
const scraped = parent.linksByAgentId.get(subagent.agentId);
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
return {
id: scraped.toolCallId,
toolCallId: scraped.toolCallId,
status: scraped.failed ? "failed" : "completed",
};
}
return null;
const outcome = parent.outcomesByToolCallId.get(toolCallId);
let status: ProviderSubagentStatus | null = null;
if (outcome) status = outcome.failed ? "failed" : "completed";
return { id: toolCallId, toolCallId, status };
}
/** A final `end_turn` is the child's own completion signal when the parent result is absent. */
/**
* The child's own transcript settles it when the parent result is absent: a final `end_turn` is a
* completion, and a final interrupt placeholder is the cancellation live reported. An interrupt
* followed by a newer prompt is not final: the child was given another turn.
*/
function readChildTerminalStatus(
entries: readonly ClaudeReplayEntry[],
): ProviderSubagentStatus | null {
let sawLaterUserText = false;
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
if (entry?.type === "user") {
const texts = readUserTexts(entry);
if (!sawLaterUserText && texts.some(isClaudeInterruptPlaceholderText)) return "canceled";
if (texts.length > 0) sawLaterUserText = true;
continue;
}
if (entry?.type !== "assistant") continue;
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
return null;
}
/** The text a user entry carries; tool results carry none. */
function readUserTexts(entry: ClaudeReplayEntry): string[] {
const content = entry.message?.content;
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
return content.flatMap((block: { type?: unknown; text?: unknown } | null) =>
block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
);
}
/**
* The parent's tool input is the same identity source the live path reads; meta.json contributes
* provider details only after that declaration has admitted the child.