Why: main advanced with PR #15/#16 (part-order preservation, artifact downloads, cross-session UI); task-sync requires the branch to carry it. Evidence: resolution keeps the arrival-ordered items stream from this branch (tool-driven process marking, pure/testable logic in live.ts) and adopts main's rendering and ids: groupParts in-place process blocks, real tool callIds on live tool parts, messageparts utilities, and the restored phase-driven showProcessInline predicate alongside the CF0 long-reply excerpt that main's rewrite had dropped. vitest 32/32 (includes main's messageparts suite and this branch's 12 live cases); tsc -b clean. Impact: single coherent live pipeline - items accumulate in event order, liveToMessage projects Parts with tool-driven process marks, rendering folds consecutive process blocks in place; responding still collapses the process area (Cursor-style) as originally reviewed. Refs: feature/chat-process-split-r1
138 lines
6.3 KiB
TypeScript
138 lines
6.3 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', 'c1')
|
|
items = applyToolResult(items, 'c1', '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 (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' },
|
|
{ kind: 'tool', callId: 'cb', name: 'b', status: 'ok', summary: '结果' },
|
|
])
|
|
// 无 callId 的事件回落到尾部配对。
|
|
items = applyToolResult(items, undefined, 'ok')
|
|
expect(items[0]).toEqual({ kind: 'tool', callId: 'ca', name: 'a', status: 'ok' })
|
|
})
|
|
})
|
|
|
|
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' } },
|
|
{ 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, '想')
|
|
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)
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|