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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user