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>;
|
toolInput?: Record<string, unknown>;
|
||||||
output?: string;
|
output?: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/** The Task tool call this came from, when a subagent produced it. See `Parented` below. */
|
||||||
|
parentToolUseId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageCost = {
|
export type MessageCost = {
|
||||||
@@ -82,6 +84,13 @@ export type ClientMessage =
|
|||||||
cursor: number;
|
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 =
|
export type ServerMessage =
|
||||||
| {
|
| {
|
||||||
type: 'session:init';
|
type: 'session:init';
|
||||||
@@ -91,26 +100,26 @@ export type ServerMessage =
|
|||||||
context?: string;
|
context?: string;
|
||||||
contextId?: string;
|
contextId?: string;
|
||||||
}
|
}
|
||||||
| {
|
| ({
|
||||||
type: 'assistant:text';
|
type: 'assistant:text';
|
||||||
text: string;
|
text: string;
|
||||||
}
|
} & Parented)
|
||||||
| {
|
| ({
|
||||||
type: 'assistant:delta';
|
type: 'assistant:delta';
|
||||||
text: string;
|
text: string;
|
||||||
}
|
} & Parented)
|
||||||
| {
|
| ({
|
||||||
type: 'tool:start';
|
type: 'tool:start';
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolInput: Record<string, unknown>;
|
toolInput: Record<string, unknown>;
|
||||||
}
|
} & Parented)
|
||||||
| {
|
| ({
|
||||||
type: 'tool:result';
|
type: 'tool:result';
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
output: string;
|
output: string;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
}
|
} & Parented)
|
||||||
| {
|
| {
|
||||||
type: 'result';
|
type: 'result';
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -166,20 +175,20 @@ export type TurnMessageType =
|
|||||||
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
|
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
|
||||||
|
|
||||||
export type ChatEvent =
|
export type ChatEvent =
|
||||||
| { type: 'text'; text: string }
|
| ({ type: 'text'; text: string } & Parented)
|
||||||
| { type: 'delta'; text: string }
|
| ({ type: 'delta'; text: string } & Parented)
|
||||||
| {
|
| ({
|
||||||
type: 'tool:start';
|
type: 'tool:start';
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolInput: Record<string, unknown>;
|
toolInput: Record<string, unknown>;
|
||||||
}
|
} & Parented)
|
||||||
| {
|
| ({
|
||||||
type: 'tool:result';
|
type: 'tool:result';
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
output: string;
|
output: string;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
}
|
} & Parented)
|
||||||
| {
|
| {
|
||||||
type: 'result';
|
type: 'result';
|
||||||
cost: MessageCost;
|
cost: MessageCost;
|
||||||
|
|||||||
@@ -164,13 +164,23 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
|||||||
function foldIntoSession(session: UserSession, msg: TurnMessage, model: string): void {
|
function foldIntoSession(session: UserSession, msg: TurnMessage, model: string): void {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'assistant:delta':
|
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;
|
break;
|
||||||
|
|
||||||
case 'assistant:text':
|
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.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;
|
break;
|
||||||
|
|
||||||
case 'tool:start':
|
case 'tool:start':
|
||||||
@@ -181,6 +191,7 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
|
|||||||
toolCallId: msg.toolCallId,
|
toolCallId: msg.toolCallId,
|
||||||
toolName: msg.toolName,
|
toolName: msg.toolName,
|
||||||
toolInput: msg.toolInput,
|
toolInput: msg.toolInput,
|
||||||
|
parentToolUseId: msg.parentToolUseId,
|
||||||
});
|
});
|
||||||
session.meta.messageCount += 1;
|
session.meta.messageCount += 1;
|
||||||
break;
|
break;
|
||||||
@@ -203,7 +214,8 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
|
|||||||
session.meta.cost.totalUSD += msg.cost.totalUSD;
|
session.meta.cost.totalUSD += msg.cost.totalUSD;
|
||||||
session.meta.updatedAt = Date.now();
|
session.meta.updatedAt = Date.now();
|
||||||
// The turn's cost belongs to the assistant message it paid for (as it did when officer built these).
|
// 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;
|
if (last?.role === 'assistant' && !last.cost) last.cost = msg.cost;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { processLine, createParseState, parseStream } from './stream-parser';
|
|||||||
import type { ChatEvent } from '../../api/chat/types';
|
import type { ChatEvent } from '../../api/chat/types';
|
||||||
import type { StreamParserCallbacks, ParseState } from './stream-parser';
|
import type { StreamParserCallbacks, ParseState } from './stream-parser';
|
||||||
|
|
||||||
|
/** The main agent's accumulated deltas — buffers are keyed by parent_tool_use_id, '' being the agent. */
|
||||||
|
const buffered = (state: ParseState, parent = '') => state.textBuffers.get(parent);
|
||||||
|
|
||||||
function makeCallbacks(): { events: ChatEvent[]; sessionIds: string[]; callbacks: StreamParserCallbacks } {
|
function makeCallbacks(): { events: ChatEvent[]; sessionIds: string[]; callbacks: StreamParserCallbacks } {
|
||||||
const events: ChatEvent[] = [];
|
const events: ChatEvent[] = [];
|
||||||
const sessionIds: string[] = [];
|
const sessionIds: string[] = [];
|
||||||
@@ -41,7 +44,7 @@ describe('processLine', () => {
|
|||||||
});
|
});
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
expect(events).toEqual([{ type: 'delta', text: 'hello' }]);
|
expect(events).toEqual([{ type: 'delta', text: 'hello' }]);
|
||||||
expect(state.textBuffer).toBe('hello');
|
expect(buffered(state)).toBe('hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accumulates text buffer across deltas', () => {
|
test('accumulates text buffer across deltas', () => {
|
||||||
@@ -54,13 +57,13 @@ describe('processLine', () => {
|
|||||||
});
|
});
|
||||||
processLine(mkDelta('hello '), state, callbacks);
|
processLine(mkDelta('hello '), state, callbacks);
|
||||||
processLine(mkDelta('world'), state, callbacks);
|
processLine(mkDelta('world'), state, callbacks);
|
||||||
expect(state.textBuffer).toBe('hello world');
|
expect(buffered(state)).toBe('hello world');
|
||||||
expect(events).toHaveLength(2);
|
expect(events).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles assistant text block — clears text buffer', () => {
|
test('handles assistant text block — clears text buffer', () => {
|
||||||
const state = createParseState();
|
const state = createParseState();
|
||||||
state.textBuffer = 'partial';
|
state.textBuffers.set('', 'partial');
|
||||||
const { events, callbacks } = makeCallbacks();
|
const { events, callbacks } = makeCallbacks();
|
||||||
const line = JSON.stringify({
|
const line = JSON.stringify({
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
@@ -68,12 +71,12 @@ describe('processLine', () => {
|
|||||||
});
|
});
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
expect(events).toEqual([{ type: 'text', text: 'full response' }]);
|
expect(events).toEqual([{ type: 'text', text: 'full response' }]);
|
||||||
expect(state.textBuffer).toBe('');
|
expect(buffered(state)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles assistant tool_use block — flushes text buffer first', () => {
|
test('handles assistant tool_use block — flushes text buffer first', () => {
|
||||||
const state = createParseState();
|
const state = createParseState();
|
||||||
state.textBuffer = 'thinking...';
|
state.textBuffers.set('', 'thinking...');
|
||||||
const { events, callbacks } = makeCallbacks();
|
const { events, callbacks } = makeCallbacks();
|
||||||
const line = JSON.stringify({
|
const line = JSON.stringify({
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
@@ -86,7 +89,7 @@ describe('processLine', () => {
|
|||||||
{ type: 'text', text: 'thinking...' },
|
{ type: 'text', text: 'thinking...' },
|
||||||
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'sqlite', toolInput: { query: 'SELECT 1' } },
|
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'sqlite', toolInput: { query: 'SELECT 1' } },
|
||||||
]);
|
]);
|
||||||
expect(state.textBuffer).toBe('');
|
expect(buffered(state)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles assistant tool_use without prior text buffer', () => {
|
test('handles assistant tool_use without prior text buffer', () => {
|
||||||
@@ -99,9 +102,7 @@ describe('processLine', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([{ type: 'tool:start', toolCallId: 'call_1', toolName: 'email_db', toolInput: {} }]);
|
||||||
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'email_db', toolInput: {} },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles user tool_result with string content', () => {
|
test('handles user tool_result with string content', () => {
|
||||||
@@ -114,9 +115,7 @@ describe('processLine', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([{ type: 'tool:result', toolCallId: 'call_1', output: 'result text', isError: false }]);
|
||||||
{ type: 'tool:result', toolCallId: 'call_1', output: 'result text', isError: false },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles user tool_result with array content', () => {
|
test('handles user tool_result with array content', () => {
|
||||||
@@ -140,9 +139,7 @@ describe('processLine', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([{ type: 'tool:result', toolCallId: 'call_2', output: 'line 1\nline 2', isError: true }]);
|
||||||
{ type: 'tool:result', toolCallId: 'call_2', output: 'line 1\nline 2', isError: true },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles system init — captures session id', () => {
|
test('handles system init — captures session id', () => {
|
||||||
@@ -155,7 +152,7 @@ describe('processLine', () => {
|
|||||||
|
|
||||||
test('handles result — sets gotResult, emits cost', () => {
|
test('handles result — sets gotResult, emits cost', () => {
|
||||||
const state = createParseState();
|
const state = createParseState();
|
||||||
state.textBuffer = 'trailing';
|
state.textBuffers.set('', 'trailing');
|
||||||
const { events, sessionIds, callbacks } = makeCallbacks();
|
const { events, sessionIds, callbacks } = makeCallbacks();
|
||||||
const line = JSON.stringify({
|
const line = JSON.stringify({
|
||||||
type: 'result',
|
type: 'result',
|
||||||
@@ -170,13 +167,13 @@ describe('processLine', () => {
|
|||||||
expect(sessionIds).toEqual(['sess_xyz']);
|
expect(sessionIds).toEqual(['sess_xyz']);
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([
|
||||||
{ type: 'text', text: 'trailing' },
|
{ type: 'text', text: 'trailing' },
|
||||||
{ type: 'result', cost: { inputTokens: 100, outputTokens: 50, totalUSD: 0.003 } },
|
{ type: 'result', cost: { inputTokens: 100, outputTokens: 50, totalUSD: 0.003 }, claudeSessionId: 'sess_xyz' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles error result — flushes buffer and emits error', () => {
|
test('handles error result — flushes buffer and emits error', () => {
|
||||||
const state = createParseState();
|
const state = createParseState();
|
||||||
state.textBuffer = 'partial';
|
state.textBuffers.set('', 'partial');
|
||||||
const { events, callbacks } = makeCallbacks();
|
const { events, callbacks } = makeCallbacks();
|
||||||
const line = JSON.stringify({ type: 'result', is_error: true, result: 'something broke' });
|
const line = JSON.stringify({ type: 'result', is_error: true, result: 'something broke' });
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
@@ -193,7 +190,87 @@ describe('processLine', () => {
|
|||||||
const line = JSON.stringify({ type: 'result', is_error: false });
|
const line = JSON.stringify({ type: 'result', is_error: false });
|
||||||
processLine(line, state, callbacks);
|
processLine(line, state, callbacks);
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([
|
||||||
{ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } },
|
{ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 }, claudeSessionId: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Subagent attribution ──
|
||||||
|
//
|
||||||
|
// The harness stamps every message a subagent produces with `parent_tool_use_id` — the id of the Task
|
||||||
|
// tool call that spawned it. Nothing read it, so a subagent's prose arrived as a top-level assistant
|
||||||
|
// message and its tool calls looked like the main agent's. Worse, its deltas landed in the one shared
|
||||||
|
// text buffer, so a flush emitted both voices concatenated into a single message.
|
||||||
|
|
||||||
|
test('stamps subagent output with its parent Task call', () => {
|
||||||
|
const state = createParseState();
|
||||||
|
const { events, callbacks } = makeCallbacks();
|
||||||
|
processLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
parent_tool_use_id: 'call_task',
|
||||||
|
message: { content: [{ type: 'text', text: 'searched 40 files' }] },
|
||||||
|
}),
|
||||||
|
state,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
|
processLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'user',
|
||||||
|
parent_tool_use_id: 'call_task',
|
||||||
|
message: { content: [{ type: 'tool_result', tool_use_id: 'call_grep', content: 'hit', is_error: false }] },
|
||||||
|
}),
|
||||||
|
state,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: 'text', text: 'searched 40 files', parentToolUseId: 'call_task' },
|
||||||
|
{ type: 'tool:result', toolCallId: 'call_grep', output: 'hit', isError: false, parentToolUseId: 'call_task' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the main agent and a subagent buffer their deltas separately', () => {
|
||||||
|
const state = createParseState();
|
||||||
|
const { events, callbacks } = makeCallbacks();
|
||||||
|
const delta = (text: string, parent?: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'stream_event',
|
||||||
|
...(parent ? { parent_tool_use_id: parent } : {}),
|
||||||
|
event: { type: 'content_block_delta', delta: { type: 'text_delta', text } },
|
||||||
|
});
|
||||||
|
|
||||||
|
processLine(delta('I will '), state, callbacks);
|
||||||
|
processLine(delta('reading the ', 'call_task'), state, callbacks);
|
||||||
|
processLine(delta('delegate.'), state, callbacks);
|
||||||
|
processLine(delta('config now.', 'call_task'), state, callbacks);
|
||||||
|
|
||||||
|
expect(buffered(state)).toBe('I will delegate.');
|
||||||
|
expect(buffered(state, 'call_task')).toBe('reading the config now.');
|
||||||
|
|
||||||
|
// And a tool call by the subagent flushes only the subagent's sentence.
|
||||||
|
processLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
parent_tool_use_id: 'call_task',
|
||||||
|
message: { content: [{ type: 'tool_use', id: 'call_read', name: 'Read', input: {} }] },
|
||||||
|
}),
|
||||||
|
state,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
|
expect(events.at(-2)).toEqual({ type: 'text', text: 'reading the config now.', parentToolUseId: 'call_task' });
|
||||||
|
expect(buffered(state)).toBe('I will delegate.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a result flushes every buffer, subagents included', () => {
|
||||||
|
const state = createParseState();
|
||||||
|
const { events, callbacks } = makeCallbacks();
|
||||||
|
state.textBuffers.set('', 'main said this');
|
||||||
|
state.textBuffers.set('call_task', 'subagent said this');
|
||||||
|
|
||||||
|
processLine(JSON.stringify({ type: 'result', is_error: false }), state, callbacks);
|
||||||
|
|
||||||
|
expect(events.slice(0, 2)).toEqual([
|
||||||
|
{ type: 'text', text: 'main said this' },
|
||||||
|
{ type: 'text', text: 'subagent said this', parentToolUseId: 'call_task' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,30 @@ type StreamParserCallbacks = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ParseState = {
|
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;
|
gotResult: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function flushTextBuffer(state: ParseState, onEvent: (event: ChatEvent) => void): void {
|
/** '' when a message is the main agent's own. Keys the buffer map and stamps every event we emit. */
|
||||||
if (state.textBuffer) {
|
function parentOf(msg: Record<string, unknown>): string {
|
||||||
onEvent({ type: 'text', text: state.textBuffer });
|
const id = msg.parent_tool_use_id;
|
||||||
state.textBuffer = '';
|
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') {
|
if (event?.type === 'content_block_delta') {
|
||||||
const delta = event.delta as Record<string, unknown> | undefined;
|
const delta = event.delta as Record<string, unknown> | undefined;
|
||||||
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
||||||
state.textBuffer += delta.text;
|
const parent = parentOf(msg);
|
||||||
onEvent({ type: 'delta', text: delta.text });
|
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;
|
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
||||||
if (!Array.isArray(content)) return;
|
if (!Array.isArray(content)) return;
|
||||||
|
|
||||||
|
const parent = parentOf(msg);
|
||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (block.type === 'text' && typeof block.text === 'string') {
|
if (block.type === 'text' && typeof block.text === 'string') {
|
||||||
onEvent({ type: 'text', text: block.text });
|
onEvent({ type: 'text', text: block.text, ...parented(parent) });
|
||||||
state.textBuffer = '';
|
state.textBuffers.delete(parent);
|
||||||
} else if (block.type === 'tool_use') {
|
} else if (block.type === 'tool_use') {
|
||||||
flushTextBuffer(state, onEvent);
|
flushTextBuffer(state, parent, onEvent);
|
||||||
onEvent({
|
onEvent({
|
||||||
type: 'tool:start',
|
type: 'tool:start',
|
||||||
toolCallId: (block.id as string) ?? '',
|
toolCallId: (block.id as string) ?? '',
|
||||||
toolName: (block.name as string) ?? 'unknown',
|
toolName: (block.name as string) ?? 'unknown',
|
||||||
toolInput: (block.input as Record<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;
|
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
||||||
if (!Array.isArray(content)) return;
|
if (!Array.isArray(content)) return;
|
||||||
|
|
||||||
|
const parent = parentOf(msg);
|
||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (block.type === 'tool_result') {
|
if (block.type === 'tool_result') {
|
||||||
let output = '';
|
let output = '';
|
||||||
@@ -80,28 +102,30 @@ function handleUser(msg: Record<string, unknown>, onEvent: (event: ChatEvent) =>
|
|||||||
toolCallId: (block.tool_use_id as string) ?? '',
|
toolCallId: (block.tool_use_id as string) ?? '',
|
||||||
output,
|
output,
|
||||||
isError: (block.is_error as boolean) ?? false,
|
isError: (block.is_error as boolean) ?? false,
|
||||||
|
...parented(parent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResult(
|
function handleResult(msg: Record<string, unknown>, state: ParseState, callbacks: StreamParserCallbacks): void {
|
||||||
msg: Record<string, unknown>,
|
|
||||||
state: ParseState,
|
|
||||||
callbacks: StreamParserCallbacks,
|
|
||||||
): void {
|
|
||||||
state.gotResult = true;
|
state.gotResult = true;
|
||||||
|
|
||||||
const isError = (msg.is_error as boolean) ?? false;
|
const isError = (msg.is_error as boolean) ?? false;
|
||||||
const resultText = (msg.result as string) ?? '';
|
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) {
|
if (isError) {
|
||||||
flushTextBuffer(state, callbacks.onEvent);
|
flushAll();
|
||||||
callbacks.onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
|
callbacks.onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
flushTextBuffer(state, callbacks.onEvent);
|
flushAll();
|
||||||
|
|
||||||
const usage = msg.usage as Record<string, number> | undefined;
|
const usage = msg.usage as Record<string, number> | undefined;
|
||||||
const cost: MessageCost = {
|
const cost: MessageCost = {
|
||||||
@@ -180,11 +204,7 @@ export function processMessage(
|
|||||||
* Process a single NDJSON line from Claude Code's stream output.
|
* Process a single NDJSON line from Claude Code's stream output.
|
||||||
* Returns false if the line was skipped (empty or malformed), true otherwise.
|
* Returns false if the line was skipped (empty or malformed), true otherwise.
|
||||||
*/
|
*/
|
||||||
export function processLine(
|
export function processLine(line: string, state: ParseState, callbacks: StreamParserCallbacks): boolean {
|
||||||
line: string,
|
|
||||||
state: ParseState,
|
|
||||||
callbacks: StreamParserCallbacks,
|
|
||||||
): boolean {
|
|
||||||
if (!line.trim()) return false;
|
if (!line.trim()) return false;
|
||||||
|
|
||||||
let msg: Record<string, unknown>;
|
let msg: Record<string, unknown>;
|
||||||
@@ -201,7 +221,7 @@ export function processLine(
|
|||||||
* Create a fresh parse state for a new stream.
|
* Create a fresh parse state for a new stream.
|
||||||
*/
|
*/
|
||||||
export function createParseState(): ParseState {
|
export function createParseState(): ParseState {
|
||||||
return { textBuffer: '', gotResult: false };
|
return { textBuffers: new Map(), gotResult: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,39 +20,53 @@ export type TurnStream = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createTurnStream(sessionId: string): 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
|
/** Only set the field when there IS a parent, so main-agent messages stay byte-identical on the wire. */
|
||||||
// assistant stops talking, so the transcript holds text blocks rather than a thousand fragments.
|
const parented = (parent: string) => (parent ? { parentToolUseId: parent } : {});
|
||||||
function flush(): TurnOutput[] {
|
|
||||||
if (!buffer) return [];
|
// Emit whatever deltas have accumulated as one complete message. Called at every boundary where that
|
||||||
const text = buffer;
|
// speaker stops talking, so the transcript holds text blocks rather than a thousand fragments.
|
||||||
buffer = '';
|
function flush(parent: string): TurnOutput[] {
|
||||||
return [{ msg: { type: 'assistant:text', text }, durable: true }];
|
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[] {
|
function push(event: ChatEvent): TurnOutput[] {
|
||||||
|
const parent = ('parentToolUseId' in event ? event.parentToolUseId : undefined) ?? '';
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'delta':
|
case 'delta':
|
||||||
buffer += event.text;
|
buffers.set(parent, (buffers.get(parent) ?? '') + event.text);
|
||||||
return [{ msg: { type: 'assistant:delta', text: event.text }, durable: false }];
|
return [{ msg: { type: 'assistant:delta', text: event.text, ...parented(parent) }, durable: false }];
|
||||||
|
|
||||||
case 'text': {
|
case 'text': {
|
||||||
// An explicit full text block wins over the accumulated deltas that produced it.
|
// An explicit full text block wins over the accumulated deltas that produced it.
|
||||||
const text = event.text || buffer;
|
const text = event.text || buffers.get(parent) || '';
|
||||||
buffer = '';
|
buffers.delete(parent);
|
||||||
return text ? [{ msg: { type: 'assistant:text', text }, durable: true }] : [];
|
return text ? [{ msg: { type: 'assistant:text', text, ...parented(parent) }, durable: true }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'tool:start':
|
case 'tool:start':
|
||||||
return [
|
return [
|
||||||
...flush(),
|
...flush(parent),
|
||||||
{
|
{
|
||||||
msg: {
|
msg: {
|
||||||
type: 'tool:start',
|
type: 'tool:start',
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
toolInput: event.toolInput,
|
toolInput: event.toolInput,
|
||||||
|
...parented(parent),
|
||||||
},
|
},
|
||||||
durable: true,
|
durable: true,
|
||||||
},
|
},
|
||||||
@@ -61,14 +75,20 @@ export function createTurnStream(sessionId: string): TurnStream {
|
|||||||
case 'tool:result':
|
case 'tool:result':
|
||||||
return [
|
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,
|
durable: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
case 'result':
|
case 'result':
|
||||||
return [
|
return [
|
||||||
...flush(),
|
...flushAll(),
|
||||||
{
|
{
|
||||||
msg: { type: 'result', sessionId, cost: event.cost, claudeSessionId: event.claudeSessionId },
|
msg: { type: 'result', sessionId, cost: event.cost, claudeSessionId: event.claudeSessionId },
|
||||||
durable: true,
|
durable: true,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||||
import { Volume2, Loader2, Square } from 'lucide-react';
|
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash } from 'lucide-react';
|
||||||
import type { ChatMessage } from '../types';
|
import type { ChatMessage } from '../types';
|
||||||
import { ToolActivity } from './ToolActivity';
|
import { ToolActivity } from './ToolActivity';
|
||||||
import { QuestionActivity } from './QuestionActivity';
|
import { QuestionActivity } from './QuestionActivity';
|
||||||
@@ -13,7 +13,9 @@ import { CopyButton } from './CopyButton';
|
|||||||
|
|
||||||
const sanitizeSchema = {
|
const sanitizeSchema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
tagNames: (defaultSchema.tagNames ?? []).filter((tag) => tag !== 'script' && tag !== 'iframe' && tag !== 'object' && tag !== 'embed' && tag !== 'form'),
|
tagNames: (defaultSchema.tagNames ?? []).filter(
|
||||||
|
(tag) => tag !== 'script' && tag !== 'iframe' && tag !== 'object' && tag !== 'embed' && tag !== 'form',
|
||||||
|
),
|
||||||
attributes: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
'*': (defaultSchema.attributes?.['*'] ?? []).filter((attr) => typeof attr === 'string' && !attr.startsWith('on')),
|
'*': (defaultSchema.attributes?.['*'] ?? []).filter((attr) => typeof attr === 'string' && !attr.startsWith('on')),
|
||||||
@@ -80,8 +82,14 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
|||||||
const url = getRawUrl(audioPath, audioRoot);
|
const url = getRawUrl(audioPath, audioRoot);
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
audioRef.current = audio;
|
audioRef.current = audio;
|
||||||
audio.onended = () => { audioRef.current = null; setState('idle'); };
|
audio.onended = () => {
|
||||||
audio.onerror = () => { audioRef.current = null; setState('idle'); };
|
audioRef.current = null;
|
||||||
|
setState('idle');
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
audioRef.current = null;
|
||||||
|
setState('idle');
|
||||||
|
};
|
||||||
await audio.play();
|
await audio.play();
|
||||||
setState('playing');
|
setState('playing');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -140,7 +148,10 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
<div className="max-w-[85%]">
|
<div className="max-w-[85%]">
|
||||||
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||||
<div className="chat-md">
|
<div className="chat-md">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||||
|
>
|
||||||
{injectImages(assistantText)}
|
{injectImages(assistantText)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,6 +180,9 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'task':
|
||||||
|
return <TaskActivity message={message} />;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start group">
|
<div className="flex justify-start group">
|
||||||
@@ -183,6 +197,40 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A background task (run_in_background / Monitor), start to finish, in one row. It renders pending the
|
||||||
|
* moment the task starts and resolves in place when the notification lands — which can be long after the
|
||||||
|
* turn that started it ended. It used to be two unrelated assistant bubbles a screenful apart, because
|
||||||
|
* both sides discarded the taskId that relates them.
|
||||||
|
*/
|
||||||
|
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
|
||||||
|
const { status, summary, description, taskType } = message;
|
||||||
|
const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
|
||||||
|
const tone =
|
||||||
|
status === 'completed'
|
||||||
|
? 'text-green-600'
|
||||||
|
: status === 'failed'
|
||||||
|
? 'text-red-600'
|
||||||
|
: status === 'stopped'
|
||||||
|
? 'text-duck-dark/50'
|
||||||
|
: 'text-amber-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1 flex items-start gap-2 px-3 py-1.5 rounded-md bg-duck-dark/5 text-sm">
|
||||||
|
<Icon className={`h-4 w-4 shrink-0 mt-0.5 ${tone} ${status ? '' : 'animate-pulse'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-duck-dark/80">
|
||||||
|
{description || 'Background task'}
|
||||||
|
{taskType && <span className="text-duck-dark/40 text-xs ml-2">{taskType}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-duck-dark/50 text-xs">
|
||||||
|
{status ? summary || `Task ${status}.` : 'Running in the background…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type StreamingBubbleProps = {
|
type StreamingBubbleProps = {
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function firstMessageKey(m: ChatMessage | undefined): string {
|
|||||||
if (!m) return '';
|
if (!m) return '';
|
||||||
if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`;
|
if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`;
|
||||||
if (m.role === 'tool') return `t:${m.toolCallId}`;
|
if (m.role === 'tool') return `t:${m.toolCallId}`;
|
||||||
|
if (m.role === 'task') return `k:${m.taskId}`;
|
||||||
return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`;
|
return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react';
|
||||||
import type { ChatMessage } from '../types';
|
import type { ChatMessage } from '../types';
|
||||||
import { CopyButton } from './CopyButton';
|
import { CopyButton } from './CopyButton';
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const toolIcons: Record<string, typeof FileText> = {
|
|||||||
Glob: Search,
|
Glob: Search,
|
||||||
WebFetch: Globe,
|
WebFetch: Globe,
|
||||||
WebSearch: Globe,
|
WebSearch: Globe,
|
||||||
|
Task: Bot,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
|
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
|
||||||
@@ -35,6 +36,13 @@ function getToolSummary(toolName: string, toolInput: Record<string, unknown>): s
|
|||||||
return (toolInput.url as string) ?? '';
|
return (toolInput.url as string) ?? '';
|
||||||
case 'WebSearch':
|
case 'WebSearch':
|
||||||
return (toolInput.query as string) ?? '';
|
return (toolInput.query as string) ?? '';
|
||||||
|
case 'Task': {
|
||||||
|
// `description` is the short label; the prompt is the essay. Showing the essay made every subagent
|
||||||
|
// row look identical for its first eighty characters.
|
||||||
|
const description = (toolInput.description as string) ?? '';
|
||||||
|
const type = (toolInput.subagent_type as string) ?? '';
|
||||||
|
return type ? `${description} · ${type}` : description;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
Object.values(toolInput)
|
Object.values(toolInput)
|
||||||
@@ -55,6 +63,7 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
const summary = getToolSummary(message.toolName, message.toolInput);
|
const summary = getToolSummary(message.toolName, message.toolInput);
|
||||||
const pending = message.output === undefined;
|
const pending = message.output === undefined;
|
||||||
const isError = message.isError === true;
|
const isError = message.isError === true;
|
||||||
|
const children = message.children ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-1">
|
<div className="my-1">
|
||||||
@@ -66,7 +75,12 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
|
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
|
||||||
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
|
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
|
||||||
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
|
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
|
||||||
<span className="shrink-0">
|
<span className="shrink-0 flex items-center gap-2">
|
||||||
|
{children.length > 0 && (
|
||||||
|
<span className="text-duck-dark/40 text-[10px]">
|
||||||
|
{children.length} step{children.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
|
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
|
||||||
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
|
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
|
||||||
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
|
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
|
||||||
@@ -79,9 +93,13 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Input</div>
|
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Input</div>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={message.toolName === 'Bash'
|
text={
|
||||||
? (message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)
|
message.toolName === 'Bash'
|
||||||
: Object.entries(message.toolInput).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join('\n')}
|
? ((message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2))
|
||||||
|
: Object.entries(message.toolInput)
|
||||||
|
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
|
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,11 +116,21 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div className="rounded-md border-l-2 border-duck-teal/30 pl-2">
|
||||||
|
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Subagent</div>
|
||||||
|
<SubagentTrace messages={children} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{message.output !== undefined && (
|
{message.output !== undefined && (
|
||||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
|
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Output</div>
|
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Output</div>
|
||||||
<CopyButton text={message.output} className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100" />
|
<CopyButton
|
||||||
|
text={message.output}
|
||||||
|
className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +141,24 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a subagent did, in order — its own tool calls nested one level further, its prose as plain text.
|
||||||
|
* Deliberately not markdown-rendered: this is a trace, and it sits inside an already-nested panel.
|
||||||
|
*/
|
||||||
|
const SubagentTrace = ({ messages }: { messages: ChatMessage[] }) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{messages.map((m, i) =>
|
||||||
|
m.role === 'tool' ? (
|
||||||
|
<ToolActivity key={m.toolCallId || i} message={m} />
|
||||||
|
) : m.role === 'assistant' ? (
|
||||||
|
<div key={m.id ?? i} className="text-duck-dark/60 whitespace-pre-wrap px-3 py-1">
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
type ToolOutputProps = {
|
type ToolOutputProps = {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
output: string;
|
output: string;
|
||||||
@@ -132,7 +178,11 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
|
|||||||
<>
|
<>
|
||||||
<pre
|
<pre
|
||||||
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
||||||
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' : 'text-duck-dark/70'
|
isBash
|
||||||
|
? 'bg-gray-900 text-green-400'
|
||||||
|
: isError
|
||||||
|
? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300'
|
||||||
|
: 'text-duck-dark/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
|
|||||||
@@ -29,9 +29,25 @@ export type ChatMessage =
|
|||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
output?: string;
|
output?: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/**
|
||||||
|
* For a `Task` call: everything the subagent said and did, in order. Kept nested rather than
|
||||||
|
* appended to the transcript because a subagent's work is not the conversation — flattened, its
|
||||||
|
* prose read as the agent you are talking to having said it, and its file edits looked like yours.
|
||||||
|
*/
|
||||||
|
children?: ChatMessage[];
|
||||||
}
|
}
|
||||||
| { role: 'result'; cost: MessageCost }
|
| { role: 'result'; cost: MessageCost }
|
||||||
| { role: 'error'; text: string };
|
| { role: 'error'; text: string }
|
||||||
|
| {
|
||||||
|
// A background task (run_in_background / Monitor). One row for its whole life: it appears pending
|
||||||
|
// and resolves in place, rather than as two unrelated bubbles minutes apart.
|
||||||
|
role: 'task';
|
||||||
|
taskId: string;
|
||||||
|
description: string;
|
||||||
|
taskType?: string;
|
||||||
|
status?: 'completed' | 'failed' | 'stopped';
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TaskInfo = {
|
export type TaskInfo = {
|
||||||
taskName: string;
|
taskName: string;
|
||||||
@@ -42,10 +58,16 @@ export type TaskInfo = {
|
|||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| { type: 'session:init'; sessionId: string; model: string; cwd: string; context?: string; contextId?: string }
|
| { type: 'session:init'; sessionId: string; model: string; cwd: string; context?: string; contextId?: string }
|
||||||
| { type: 'assistant:text'; text: string }
|
| { type: 'assistant:text'; text: string; parentToolUseId?: string }
|
||||||
| { type: 'assistant:delta'; text: string }
|
| { type: 'assistant:delta'; text: string; parentToolUseId?: string }
|
||||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
|
| {
|
||||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
|
type: 'tool:start';
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
toolInput: Record<string, unknown>;
|
||||||
|
parentToolUseId?: string;
|
||||||
|
}
|
||||||
|
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; parentToolUseId?: string }
|
||||||
| { type: 'result'; sessionId: string; cost: MessageCost }
|
| { type: 'result'; sessionId: string; cost: MessageCost }
|
||||||
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
|
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
|
||||||
| { type: 'error'; message: string; errorCode?: string }
|
| { type: 'error'; message: string; errorCode?: string }
|
||||||
@@ -66,6 +88,7 @@ export type Message = {
|
|||||||
toolInput?: Record<string, unknown>;
|
toolInput?: Record<string, unknown>;
|
||||||
output?: string;
|
output?: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
parentToolUseId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlashCommand = {
|
export type SlashCommand = {
|
||||||
|
|||||||
@@ -138,6 +138,60 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
|
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply `fn` to the children of the tool call `parentId`, wherever it sits in the transcript. A subagent
|
||||||
|
* runs for minutes, so its parent Task row is rarely the last message by the time output arrives.
|
||||||
|
*/
|
||||||
|
function withChildren(prev: ChatMessage[], parentId: string, fn: (children: ChatMessage[]) => ChatMessage[]) {
|
||||||
|
let found = false;
|
||||||
|
const next = prev.map((m) => {
|
||||||
|
if (found || m.role !== 'tool' || m.toolCallId !== parentId) return m;
|
||||||
|
found = true;
|
||||||
|
return { ...m, children: fn(m.children ?? []) };
|
||||||
|
});
|
||||||
|
// The Task row itself never arrived (a replay that starts mid-subagent). Dropping the output would be
|
||||||
|
// worse than showing it unattributed, so fall through to the top level.
|
||||||
|
return found ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the nested transcript from the flat `Message[]` a resume replays. The wire format stays flat
|
||||||
|
* — officer folds messages in arrival order and stamps each with its `parentToolUseId` — so the nesting
|
||||||
|
* is reconstructed here rather than stored twice.
|
||||||
|
*/
|
||||||
|
function rebuildTranscript(messages: Message[]): ChatMessage[] {
|
||||||
|
const top: ChatMessage[] = [];
|
||||||
|
const byToolCallId = new Map<string, Extract<ChatMessage, { role: 'tool' }>>();
|
||||||
|
|
||||||
|
for (const m of messages) {
|
||||||
|
let converted: ChatMessage;
|
||||||
|
if (m.role === 'user') {
|
||||||
|
converted = { role: 'user', text: m.text || '' };
|
||||||
|
} else if (m.role === 'tool') {
|
||||||
|
const call: Extract<ChatMessage, { role: 'tool' }> = {
|
||||||
|
role: 'tool',
|
||||||
|
toolName: m.toolName || '',
|
||||||
|
toolInput: m.toolInput || {},
|
||||||
|
toolCallId: m.toolCallId || '',
|
||||||
|
output: m.output,
|
||||||
|
isError: m.isError,
|
||||||
|
};
|
||||||
|
if (call.toolCallId) byToolCallId.set(call.toolCallId, call);
|
||||||
|
converted = call;
|
||||||
|
} else {
|
||||||
|
converted = { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A subagent's output belongs under the Task row that spawned it. If that row is missing (pruned
|
||||||
|
// from the replay window) it goes top-level — unattributed beats dropped.
|
||||||
|
const parent = m.parentToolUseId ? byToolCallId.get(m.parentToolUseId) : undefined;
|
||||||
|
if (parent) parent.children = [...(parent.children ?? []), converted];
|
||||||
|
else top.push(converted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
function handleMessage(data: unknown) {
|
function handleMessage(data: unknown) {
|
||||||
const msg = data as ServerMessage;
|
const msg = data as ServerMessage;
|
||||||
|
|
||||||
@@ -169,40 +223,57 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'assistant:delta':
|
case 'assistant:delta':
|
||||||
|
// A subagent's deltas are deliberately not streamed. Two speakers cannot share one cursor, and the
|
||||||
|
// complete `assistant:text` that follows lands in the Task row a moment later regardless.
|
||||||
|
if (msg.parentToolUseId) break;
|
||||||
streamingRef.current += msg.text;
|
streamingRef.current += msg.text;
|
||||||
flushStreaming();
|
flushStreaming();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'assistant:text':
|
case 'assistant:text': {
|
||||||
|
const parent = msg.parentToolUseId;
|
||||||
|
if (parent) {
|
||||||
|
setMessages(
|
||||||
|
(prev) =>
|
||||||
|
withChildren(prev, parent, (kids) => [
|
||||||
|
...kids,
|
||||||
|
{ role: 'assistant', id: crypto.randomUUID(), text: msg.text },
|
||||||
|
]) ?? [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (streamingRef.current) {
|
if (streamingRef.current) {
|
||||||
commitStreaming();
|
commitStreaming();
|
||||||
} else {
|
} else {
|
||||||
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]);
|
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool:start':
|
case 'tool:start': {
|
||||||
toolCallsInTurnRef.current = true;
|
toolCallsInTurnRef.current = true;
|
||||||
setMessages((prev) => [
|
const call: ChatMessage = {
|
||||||
...prev,
|
role: 'tool',
|
||||||
{
|
toolName: msg.toolName,
|
||||||
role: 'tool',
|
toolInput: msg.toolInput,
|
||||||
toolName: msg.toolName,
|
toolCallId: msg.toolCallId,
|
||||||
toolInput: msg.toolInput,
|
};
|
||||||
toolCallId: msg.toolCallId,
|
const parent = msg.parentToolUseId;
|
||||||
},
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool:result':
|
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
parent ? (withChildren(prev, parent, (kids) => [...kids, call]) ?? [...prev, call]) : [...prev, call],
|
||||||
m.role === 'tool' && m.toolCallId === msg.toolCallId
|
|
||||||
? { ...m, output: msg.output, isError: msg.isError }
|
|
||||||
: m,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool:result': {
|
||||||
|
const patch = (m: ChatMessage): ChatMessage =>
|
||||||
|
m.role === 'tool' && m.toolCallId === msg.toolCallId ? { ...m, output: msg.output, isError: msg.isError } : m;
|
||||||
|
const parent = msg.parentToolUseId;
|
||||||
|
setMessages((prev) =>
|
||||||
|
parent ? (withChildren(prev, parent, (kids) => kids.map(patch)) ?? prev.map(patch)) : prev.map(patch),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'result': {
|
case 'result': {
|
||||||
commitStreaming();
|
commitStreaming();
|
||||||
@@ -220,35 +291,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'sync:messages':
|
case 'sync:messages': {
|
||||||
sessionIdRef.current = msg.sessionId;
|
sessionIdRef.current = msg.sessionId;
|
||||||
setSessionId(msg.sessionId);
|
setSessionId(msg.sessionId);
|
||||||
// Convert Message[] to ChatMessage[]
|
const transcript = rebuildTranscript(msg.messages);
|
||||||
const chatMessages = msg.messages.map((m): ChatMessage => {
|
setMessages(transcript);
|
||||||
if (m.role === 'user') {
|
if (transcript.length > 0) setHasStarted(true);
|
||||||
return { role: 'user', text: m.text || '' };
|
|
||||||
} else if (m.role === 'assistant') {
|
|
||||||
return { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' };
|
|
||||||
} else if (m.role === 'tool') {
|
|
||||||
return {
|
|
||||||
role: 'tool',
|
|
||||||
toolName: m.toolName || '',
|
|
||||||
toolInput: m.toolInput || {},
|
|
||||||
toolCallId: m.toolCallId || '',
|
|
||||||
output: m.output,
|
|
||||||
isError: m.isError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { role: 'assistant', text: '' }; // Fallback
|
|
||||||
});
|
|
||||||
setMessages(chatMessages);
|
|
||||||
if (chatMessages.length > 0) setHasStarted(true);
|
|
||||||
setIsGenerating(msg.isGenerating);
|
setIsGenerating(msg.isGenerating);
|
||||||
if (msg.streamingText) {
|
if (msg.streamingText) {
|
||||||
streamingRef.current = msg.streamingText;
|
streamingRef.current = msg.streamingText;
|
||||||
flushStreaming();
|
flushStreaming();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
commitStreaming();
|
commitStreaming();
|
||||||
@@ -271,21 +326,32 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
case 'task:started':
|
case 'task:started':
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'assistant', id: crypto.randomUUID(), text: `⏳ Background task started — ${msg.description}` },
|
{ role: 'task', taskId: msg.taskId, description: msg.description, taskType: msg.taskType },
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'task:notification': {
|
case 'task:notification': {
|
||||||
// The fix in action: a background task's completion arriving after the turn ended.
|
// A background task's completion, arriving after the turn ended — the whole point of the
|
||||||
const icon = msg.status === 'completed' ? '✅' : msg.status === 'failed' ? '❌' : '⏹️';
|
// persistent worker. Resolved onto the row that announced it rather than appended as a second
|
||||||
setMessages((prev) => [
|
// bubble: the two used to be minutes and a screenful of scrollback apart, with the taskId that
|
||||||
...prev,
|
// relates them thrown away on both sides.
|
||||||
{
|
setMessages((prev) => {
|
||||||
role: 'assistant',
|
const at = prev.findIndex((m) => m.role === 'task' && m.taskId === msg.taskId);
|
||||||
id: crypto.randomUUID(),
|
// No announcing row — a replay that starts after `task:started` was pruned. Stand one up.
|
||||||
text: `${icon} Background task ${msg.status} — ${msg.summary}`,
|
if (at < 0) {
|
||||||
},
|
return [
|
||||||
]);
|
...prev,
|
||||||
|
{ role: 'task', taskId: msg.taskId, description: msg.summary, status: msg.status, summary: msg.summary },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[at] = {
|
||||||
|
...(next[at] as Extract<ChatMessage, { role: 'task' }>),
|
||||||
|
status: msg.status,
|
||||||
|
summary: msg.summary,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user