// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、 // 轮次穿插同构、过程区折叠谓词。 import { describe, it, expect } from 'vitest' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyCheck, applyFinalizing, hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem, runEventsToTimeline, } from '../src/live' import type { StreamEvent } from '../src/contracts' import type { LiveState } from '../src/store' function live(over: Partial = {}): LiveState { return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over } } // 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。 function twoTurnItems(): LiveItem[] { let items: LiveItem[] = [] items = applyThinkingDelta(items, '先想想', 0) items = applyThinkingDelta(items, '一下', 0) items = applyTextDelta(items, 0, '我先更新待办。') items = applyToolCall(items, 'update_todos', 'c1') items = applyToolResult(items, 'c1', 'ok', '已更新') items = applyThinkingDelta(items, '再想想', 0) items = applyTextDelta(items, 1, '最终') items = applyTextDelta(items, 1, '回复') return items } describe('applyThinkingDelta / applyTextDelta', () => { it('merges thinking deltas into the last thinking segment', () => { let items: LiveItem[] = [] items = applyThinkingDelta(items, '先', 0) items = applyThinkingDelta(items, '想想', 0) expect(items).toEqual([{ kind: 'thinking', text: '先想想', startedAt: 0, endedAt: 0 }]) }) it('opens a new thinking segment after text/tool (turn boundary)', () => { let items: LiveItem[] = applyThinkingDelta([], '第一轮思考', 0) items = applyTextDelta(items, 0, '解说') items = applyThinkingDelta(items, '第二轮思考', 0) expect(items).toEqual([ { kind: 'thinking', text: '第一轮思考', startedAt: 0, endedAt: 0 }, { kind: 'text', index: 0, text: '解说' }, { kind: 'thinking', text: '第二轮思考', startedAt: 0, endedAt: 0 }, ]) }) it('appends text to the last segment when index matches', () => { let items: LiveItem[] = [] items = applyTextDelta(items, 0, '先查') items = applyTextDelta(items, 0, '一下') expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }]) }) it('opens a new text segment when index advances (new turn)', () => { let items: LiveItem[] = applyTextDelta([], 0, '解说') items = applyTextDelta(items, 1, '最终') items = applyTextDelta(items, 1, '回复') expect(items).toEqual([ { kind: 'text', index: 0, text: '解说' }, { kind: 'text', index: 1, text: '最终回复' }, ]) }) it('fills the most recent pending tool call (callId match, tail fallback)', () => { let items: LiveItem[] = [] items = applyToolCall(items, 'a', 'ca') items = applyToolCall(items, 'b', 'cb') items = applyToolResult(items, 'cb', 'ok', '结果') expect(items).toEqual([ { kind: 'tool', callId: 'ca', name: 'a', args: '', state: 'running', startedAt: 0, endedAt: 0, output: '' }, { kind: 'tool', callId: 'cb', name: 'b', args: '', state: 'done', startedAt: 0, endedAt: 0, output: '', status: 'ok', summary: '结果' }, ]) items = applyToolResult(items, undefined, 'ok') expect(items[0]).toMatchObject({ kind: 'tool', callId: 'ca', name: 'a', state: 'done', status: 'ok' }) }) it('walks a tool from composing to running to done, and marks the narration as process', () => { let items: LiveItem[] = applyTextDelta([], 0, '我来生成文档。') items = applyToolCallDelta(items, 0, '{"script":', 'c1', 'run_python', 1000) items = applyToolCallDelta(items, 0, '"print(1)"}', undefined, undefined, 1100) const composing = liveToMessage(live({ items })) expect(composing.parts[0].process).toBe(true) expect(composing.parts[1]).toMatchObject({ type: 'tool_call', toolPhase: 'composing', toolCall: { name: 'run_python', arguments: '{"script":"print(1)"}' } }) items = applyToolCall(items, 'run_python', 'c1', undefined, 2000) items = applyToolOutput(items, 'c1', 'hello\n') items = applyToolResult(items, 'c1', 'ok', 'exit=0', 3500) const done = liveToMessage(live({ items })) expect(done.parts[1]).toMatchObject({ toolPhase: 'done', toolOutput: 'hello\n', toolCall: { durationMs: 1500 } }) expect(done.parts[2]).toMatchObject({ type: 'tool_result', toolResult: { summary: 'exit=0' } }) }) }) describe('liveToMessage', () => { it('interleaves parts per turn in arrival order, matching persisted parts', () => { const msg = liveToMessage(live({ items: twoTurnItems() })) expect(msg.parts).toEqual([ { type: 'thinking', text: '先想想一下', process: true }, { type: 'text', text: '我先更新待办。', process: true }, { type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' }, toolPhase: 'done' }, { type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' }, toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } }, { type: 'thinking', text: '再想想', process: true }, { type: 'text', text: '最终回复' }, ]) }) it('marks the in-flight segment as process the moment its tool call arrives', () => { let items: LiveItem[] = [] items = applyTextDelta(items, 0, '我先查一下') const before = liveToMessage(live({ items })) expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位 items = applyToolCall(items, 'web_search') const after = liveToMessage(live({ items })) expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标 }) it('single final segment without tools carries no process flag', () => { let items: LiveItem[] = [] items = applyTextDelta(items, 0, '直接回答') const msg = liveToMessage(live({ items })) expect(msg.parts.map(p => p.process)).toEqual([undefined]) }) it('tools between segments keep every non-final segment in the process area', () => { let items: LiveItem[] = [] items = applyTextDelta(items, 0, '第一轮') items = applyToolCall(items, 'a') items = applyToolResult(items, undefined, 'ok') items = applyTextDelta(items, 1, '第二轮') items = applyToolCall(items, 'b') items = applyTextDelta(items, 2, '最终回复') const msg = liveToMessage(live({ items })) expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined]) // 顺序必须与落库穿插序一致:text → tool → text → tool → text。 expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text']) }) it('thinking and tool parts are always process', () => { let items: LiveItem[] = [] items = applyThinkingDelta(items, '想', 0) items = applyTextDelta(items, 0, '答') items = applyToolCall(items, 'web_search') items = applyToolResult(items, undefined, 'ok', '命中') const msg = liveToMessage(live({ items })) for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) }) it('does not project deliverable cards while the run is live', () => { let items: LiveItem[] = [] items = applyTextDelta(items, 0, '生成报表。') items = applyToolCall(items, 'make_report', 'c1') items = applyToolResult(items, 'c1', 'ok') items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' }) items = applyTextDelta(items, 1, '完成。') const msg = liveToMessage(live({ items })) expect(msg.parts.some(p => p.type === 'artifact_ref')).toBe(false) expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) }) it('moves earlier text into the process area once finalizing starts', () => { let items: LiveItem[] = applyTextDelta([], 0, '草稿回复') items = applyCheck(items, false, '纸张不是 A4') items = applyFinalizing(items) items = applyTextDelta(items, 1, '纪要已写好。') const msg = liveToMessage(live({ phase: 'finalizing', items })) expect(msg.parts.map(p => p.type)).toEqual(['text', 'check', 'text']) expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) expect(msg.parts[1]).toMatchObject({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }) }) }) it('reopens the ordered complete process from persisted run events', () => { const output = 'x'.repeat(5000) const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent => ({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 }) const parts = runEventsToTimeline([ event(1, 'thinking.delta', { index: 0, text: '先核对' }), event(2, 'message.delta', { index: 0, text: '开始读取材料' }), event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }), event(4, 'tool.output', { callId: 'call1', text: output }), event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }), event(6, 'run.phase', { phase: 'finalizing' }), event(7, 'message.delta', { index: 1, text: '最终回复' }), ]) expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result']) expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output) expect(parts.some(part => part.text === '最终回复')).toBe(false) }) it('retains collaboration notes from a child after the parent has replied', () => { const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent => ({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 }) const parts = runEventsToTimeline([ event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }), event(2, 'run.phase', { phase: 'finalizing' }), event(3, 'message.delta', { index: 0, text: '最终回复' }), event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'), event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'), ], 'parent') expect(parts.map(part => part.text)).toEqual([ '协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对', ]) expect(parts.every(part => part.replaySource === 'agent')).toBe(true) }) describe('livePhaseLabel', () => { it('names the current activity in Chinese', () => { let items: LiveItem[] = applyThinkingDelta([], '想', 0) expect(livePhaseLabel(live({ items }), 0)).toBe('思考中') items = applyToolCallDelta(items, 0, '{}', 'c', 'run_python', 0) expect(livePhaseLabel(live({ items }), 0)).toBe('正在编写 run_python') items = applyToolCall(items, 'run_python', 'c', undefined, 1000) expect(livePhaseLabel(live({ items }), 4000)).toBe('正在运行 run_python · 3 秒') items = applyToolResult(items, 'c', 'ok', 'done', 4000) items = applyTextDelta(items, 1, '好了') expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中') expect(livePhaseLabel(live({ items, phase: 'finalizing' }), 4000)).toBe('整理回复中') }) }) describe('showProcessInline', () => { it('keeps live technical records folded under the default reading preference', () => { expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(false) expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(false) expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false) }) it('honors the advanced preference to expose live records', () => { expect(showProcessInline(live({ phase: 'acting' }), false)).toBe(true) }) it('records thinking duration from the first delta to the last', () => { let items: LiveItem[] = applyThinkingDelta([], '先', 1000) items = applyThinkingDelta(items, '想想', 2500) const msg = liveToMessage(live({ items })) expect(msg.parts[0]).toMatchObject({ type: 'thinking', text: '先想想', durationMs: 1500 }) }) it('falls back to the auto-collapse preference when no live run', () => { expect(showProcessInline(null, true)).toBe(false) expect(showProcessInline(null, false)).toBe(true) }) }) describe('hasSettledReply', () => { const user = { role: 'user', runId: 'r1' } const assistant = { role: 'assistant', runId: 'r1' } it('stays false while only the user message carries the run id', () => { expect(hasSettledReply([user], 'r1')).toBe(false) expect(hasSettledReply([user, { role: 'assistant', runId: 'other' }], 'r1')).toBe(false) }) it('turns true once an assistant message for that run is stored', () => { expect(hasSettledReply([user, assistant], 'r1')).toBe(true) }) })