diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts index fb52eeea4a..f1b2ef0b4e 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -719,6 +719,10 @@ describe('SubAgentTracker', () => { type: 'text', text: 'Hello, this is a response from the model.', }, + _meta: expect.objectContaining({ + parentToolCallId: 'parent-call-123', + subagentType: 'test-subagent', + }), }), ); }); diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index 133339fad6..fe1fbc3a63 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -276,6 +276,8 @@ export class SubAgentTracker { event.text, 'assistant', event.thought ?? false, + undefined, + this.getSubagentMeta(), ); }; } diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 3a92c1131c..db4765adc9 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -69,12 +69,16 @@ export class MessageEmitter extends BaseEmitter { async emitAgentThought( text: string, timestamp?: string | number, + subagentMeta?: SubagentMeta, ): Promise { - const epochMs = BaseEmitter.toEpochMs(timestamp); + const _meta = this.buildChunkMeta( + BaseEmitter.toEpochMs(timestamp), + subagentMeta, + ); await this.sendUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text }, - ...(epochMs != null && { _meta: { timestamp: epochMs } }), + ...(_meta ? { _meta } : {}), }); } @@ -87,12 +91,16 @@ export class MessageEmitter extends BaseEmitter { async emitAgentMessage( text: string, timestamp?: string | number, + subagentMeta?: SubagentMeta, ): Promise { - const epochMs = BaseEmitter.toEpochMs(timestamp); + const _meta = this.buildChunkMeta( + BaseEmitter.toEpochMs(timestamp), + subagentMeta, + ); await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, - ...(epochMs != null && { _meta: { timestamp: epochMs } }), + ...(_meta ? { _meta } : {}), }); } @@ -139,12 +147,28 @@ export class MessageEmitter extends BaseEmitter { role: 'user' | 'assistant', isThought: boolean = false, timestamp?: string | number, + subagentMeta?: SubagentMeta, ): Promise { if (role === 'user') { return this.emitUserMessage(text, timestamp); } return isThought - ? this.emitAgentThought(text, timestamp) - : this.emitAgentMessage(text, timestamp); + ? this.emitAgentThought(text, timestamp, subagentMeta) + : this.emitAgentMessage(text, timestamp, subagentMeta); + } + + private buildChunkMeta( + epochMs: number | undefined, + subagentMeta?: SubagentMeta, + ): Record | undefined { + const meta: Record = {}; + if (subagentMeta?.parentToolCallId) { + meta['parentToolCallId'] = subagentMeta.parentToolCallId; + } + if (subagentMeta?.subagentType) { + meta['subagentType'] = subagentMeta.subagentType; + } + if (epochMs != null) meta['timestamp'] = epochMs; + return Object.keys(meta).length > 0 ? meta : undefined; } } diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 98c1b116a2..11fd7de1cb 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -408,11 +408,29 @@ function normalizeSessionUpdate( } case 'agent_message_chunk': { const text = getTextContent(update['content']); - return text ? [{ ...base, type: 'assistant.text.delta', text }] : []; + if (!text) return []; + const parentToolCallId = extractParentToolCallId(update); + return [ + { + ...base, + type: 'assistant.text.delta' as const, + text, + ...(parentToolCallId ? { parentToolCallId } : {}), + }, + ]; } case 'agent_thought_chunk': { const text = getTextContent(update['content']); - return text ? [{ ...base, type: 'thought.text.delta', text }] : []; + if (!text) return []; + const parentToolCallId = extractParentToolCallId(update); + return [ + { + ...base, + type: 'thought.text.delta' as const, + text, + ...(parentToolCallId ? { parentToolCallId } : {}), + }, + ]; } case 'tool_call': case 'tool_call_update': @@ -465,6 +483,13 @@ function normalizeSessionUpdate( } } +function extractParentToolCallId( + update: Record, +): string | undefined { + const meta = isRecord(update['_meta']) ? update['_meta'] : undefined; + return meta ? getString(meta, 'parentToolCallId') : undefined; +} + function normalizeToolUpdate( update: Record, base: NormalizedEventBase, diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 68223be692..b7029be675 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -142,6 +142,12 @@ function createState( ...(seed.permissionBlockByRequestId ?? {}), }, toolProgress: { ...(seed.toolProgress ?? {}) }, + activeAssistantBlockByParent: { + ...(seed.activeAssistantBlockByParent ?? {}), + }, + activeThoughtBlockByParent: { + ...(seed.activeThoughtBlockByParent ?? {}), + }, lastResyncRequired: seed.lastResyncRequired !== undefined ? { ...seed.lastResyncRequired } diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 5f41e09f98..22256695b7 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -13,6 +13,7 @@ import type { DaemonTranscriptReducerOptions, DaemonTranscriptState, DaemonUiEvent, + DaemonUiTextEvent, DaemonUserShellTranscriptBlock, } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; @@ -42,6 +43,8 @@ export function createDaemonTranscriptState( toolBlockByCallId: {}, trimmedToolNotificationByCallId: {}, permissionBlockByRequestId: {}, + activeAssistantBlockByParent: {}, + activeThoughtBlockByParent: {}, // PR-E sidechannel: track current tool / approval mode / progress toolProgress: {}, awaitingResync: false, @@ -98,6 +101,8 @@ export function appendLocalUserTranscriptMessage( next.activeUserBlockId = block.id; next.activeAssistantBlockId = undefined; next.activeThoughtBlockId = undefined; + next.activeAssistantBlockByParent = {}; + next.activeThoughtBlockByParent = {}; return trimTranscriptState(next); } @@ -364,6 +369,10 @@ export function selectPendingPermissionBlocks( ); } +// Keyed (parentToolCallId) and scalar (activeAssistantBlockId) paths are +// fully independent. Neither clears nor finalizes the other's blocks. +// Only finishAssistant() or clearActiveText() with matching parentToolCallId +// can finalize keyed-path blocks. function appendTextDelta( state: DaemonTranscriptState, kind: 'user' | 'assistant' | 'thought', @@ -374,7 +383,24 @@ function appendTextDelta( text: string, event: DaemonUiEvent, ): void { - const existing = getWritableBlockById(state, state[activeKey]); + const parentId = + kind !== 'user' && 'parentToolCallId' in event + ? (event as DaemonUiTextEvent).parentToolCallId + : undefined; + + const parentMap = + parentId != null + ? kind === 'assistant' + ? state.activeAssistantBlockByParent + : kind === 'thought' + ? state.activeThoughtBlockByParent + : undefined + : undefined; + + const effectiveId = + parentMap && parentId != null ? parentMap[parentId] : state[activeKey]; + + const existing = getWritableBlockById(state, effectiveId); if (existing && existing.kind === kind) { existing.text = appendBoundedText(existing.text, text); existing.updatedAt = state.now; @@ -392,11 +418,37 @@ function appendTextDelta( ); if (kind === 'assistant') block.streaming = true; if (kind === 'thought') block.collapsed = true; + if (parentId != null) { + (block as DaemonTextTranscriptBlock).parentToolCallId = parentId; + } appendBlock(state, block); - state[activeKey] = block.id; - if (kind !== 'user') state.activeUserBlockId = undefined; - if (kind !== 'assistant') state.activeAssistantBlockId = undefined; - if (kind !== 'thought') state.activeThoughtBlockId = undefined; + + if (parentMap && parentId != null) { + parentMap[parentId] = block.id; + } else { + state[activeKey] = block.id; + } + + if (parentId != null) { + if (kind === 'assistant') { + delete state.activeThoughtBlockByParent[parentId]; + } + if (kind === 'thought') { + const evictedAssistId = state.activeAssistantBlockByParent[parentId]; + if (evictedAssistId) { + const evicted = getWritableBlockById(state, evictedAssistId); + if (evicted?.kind === 'assistant') { + evicted.streaming = false; + evicted.updatedAt = state.now; + } + } + delete state.activeAssistantBlockByParent[parentId]; + } + } else { + if (kind !== 'user') state.activeUserBlockId = undefined; + if (kind !== 'assistant') state.activeAssistantBlockId = undefined; + if (kind !== 'thought') state.activeThoughtBlockId = undefined; + } } function finishAssistant(state: DaemonTranscriptState): void { @@ -406,6 +458,16 @@ function finishAssistant(state: DaemonTranscriptState): void { existing.updatedAt = state.now; } state.activeAssistantBlockId = undefined; + + for (const blockId of Object.values(state.activeAssistantBlockByParent)) { + const block = getWritableBlockById(state, blockId); + if (block?.kind === 'assistant') { + block.streaming = false; + block.updatedAt = state.now; + } + } + state.activeAssistantBlockByParent = {}; + state.activeThoughtBlockByParent = {}; } function upsertToolBlock( @@ -542,7 +604,7 @@ function upsertToolBlock( // never points at it. Effective-status keeps the pointer in sync // with what was actually written to the block. updateCurrentToolPointer(state, event.toolCallId, event.status ?? 'pending'); - clearActiveText(state); + clearActiveText(state, event.parentToolCallId); } /** @@ -842,6 +904,8 @@ function cloneTranscriptState( blocks: state.blocks, blockIndexById: state.blockIndexById, toolBlockByCallId: { ...state.toolBlockByCallId }, + activeAssistantBlockByParent: { ...state.activeAssistantBlockByParent }, + activeThoughtBlockByParent: { ...state.activeThoughtBlockByParent }, trimmedToolNotificationByCallId: { ...state.trimmedToolNotificationByCallId, }, @@ -926,6 +990,20 @@ function trimTranscriptState( if (!keptIds.has(state.activeThoughtBlockId ?? '')) { state.activeThoughtBlockId = undefined; } + for (const [parentId, blockId] of Object.entries( + state.activeAssistantBlockByParent, + )) { + if (!keptIds.has(blockId)) { + delete state.activeAssistantBlockByParent[parentId]; + } + } + for (const [parentId, blockId] of Object.entries( + state.activeThoughtBlockByParent, + )) { + if (!keptIds.has(blockId)) { + delete state.activeThoughtBlockByParent[parentId]; + } + } return state; } @@ -1021,10 +1099,26 @@ function allocateBlockId(state: DaemonTranscriptState, prefix: string): string { return id; } -function clearActiveText(state: DaemonTranscriptState): void { - finishAssistant(state); - state.activeUserBlockId = undefined; - state.activeThoughtBlockId = undefined; +function clearActiveText( + state: DaemonTranscriptState, + parentToolCallId?: string, +): void { + if (parentToolCallId) { + const assistId = state.activeAssistantBlockByParent[parentToolCallId]; + if (assistId) { + const block = getWritableBlockById(state, assistId); + if (block?.kind === 'assistant') { + block.streaming = false; + block.updatedAt = state.now; + } + delete state.activeAssistantBlockByParent[parentToolCallId]; + } + delete state.activeThoughtBlockByParent[parentToolCallId]; + } else { + finishAssistant(state); + state.activeUserBlockId = undefined; + state.activeThoughtBlockId = undefined; + } } function appendBoundedText(existing: string, text: string): string { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 2ded6e8305..76eff2d49c 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -82,6 +82,7 @@ export interface DaemonUiEventBase { export interface DaemonUiTextEvent extends DaemonUiEventBase { type: 'user.text.delta' | 'assistant.text.delta' | 'thought.text.delta'; text: string; + parentToolCallId?: string; } export interface DaemonUiUserShellCommandEvent extends DaemonUiEventBase { @@ -647,6 +648,8 @@ export interface DaemonTextTranscriptBlock extends DaemonTranscriptBlockBase { text: string; streaming?: boolean; collapsed?: boolean; + /** Used by the reducer for per-subAgent block routing; renderers may use it for nesting. */ + parentToolCallId?: string; } export interface DaemonToolTranscriptBlock extends DaemonTranscriptBlockBase { @@ -793,6 +796,8 @@ export interface DaemonTranscriptState activeUserBlockId?: string; activeAssistantBlockId?: string; activeThoughtBlockId?: string; + activeAssistantBlockByParent: Record; + activeThoughtBlockByParent: Record; blockIndexById: Record; toolBlockByCallId: Record; trimmedToolNotificationByCallId: Record; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ccc4fb597b..6a68f596d8 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -5610,3 +5610,623 @@ describe('permission_request normalization for Agent tools', () => { ); }); }); + +describe('parallel subAgent text interleaving — normalizer', () => { + it('extracts parentToolCallId from _meta on agent_message_chunk', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + _meta: { parentToolCallId: 'task-A', subagentType: 'reviewer' }, + }, + }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'assistant.text.delta', + text: 'hello', + parentToolCallId: 'task-A', + }), + ]); + }); + + it('extracts parentToolCallId from _meta on agent_thought_chunk', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thinking' }, + _meta: { parentToolCallId: 'task-B' }, + }, + }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'thought.text.delta', + text: 'thinking', + parentToolCallId: 'task-B', + }), + ]); + }); + + it('omits parentToolCallId when _meta is absent', () => { + const events = normalizeDaemonEvent({ + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'no meta' }, + }, + }, + } as never); + expect(events[0]).not.toHaveProperty('parentToolCallId'); + }); + + it('drops non-string parentToolCallId from _meta', () => { + const events = normalizeDaemonEvent({ + id: 4, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'bad meta' }, + _meta: { parentToolCallId: 12345 }, + }, + }, + } as never); + expect(events[0]).not.toHaveProperty('parentToolCallId'); + }); +}); + +describe('parallel subAgent text interleaving fix', () => { + it('T1: separates text chunks by parentToolCallId into independent blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'agent-task-A', + title: 'Agent (code-reviewer)', + status: 'running', + subagentType: 'code-reviewer', + } as DaemonUiEvent, + { + type: 'tool.update', + toolCallId: 'agent-task-B', + title: 'Agent (pr-test-analyzer)', + status: 'running', + subagentType: 'pr-test-analyzer', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'Agent A says: hello ', + parentToolCallId: 'agent-task-A', + }, + { + type: 'assistant.text.delta', + text: 'Agent B says: world ', + parentToolCallId: 'agent-task-B', + }, + { + type: 'assistant.text.delta', + text: 'from agent A', + parentToolCallId: 'agent-task-A', + }, + { + type: 'assistant.text.delta', + text: 'from agent B', + parentToolCallId: 'agent-task-B', + }, + ], + { now: 3 }, + ); + + const assistantBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ text: string; parentToolCallId?: string }>; + + expect(assistantBlocks).toHaveLength(2); + expect(assistantBlocks[0]!.text).toBe('Agent A says: hello from agent A'); + expect(assistantBlocks[0]!.parentToolCallId).toBe('agent-task-A'); + expect(assistantBlocks[1]!.text).toBe('Agent B says: world from agent B'); + expect(assistantBlocks[1]!.parentToolCallId).toBe('agent-task-B'); + }); + + it('T2: scoped clearActiveText — subAgent A tool does not interrupt subAgent B text', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'A text ', + parentToolCallId: 'task-A', + }, + { + type: 'assistant.text.delta', + text: 'B text ', + parentToolCallId: 'task-B', + }, + { + type: 'tool.update', + toolCallId: 'child-tool-1', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-A', + } as DaemonUiEvent, + { + type: 'assistant.text.delta', + text: 'B continues', + parentToolCallId: 'task-B', + }, + ], + { now: 2 }, + ); + + const bBlocks = state.blocks.filter( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-B', + ) as Array<{ text: string }>; + expect(bBlocks).toHaveLength(1); + expect(bBlocks[0]!.text).toBe('B text B continues'); + }); + + it('T3: finishAssistant sets streaming=false on all keyed-map blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'A streaming', + parentToolCallId: 'task-A', + }, + { + type: 'assistant.text.delta', + text: 'B streaming', + parentToolCallId: 'task-B', + }, + ], + { now: 2 }, + ); + + const before = state.blocks.filter((b) => b.kind === 'assistant') as Array<{ + streaming?: boolean; + }>; + expect(before[0]!.streaming).toBe(true); + expect(before[1]!.streaming).toBe(true); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + const after = state.blocks.filter((b) => b.kind === 'assistant') as Array<{ + streaming?: boolean; + }>; + expect(after[0]!.streaming).toBe(false); + expect(after[1]!.streaming).toBe(false); + }); + + it('T4: regression — text without parentToolCallId uses scalar path', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'assistant.text.delta', text: 'first ' }, + { type: 'assistant.text.delta', text: 'second' }, + ], + { now: 2 }, + ); + + const blocks = state.blocks.filter((b) => b.kind === 'assistant'); + expect(blocks).toHaveLength(1); + expect((blocks[0] as { text: string }).text).toBe('first second'); + }); + + it('T5: keyed and scalar paths coexist without interference', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'subagent text', + parentToolCallId: 'task-X', + }, + { type: 'assistant.text.delta', text: 'top-level text' }, + { + type: 'assistant.text.delta', + text: ' more subagent', + parentToolCallId: 'task-X', + }, + ], + { now: 2 }, + ); + + const assistantBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ text: string; parentToolCallId?: string }>; + + expect(assistantBlocks).toHaveLength(2); + + const subagentBlock = assistantBlocks.find( + (b) => b.parentToolCallId === 'task-X', + ); + const topLevelBlock = assistantBlocks.find( + (b) => b.parentToolCallId === undefined, + ); + expect(subagentBlock!.text).toBe('subagent text more subagent'); + expect(topLevelBlock!.text).toBe('top-level text'); + }); + + it('T6: trimTranscriptState prunes stale entries from activeAssistantBlockByParent', () => { + let state = createDaemonTranscriptState({ now: 1, maxBlocks: 3 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'will be trimmed', + parentToolCallId: 'old-task', + }, + { + type: 'tool.update', + toolCallId: 'tool-1', + title: 'Bash', + status: 'running', + } as DaemonUiEvent, + { type: 'user.text.delta', text: 'user msg' }, + { + type: 'tool.update', + toolCallId: 'tool-2', + title: 'Read', + status: 'running', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + expect(state.blocks.length).toBeLessThanOrEqual(3); + expect(state.activeAssistantBlockByParent['old-task']).toBeUndefined(); + }); + + it('T7: appendLocalUserTranscriptMessage clears active subAgent text maps', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'streaming...', + parentToolCallId: 'task-Y', + }, + ], + { now: 2 }, + ); + + expect(state.activeAssistantBlockByParent['task-Y']).toBeDefined(); + + state = appendLocalUserTranscriptMessage(state, 'user msg', { now: 3 }); + + expect(state.activeAssistantBlockByParent).toEqual({}); + expect(state.activeThoughtBlockByParent).toEqual({}); + }); + + it('T8: thought evicts assistant block and finalizes streaming for same parent', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'assistant streaming', + parentToolCallId: 'task-Z', + }, + ], + { now: 2 }, + ); + + const beforeBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ streaming?: boolean }>; + expect(beforeBlocks[0]!.streaming).toBe(true); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'now thinking', + parentToolCallId: 'task-Z', + }, + ], + { now: 3 }, + ); + + expect(state.activeAssistantBlockByParent['task-Z']).toBeUndefined(); + const afterBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ streaming?: boolean }>; + expect(afterBlocks[0]!.streaming).toBe(false); + + const thoughtBlocks = state.blocks.filter((b) => b.kind === 'thought'); + expect(thoughtBlocks).toHaveLength(1); + }); + + it('T9: thought text cleared by scoped clearActiveText from tool.update', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'thinking...', + parentToolCallId: 'task-W', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-W', + } as DaemonUiEvent, + { + type: 'thought.text.delta', + text: 'new thought', + parentToolCallId: 'task-W', + }, + ], + { now: 2 }, + ); + + const thoughtBlocks = state.blocks.filter( + (b) => + b.kind === 'thought' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-W', + ) as Array<{ text: string }>; + expect(thoughtBlocks).toHaveLength(2); + expect(thoughtBlocks[0]!.text).toBe('thinking...'); + expect(thoughtBlocks[1]!.text).toBe('new thought'); + }); + + it('T10: trimTranscriptState prunes activeThoughtBlockByParent', () => { + let state = createDaemonTranscriptState({ now: 1, maxBlocks: 3 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'will be trimmed', + parentToolCallId: 'old-thought', + }, + { + type: 'tool.update', + toolCallId: 'tool-1', + title: 'Bash', + status: 'running', + } as DaemonUiEvent, + { type: 'user.text.delta', text: 'user msg' }, + { + type: 'tool.update', + toolCallId: 'tool-2', + title: 'Read', + status: 'running', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + expect(state.blocks.length).toBeLessThanOrEqual(3); + expect(state.activeThoughtBlockByParent['old-thought']).toBeUndefined(); + }); + + it('T11: finishAssistant clears activeThoughtBlockByParent with entries', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'thinking...', + parentToolCallId: 'task-T', + }, + { + type: 'assistant.text.delta', + text: 'responding...', + parentToolCallId: 'task-T2', + }, + ], + { now: 2 }, + ); + + expect(state.activeThoughtBlockByParent['task-T']).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + expect(state.activeThoughtBlockByParent).toEqual({}); + expect(state.activeAssistantBlockByParent).toEqual({}); + }); + + it('T12: assistant evicts thought block for same parent', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'thinking first', + parentToolCallId: 'task-E', + }, + ], + { now: 2 }, + ); + + expect(state.activeThoughtBlockByParent['task-E']).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'now responding', + parentToolCallId: 'task-E', + }, + ], + { now: 3 }, + ); + + expect(state.activeThoughtBlockByParent['task-E']).toBeUndefined(); + expect(state.activeAssistantBlockByParent['task-E']).toBeDefined(); + }); + + it('T13: scoped clearActiveText sets streaming=false on cleared block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'A streaming', + parentToolCallId: 'task-A', + }, + { + type: 'assistant.text.delta', + text: 'B streaming', + parentToolCallId: 'task-B', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-A', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + const aBlocks = state.blocks.filter( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-A', + ) as Array<{ streaming?: boolean }>; + expect(aBlocks[0]!.streaming).toBe(false); + + const bBlocks = state.blocks.filter( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-B', + ) as Array<{ streaming?: boolean }>; + expect(bBlocks[0]!.streaming).toBe(true); + }); + + it('T14: scoped clearActiveText preserves scalar activeAssistantBlockId', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'assistant.text.delta', text: 'scalar text' }, + { + type: 'assistant.text.delta', + text: 'keyed text', + parentToolCallId: 'task-K', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-K', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + const scalarBlock = state.blocks.find( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === undefined, + ) as { streaming?: boolean } | undefined; + expect(scalarBlock?.streaming).toBe(true); + expect(state.activeAssistantBlockId).toBeDefined(); + + const keyedBlock = state.blocks.find( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-K', + ) as { streaming?: boolean } | undefined; + expect(keyedBlock?.streaming).toBe(false); + expect(state.activeAssistantBlockByParent['task-K']).toBeUndefined(); + }); + + it('T15: finishAssistant finalizes both scalar and keyed blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'assistant.text.delta', text: 'scalar streaming' }, + { + type: 'assistant.text.delta', + text: 'keyed streaming', + parentToolCallId: 'task-M', + }, + ], + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + const allAssistant = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ streaming?: boolean }>; + expect(allAssistant).toHaveLength(2); + expect(allAssistant[0]!.streaming).toBe(false); + expect(allAssistant[1]!.streaming).toBe(false); + expect(state.activeAssistantBlockId).toBeUndefined(); + expect(state.activeAssistantBlockByParent).toEqual({}); + }); +});