verify-reaction-coverage.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. #!/usr/bin/env node
  2. /**
  3. * 验证 ✅ reaction 作为结束信号的覆盖率与边界(2026-11-11)
  4. *
  5. * 关心三件事:
  6. * ① 普通回合:✅ 是否稳定出现、是否晚于正文
  7. * ② 多步/长工具回合:✅ 是否仍在**最后**才出现(不会被中间步骤骗)
  8. * ③ 并发两个会话:各自的 ✅ 是否只出现在各自房间
  9. *
  10. * 用法:node scripts/verify-reaction-coverage.mjs
  11. */
  12. import { readFileSync } from 'node:fs';
  13. import { homedir } from 'node:os';
  14. import { join } from 'node:path';
  15. const F = 'https://server.fmode.cn/api/functions';
  16. const TARGET = 'agent-node-lj-test';
  17. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  18. const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
  19. async function prov(id) {
  20. const r = await fetch(F, {
  21. method: 'POST',
  22. headers: { 'Content-Type': 'application/json' },
  23. body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }),
  24. });
  25. const j = await r.json();
  26. const d = j.result ?? j;
  27. if (!d?.accessToken) throw new Error('provision 失败');
  28. return d;
  29. }
  30. const p = await prov(TARGET);
  31. const a = await prov(TARGET);
  32. const mx = await import('matrix-js-sdk');
  33. const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId });
  34. await c.startClient({ initialSyncLimit: 1 });
  35. await sleep(3000);
  36. const hs = p.homeserver.replace(/\/$/, '');
  37. const auth = { Authorization: `Bearer ${p.accessToken}` };
  38. async function makeRoom(tag) {
  39. const room = (
  40. await c.createRoom({
  41. invite: [a.agentMxId],
  42. name: `${tag}-${Date.now()}`,
  43. preset: mx.Preset.TrustedPrivateChat,
  44. })
  45. ).room_id;
  46. for (let i = 0; i < 30; i++) {
  47. await sleep(2000);
  48. const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, {
  49. headers: auth,
  50. }).catch(() => null);
  51. const j = r ? await r.json().catch(() => ({})) : {};
  52. if ((j.joined ?? {})[a.agentMxId]) return room;
  53. }
  54. throw new Error('容器未进房');
  55. }
  56. const log = new Map(); // roomId -> [{t, kind, value}]
  57. const T0 = Date.now();
  58. const rel = (t) => ((t - T0) / 1000).toFixed(1);
  59. c.on('Room.timeline', (ev, rm) => {
  60. const rid = rm?.roomId;
  61. if (!log.has(rid)) return;
  62. if (ev.getSender() !== a.agentMxId) return;
  63. const type = ev.getType();
  64. const arr = log.get(rid);
  65. if (type === 'm.reaction') {
  66. arr.push({ t: Date.now(), kind: 'reaction', value: ev.getContent()?.['m.relates_to']?.key ?? '' });
  67. } else if (type === 'm.room.message') {
  68. arr.push({ t: Date.now(), kind: 'msg', value: String(ev.getContent()?.body ?? '').slice(0, 35) });
  69. }
  70. });
  71. console.log('建 3 个房间:普通 / 多步长任务 / 并发B\n');
  72. const rNormal = await makeRoom('rc-normal');
  73. const rMulti = await makeRoom('rc-multi');
  74. const rB = await makeRoom('rc-b');
  75. log.set(rNormal, []);
  76. log.set(rMulti, []);
  77. log.set(rB, []);
  78. const send = (rid, body) =>
  79. c.sendEvent(rid, 'm.room.message', {
  80. msgtype: 'm.text',
  81. body,
  82. 'm.mentions': { user_ids: [a.agentMxId] },
  83. });
  84. // ① 普通一问一答
  85. await send(rNormal, '用一句话回答:一年有多少个月?不要调用工具。');
  86. await sleep(1000);
  87. // ② 多步长任务(要求调用工具多次)
  88. await send(rMulti, '帮我依次算:12*12、13*13、14*14、15*15、16*16,每算一个都要用工具实际计算,最后汇总。');
  89. await sleep(1000);
  90. // ③ 并发第二个会话
  91. await send(rB, '用一句话回答:中国的首都是哪里?不要调用工具。');
  92. console.log('三条已发送,监听 180 秒…');
  93. await sleep(180_000);
  94. const names = [
  95. ['普通一问一答', rNormal],
  96. ['多步长任务', rMulti],
  97. ['并发B', rB],
  98. ];
  99. let allOk = true;
  100. for (const [name, rid] of names) {
  101. const arr = log.get(rid);
  102. console.log(`\n########## ${name} ##########`);
  103. arr.forEach((e) => {
  104. const tag = e.kind === 'reaction' ? `reaction ${e.value}` : `消息「${e.value}」`;
  105. console.log(` +${rel(e.t)}s ${tag}`);
  106. });
  107. const msgs = arr.filter((e) => e.kind === 'msg');
  108. const ok = arr.filter((e) => e.kind === 'reaction' && e.value === '✅');
  109. const bad = arr.filter((e) => e.kind === 'reaction' && e.value === '❌');
  110. const eye = arr.filter((e) => e.kind === 'reaction' && e.value === '👀');
  111. console.log(
  112. ` → 消息 ${msgs.length} 条 / 👀 ${eye.length} / ✅ ${ok.length} / ❌ ${bad.length}`,
  113. );
  114. if (ok.length === 0) {
  115. console.log(' ⚠️ 没有 ✅ —— 需要兜底');
  116. allOk = false;
  117. } else {
  118. const lastMsg = msgs.at(-1);
  119. const lastOk = ok.at(-1);
  120. if (lastMsg && lastOk) {
  121. const d = (lastOk.t - lastMsg.t) / 1000;
  122. console.log(
  123. d >= 0
  124. ? ` ✅ ✅ 晚于末条消息 ${d.toFixed(1)}s`
  125. : ` ❌ ✅ 早于末条消息 ${(-d).toFixed(1)}s —— 会导致提前收尾`,
  126. );
  127. if (d < 0) allOk = false;
  128. }
  129. // 多步回合:中间是否出现多个 ✅(若多个,取最后一个才是真结束)
  130. if (ok.length > 1) console.log(` ℹ️ 出现 ${ok.length} 个 ✅ —— App 只认最后一个,需确认不会提前收尾`);
  131. }
  132. }
  133. console.log(`\n===== 总结 =====`);
  134. console.log(allOk ? '✅ ✅ reaction 可作为可靠结束信号' : '❌ 存在覆盖缺口,App 侧必须保留静默兜底');
  135. process.exit(0);