Deduplicate partial streamed narration in process history

Why: A dropped delta batch can leave a short event prefix next to the complete saved message.

Evidence: 271 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Suppress overlapping partial replay text and thinking while retaining saved content.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
This commit is contained in:
2026-10-14 14:25:04 +00:00
parent 7afae7f20b
commit b175506ebd
3 changed files with 35 additions and 11 deletions
+12 -8
View File
@@ -226,18 +226,20 @@ test('replay enriches saved notes and keeps collaboration progress after complet
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
event(2, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(3, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(4, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(5, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(8, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(9, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
event(2, 'message.delta', { index: 1, text: '开始读取' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(7, 'run.phase', { phase: 'finalizing' }),
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
]
const savedParts: Part[] = [
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
{ type: 'thinking', process: true, text: '已解析的思考' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
{ type: 'text', text: '最终回复已完成' },
@@ -247,6 +249,8 @@ test('replay enriches saved notes and keeps collaboration progress after complet
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
await expect(message.getByText('已解析的思考')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
+15 -3
View File
@@ -48,17 +48,29 @@ function sameProcessPart(saved: Part, replayed: Part): boolean {
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
export function mergeProcessTimeline(saved: Part[], replayed: Part[], final: Part[]): Part[] {
if (replayed.length === 0) return saved
const finalTexts = new Set(final.filter(part => part.type === 'text').map(part => normalized(part.text)))
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
const merged: Part[] = []
let cursor = 0
let pending: Part[] = []
for (const part of replayed) {
if (part.type === 'text' && (finalTexts.has(normalized(part.text)) || /<\/?think\b/i.test(part.text ?? ''))) continue
const text = normalized(part.text)
if (part.type === 'text' && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|| /<\/?think\b/i.test(part.text ?? ''))) continue
let match = -1
for (let i = cursor; i < saved.length; i++) {
if (sameProcessPart(saved[i], part)) { match = i; break }
}
if (match < 0) { pending.push(part); continue }
if (match < 0) {
// A missing delta batch can leave only a prefix in events. The complete
// terminal part is authoritative; never render that prefix beside it.
if ((part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
if (item.type !== part.type) return false
const value = normalized(item.text)
return value && (value.includes(text) || text.includes(value))
})) continue
pending.push(part)
continue
}
merged.push(...saved.slice(cursor, match), ...pending)
pending = []
const output = (part as Part & { toolOutput?: string }).toolOutput
+8
View File
@@ -135,6 +135,14 @@ it('enriches saved process without losing retry notes or parsed inline thinking'
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(['开始读取材料'])
})
describe('artifact ids in reply text', () => {
it('extracts unique ids in order', () => {
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))