fix(server): preserve resumed subagent identity

A resumed native child can be announced under a fresh tool call while retaining its session-scoped task identity. Treat later tool-call identifiers as aliases so lifecycle, usage, and timeline events continue through the original descriptor instead of splitting into contradictory rows.
This commit is contained in:
Mohamed Boudra
2026-08-18 15:20:06 +02:00
parent c779ef06c5
commit c09be7efa2
5 changed files with 253 additions and 18 deletions
+1
View File
@@ -160,6 +160,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent. - **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table. - **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`. - **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. - **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. - **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22. - **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
@@ -402,6 +402,117 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
}); });
}); });
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
queryFactory.mockImplementation(() =>
buildQueryMock([
{
type: "system",
subtype: "init",
session_id: "resume-session",
permissionMode: "default",
model: "opus",
},
{
type: "stream_event",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "task-original",
name: "Task",
input: { subagent_type: "Explore", description: "Original child" },
},
},
},
{
type: "system",
subtype: "task_started",
task_id: "native-task",
tool_use_id: "task-original",
task_type: "local_agent",
subagent_type: "Explore",
description: "Original child",
prompt: "Initial child prompt",
},
{
type: "system",
subtype: "task_updated",
task_id: "native-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_started",
task_id: "native-task",
tool_use_id: "task-resumed",
task_type: "local_agent",
subagent_type: "Explore",
description: "Changed description must not replace the original",
prompt: "Resumed child prompt",
},
{
type: "stream_event",
parent_tool_use_id: "task-resumed",
event: {
type: "content_block_start",
index: 1,
content_block: { type: "text_delta", text: "resumed" },
},
},
{
type: "assistant",
parent_tool_use_id: "task-resumed",
message: { model: "claude-opus-5", content: [{ type: "text", text: "resumed output" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "native-task",
patch: { status: "completed" },
},
{
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
},
]),
);
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const events = await collectUntilTerminal(streamSession(session, "resume child"));
await session.close();
const providerEvents = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(
providerEvents.filter((event) => event.type === "upsert").map((event) => event.id),
).toEqual(expect.arrayContaining(["task-original"]));
expect(providerEvents.every((event) => event.id !== "task-resumed")).toBe(true);
expect(providerEvents).toContainEqual({
type: "timeline",
id: "task-original",
item: { type: "user_message", text: "Resumed child prompt" },
});
expect(providerEvents).toContainEqual(
expect.objectContaining({ type: "timeline", id: "task-original" }),
);
const taskCards = events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "task-resumed",
);
expect(taskCards).toEqual([]);
});
test("keeps a failed Task subagent failed when the parent turn succeeds", async () => { test("keeps a failed Task subagent failed when the parent turn succeeds", async () => {
const failedEvents = buildTailScenarioEvents(1); const failedEvents = buildTailScenarioEvents(1);
const taskResult = failedEvents.find( const taskResult = failedEvents.find(
@@ -3970,24 +3970,26 @@ class ClaudeAgentSession implements AgentSession {
message: SDKMessage, message: SDKMessage,
parentToolUseId: string, parentToolUseId: string,
): AgentStreamEvent[] { ): AgentStreamEvent[] {
const canonicalSubagentId = this.taskProtocolSource.resolveSubagentId(parentToolUseId);
// Once a CLI announces its tasks it announces all of them, so a frame for one that was never // Once a CLI announces its tasks it announces all of them, so a frame for one that was never
// declared is work the filter already rejected — a workflow child, ambient housekeeping, a // declared is work the filter already rejected — a workflow child, ambient housekeeping, a
// grandchild announced in someone else's session. Attributing it anyway materializes exactly // grandchild announced in someone else's session. Attributing it anyway materializes exactly
// what the filter prevents: a nameless descriptor stuck running, plus a timeline no surface // what the filter prevents: a nameless descriptor stuck running, plus a timeline no surface
// can open, both held for the session's lifetime. // can open, both held for the session's lifetime.
if ( if (this.taskProtocolSource.announcesTasks && !canonicalSubagentId) {
this.taskProtocolSource.announcesTasks &&
!this.taskProtocolSource.isDeclared(parentToolUseId)
) {
return []; return [];
} }
// The child's own frames are the only place its model appears; the task protocol does not // The child's own frames are the only place its model appears; the task protocol does not
// announce it. Read per frame rather than snapshotting, since a provider can swap models // announce it. Read per frame rather than snapshotting, since a provider can swap models
// mid-flight on overload or refusal fallback. // mid-flight on overload or refusal fallback.
const runtimeEvents = foldSubagentObservations( const runtimeEvents = foldSubagentObservations(
this.taskProtocolSource.observeSidechainFrame(message, parentToolUseId), this.taskProtocolSource.observeSidechainFrame(
message,
canonicalSubagentId ?? parentToolUseId,
),
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event })); ).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, parentToolUseId)]; const routedId = canonicalSubagentId ?? parentToolUseId;
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)];
} }
/** /**
@@ -39,6 +39,16 @@ function taskNotification(status: string, taskId = "a1730a6215e1f5cf6"): SDKMess
} as unknown as SDKMessage; } as unknown as SDKMessage;
} }
function taskProgress(usage: Record<string, number>, taskId = "a1730a6215e1f5cf6"): SDKMessage {
return {
type: "system",
subtype: "task_progress",
task_id: taskId,
usage,
last_tool_name: "Read",
} as unknown as SDKMessage;
}
describe("ClaudeTaskProtocolSource", () => { describe("ClaudeTaskProtocolSource", () => {
it("declares a subagent from its announcement, keyed by the Task tool_use id", () => { it("declares a subagent from its announcement, keyed by the Task tool_use id", () => {
const source = new ClaudeTaskProtocolSource(); const source = new ClaudeTaskProtocolSource();
@@ -83,6 +93,93 @@ describe("ClaudeTaskProtocolSource", () => {
]); ]);
}); });
it("keeps a resumed task on its original identity and timeline", () => {
const source = new ClaudeTaskProtocolSource();
const initial = source.observe(
taskStarted({ tool_use_id: "toolu_original", prompt: "First prompt" }),
);
const firstCompleted = source.observe(taskUpdated("completed"));
const resumed = source.observe(
taskStarted({ tool_use_id: "toolu_resumed", prompt: "Resumed prompt" }),
);
const resumedUsage = source.observe(taskProgress({ total_tokens: 12345 }));
const secondCompleted = source.observe(taskUpdated("completed"));
expect(initial).toContainEqual({
kind: "declared",
id: "toolu_original",
toolCallId: "toolu_original",
title: "general-purpose",
description: "Summarize hover and unistyles docs",
});
expect(initial).toContainEqual({
kind: "timeline",
id: "toolu_original",
item: { type: "user_message", text: "First prompt" },
});
expect(resumed).toEqual([
{ kind: "status", id: "toolu_original", status: "running" },
{
kind: "timeline",
id: "toolu_original",
item: { type: "user_message", text: "Resumed prompt" },
},
]);
expect(resumedUsage).toEqual([
{
kind: "subtitle",
id: "toolu_original",
subtitle: "general-purpose · 12.3k tokens",
},
]);
expect(firstCompleted).toEqual([{ kind: "status", id: "toolu_original", status: "completed" }]);
expect(secondCompleted).toEqual([
{ kind: "status", id: "toolu_original", status: "completed" },
]);
expect(source.resolveSubagentId("toolu_original")).toBe("toolu_original");
expect(source.resolveSubagentId("toolu_resumed")).toBe("toolu_original");
expect(source.resolveSubagentId("unknown_tool")).toBeUndefined();
expect(
source.observeSidechainFrame(
{
type: "assistant",
message: { model: "claude-opus-5" },
} as unknown as SDKMessage,
source.resolveSubagentId("toolu_resumed") ?? "toolu_resumed",
),
).toEqual([
{
kind: "subtitle",
id: "toolu_original",
subtitle: "general-purpose · Opus 5 · 12.3k tokens",
},
]);
});
it("declares a distinct task separately", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(taskStarted({ task_id: "first", tool_use_id: "toolu_first" }));
expect(
source.observe(
taskStarted({ task_id: "second", tool_use_id: "toolu_second", prompt: "Another child" }),
),
).toEqual([
{
kind: "declared",
id: "toolu_second",
toolCallId: "toolu_second",
title: "general-purpose",
description: "Summarize hover and unistyles docs",
},
{
kind: "timeline",
id: "toolu_second",
item: { type: "user_message", text: "Another child" },
},
]);
});
it("does not re-announce a status that already holds", () => { it("does not re-announce a status that already holds", () => {
const source = new ClaudeTaskProtocolSource(); const source = new ClaudeTaskProtocolSource();
source.observe(taskStarted()); source.observe(taskStarted());
@@ -376,16 +473,6 @@ describe("ClaudeTaskProtocolSource", () => {
}); });
describe("ClaudeTaskProtocolSource usage and runtime", () => { describe("ClaudeTaskProtocolSource usage and runtime", () => {
function taskProgress(usage: Record<string, number>, taskId = "a1730a6215e1f5cf6"): SDKMessage {
return {
type: "system",
subtype: "task_progress",
task_id: taskId,
usage,
last_tool_name: "Read",
} as unknown as SDKMessage;
}
function assistantFrame(model: string): SDKMessage { function assistantFrame(model: string): SDKMessage {
return { type: "assistant", message: { model, content: [] } } as unknown as SDKMessage; return { type: "assistant", message: { model, content: [] } } as unknown as SDKMessage;
} }
@@ -21,8 +21,10 @@ import {
* task_notification task_id, tool_use_id, status * task_notification task_id, tool_use_id, status
* *
* Only `task_started` carries `tool_use_id`, so the mapping from task id to the canonical * Only `task_started` carries `tool_use_id`, so the mapping from task id to the canonical
* subagent id has to be remembered. The source also accumulates Claude's presentation facts so * subagent id has to be remembered. Claude may re-announce a session task with a new tool id when
* the shared descriptor receives one complete provider-owned subtitle rather than Claude fields. * resuming it; later ids are aliases for the first id. The source also accumulates Claude's
* presentation facts so the shared descriptor receives one complete provider-owned subtitle
* rather than Claude fields.
* Neither is a lifecycle state machine: status comes directly from task announcements. * Neither is a lifecycle state machine: status comes directly from task announcements.
* *
* The table is session-scoped, because task ids are. It survives a turn ending — the one thing a * The table is session-scoped, because task ids are. It survives a turn ending — the one thing a
@@ -150,6 +152,8 @@ export interface ClaudeTaskProtocolSourceInput {
export class ClaudeTaskProtocolSource { export class ClaudeTaskProtocolSource {
/** task_id -> canonical subagent id (the Task tool_use id). Populated by task_started. */ /** task_id -> canonical subagent id (the Task tool_use id). Populated by task_started. */
private readonly subagentIdByTaskId = new Map<string, string>(); private readonly subagentIdByTaskId = new Map<string, string>();
/** Every announced tool id -> the first tool id that publicly identifies the child. */
private readonly canonicalIdByToolUseId = new Map<string, string>();
/** /**
* Every subagent id this source declared. It is the source's whole vocabulary: an id that is * Every subagent id this source declared. It is the source's whole vocabulary: an id that is
* not in here was either filtered at declaration or never announced, and this source has * not in here was either filtered at declaration or never announced, and this source has
@@ -213,6 +217,12 @@ export class ClaudeTaskProtocolSource {
return this.declaredIds.has(subagentId); return this.declaredIds.has(subagentId);
} }
/** Resolve a Claude tool id to the provider descriptor id that owns its state and timeline. */
resolveSubagentId(toolUseId: string): string | undefined {
const canonicalId = this.canonicalIdByToolUseId.get(toolUseId);
return canonicalId && this.declaredIds.has(canonicalId) ? canonicalId : undefined;
}
/** Whether Claude's task protocol declared this task as a provider subagent. */ /** Whether Claude's task protocol declared this task as a provider subagent. */
isDeclaredTask(taskId: string): boolean { isDeclaredTask(taskId: string): boolean {
const subagentId = this.subagentIdByTaskId.get(taskId); const subagentId = this.subagentIdByTaskId.get(taskId);
@@ -248,6 +258,7 @@ export class ClaudeTaskProtocolSource {
*/ */
reset(): void { reset(): void {
this.subagentIdByTaskId.clear(); this.subagentIdByTaskId.clear();
this.canonicalIdByToolUseId.clear();
this.declaredIds.clear(); this.declaredIds.clear();
this.workflowTaskIds.clear(); this.workflowTaskIds.clear();
this.lastWorkflowResultByTaskId.clear(); this.lastWorkflowResultByTaskId.clear();
@@ -303,7 +314,30 @@ export class ClaudeTaskProtocolSource {
if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return []; if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return [];
this.sawTaskStarted = true; this.sawTaskStarted = true;
const existingId = this.subagentIdByTaskId.get(message.task_id);
if (existingId) {
this.canonicalIdByToolUseId.set(id, existingId);
const observations: SubagentObservation[] = [];
if (this.lastStatusById.get(existingId) !== "running") {
this.lastStatusById.set(existingId, "running");
observations.push({ kind: "status", id: existingId, status: "running" });
}
const prompt =
message.task_type === CLAUDE_WORKFLOW_TASK_TYPE
? readString(message.description)
: readString(message.prompt);
if (prompt) {
observations.push({
kind: "timeline",
id: existingId,
item: { type: "user_message", text: prompt },
});
}
return observations;
}
this.subagentIdByTaskId.set(message.task_id, id); this.subagentIdByTaskId.set(message.task_id, id);
this.canonicalIdByToolUseId.set(id, id);
this.declaredIds.add(id); this.declaredIds.add(id);
this.lastStatusById.set(id, "running"); this.lastStatusById.set(id, "running");