mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Keep a background Claude subagent running when it got a new prompt after an interrupt
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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] : [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user