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:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user