mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Keep an interrupted background Claude subagent canceled after a daemon restart (#6259)
* 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. * Keep a background Claude subagent running when it got a new prompt after an interrupt * fix(claude): preserve background subagent outcomes on replay Use persisted task notifications as replay outcomes with the live status mapping. Diagnostic API request copies contain launch tool results without async metadata, so only transcript messages supply task declarations and tool outcomes. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -422,6 +422,237 @@ 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.each(["interrupted", "running"])(
|
||||
"ignores an API request copy of a %s child's launch result",
|
||||
async (state) => {
|
||||
const launch = backgroundLaunch();
|
||||
const result = JSON.parse(launch[1]!);
|
||||
writeSession({
|
||||
parentLines: [
|
||||
...launch,
|
||||
JSON.stringify({
|
||||
type: "api-request-blob",
|
||||
sessionId: "replay-session",
|
||||
hash: "request-copy",
|
||||
message: result.message,
|
||||
}),
|
||||
],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines:
|
||||
state === "interrupted"
|
||||
? interruptedChild()
|
||||
: [sidechainEntry({ stopReason: "tool_use" })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||
id: TOOL_USE_ID,
|
||||
status: state === "interrupted" ? "canceled" : "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");
|
||||
});
|
||||
|
||||
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>`;
|
||||
const records = {
|
||||
user: { message: { role: "user", content: [{ type: "text", text }] } },
|
||||
system: {
|
||||
subtype: "task_notification",
|
||||
task_id: AGENT_ID,
|
||||
tool_use_id: TOOL_USE_ID,
|
||||
status,
|
||||
},
|
||||
"queue-operation": { operation: "enqueue", content: text },
|
||||
};
|
||||
return JSON.stringify({
|
||||
type: form,
|
||||
timestamp: "2026-07-26T06:29:00.000Z",
|
||||
...records[form as keyof typeof records],
|
||||
});
|
||||
}
|
||||
|
||||
for (const form of ["user", "system", "queue-operation"]) {
|
||||
test.each([
|
||||
["completed", "completed"],
|
||||
["failed", "failed"],
|
||||
["killed", "canceled"],
|
||||
["stopped", "canceled"],
|
||||
])(
|
||||
`restores a %s task notification from ${form} when the child stop_reason is null`,
|
||||
async (status, expected) => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: null })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||
id: TOOL_USE_ID,
|
||||
status: expected,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("prefers a stopped notification over the child's end_turn", async () => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
|
||||
});
|
||||
|
||||
test("does not invent an outcome for an unknown notification status", async () => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: null })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "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,
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
mapTaskNotificationSystemRecordToToolCall,
|
||||
mapTaskNotificationUserContentToToolCall,
|
||||
readTaskNotificationToolUseIdFromHistoryRecord,
|
||||
readTaskNotificationFromHistoryRecord,
|
||||
} from "./task-notification-tool-call.js";
|
||||
import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js";
|
||||
import {
|
||||
getClaudeContextWindowMaxTokens,
|
||||
getClaudeModelsWithSettings,
|
||||
@@ -56,6 +58,8 @@ import {
|
||||
type ClaudeHookObservationInput,
|
||||
} from "./subagents/live-source.js";
|
||||
import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js";
|
||||
import { mapTaskStatus } from "./subagents/task-status.js";
|
||||
import type { ProviderSubagentStatus } from "../../provider-subagents/store.js";
|
||||
import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js";
|
||||
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
|
||||
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
||||
@@ -425,7 +429,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.";
|
||||
@@ -789,11 +792,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;
|
||||
}
|
||||
@@ -6262,33 +6260,34 @@ async function readClaudeReplayParentFacts(
|
||||
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
|
||||
): Promise<ClaudeReplayParentFacts> {
|
||||
const toolCalls = new Map<string, { title?: string; description?: string }>();
|
||||
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
|
||||
const outcomes = new Map<string, { failed: boolean }>();
|
||||
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 content = toObjectRecord(entry.message)?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
const block = toObjectRecord(value);
|
||||
const declaration = readClaudeReplayTaskDeclaration(entry.type, block);
|
||||
if (declaration) toolCalls.set(declaration.id, declaration.identity);
|
||||
if (
|
||||
block?.type === "tool_use" &&
|
||||
(block.name === "Task" || block.name === "Agent") &&
|
||||
typeof block.id === "string"
|
||||
) {
|
||||
const input = toObjectRecord(block.input);
|
||||
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
|
||||
const description = readNonEmptyString(input?.description);
|
||||
toolCalls.set(block.id, {
|
||||
...(title ? { title } : {}),
|
||||
...(description ? { description } : {}),
|
||||
entry.type !== "user" ||
|
||||
block?.type !== "tool_result" ||
|
||||
typeof block.tool_use_id !== "string"
|
||||
)
|
||||
continue;
|
||||
if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched")
|
||||
outcomes.set(block.tool_use_id, {
|
||||
status: block.is_error === true ? "failed" : "completed",
|
||||
});
|
||||
}
|
||||
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
|
||||
outcomes.set(block.tool_use_id, { failed: block.is_error === true });
|
||||
const agentId = readLegacyClaudeSubagentId(block.content);
|
||||
if (agentId)
|
||||
linksByAgentId.set(agentId, {
|
||||
toolCallId: block.tool_use_id,
|
||||
failed: block.is_error === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6299,6 +6298,29 @@ async function readClaudeReplayParentFacts(
|
||||
};
|
||||
}
|
||||
|
||||
function readClaudeReplayTaskDeclaration(
|
||||
entryType: unknown,
|
||||
block: Record<string, unknown> | undefined,
|
||||
): {
|
||||
id: string;
|
||||
identity: { title?: string; description?: string };
|
||||
} | null {
|
||||
if (
|
||||
entryType !== "assistant" ||
|
||||
block?.type !== "tool_use" ||
|
||||
(block.name !== "Task" && block.name !== "Agent") ||
|
||||
typeof block.id !== "string"
|
||||
)
|
||||
return null;
|
||||
const input = toObjectRecord(block.input);
|
||||
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
|
||||
const description = readNonEmptyString(input?.description);
|
||||
return {
|
||||
id: block.id,
|
||||
identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function readLegacyClaudeSubagentId(content: unknown): string | undefined {
|
||||
return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1];
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { mapTaskStatus } from "./task-status.js";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentMetadata } from "../../../agent-sdk-types.js";
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { resolveObservedClaudeModelId } from "../models.js";
|
||||
import type { SubagentObservation } from "./observation.js";
|
||||
import {
|
||||
@@ -120,28 +121,6 @@ function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The descriptor has no paused or pending state, and neither is terminal, so both read as
|
||||
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
|
||||
*/
|
||||
function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
case "running":
|
||||
case "paused":
|
||||
return "running";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "killed":
|
||||
case "stopped":
|
||||
return "canceled";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClaudeTaskProtocolSourceInput {
|
||||
/**
|
||||
* The parent's Task tool input, by tool_use id.
|
||||
|
||||
@@ -36,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
|
||||
],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]),
|
||||
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("observeReplaySubagents", () => {
|
||||
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
|
||||
outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -173,8 +173,8 @@ describe("observeReplaySubagents", () => {
|
||||
it("falls back to the scraped link when there is no meta file", async () => {
|
||||
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, { status: "completed" }]]),
|
||||
};
|
||||
|
||||
const [declared, ...rest] = (
|
||||
@@ -263,8 +263,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, { status: "failed" }]]),
|
||||
},
|
||||
convertEntry: () => [],
|
||||
})
|
||||
|
||||
@@ -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";
|
||||
@@ -139,9 +140,12 @@ 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. */
|
||||
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
|
||||
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, { status: ProviderSubagentStatus }>;
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
@@ -177,27 +181,14 @@ 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 status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null;
|
||||
return { id: toolCallId, toolCallId, status };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,9 +267,7 @@ async function observeSubagent(
|
||||
lastTimestamp = timestamp;
|
||||
Object.assign(runtime, readRuntime(entry));
|
||||
usage = readUsage(entry) ?? usage;
|
||||
if (entry.type === "assistant") {
|
||||
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||
}
|
||||
childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus);
|
||||
recordReplayToolOwners(toolOwners, entry, link.id);
|
||||
for (const item of await convertEntry(entry)) {
|
||||
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
|
||||
@@ -308,6 +297,30 @@ async function observeSubagent(
|
||||
return observations;
|
||||
}
|
||||
|
||||
/** Advance the child's fallback outcome without retaining transcript entries. */
|
||||
function readChildTerminalStatus(
|
||||
entry: ClaudeReplayEntry,
|
||||
previous: ProviderSubagentStatus | null,
|
||||
): ProviderSubagentStatus | null {
|
||||
if (entry.type === "assistant") {
|
||||
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||
}
|
||||
if (entry.type !== "user") return previous;
|
||||
const texts = readUserTexts(entry);
|
||||
if (texts.length === 0) return previous;
|
||||
return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null;
|
||||
}
|
||||
|
||||
/** Tool results carry no user text and do not change the child's terminal status. */
|
||||
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] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function recordReplayToolOwners(
|
||||
owners: Map<string, string>,
|
||||
entry: ClaudeReplayEntry,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
|
||||
/**
|
||||
* The descriptor has no paused or pending state, and neither is terminal, so both read as
|
||||
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
|
||||
*/
|
||||
export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
case "running":
|
||||
case "paused":
|
||||
return "running";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "killed":
|
||||
case "stopped":
|
||||
return "canceled";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -407,19 +407,21 @@ export function mapTaskNotificationSystemRecordToToolCall(
|
||||
}
|
||||
|
||||
export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null {
|
||||
return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null;
|
||||
}
|
||||
|
||||
export function readTaskNotificationFromHistoryRecord(
|
||||
record: unknown,
|
||||
): TaskNotificationEnvelope | null {
|
||||
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
|
||||
if (!parsedRecord.success) {
|
||||
return null;
|
||||
}
|
||||
if (!parsedRecord.success) return null;
|
||||
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
|
||||
return (
|
||||
parseTaskNotificationFromUserContent({
|
||||
content: parsedRecord.data.message.content,
|
||||
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
|
||||
})?.toolUseId ?? null
|
||||
);
|
||||
return parseTaskNotificationFromUserContent({
|
||||
content: parsedRecord.data.message.content,
|
||||
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
|
||||
});
|
||||
}
|
||||
return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null;
|
||||
return parseTaskNotificationFromSystemRecord(record);
|
||||
}
|
||||
|
||||
export function coerceTaskNotificationHistoryRecordToSystemMessage(
|
||||
|
||||
Reference in New Issue
Block a user