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
+43 -23
View File
@@ -16,14 +16,30 @@ type StreamParserCallbacks = {
};
type ParseState = {
textBuffer: string;
/**
* Accumulated deltas, keyed by the `parent_tool_use_id` that produced them ('' for the agent you are
* talking to). One shared buffer was wrong as soon as a subagent ran: its deltas appended to whatever
* the main agent had said so far, and the next flush emitted the two of them concatenated as a single
* message from the main agent.
*/
textBuffers: Map<string, string>;
gotResult: boolean;
};
function flushTextBuffer(state: ParseState, onEvent: (event: ChatEvent) => void): void {
if (state.textBuffer) {
onEvent({ type: 'text', text: state.textBuffer });
state.textBuffer = '';
/** '' when a message is the main agent's own. Keys the buffer map and stamps every event we emit. */
function parentOf(msg: Record<string, unknown>): string {
const id = msg.parent_tool_use_id;
return typeof id === 'string' ? id : '';
}
/** Only set the field when there IS a parent, so main-agent events stay byte-identical on the wire. */
const parented = (parent: string) => (parent ? { parentToolUseId: parent } : {});
function flushTextBuffer(state: ParseState, parent: string, onEvent: (event: ChatEvent) => void): void {
const buffered = state.textBuffers.get(parent);
if (buffered) {
onEvent({ type: 'text', text: buffered, ...parented(parent) });
state.textBuffers.delete(parent);
}
}
@@ -32,8 +48,9 @@ function handleStreamEvent(msg: Record<string, unknown>, state: ParseState, onEv
if (event?.type === 'content_block_delta') {
const delta = event.delta as Record<string, unknown> | undefined;
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
state.textBuffer += delta.text;
onEvent({ type: 'delta', text: delta.text });
const parent = parentOf(msg);
state.textBuffers.set(parent, (state.textBuffers.get(parent) ?? '') + delta.text);
onEvent({ type: 'delta', text: delta.text, ...parented(parent) });
}
}
}
@@ -43,17 +60,20 @@ function handleAssistant(msg: Record<string, unknown>, state: ParseState, onEven
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) return;
const parent = parentOf(msg);
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
onEvent({ type: 'text', text: block.text });
state.textBuffer = '';
onEvent({ type: 'text', text: block.text, ...parented(parent) });
state.textBuffers.delete(parent);
} else if (block.type === 'tool_use') {
flushTextBuffer(state, onEvent);
flushTextBuffer(state, parent, onEvent);
onEvent({
type: 'tool:start',
toolCallId: (block.id as string) ?? '',
toolName: (block.name as string) ?? 'unknown',
toolInput: (block.input as Record<string, unknown>) ?? {},
...parented(parent),
});
}
}
@@ -64,6 +84,8 @@ function handleUser(msg: Record<string, unknown>, onEvent: (event: ChatEvent) =>
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) return;
const parent = parentOf(msg);
for (const block of content) {
if (block.type === 'tool_result') {
let output = '';
@@ -80,28 +102,30 @@ function handleUser(msg: Record<string, unknown>, onEvent: (event: ChatEvent) =>
toolCallId: (block.tool_use_id as string) ?? '',
output,
isError: (block.is_error as boolean) ?? false,
...parented(parent),
});
}
}
}
function handleResult(
msg: Record<string, unknown>,
state: ParseState,
callbacks: StreamParserCallbacks,
): void {
function handleResult(msg: Record<string, unknown>, state: ParseState, callbacks: StreamParserCallbacks): void {
state.gotResult = true;
const isError = (msg.is_error as boolean) ?? false;
const resultText = (msg.result as string) ?? '';
// A `result` ends the turn, so every buffer is stale — including a subagent's, if it died mid-sentence.
function flushAll(): void {
for (const parent of [...state.textBuffers.keys()]) flushTextBuffer(state, parent, callbacks.onEvent);
}
if (isError) {
flushTextBuffer(state, callbacks.onEvent);
flushAll();
callbacks.onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
return;
}
flushTextBuffer(state, callbacks.onEvent);
flushAll();
const usage = msg.usage as Record<string, number> | undefined;
const cost: MessageCost = {
@@ -180,11 +204,7 @@ export function processMessage(
* Process a single NDJSON line from Claude Code's stream output.
* Returns false if the line was skipped (empty or malformed), true otherwise.
*/
export function processLine(
line: string,
state: ParseState,
callbacks: StreamParserCallbacks,
): boolean {
export function processLine(line: string, state: ParseState, callbacks: StreamParserCallbacks): boolean {
if (!line.trim()) return false;
let msg: Record<string, unknown>;
@@ -201,7 +221,7 @@ export function processLine(
* Create a fresh parse state for a new stream.
*/
export function createParseState(): ParseState {
return { textBuffer: '', gotResult: false };
return { textBuffers: new Map(), gotResult: false };
}
/**