Why: liveToMessage emitted all text buckets then all tools, while the persisted message interleaves per turn (thinking, text, calls), so parts reordered on reconcile with 2+ tool turns. Commentary stayed in the reply area until the next turn's text bucket appeared, because the process flag was positional (i < length - 1) instead of tool-driven. Evidence: LiveState now holds an arrival-ordered items stream (thinking/text deltas merge into their last segment; tools land in place), so event order - which matches the backend's per-turn append order - maps 1:1 onto persisted parts. A text segment is process iff a tool.call lands before the next thinking/text segment, aligning with the backend's Process: len(calls) > 0; the collapse predicate moved to testable showProcessInline. Long replies (>800 chars) now show a 100-char excerpt and label the count as Markdown source length. vitest 27/27 pass (was 5 live cases, now 12 incl. multi-turn order, immediate process marking, collapse predicate); tsc -b clean. Impact: streaming UI no longer reshuffles parts across turns or on final reconcile; tool-turn commentary folds into the process area as soon as the tool call arrives. Residual edge (documented in live.ts): a silent tool-only turn with no text and no thinking attributes its call to the previous text segment. Refs: code review of feature/chat-process-split-r1 (2026-09-30)
135 lines
6.0 KiB
TypeScript
135 lines
6.0 KiB
TypeScript
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
|
|
// 轮次穿插同构、过程区折叠谓词。
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult,
|
|
liveToMessage, showProcessInline, type LiveItem,
|
|
} from '../src/live'
|
|
import type { LiveState } from '../src/store'
|
|
|
|
function live(over: Partial<LiveState> = {}): LiveState {
|
|
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
|
|
}
|
|
|
|
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
|
|
function twoTurnItems(): LiveItem[] {
|
|
let items: LiveItem[] = []
|
|
items = applyThinkingDelta(items, '先想想')
|
|
items = applyThinkingDelta(items, '一下')
|
|
items = applyTextDelta(items, 0, '我先更新待办。')
|
|
items = applyToolCall(items, 'update_todos')
|
|
items = applyToolResult(items, 'ok', '已更新')
|
|
items = applyThinkingDelta(items, '再想想')
|
|
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, '先')
|
|
items = applyThinkingDelta(items, '想想')
|
|
expect(items).toEqual([{ kind: 'thinking', text: '先想想' }])
|
|
})
|
|
it('opens a new thinking segment after text/tool (turn boundary)', () => {
|
|
let items: LiveItem[] = applyThinkingDelta([], '第一轮思考')
|
|
items = applyTextDelta(items, 0, '解说')
|
|
items = applyThinkingDelta(items, '第二轮思考')
|
|
expect(items).toEqual([
|
|
{ kind: 'thinking', text: '第一轮思考' },
|
|
{ kind: 'text', index: 0, text: '解说' },
|
|
{ kind: 'thinking', text: '第二轮思考' },
|
|
])
|
|
})
|
|
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', () => {
|
|
let items: LiveItem[] = []
|
|
items = applyToolCall(items, 'a')
|
|
items = applyToolCall(items, 'b')
|
|
items = applyToolResult(items, 'ok', '结果')
|
|
expect(items).toEqual([
|
|
{ kind: 'tool', name: 'a' },
|
|
{ kind: 'tool', name: 'b', status: 'ok', summary: '结果' },
|
|
])
|
|
})
|
|
})
|
|
|
|
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: '', name: 'update_todos' } },
|
|
{ type: 'tool_result', process: true, toolCall: { id: '', name: 'update_todos' },
|
|
toolResult: { callId: '', 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, '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, '想')
|
|
items = applyTextDelta(items, 0, '答')
|
|
items = applyToolCall(items, 'web_search')
|
|
items = applyToolResult(items, 'ok', '命中')
|
|
const msg = liveToMessage(live({ items }))
|
|
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('showProcessInline', () => {
|
|
it('expands during thinking/acting and collapses the moment responding starts', () => {
|
|
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
|
|
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
|
|
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
|
|
})
|
|
it('falls back to the auto-collapse preference when no live run', () => {
|
|
expect(showProcessInline(null, true)).toBe(false)
|
|
expect(showProcessInline(null, false)).toBe(true)
|
|
})
|
|
})
|