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