assistant-turn.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import { GroupBlock, GroupTurn } from './group-turns';
  2. import { Message } from './api/models';
  3. import { Turn } from './turn-aggregator';
  4. import {
  5. isTurnTimedOut,
  6. TURN_TIMEOUT_MS,
  7. turnsFromDm,
  8. turnsFromGroup,
  9. waitText,
  10. } from './assistant-turn';
  11. /**
  12. * 回合视图模型单测(2026-11-06)
  13. *
  14. * 覆盖重点是**拆分口径**:哪些内容进顶部过程区、哪些留下半区,
  15. * 以及单聊/群聊两条输入路径产出的结构是否同构。
  16. */
  17. /* ---------- 构造器 ---------- */
  18. /**
  19. * 构造一条消息。
  20. *
  21. * 🔴 `ts` 用**相对偏移**而不是绝对时间戳:`ts=0` 会被 `new Date(0)` 解释为
  22. * 1970 年,与 `startedAt/updatedAt` 的判空口径(0 = 无数据)冲突。
  23. * 这里统一加一个基准时间,保证 `ts > 0`。
  24. */
  25. const T0 = Date.parse('2026-11-06T10:00:00Z');
  26. function msg(id: string, sender: 'me' | 'agent', text: string, ts = 0): Message {
  27. return {
  28. id,
  29. conversationId: 'c1',
  30. sender,
  31. kind: 'text',
  32. text,
  33. ts: new Date(T0 + ts).toISOString(),
  34. state: 'sent',
  35. };
  36. }
  37. function block(agentId: string, messages: Message[], opts: Partial<GroupBlock> = {}): GroupBlock {
  38. const times = messages.map((m) => Date.parse(m.ts)).filter((n) => n > 0);
  39. return {
  40. agentId,
  41. messages,
  42. startedAt: times.length ? Math.min(...times) : 0,
  43. updatedAt: times.length ? Math.max(...times) : 0,
  44. typing: false,
  45. closed: true,
  46. ...opts,
  47. };
  48. }
  49. function groupTurn(blocks: GroupBlock[], opts: Partial<GroupTurn> = {}): GroupTurn {
  50. return {
  51. id: 'u1',
  52. userMessage: msg('u1', 'me', '帮我看看'),
  53. blocks,
  54. targets: blocks.map((b) => b.agentId),
  55. userMessageTs: 0,
  56. closed: true,
  57. ...opts,
  58. };
  59. }
  60. function dmTurn(blocks: Message[], opts: Partial<Turn> = {}): Turn {
  61. const times = blocks.map((m) => Date.parse(m.ts)).filter((n) => n > 0);
  62. return {
  63. id: 'u1',
  64. userMessage: msg('u1', 'me', '你好'),
  65. blocks,
  66. startedAt: times.length ? Math.min(...times) : 0,
  67. updatedAt: times.length ? Math.max(...times) : 0,
  68. closed: true,
  69. ...opts,
  70. };
  71. }
  72. /* ---------- 用例 ---------- */
  73. describe('turnsFromDm(单聊)', () => {
  74. it('固定产出 1 个块,agentId 为 null', () => {
  75. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '好的')])], 0, false);
  76. expect(t.blocks.length).toBe(1);
  77. expect(t.blocks[0].agentId).toBeNull();
  78. expect(t.blocks[0].key).toBe('agent');
  79. });
  80. it('正文留在下半区,不被误判为过程', () => {
  81. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '这是正常的一段回答。')])], 0, false);
  82. expect(t.blocks[0].steps.length).toBe(0);
  83. expect(t.blocks[0].body.length).toBe(1);
  84. expect(t.blocks[0].body[0].kind).toBe('text');
  85. });
  86. it('混在正文里的过程行被拆到顶部过程区', () => {
  87. const text = '我先看一下环境。\n· bash\n找到了。';
  88. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
  89. const b = t.blocks[0];
  90. expect(b.steps.length).toBeGreaterThan(0);
  91. // 正文里不能再出现过程行,否则折叠后还会看到一遍
  92. const bodyText = b.body
  93. .filter((x) => x.kind === 'text')
  94. .map((x) => (x.kind === 'text' ? x.message.text : ''))
  95. .join('\n');
  96. expect(bodyText).toContain('我先看一下环境');
  97. expect(bodyText).not.toContain('· bash');
  98. });
  99. it('以 emoji 开头的**成句中文**按正文处理,不折进过程区', () => {
  100. /*
  101. * 真实案例(2026-11-07 实测):容器这样汇报一次自我升级 ——
  102. * 💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。
  103. * 行首 💾 会命中工具 emoji,但它是一句结论,用户要读的是这句话。
  104. */
  105. const text = '💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。';
  106. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
  107. const b = t.blocks[0];
  108. expect(b.steps.length).toBe(0);
  109. expect(b.body.length).toBe(1);
  110. expect(b.body[0].kind === 'text' && b.body[0].message.text).toContain('自我升级小结');
  111. });
  112. it('真正的终端活动行(英文工具名)仍然进过程区', () => {
  113. const text = '💻 bash\n✍️ Writing /opt/data/work/index.html';
  114. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
  115. expect(t.blocks[0].steps.length).toBeGreaterThan(0);
  116. expect(t.blocks[0].body.length).toBe(0);
  117. });
  118. it('正在输出时 live 为真,且不受 closed 影响时按静默窗口判断', () => {
  119. const [open] = turnsFromDm(
  120. [dmTurn([msg('a1', 'agent', '输出中', 1000)], { closed: false })],
  121. T0 + 1500,
  122. true,
  123. );
  124. expect(open.live).toBe(true);
  125. expect(open.phase).toBe('replying');
  126. // 末条消息之后已远超静默窗口,且不再 typing → 视为结束
  127. const [quiet] = turnsFromDm(
  128. [dmTurn([msg('a1', 'agent', '说完了', 1000)], { closed: false })],
  129. T0 + 60_000,
  130. false,
  131. );
  132. expect(quiet.live).toBe(false);
  133. });
  134. /*
  135. * 耗时口径(2026-11-10 用户裁决):从**用户发出消息**起算。
  136. * 原实现是「首末 agent 消息时间差」,会卡住再跳变,已废弃。
  137. */
  138. it('耗时从用户发消息起算,取首末消息跨度', () => {
  139. const [t] = turnsFromDm(
  140. [dmTurn([msg('a1', 'agent', 'x', 0), msg('a2', 'agent', 'y', 4200)], { userMessage: undefined })],
  141. 0,
  142. false,
  143. );
  144. // 无用户消息时回落到首条 agent 消息作锚点
  145. expect(t.blocks[0].seconds).toBe(4);
  146. });
  147. it('live 时按当前时钟稳定递增(不再卡住)', () => {
  148. const userAt = msg('u1', 'me', '你好', 0);
  149. const turn = dmTurn([msg('a1', 'agent', 'x', 2000)], {
  150. userMessage: userAt,
  151. closed: false,
  152. });
  153. // 第三个参数是 typing —— 容器正在输出,回合保持 live
  154. const at10s = turnsFromDm([turn], T0 + 10_000, true)[0];
  155. const at15s = turnsFromDm([turn], T0 + 15_000, true)[0];
  156. // 同一个回合、同一条最后消息,但时钟推进 → 秒数必须跟着涨
  157. expect(at10s.blocks[0].live).toBe(true);
  158. expect(at10s.blocks[0].seconds).toBe(10);
  159. expect(at15s.blocks[0].seconds).toBe(15);
  160. });
  161. it('回合结束后冻结,不再随时间增长', () => {
  162. const userAt = msg('u1', 'me', '你好', 0);
  163. const turn = dmTurn([msg('a1', 'agent', 'x', 8000)], {
  164. userMessage: userAt,
  165. closed: true,
  166. });
  167. const early = turnsFromDm([turn], T0 + 30_000, false)[0];
  168. const later = turnsFromDm([turn], T0 + 600_000, false)[0];
  169. expect(early.blocks[0].live).toBe(false);
  170. // 冻结在「末条消息 - 用户发消息」= 8 秒
  171. expect(early.blocks[0].seconds).toBe(8);
  172. expect(later.blocks[0].seconds).toBe(8);
  173. });
  174. it('容器排队时间也算进用户等待', () => {
  175. const userAt = msg('u1', 'me', '你好', 0);
  176. // 用户 0s 发出,容器 40s 才回
  177. const turn = dmTurn([msg('a1', 'agent', 'x', 40_000)], {
  178. userMessage: userAt,
  179. closed: true,
  180. });
  181. const [t] = turnsFromDm([turn], T0 + 60_000, false);
  182. expect(t.blocks[0].seconds).toBe(40);
  183. });
  184. it('无 message 的回合 userMessage 为空', () => {
  185. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', 'x')], { userMessage: undefined })], 0, false);
  186. expect(t.userMessage).toBeUndefined();
  187. });
  188. });
  189. describe('turnsFromGroup(群聊)', () => {
  190. it('每个容器一块,顺序保持 targets 声明顺序', () => {
  191. const turns = [
  192. groupTurn([
  193. block('agent-a', [msg('a1', 'agent', 'A 的回复')]),
  194. block('agent-b', [msg('b1', 'agent', 'B 的回复')]),
  195. ]),
  196. ];
  197. const [t] = turnsFromGroup(turns, 0);
  198. expect(t.blocks.map((b) => b.agentId)).toEqual(['agent-a', 'agent-b']);
  199. expect(t.blocks.every((b) => b.agentId !== null)).toBe(true);
  200. });
  201. it('nameOf 能注入成员显示名', () => {
  202. const turns = [groupTurn([block('agent-a', [msg('a1', 'agent', 'x')])])];
  203. const [t] = turnsFromGroup(turns, 0, 3000, (id) => (id === 'agent-a' ? '见微' : id));
  204. expect(t.blocks[0].agentName).toBe('见微');
  205. });
  206. it('userFiles 透传到回合视图', () => {
  207. const file = { id: 'f1', name: 'a.png', url: 'u', mimeType: 'image/png', size: 1024 };
  208. const turns = [
  209. groupTurn([block('agent-a', [msg('a1', 'agent', 'x')])], {
  210. userFiles: [{ ...msg('f1', 'me', ''), file: { kind: 'file', ...file } }],
  211. }),
  212. ];
  213. const [t] = turnsFromGroup(turns, 0);
  214. expect(t.userFiles.length).toBe(1);
  215. });
  216. it('某成员未响应时,该块 empty 为真', () => {
  217. const turns = [
  218. groupTurn([
  219. block('agent-a', [msg('a1', 'agent', '有内容')]),
  220. block('agent-b', [], { startedAt: 0, updatedAt: 0 }),
  221. ]),
  222. ];
  223. const [t] = turnsFromGroup(turns, 0);
  224. expect(t.blocks[0].empty).toBe(false);
  225. expect(t.blocks[1].empty).toBe(true);
  226. });
  227. it('回合步骤数为各块之和', () => {
  228. const turns = [
  229. groupTurn([
  230. block('agent-a', [msg('a1', 'agent', '· bash')]),
  231. block('agent-b', [msg('b1', 'agent', '· read_file')]),
  232. ]),
  233. ];
  234. const [t] = turnsFromGroup(turns, 0);
  235. expect(t.stepCount).toBe(t.blocks[0].steps.length + t.blocks[1].steps.length);
  236. expect(t.stepCount).toBeGreaterThan(0);
  237. });
  238. it('多个成员并行输出时,任一 live 即回合 live', () => {
  239. const turns = [
  240. groupTurn(
  241. [
  242. block('agent-a', [msg('a1', 'agent', 'x', 1000)], { typing: true, closed: false }),
  243. block('agent-b', [msg('b1', 'agent', 'y', 1000)], { typing: false, closed: false }),
  244. ],
  245. { closed: false },
  246. ),
  247. ];
  248. const [t] = turnsFromGroup(turns, 1200);
  249. expect(t.blocks[0].live).toBe(true);
  250. expect(t.blocks[1].live).toBe(true); // 静默窗口内仍算在输出
  251. expect(t.live).toBe(true);
  252. });
  253. });
  254. describe('单聊与群聊的结构一致性', () => {
  255. it('两条路径产出的 body 块结构相同(单聊 1 块 vs 群聊 N 块)', () => {
  256. const dm = turnsFromDm([dmTurn([msg('a1', 'agent', '同一段回答')])], 0, false)[0];
  257. const gp = turnsFromGroup(
  258. [groupTurn([block('agent-a', [msg('a1', 'agent', '同一段回答')])])],
  259. 0,
  260. )[0];
  261. expect(dm.blocks.length).toBe(1);
  262. expect(gp.blocks.length).toBe(1);
  263. expect(dm.blocks[0].body.map((b) => b.kind)).toEqual(gp.blocks[0].body.map((b) => b.kind));
  264. expect(dm.stepCount).toBe(gp.stepCount);
  265. expect(dm.phase).toBe(gp.phase);
  266. });
  267. });
  268. /**
  269. * 等待文案(2026-11-09 用户裁决)。
  270. *
  271. * 容器回一轮要十几秒。原来界面上只有「正在思考…」,没有时长也没有阶段,
  272. * 用户分不清是「发出去了在等」还是「根本没发出去」。
  273. */
  274. describe('waitText(等待 / 输出占位文案)', () => {
  275. it('刚开始等待(< 8s)→ 正在思考…', () => {
  276. expect(waitText(0, false, true, false)).toBe('正在思考…');
  277. expect(waitText(7_000, false, true, false)).toBe('正在思考…');
  278. });
  279. it('超过 8s → 明确告诉用户「已送达,等待回复」并带秒数', () => {
  280. expect(waitText(8_000, false, true, false)).toBe('已送达,等待回复…(8s)');
  281. expect(waitText(13_000, false, true, false)).toBe('已送达,等待回复…(13s)');
  282. });
  283. it('🔴 等待文案一直兜底,不会因为等得久就消失', () => {
  284. expect(waitText(60_000, false, true, false)).toContain('等待回复');
  285. expect(waitText(300_000, false, true, false)).toContain('等待回复');
  286. });
  287. it('开始输出 → 切换为「正在输出」并带秒数', () => {
  288. expect(waitText(13_000, true, true, false)).toBe('正在输出…(13s)');
  289. });
  290. it('已结束且有内容 → 不显示占位', () => {
  291. expect(waitText(20_000, true, false, false)).toBe('');
  292. });
  293. it('🔴 已结束且什么也没有 → 不再显示「未响应」气泡(2026-11-10 改为全局提示)', () => {
  294. expect(waitText(30_000, false, false, true)).toBe('');
  295. });
  296. });
  297. /*
  298. * 🔴 2026-11-10:「已完成」必须代表真的有交付。
  299. * 原判据「只要不是全空就算 done」,容器只回一串进度也会标「已完成」。
  300. */
  301. describe('phaseOf(交付口径)', () => {
  302. const user = () => msg('u1', 'me', '帮我写个文件', 0);
  303. it('有正文 → done', () => {
  304. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '这是最终答复。', 1000)], { userMessage: user() })], T0 + 60_000, false);
  305. expect(t.phase).toBe('done');
  306. });
  307. it('🔴 只有过程步骤、没有正文 → progressOnly', () => {
  308. const progress = ['🐍 Running code from hermes_tools import terminal r=t...', '🐍 Running code from hermes_tools import search_files...'].join('\n');
  309. const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', progress, 1000)], { userMessage: user() })], T0 + 60_000, false);
  310. expect(t.phase).toBe('progressOnly');
  311. });
  312. it('什么都没有 → idle', () => {
  313. const [t] = turnsFromDm([dmTurn([], { userMessage: user() })], T0 + 60_000, false);
  314. expect(t.phase).toBe('idle');
  315. });
  316. });
  317. /*
  318. * 超时未响应判定(2026-11-10 用户裁决:超时改为全局提示)。
  319. */
  320. describe('isTurnTimedOut', () => {
  321. const MIN = 60_000;
  322. it('超过 3 分钟且一个字没回 → 超时', () => {
  323. expect(isTurnTimedOut(181_000, false, false)).toBe(true);
  324. expect(isTurnTimedOut(TURN_TIMEOUT_MS, false, false)).toBe(true);
  325. });
  326. it('未到 3 分钟 → 不算超时', () => {
  327. expect(isTurnTimedOut(179_000, false, false)).toBe(false);
  328. });
  329. it('🔴 已经有输出 → 不算超时(容器干活慢不等于异常)', () => {
  330. expect(isTurnTimedOut(10 * MIN, true, false)).toBe(false);
  331. });
  332. it('🔴 仍在进行中 → 不算超时(可能正在跑长任务)', () => {
  333. expect(isTurnTimedOut(10 * MIN, false, true)).toBe(false);
  334. });
  335. });
  336. /*
  337. * 🔴 2026-11-10 用户实测:容器不回时界面永远挂「已送达,等待回复…(253s)」,
  338. * 超时全局提示永远不弹。根因是 `awaitingFirstReply` 让 `live` 恒为真。
  339. */
  340. describe('未回复回合的超时收尾', () => {
  341. const userMsg = () => msg('u1', 'me', '你好', 0);
  342. /** 用户发了消息,容器一条都没回 */
  343. const emptyTurn = (): Turn => dmTurn([], { userMessage: userMsg(), closed: false });
  344. it('🔴 等待未到 3 分钟 → 仍算 live(正常等待)', () => {
  345. const [t] = turnsFromDm([emptyTurn()], T0 + 30_000, false);
  346. expect(t.live).toBe(true);
  347. expect(t.blocks[0].live).toBe(true);
  348. });
  349. it('🔴 等待超过 3 分钟 → 不再 live,占位文案消失', () => {
  350. const [t] = turnsFromDm([emptyTurn()], T0 + TURN_TIMEOUT_MS + 5_000, false);
  351. expect(t.blocks[0].live).toBe(false);
  352. // 不再是「等待回复…」,而是空(交给全局提示)
  353. const text = waitText(
  354. T0 + TURN_TIMEOUT_MS + 5_000 - T0,
  355. false,
  356. t.blocks[0].live,
  357. t.blocks[0].empty,
  358. );
  359. expect(text).toBe('');
  360. });
  361. it('🔴 超过 3 分钟后判定为超时(全局提示依据)', () => {
  362. const elapsed = TURN_TIMEOUT_MS + 5_000;
  363. const [t] = turnsFromDm([emptyTurn()], T0 + elapsed, false);
  364. expect(isTurnTimedOut(elapsed, false, t.blocks[0].live)).toBe(true);
  365. });
  366. it('已经回过内容 → 不受该窗口影响', () => {
  367. const reply = msg('a1', 'agent', '这是回复。', 2_000);
  368. const t0 = dmTurn([reply], { userMessage: userMsg(), closed: false });
  369. const [t] = turnsFromDm([t0], T0 + TURN_TIMEOUT_MS + 5_000, false);
  370. expect(t.blocks[0].body.length).toBeGreaterThan(0);
  371. });
  372. });