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'))