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:
@@ -10,6 +10,8 @@ export type Message = {
|
||||
toolInput?: Record<string, unknown>;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
/** The Task tool call this came from, when a subagent produced it. See `Parented` below. */
|
||||
parentToolUseId?: string;
|
||||
};
|
||||
|
||||
export type MessageCost = {
|
||||
@@ -82,6 +84,13 @@ export type ClientMessage =
|
||||
cursor: number;
|
||||
};
|
||||
|
||||
// The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are
|
||||
// talking to. The harness stamps every nested message with `parent_tool_use_id`; nothing used to read it,
|
||||
// so a subagent's prose and tool calls were spliced into the main transcript as if the main agent had
|
||||
// produced them — and its deltas were appended to the main agent's text buffer, interleaving two voices
|
||||
// inside one bubble. Attributing them lets the UI file them under the Task row that spawned them.
|
||||
type Parented = { parentToolUseId?: string };
|
||||
|
||||
export type ServerMessage =
|
||||
| {
|
||||
type: 'session:init';
|
||||
@@ -91,26 +100,26 @@ export type ServerMessage =
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
}
|
||||
| {
|
||||
| ({
|
||||
type: 'assistant:text';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
} & Parented)
|
||||
| ({
|
||||
type: 'assistant:delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
} & Parented)
|
||||
| ({
|
||||
type: 'tool:start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
} & Parented)
|
||||
| ({
|
||||
type: 'tool:result';
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
}
|
||||
} & Parented)
|
||||
| {
|
||||
type: 'result';
|
||||
sessionId: string;
|
||||
@@ -166,20 +175,20 @@ export type TurnMessageType =
|
||||
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
|
||||
|
||||
export type ChatEvent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'delta'; text: string }
|
||||
| {
|
||||
| ({ type: 'text'; text: string } & Parented)
|
||||
| ({ type: 'delta'; text: string } & Parented)
|
||||
| ({
|
||||
type: 'tool:start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
} & Parented)
|
||||
| ({
|
||||
type: 'tool:result';
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
}
|
||||
} & Parented)
|
||||
| {
|
||||
type: 'result';
|
||||
cost: MessageCost;
|
||||
|
||||
@@ -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