diff --git a/e2e/work-companion.playwright.ts b/e2e/work-companion.playwright.ts index 1a9f375..f8a6b1d 100644 --- a/e2e/work-companion.playwright.ts +++ b/e2e/work-companion.playwright.ts @@ -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: '已解析的思考' }), - 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('已解析的思考')).toHaveCount(0) diff --git a/src/messageparts.ts b/src/messageparts.ts index d6c2b02..695014e 100644 --- a/src/messageparts.ts +++ b/src/messageparts.ts @@ -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 diff --git a/test/messageparts.test.ts b/test/messageparts.test.ts index a61d50b..25ab6b3 100644 --- a/test/messageparts.test.ts +++ b/test/messageparts.test.ts @@ -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'))