Interleave live parts per turn and mark commentary process on tool.call
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)
This commit is contained in:
+117
-31
@@ -1,48 +1,134 @@
|
||||
// live 流式投影纯逻辑测试(中栏过程/回复分离):分桶、process 标记、终态同构。
|
||||
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
|
||||
// 轮次穿插同构、过程区折叠谓词。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyTextDelta, liveToMessage, type TextBucket } from '../src/live'
|
||||
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', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: 0, ...over }
|
||||
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
|
||||
}
|
||||
|
||||
describe('applyTextDelta', () => {
|
||||
it('appends to the last bucket when index matches', () => {
|
||||
let b: TextBucket[] = []
|
||||
b = applyTextDelta(b, 0, '先查')
|
||||
b = applyTextDelta(b, 0, '一下')
|
||||
expect(b).toEqual([{ index: 0, text: '先查一下' }])
|
||||
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
|
||||
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 bucket when index advances (new turn)', () => {
|
||||
let b: TextBucket[] = applyTextDelta([], 0, '解说')
|
||||
b = applyTextDelta(b, 1, '最终')
|
||||
b = applyTextDelta(b, 1, '回复')
|
||||
expect(b).toEqual([{ index: 0, text: '解说' }, { index: 1, 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('marks all but the last text bucket as process (tool-turn commentary)', () => {
|
||||
const msg = liveToMessage(live({
|
||||
thinking: '思考',
|
||||
texts: [{ index: 0, text: '我先更新待办。' }, { index: 1, text: '完成了。' }],
|
||||
toolCalls: [{ name: 'update_todos', status: 'ok' }],
|
||||
}))
|
||||
const texts = msg.parts.filter(p => p.type === 'text')
|
||||
expect(texts.map(p => p.process)).toEqual([true, undefined])
|
||||
expect(texts.map(p => p.text)).toEqual(['我先更新待办。', '完成了。'])
|
||||
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('single bucket (final turn only) carries no process flag', () => {
|
||||
const msg = liveToMessage(live({ texts: [{ index: 0, text: '直接回答' }] }))
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([undefined])
|
||||
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', () => {
|
||||
const msg = liveToMessage(live({
|
||||
thinking: '想',
|
||||
texts: [{ index: 0, text: '答' }],
|
||||
toolCalls: [{ name: 'web_search', status: 'ok', summary: '命中' }],
|
||||
}))
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user