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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user