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
+36 -16
View File
@@ -20,39 +20,53 @@ export type TurnStream = {
};
export function createTurnStream(sessionId: string): TurnStream {
let buffer = '';
// One buffer per speaker, keyed by the Task tool call that owns it ('' for the main agent). A subagent
// runs concurrently with the agent that spawned it, so a single buffer interleaved their sentences and
// flushed the result as one message attributed to whoever happened to hit the next boundary.
const buffers = new Map<string, string>();
// Emit whatever deltas have accumulated as one complete message. Called at every boundary where the
// assistant stops talking, so the transcript holds text blocks rather than a thousand fragments.
function flush(): TurnOutput[] {
if (!buffer) return [];
const text = buffer;
buffer = '';
return [{ msg: { type: 'assistant:text', text }, durable: true }];
/** Only set the field when there IS a parent, so main-agent messages stay byte-identical on the wire. */
const parented = (parent: string) => (parent ? { parentToolUseId: parent } : {});
// Emit whatever deltas have accumulated as one complete message. Called at every boundary where that
// speaker stops talking, so the transcript holds text blocks rather than a thousand fragments.
function flush(parent: string): TurnOutput[] {
const text = buffers.get(parent);
if (!text) return [];
buffers.delete(parent);
return [{ msg: { type: 'assistant:text', text, ...parented(parent) }, durable: true }];
}
/** A turn's end invalidates every buffer, the subagents' included. */
function flushAll(): TurnOutput[] {
return [...buffers.keys()].flatMap(flush);
}
function push(event: ChatEvent): TurnOutput[] {
const parent = ('parentToolUseId' in event ? event.parentToolUseId : undefined) ?? '';
switch (event.type) {
case 'delta':
buffer += event.text;
return [{ msg: { type: 'assistant:delta', text: event.text }, durable: false }];
buffers.set(parent, (buffers.get(parent) ?? '') + event.text);
return [{ msg: { type: 'assistant:delta', text: event.text, ...parented(parent) }, durable: false }];
case 'text': {
// An explicit full text block wins over the accumulated deltas that produced it.
const text = event.text || buffer;
buffer = '';
return text ? [{ msg: { type: 'assistant:text', text }, durable: true }] : [];
const text = event.text || buffers.get(parent) || '';
buffers.delete(parent);
return text ? [{ msg: { type: 'assistant:text', text, ...parented(parent) }, durable: true }] : [];
}
case 'tool:start':
return [
...flush(),
...flush(parent),
{
msg: {
type: 'tool:start',
toolCallId: event.toolCallId,
toolName: event.toolName,
toolInput: event.toolInput,
...parented(parent),
},
durable: true,
},
@@ -61,14 +75,20 @@ export function createTurnStream(sessionId: string): TurnStream {
case 'tool:result':
return [
{
msg: { type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError },
msg: {
type: 'tool:result',
toolCallId: event.toolCallId,
output: event.output,
isError: event.isError,
...parented(parent),
},
durable: true,
},
];
case 'result':
return [
...flush(),
...flushAll(),
{
msg: { type: 'result', sessionId, cost: event.cost, claudeSessionId: event.claudeSessionId },
durable: true,