From c30d967cd9015db0687c668dff830a4a70aef9ce Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:11:00 +0200 Subject: [PATCH] Keep a background Claude subagent running when it got a new prompt after an interrupt --- .../claude/agent.sub-agent-replay.test.ts | 21 +++++++++++++++++ .../claude/subagents/replay-source.ts | 23 ++++++++++++------- 2 files changed, 36 insertions(+), 8 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 9eddd091ef..3a8f6ad89f 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 @@ -519,6 +519,27 @@ describe("ClaudeAgentSession persisted subagent replay", () => { }); }); + 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(), diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index dbe9720769..b3e0dcef7c 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -206,27 +206,34 @@ function resolveParentLink( /** * 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. + * 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" && isInterruptPlaceholderEntry(entry)) return "canceled"; + 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; } -function isInterruptPlaceholderEntry(entry: ClaudeReplayEntry): boolean { +/** 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 isClaudeInterruptPlaceholderText(content); - if (!Array.isArray(content)) return false; - return content.some( - (block: { type?: unknown; text?: unknown } | null) => - block?.type === "text" && isClaudeInterruptPlaceholderText(block.text), + 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] : [], ); }