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:
2026-09-30 15:30:41 +00:00
parent 22a87232d7
commit 754ce83ffd
5 changed files with 212 additions and 74 deletions
+117 -31
View File
@@ -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)
})
})