Files
weichen e10e447dab Keep collaboration events distinct from partial text
Why: Status words shared with a final reply could suppress a child completion record.

Evidence: 272 frontend unit tests, build, and 15 isolated browser scenarios.

Impact: Tag client-side collaboration replay so partial-delta deduplication preserves agent notes.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:27:28 +00:00

215 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { splitTimeline, mergeProcessTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
import type { Part } from '../src/contracts'
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
return partial as Part
}
describe('splitTimeline', () => {
it('keeps intermediate narration with its tools in reading order and the reply outside', () => {
const parts: Part[] = [
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '原始读取结果' } }),
p({ type: 'text', text: '评审意见未能读取,完整结论尚未生成。' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'search' } }),
p({ type: 'text', text: '接下来确认日期冲突。' }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
])
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
})
it('keeps live scope limits visible while execution records are folded by default', () => {
let items = applyThinkingDelta([], '内部分析,不向用户展示', 0)
items = applyTextDelta(items, 0, '**范围限制:**目前仅整理两份材料,评审意见尚未读取。')
items = applyToolCall(items, 'run_python', 'c1', '{"script":"print(42)"}')
items = applyToolOutput(items, 'c1', '42\n')
const live = { runId: 'r1', phase: 'tool', items, agentNotes: {}, startedAt: 0 }
const message = liveToMessage(live)
expect(message.parts[1].process).toBe(true)
expect(showProcessInline(live, true)).toBe(false)
const split = splitTimeline(message.parts)
expect(split.final).toEqual([])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call'])
})
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
const parts: Part[] = [
p({ type: 'text', process: true, text: '评审意见尚未读取。' }),
p({ type: 'thinking', process: true, text: '内部推理' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '诊断输出' } }),
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
p({ type: 'text', text: '已整理两份材料,请补充清晰的评审意见。' }),
]
for (const finished of [false, true]) {
const split = splitTimeline(parts, finished)
expect(split.final.map(part => part.text)).toEqual(['已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['text', 'thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.length + split.timeline.length).toBe(parts.length)
}
})
it('keeps errors and file references outside a technical record even before a later tool', () => {
const parts: Part[] = [
p({ type: 'error', process: true, error: { code: 'read.failed', message: '评审意见无法读取' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '已整理的材料.docx' } }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'text', text: '已整理另外两份材料' }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
})
it('keeps narration beside execution records when it precedes the last process part', () => {
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
p({ type: 'text', text: '文档已生成,请查收' }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
})
it('keeps a card sandwiched between process parts inside the timeline', () => {
const parts: Part[] = [
p({ type: 'thinking', process: true, text: '想' }),
p({ type: 'card', card: { kind: 'result', data: [] } }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
p({ type: 'text', text: '最终' }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['thinking', 'card', 'tool_call'])
expect(split.final.map(x => x.text)).toEqual(['最终'])
})
it('promotes only the last narration when a finished run has no final text', () => {
const parts: Part[] = [
p({ type: 'text', text: '先写一版', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
p({ type: 'text', text: '纪要已经整理好', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
]
const split = splitTimeline(parts, true)
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual([])
})
it('puts a message with no process parts entirely in the final section', () => {
const parts = [p({ type: 'text', text: 'hi' })]
expect(splitTimeline(parts).timeline).toEqual([])
expect(splitTimeline(parts).final).toEqual(parts)
expect(splitTimeline([])).toEqual({ timeline: [], final: [] })
})
})
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
const saved: Part[] = [
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
p({ type: 'thinking', process: true, text: '检查附件边界' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
]
const replayed: Part[] = [
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
p({ type: 'text', process: true, text: '最终回复' }),
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
]
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
])
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
})
it('does not repeat a partial event delta beside the complete saved narration', () => {
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
})
it('keeps a collaboration result even when the final reply contains its short status', () => {
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
[p({ type: 'text', text: '已完成' })])
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
})
describe('artifact ids in reply text', () => {
it('extracts unique ids in order', () => {
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
.toEqual(['art_0123456789ab', 'art_abcdefabcdef'])
})
it('marks only ids the project does not have', () => {
const text = '文件 art_0123456789ab 与 art_abcdefabcdef'
const out = markMissingArtifacts(text, id => id === 'art_0123456789ab')
expect(out).toContain('art_0123456789ab')
expect(out).not.toContain('art_abcdefabcdef')
expect(out).toContain('未找到该文件')
})
})
describe('deliverables, errors, and the process summary', () => {
it('keeps the latest card for a repeated file name', () => {
const parts: Part[] = [
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '精美版.docx' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a2', name: '精美版.docx' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'b1', name: '纪要.md' } }),
]
expect(dedupeDeliverables(parts)).toEqual([
{ artifact: { artifactId: 'a2', name: '精美版.docx' }, updates: 1 },
{ artifact: { artifactId: 'b1', name: '纪要.md' }, updates: 0 },
])
})
it('folds a same-run error into the assistant message that has a body', () => {
const body = { id: 'm1', runId: 'r1', role: 'assistant', parts: [p({ type: 'text', text: '做到这里' })] }
const err = { id: 'm2', runId: 'r1', role: 'assistant', parts: [p({ type: 'error', error: { code: 'budget', message: 'max turns' } })] }
const folded = foldRunErrors([body, err])
expect(folded.map(m => m.id)).toEqual(['m1'])
expect(folded[0].parts.map(part => part.type)).toEqual(['text', 'error'])
expect(humanError('run.cancelled', '已取消').title).toBe('已取消')
expect(humanError('budget', 'max turns').title).toContain('已停止')
expect(processSummary(12, 3, '2026-10-01T00:00:00.000Z', '2026-10-01T00:03:13.000Z'))
.toBe('已工作 3 分 13 秒 · 12 次工具调用 · 3 个文件')
})
})
describe('workedLabel', () => {
it('formats the run span and degrades without timestamps', () => {
expect(workedLabel('2026-10-01T00:00:00.000Z', '2026-10-01T00:01:05.000Z')).toBe('已工作 1 分 5 秒')
expect(workedLabel(undefined, undefined)).toBe('已工作')
})
})
describe('humanSize / artifactIcon', () => {
it('formats sizes compactly', () => {
expect(humanSize(undefined)).toBe('')
expect(humanSize(0)).toBe('')
expect(humanSize(512)).toBe('512 B')
expect(humanSize(2048)).toBe('2 KB')
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
})
it('picks icons by extension then kind', () => {
expect(artifactIcon('会议纪要.docx')).toBe('file')
expect(artifactIcon('截图.png')).toBe('image')
expect(artifactIcon('blob', 'image')).toBe('image')
expect(artifactIcon('打包.zip')).toBe('package')
})
})