import { Message } from './api/models'; import { isMergeableBody, mergeConsecutiveBodies } from './message-merge'; function m( id: string, sender: 'me' | 'agent', text: string, extra: Partial = {}, ): Message { return { id, conversationId: 'c', sender, kind: 'text', text, ts: new Date(Date.UTC(2026, 10, 6, 10, 0, Number(id.replace(/\D/g, '')) || 0)).toISOString(), state: 'sent', ...extra, }; } describe('mergeConsecutiveBodies', () => { it('连续的 agent 正文合成一条', () => { const out = mergeConsecutiveBodies([m('a1', 'agent', '第一段'), m('a2', 'agent', '第二段')]); expect(out.length).toBe(1); expect(out[0].text).toBe('第一段\n\n第二段'); expect(out[0].mergedIds).toEqual(['a1', 'a2']); }); it('✅ Markdown 分段用空行分隔(不会糊成一句)', () => { const out = mergeConsecutiveBodies([m('a1', 'agent', '## 标题'), m('a2', 'agent', '- 列表项')]); expect(out[0].text).toBe('## 标题\n\n- 列表项'); }); it('用户消息断开合并', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', '回答一'), m('u1', 'me', '追问'), m('a2', 'agent', '回答二'), ]); expect(out.map((x) => x.id)).toEqual(['a1', 'u1', 'a2']); }); it('🔴 中间夹工具过程时断开', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', '先说结论'), m('t1', 'agent', '💻 terminal: ls'), m('a2', 'agent', '再说细节'), ]); expect(out.length).toBe(3); expect(out[1].text).toContain('💻'); }); it('🔴 带附件的消息不并入正文', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', '看这个文件'), m('a2', 'agent', '', { file: { kind: 'file', name: 'r.pdf', size: 1, mimeType: 'application/pdf', url: 'u' }, }), m('a3', 'agent', '还有补充'), ]); expect(out.length).toBe(3); expect(out[1].file?.name).toBe('r.pdf'); }); it('🔴 失败消息不合并(保留重试与错误原因)', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', 'ok'), m('a2', 'agent', '失败的内容', { state: 'failed', error: '超时' }), ]); expect(out.length).toBe(2); expect(out[1].error).toBe('超时'); }); it('🔴 群聊:不同发送者的相邻消息不合并', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', 'A 说的', { senderId: '@a:x' }), m('a2', 'agent', 'B 说的', { senderId: '@b:x' }), ]); expect(out.length).toBe(2); }); it('同一发送者(senderId 相同)仍合并', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', '一段', { senderId: '@a:x' }), m('a2', 'agent', '二段', { senderId: '@a:x' }), ]); expect(out.length).toBe(1); }); it('合并后时间取最后一段', () => { const out = mergeConsecutiveBodies([m('a1', 'agent', 'x'), m('a5', 'agent', 'y')]); expect(out[0].ts).toBe(m('a5', 'agent', 'y').ts); }); it('合并后 @ 列表取并集去重', () => { const out = mergeConsecutiveBodies([ m('a1', 'agent', 'x', { mentions: ['@p1:x', '@p2:x'] }), m('a2', 'agent', 'y', { mentions: ['@p2:x'] }), ]); expect(out[0].mentions).toEqual(['@p1:x', '@p2:x']); }); it('单条消息原样返回(补 mergedIds)', () => { const out = mergeConsecutiveBodies([m('a1', 'agent', '只有一条')]); expect(out.length).toBe(1); expect(out[0].mergedIds).toEqual(['a1']); }); it('空列表', () => { expect(mergeConsecutiveBodies([])).toEqual([]); }); it('空白正文不参与合并', () => { const out = mergeConsecutiveBodies([m('a1', 'agent', '有内容'), m('a2', 'agent', ' ')]); expect(out.length).toBe(1); expect(out[0].text).toBe('有内容'); }); }); describe('isMergeableBody', () => { it('普通 agent 正文可合并', () => { expect(isMergeableBody(m('a1', 'agent', 'x'))).toBe(true); }); it('用户消息不可合并', () => { expect(isMergeableBody(m('u1', 'me', 'x'))).toBe(false); }); it('空正文不可合并', () => { expect(isMergeableBody(m('a1', 'agent', ''))).toBe(false); }); it('带任务 id 不可合并', () => { expect(isMergeableBody(m('a1', 'agent', 'x', { taskId: 't1' }))).toBe(false); }); }); /* * 🔴 2026-11-10 移动端实测:容器用多行 ✅/❌ 列表输出结论时, * 每条各自成块 → 一屏出现一堆小卡片。结果 emoji 应作为正文参与合并。 */ describe('结果 emoji 行参与合并', () => { const agent = (id: string, text: string, ts: number) => ({ id, conversationId: 'c1', sender: 'agent' as const, kind: 'text' as const, text, ts: new Date(ts).toISOString(), state: 'sent' as const, }); it('连续的 ✅/❌ 结论行合并成一段', () => { const out = mergeConsecutiveBodies([ agent('a', '✅ 成果表有记录(05:34:19Z)', 1000), agent('b', '❌ 但回复正文没有任何 https', 2000), agent('c', '❌ 正文还有残留:「本地路径」', 3000), ]); expect(out.length).toBe(1); expect(out[0].text).toContain('成果表有记录'); // 合并后用空行分隔(mergeBodies 的既定规则) expect(out[0].text).toContain('https'); expect(out[0].text).toContain('本地路径'); }); it('纯工具 emoji 行仍然不参与合并(保持原行为)', () => { const out = mergeConsecutiveBodies([ agent('a', '💻 terminal', 1000), agent('b', '🔎 Searching files', 2000), ]); // 不可合并的消息**原样保留**(不丢弃),所以仍是 2 条 expect(out.length).toBe(2); expect(out[0].mergedIds).toEqual(['a']); expect(out[1].mergedIds).toEqual(['b']); }); it('正文与结论混排也合并', () => { const out = mergeConsecutiveBodies([ agent('a', '我来检查一下结果。', 1000), agent('b', '✅ 全部通过', 2000), ]); expect(out.length).toBe(1); }); });