mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 23:02:36 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c30d967cd9 | ||
|
|
8353a93f71 |
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user