diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index b383e37d..ec6507e0 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -10,6 +10,8 @@ export type Message = { toolInput?: Record; 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; - } - | { + } & 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 & { 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; - } - | { + } & Parented) + | ({ type: 'tool:result'; toolCallId: string; output: string; isError: boolean; - } + } & Parented) | { type: 'result'; cost: MessageCost; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7cd4a469..4de6139a 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -164,13 +164,23 @@ export function close(ws: ServerWebSocket): 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; } diff --git a/src/servers/sidecar/claude/stream-parser.test.ts b/src/servers/sidecar/claude/stream-parser.test.ts index 07457fc8..f2d78e16 100644 --- a/src/servers/sidecar/claude/stream-parser.test.ts +++ b/src/servers/sidecar/claude/stream-parser.test.ts @@ -3,6 +3,9 @@ import { processLine, createParseState, parseStream } from './stream-parser'; import type { ChatEvent } from '../../api/chat/types'; 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 } { const events: ChatEvent[] = []; const sessionIds: string[] = []; @@ -41,7 +44,7 @@ describe('processLine', () => { }); processLine(line, state, callbacks); expect(events).toEqual([{ type: 'delta', text: 'hello' }]); - expect(state.textBuffer).toBe('hello'); + expect(buffered(state)).toBe('hello'); }); test('accumulates text buffer across deltas', () => { @@ -54,13 +57,13 @@ describe('processLine', () => { }); processLine(mkDelta('hello '), state, callbacks); processLine(mkDelta('world'), state, callbacks); - expect(state.textBuffer).toBe('hello world'); + expect(buffered(state)).toBe('hello world'); expect(events).toHaveLength(2); }); test('handles assistant text block — clears text buffer', () => { const state = createParseState(); - state.textBuffer = 'partial'; + state.textBuffers.set('', 'partial'); const { events, callbacks } = makeCallbacks(); const line = JSON.stringify({ type: 'assistant', @@ -68,12 +71,12 @@ describe('processLine', () => { }); processLine(line, state, callbacks); 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', () => { const state = createParseState(); - state.textBuffer = 'thinking...'; + state.textBuffers.set('', 'thinking...'); const { events, callbacks } = makeCallbacks(); const line = JSON.stringify({ type: 'assistant', @@ -86,7 +89,7 @@ describe('processLine', () => { { type: 'text', text: 'thinking...' }, { 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', () => { @@ -99,9 +102,7 @@ describe('processLine', () => { }, }); processLine(line, state, callbacks); - expect(events).toEqual([ - { type: 'tool:start', toolCallId: 'call_1', toolName: 'email_db', toolInput: {} }, - ]); + expect(events).toEqual([{ type: 'tool:start', toolCallId: 'call_1', toolName: 'email_db', toolInput: {} }]); }); test('handles user tool_result with string content', () => { @@ -114,9 +115,7 @@ describe('processLine', () => { }, }); processLine(line, state, callbacks); - expect(events).toEqual([ - { type: 'tool:result', toolCallId: 'call_1', output: 'result text', isError: false }, - ]); + expect(events).toEqual([{ type: 'tool:result', toolCallId: 'call_1', output: 'result text', isError: false }]); }); test('handles user tool_result with array content', () => { @@ -140,9 +139,7 @@ describe('processLine', () => { }, }); processLine(line, state, callbacks); - expect(events).toEqual([ - { type: 'tool:result', toolCallId: 'call_2', output: 'line 1\nline 2', isError: true }, - ]); + expect(events).toEqual([{ type: 'tool:result', toolCallId: 'call_2', output: 'line 1\nline 2', isError: true }]); }); test('handles system init — captures session id', () => { @@ -155,7 +152,7 @@ describe('processLine', () => { test('handles result — sets gotResult, emits cost', () => { const state = createParseState(); - state.textBuffer = 'trailing'; + state.textBuffers.set('', 'trailing'); const { events, sessionIds, callbacks } = makeCallbacks(); const line = JSON.stringify({ type: 'result', @@ -170,13 +167,13 @@ describe('processLine', () => { expect(sessionIds).toEqual(['sess_xyz']); expect(events).toEqual([ { 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', () => { const state = createParseState(); - state.textBuffer = 'partial'; + state.textBuffers.set('', 'partial'); const { events, callbacks } = makeCallbacks(); const line = JSON.stringify({ type: 'result', is_error: true, result: 'something broke' }); processLine(line, state, callbacks); @@ -193,7 +190,87 @@ describe('processLine', () => { const line = JSON.stringify({ type: 'result', is_error: false }); processLine(line, state, callbacks); 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' }, ]); }); diff --git a/src/servers/sidecar/claude/stream-parser.ts b/src/servers/sidecar/claude/stream-parser.ts index ae35bb5c..fbf04fd9 100644 --- a/src/servers/sidecar/claude/stream-parser.ts +++ b/src/servers/sidecar/claude/stream-parser.ts @@ -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; 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 { + 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, state: ParseState, onEv if (event?.type === 'content_block_delta') { const delta = event.delta as Record | 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, state: ParseState, onEven const content = message?.content as Array> | 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) ?? {}, + ...parented(parent), }); } } @@ -64,6 +84,8 @@ function handleUser(msg: Record, onEvent: (event: ChatEvent) => const content = message?.content as Array> | 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, onEvent: (event: ChatEvent) => toolCallId: (block.tool_use_id as string) ?? '', output, isError: (block.is_error as boolean) ?? false, + ...parented(parent), }); } } } -function handleResult( - msg: Record, - state: ParseState, - callbacks: StreamParserCallbacks, -): void { +function handleResult(msg: Record, 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 | 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; @@ -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 }; } /** diff --git a/src/servers/sidecar/claude/turn-stream.ts b/src/servers/sidecar/claude/turn-stream.ts index 31032324..b3813372 100644 --- a/src/servers/sidecar/claude/turn-stream.ts +++ b/src/servers/sidecar/claude/turn-stream.ts @@ -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(); - // 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, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index e53fd7c7..4b16e31e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; 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 { ToolActivity } from './ToolActivity'; import { QuestionActivity } from './QuestionActivity'; @@ -13,7 +13,9 @@ import { CopyButton } from './CopyButton'; const sanitizeSchema = { ...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: { ...defaultSchema.attributes, '*': (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 audio = new Audio(url); audioRef.current = audio; - audio.onended = () => { audioRef.current = null; setState('idle'); }; - audio.onerror = () => { audioRef.current = null; setState('idle'); }; + audio.onended = () => { + audioRef.current = null; + setState('idle'); + }; + audio.onerror = () => { + audioRef.current = null; + setState('idle'); + }; await audio.play(); setState('playing'); } catch { @@ -140,7 +148,10 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
- + {injectImages(assistantText)}
@@ -169,6 +180,9 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
); + case 'task': + return ; + case 'error': return (
@@ -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 }) => { + 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 ( +
+ +
+
+ {description || 'Background task'} + {taskType && {taskType}} +
+
+ {status ? summary || `Task ${status}.` : 'Running in the background…'} +
+
+
+ ); +}; + type StreamingBubbleProps = { text: string; }; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index e8cdf15d..1d251be9 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -13,6 +13,7 @@ function firstMessageKey(m: ChatMessage | undefined): string { if (!m) return ''; if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`; 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) : ''}`; } diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx index e94b19d4..efb0c18f 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx @@ -1,5 +1,5 @@ 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 { CopyButton } from './CopyButton'; @@ -18,6 +18,7 @@ const toolIcons: Record = { Glob: Search, WebFetch: Globe, WebSearch: Globe, + Task: Bot, }; function getToolSummary(toolName: string, toolInput: Record): string { @@ -35,6 +36,13 @@ function getToolSummary(toolName: string, toolInput: Record): s return (toolInput.url as string) ?? ''; case 'WebSearch': 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: return ( Object.values(toolInput) @@ -55,6 +63,7 @@ export const ToolActivity = ({ message }: ToolActivityProps) => { const summary = getToolSummary(message.toolName, message.toolInput); const pending = message.output === undefined; const isError = message.isError === true; + const children = message.children ?? []; return (
@@ -66,7 +75,12 @@ export const ToolActivity = ({ message }: ToolActivityProps) => { {message.toolName} {summary} - + + {children.length > 0 && ( + + {children.length} step{children.length === 1 ? '' : 's'} + + )} {pending && } {!pending && !isError && done} {!pending && isError && error} @@ -79,9 +93,13 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
Input
`${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join('\n')} + text={ + message.toolName === 'Bash' + ? ((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" />
@@ -98,11 +116,21 @@ export const ToolActivity = ({ message }: ToolActivityProps) => { )}
+ {children.length > 0 && ( +
+
Subagent
+ +
+ )} + {message.output !== undefined && (
Output
- +
@@ -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[] }) => ( +
+ {messages.map((m, i) => + m.role === 'tool' ? ( + + ) : m.role === 'assistant' ? ( +
+ {m.text} +
+ ) : null, + )} +
+); + type ToolOutputProps = { toolName: string; output: string; @@ -132,7 +178,11 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => { <>
         {displayText}
diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts
index bfd3e04f..1bc8431c 100644
--- a/src/workspaces/officerdev/src/apps/Chat/types.ts
+++ b/src/workspaces/officerdev/src/apps/Chat/types.ts
@@ -29,9 +29,25 @@ export type ChatMessage =
       toolCallId: string;
       output?: string;
       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: '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 = {
   taskName: string;
@@ -42,10 +58,16 @@ export type TaskInfo = {
 
 export type ServerMessage =
   | { type: 'session:init'; sessionId: string; model: string; cwd: string; context?: string; contextId?: string }
-  | { type: 'assistant:text'; text: string }
-  | { type: 'assistant:delta'; text: string }
-  | { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record }
-  | { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
+  | { type: 'assistant:text'; text: string; parentToolUseId?: string }
+  | { type: 'assistant:delta'; text: string; parentToolUseId?: string }
+  | {
+      type: 'tool:start';
+      toolCallId: string;
+      toolName: string;
+      toolInput: Record;
+      parentToolUseId?: string;
+    }
+  | { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; parentToolUseId?: string }
   | { type: 'result'; sessionId: string; cost: MessageCost }
   | { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
   | { type: 'error'; message: string; errorCode?: string }
@@ -66,6 +88,7 @@ export type Message = {
   toolInput?: Record;
   output?: string;
   isError?: boolean;
+  parentToolUseId?: string;
 };
 
 export type SlashCommand = {
diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts
index ce68e390..58e5bf46 100644
--- a/src/workspaces/officerdev/src/hooks/useChat.ts
+++ b/src/workspaces/officerdev/src/hooks/useChat.ts
@@ -138,6 +138,60 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
     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>();
+
+    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 = {
+          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) {
     const msg = data as ServerMessage;
 
@@ -169,40 +223,57 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
         break;
 
       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;
         flushStreaming();
         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) {
           commitStreaming();
         } else {
           setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]);
         }
         break;
+      }
 
-      case 'tool:start':
+      case 'tool:start': {
         toolCallsInTurnRef.current = true;
-        setMessages((prev) => [
-          ...prev,
-          {
-            role: 'tool',
-            toolName: msg.toolName,
-            toolInput: msg.toolInput,
-            toolCallId: msg.toolCallId,
-          },
-        ]);
-        break;
-
-      case 'tool:result':
+        const call: ChatMessage = {
+          role: 'tool',
+          toolName: msg.toolName,
+          toolInput: msg.toolInput,
+          toolCallId: msg.toolCallId,
+        };
+        const parent = msg.parentToolUseId;
         setMessages((prev) =>
-          prev.map((m) =>
-            m.role === 'tool' && m.toolCallId === msg.toolCallId
-              ? { ...m, output: msg.output, isError: msg.isError }
-              : m,
-          ),
+          parent ? (withChildren(prev, parent, (kids) => [...kids, call]) ?? [...prev, call]) : [...prev, call],
         );
         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': {
         commitStreaming();
@@ -220,35 +291,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
         break;
       }
 
-      case 'sync:messages':
+      case 'sync:messages': {
         sessionIdRef.current = msg.sessionId;
         setSessionId(msg.sessionId);
-        // Convert Message[] to ChatMessage[]
-        const chatMessages = msg.messages.map((m): ChatMessage => {
-          if (m.role === 'user') {
-            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);
+        const transcript = rebuildTranscript(msg.messages);
+        setMessages(transcript);
+        if (transcript.length > 0) setHasStarted(true);
         setIsGenerating(msg.isGenerating);
         if (msg.streamingText) {
           streamingRef.current = msg.streamingText;
           flushStreaming();
         }
         break;
+      }
 
       case 'error':
         commitStreaming();
@@ -271,21 +326,32 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
       case 'task:started':
         setMessages((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;
 
       case 'task:notification': {
-        // The fix in action: a background task's completion arriving after the turn ended.
-        const icon = msg.status === 'completed' ? '✅' : msg.status === 'failed' ? '❌' : '⏹️';
-        setMessages((prev) => [
-          ...prev,
-          {
-            role: 'assistant',
-            id: crypto.randomUUID(),
-            text: `${icon} Background task ${msg.status} — ${msg.summary}`,
-          },
-        ]);
+        // A background task's completion, arriving after the turn ended — the whole point of the
+        // persistent worker. Resolved onto the row that announced it rather than appended as a second
+        // bubble: the two used to be minutes and a screenful of scrollback apart, with the taskId that
+        // relates them thrown away on both sides.
+        setMessages((prev) => {
+          const at = prev.findIndex((m) => m.role === 'task' && m.taskId === msg.taskId);
+          // No announcing row — a replay that starts after `task:started` was pruned. Stand one up.
+          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),
+            status: msg.status,
+            summary: msg.summary,
+          };
+          return next;
+        });
         break;
       }
     }