message-merge.spec.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import { Message } from './api/models';
  2. import { isMergeableBody, mergeConsecutiveBodies } from './message-merge';
  3. function m(
  4. id: string,
  5. sender: 'me' | 'agent',
  6. text: string,
  7. extra: Partial<Message> = {},
  8. ): Message {
  9. return {
  10. id,
  11. conversationId: 'c',
  12. sender,
  13. kind: 'text',
  14. text,
  15. ts: new Date(Date.UTC(2026, 10, 6, 10, 0, Number(id.replace(/\D/g, '')) || 0)).toISOString(),
  16. state: 'sent',
  17. ...extra,
  18. };
  19. }
  20. describe('mergeConsecutiveBodies', () => {
  21. it('连续的 agent 正文合成一条', () => {
  22. const out = mergeConsecutiveBodies([m('a1', 'agent', '第一段'), m('a2', 'agent', '第二段')]);
  23. expect(out.length).toBe(1);
  24. expect(out[0].text).toBe('第一段\n\n第二段');
  25. expect(out[0].mergedIds).toEqual(['a1', 'a2']);
  26. });
  27. it('✅ Markdown 分段用空行分隔(不会糊成一句)', () => {
  28. const out = mergeConsecutiveBodies([m('a1', 'agent', '## 标题'), m('a2', 'agent', '- 列表项')]);
  29. expect(out[0].text).toBe('## 标题\n\n- 列表项');
  30. });
  31. it('用户消息断开合并', () => {
  32. const out = mergeConsecutiveBodies([
  33. m('a1', 'agent', '回答一'),
  34. m('u1', 'me', '追问'),
  35. m('a2', 'agent', '回答二'),
  36. ]);
  37. expect(out.map((x) => x.id)).toEqual(['a1', 'u1', 'a2']);
  38. });
  39. it('🔴 中间夹工具过程时断开', () => {
  40. const out = mergeConsecutiveBodies([
  41. m('a1', 'agent', '先说结论'),
  42. m('t1', 'agent', '💻 terminal: ls'),
  43. m('a2', 'agent', '再说细节'),
  44. ]);
  45. expect(out.length).toBe(3);
  46. expect(out[1].text).toContain('💻');
  47. });
  48. it('🔴 带附件的消息不并入正文', () => {
  49. const out = mergeConsecutiveBodies([
  50. m('a1', 'agent', '看这个文件'),
  51. m('a2', 'agent', '', {
  52. file: { kind: 'file', name: 'r.pdf', size: 1, mimeType: 'application/pdf', url: 'u' },
  53. }),
  54. m('a3', 'agent', '还有补充'),
  55. ]);
  56. expect(out.length).toBe(3);
  57. expect(out[1].file?.name).toBe('r.pdf');
  58. });
  59. it('🔴 失败消息不合并(保留重试与错误原因)', () => {
  60. const out = mergeConsecutiveBodies([
  61. m('a1', 'agent', 'ok'),
  62. m('a2', 'agent', '失败的内容', { state: 'failed', error: '超时' }),
  63. ]);
  64. expect(out.length).toBe(2);
  65. expect(out[1].error).toBe('超时');
  66. });
  67. it('🔴 群聊:不同发送者的相邻消息不合并', () => {
  68. const out = mergeConsecutiveBodies([
  69. m('a1', 'agent', 'A 说的', { senderId: '@a:x' }),
  70. m('a2', 'agent', 'B 说的', { senderId: '@b:x' }),
  71. ]);
  72. expect(out.length).toBe(2);
  73. });
  74. it('同一发送者(senderId 相同)仍合并', () => {
  75. const out = mergeConsecutiveBodies([
  76. m('a1', 'agent', '一段', { senderId: '@a:x' }),
  77. m('a2', 'agent', '二段', { senderId: '@a:x' }),
  78. ]);
  79. expect(out.length).toBe(1);
  80. });
  81. it('合并后时间取最后一段', () => {
  82. const out = mergeConsecutiveBodies([m('a1', 'agent', 'x'), m('a5', 'agent', 'y')]);
  83. expect(out[0].ts).toBe(m('a5', 'agent', 'y').ts);
  84. });
  85. it('合并后 @ 列表取并集去重', () => {
  86. const out = mergeConsecutiveBodies([
  87. m('a1', 'agent', 'x', { mentions: ['@p1:x', '@p2:x'] }),
  88. m('a2', 'agent', 'y', { mentions: ['@p2:x'] }),
  89. ]);
  90. expect(out[0].mentions).toEqual(['@p1:x', '@p2:x']);
  91. });
  92. it('单条消息原样返回(补 mergedIds)', () => {
  93. const out = mergeConsecutiveBodies([m('a1', 'agent', '只有一条')]);
  94. expect(out.length).toBe(1);
  95. expect(out[0].mergedIds).toEqual(['a1']);
  96. });
  97. it('空列表', () => {
  98. expect(mergeConsecutiveBodies([])).toEqual([]);
  99. });
  100. it('空白正文不参与合并', () => {
  101. const out = mergeConsecutiveBodies([m('a1', 'agent', '有内容'), m('a2', 'agent', ' ')]);
  102. expect(out.length).toBe(1);
  103. expect(out[0].text).toBe('有内容');
  104. });
  105. });
  106. describe('isMergeableBody', () => {
  107. it('普通 agent 正文可合并', () => {
  108. expect(isMergeableBody(m('a1', 'agent', 'x'))).toBe(true);
  109. });
  110. it('用户消息不可合并', () => {
  111. expect(isMergeableBody(m('u1', 'me', 'x'))).toBe(false);
  112. });
  113. it('空正文不可合并', () => {
  114. expect(isMergeableBody(m('a1', 'agent', ''))).toBe(false);
  115. });
  116. it('带任务 id 不可合并', () => {
  117. expect(isMergeableBody(m('a1', 'agent', 'x', { taskId: 't1' }))).toBe(false);
  118. });
  119. });
  120. /*
  121. * 🔴 2026-11-10 移动端实测:容器用多行 ✅/❌ 列表输出结论时,
  122. * 每条各自成块 → 一屏出现一堆小卡片。结果 emoji 应作为正文参与合并。
  123. */
  124. describe('结果 emoji 行参与合并', () => {
  125. const agent = (id: string, text: string, ts: number) => ({
  126. id,
  127. conversationId: 'c1',
  128. sender: 'agent' as const,
  129. kind: 'text' as const,
  130. text,
  131. ts: new Date(ts).toISOString(),
  132. state: 'sent' as const,
  133. });
  134. it('连续的 ✅/❌ 结论行合并成一段', () => {
  135. const out = mergeConsecutiveBodies([
  136. agent('a', '✅ 成果表有记录(05:34:19Z)', 1000),
  137. agent('b', '❌ 但回复正文没有任何 https', 2000),
  138. agent('c', '❌ 正文还有残留:「本地路径」', 3000),
  139. ]);
  140. expect(out.length).toBe(1);
  141. expect(out[0].text).toContain('成果表有记录');
  142. // 合并后用空行分隔(mergeBodies 的既定规则)
  143. expect(out[0].text).toContain('https');
  144. expect(out[0].text).toContain('本地路径');
  145. });
  146. it('纯工具 emoji 行仍然不参与合并(保持原行为)', () => {
  147. const out = mergeConsecutiveBodies([
  148. agent('a', '💻 terminal', 1000),
  149. agent('b', '🔎 Searching files', 2000),
  150. ]);
  151. // 不可合并的消息**原样保留**(不丢弃),所以仍是 2 条
  152. expect(out.length).toBe(2);
  153. expect(out[0].mergedIds).toEqual(['a']);
  154. expect(out[1].mergedIds).toEqual(['b']);
  155. });
  156. it('正文与结论混排也合并', () => {
  157. const out = mergeConsecutiveBodies([
  158. agent('a', '我来检查一下结果。', 1000),
  159. agent('b', '✅ 全部通过', 2000),
  160. ]);
  161. expect(out.length).toBe(1);
  162. });
  163. });