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
+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;
}