attribute subagent output to the task that spawned it

the harness stamps every message a subagent produces with parent_tool_use_id.
the sidecar wrote it outgoing and nothing ever read it coming back, so a
subagent's prose and tool calls were spliced into the main transcript as if the
agent you are talking to had produced them — and worse, its deltas were appended
to the same text buffer, so two voices were concatenated inside one bubble.

both buffering layers (stream-parser's textBuffer and turn-stream's buffer) are
now maps keyed by parent, and parentToolUseId rides on ChatEvent, ServerMessage
and Message. useChat nests parented output under the Task row that spawned it;
ToolActivity draws the trace inside the expanded panel.

background tasks get the same treatment from the other end: task:started and
task:notification were two unrelated fake assistant bubbles minutes apart, and
are now one role:'task' row correlated by taskId that appears pending and
resolves in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 01:48:21 +00:00
co-authored by Claude Opus 5
parent 96ceb3aca6
commit 7b6ca5f4ca
10 changed files with 470 additions and 144 deletions
+23 -14
View File
@@ -10,6 +10,8 @@ export type Message = {
toolInput?: Record<string, unknown>;
output?: string;
isError?: boolean;
/** The Task tool call this came from, when a subagent produced it. See `Parented` below. */
parentToolUseId?: string;
};
export type MessageCost = {
@@ -82,6 +84,13 @@ export type ClientMessage =
cursor: number;
};
// The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are
// talking to. The harness stamps every nested message with `parent_tool_use_id`; nothing used to read it,
// so a subagent's prose and tool calls were spliced into the main transcript as if the main agent had
// produced them — and its deltas were appended to the main agent's text buffer, interleaving two voices
// inside one bubble. Attributing them lets the UI file them under the Task row that spawned them.
type Parented = { parentToolUseId?: string };
export type ServerMessage =
| {
type: 'session:init';
@@ -91,26 +100,26 @@ export type ServerMessage =
context?: string;
contextId?: string;
}
| {
| ({
type: 'assistant:text';
text: string;
}
| {
} & Parented)
| ({
type: 'assistant:delta';
text: string;
}
| {
} & Parented)
| ({
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
| {
} & Parented)
| ({
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
}
} & Parented)
| {
type: 'result';
sessionId: string;
@@ -166,20 +175,20 @@ export type TurnMessageType =
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
export type ChatEvent =
| { type: 'text'; text: string }
| { type: 'delta'; text: string }
| {
| ({ type: 'text'; text: string } & Parented)
| ({ type: 'delta'; text: string } & Parented)
| ({
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
| {
} & Parented)
| ({
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
}
} & Parented)
| {
type: 'result';
cost: MessageCost;
+16 -4
View File
@@ -164,13 +164,23 @@ export function close(ws: ServerWebSocket<WSData>): void {
function foldIntoSession(session: UserSession, msg: TurnMessage, model: string): void {
switch (msg.type) {
case 'assistant:delta':
session.streamBuffer += msg.text;
// A subagent's deltas are not the main agent typing; appending them here spliced its sentences into
// whatever the agent you are talking to was mid-way through saying.
if (!msg.parentToolUseId) session.streamBuffer += msg.text;
break;
case 'assistant:text':
session.messages.push({ id: randomUUID(), timestamp: Date.now(), role: 'assistant', text: msg.text, model });
session.messages.push({
id: randomUUID(),
timestamp: Date.now(),
role: 'assistant',
text: msg.text,
model,
parentToolUseId: msg.parentToolUseId,
});
session.meta.messageCount += 1;
session.streamBuffer = '';
// Only the main agent's own stream feeds the buffer a resume replays as `streamingText`.
if (!msg.parentToolUseId) session.streamBuffer = '';
break;
case 'tool:start':
@@ -181,6 +191,7 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
parentToolUseId: msg.parentToolUseId,
});
session.meta.messageCount += 1;
break;
@@ -203,7 +214,8 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
session.meta.cost.totalUSD += msg.cost.totalUSD;
session.meta.updatedAt = Date.now();
// The turn's cost belongs to the assistant message it paid for (as it did when officer built these).
const last = session.messages[session.messages.length - 1];
// The turn's cost is the main agent's, so skip past any subagent tail.
const last = [...session.messages].reverse().find((m) => !m.parentToolUseId);
if (last?.role === 'assistant' && !last.cost) last.cost = msg.cost;
break;
}