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
@@ -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 () => {
const failedEvents = buildTailScenarioEvents(1);
const taskResult = failedEvents.find(
@@ -3970,24 +3970,26 @@ class ClaudeAgentSession implements AgentSession {
message: SDKMessage,
parentToolUseId: string,
): 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
// 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
// what the filter prevents: a nameless descriptor stuck running, plus a timeline no surface
// can open, both held for the session's lifetime.
if (
this.taskProtocolSource.announcesTasks &&
!this.taskProtocolSource.isDeclared(parentToolUseId)
) {
if (this.taskProtocolSource.announcesTasks && !canonicalSubagentId) {
return [];
}
// 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
// mid-flight on overload or refusal fallback.
const runtimeEvents = foldSubagentObservations(
this.taskProtocolSource.observeSidechainFrame(message, parentToolUseId),
this.taskProtocolSource.observeSidechainFrame(
message,
canonicalSubagentId ?? parentToolUseId,
),
).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;
}
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", () => {
it("declares a subagent from its announcement, keyed by the Task tool_use id", () => {
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", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(taskStarted());
@@ -376,16 +473,6 @@ describe("ClaudeTaskProtocolSource", () => {
});
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 {
return { type: "assistant", message: { model, content: [] } } as unknown as SDKMessage;
}
@@ -21,8 +21,10 @@ import {
* task_notification task_id, tool_use_id, status
*
* 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
* the shared descriptor receives one complete provider-owned subtitle rather than Claude fields.
* subagent id has to be remembered. Claude may re-announce a session task with a new tool id when
* 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.
*
* 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 {
/** task_id -> canonical subagent id (the Task tool_use id). Populated by task_started. */
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
* 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);
}
/** 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. */
isDeclaredTask(taskId: string): boolean {
const subagentId = this.subagentIdByTaskId.get(taskId);
@@ -248,6 +258,7 @@ export class ClaudeTaskProtocolSource {
*/
reset(): void {
this.subagentIdByTaskId.clear();
this.canonicalIdByToolUseId.clear();
this.declaredIds.clear();
this.workflowTaskIds.clear();
this.lastWorkflowResultByTaskId.clear();
@@ -303,7 +314,30 @@ export class ClaudeTaskProtocolSource {
if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return [];
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.canonicalIdByToolUseId.set(id, id);
this.declaredIds.add(id);
this.lastStatusById.set(id, "running");