verify-typing-order.mjs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. #!/usr/bin/env node
  2. /**
  3. * 验证容器 typing 信号时序(2026-11-11)
  4. *
  5. * 要回答两个问题:
  6. * ① adapter 自带的 typing 循环是否已关闭(配置 typing_indicator: false)
  7. * ② typing=false 是否**晚于**最后一条 agent 消息
  8. *
  9. * 🔴 必须用 /sync 监听:`m.typing` 是 ephemeral 事件,**不写进 timeline**,
  10. * 用 /messages 读永远读不到(2026-11-11 踩过这个坑,误判过一次)。
  11. *
  12. * 用法:node scripts/verify-typing-order.mjs
  13. */
  14. import { readFileSync } from 'node:fs';
  15. import { homedir } from 'node:os';
  16. import { join } from 'node:path';
  17. const F = 'https://server.fmode.cn/api/functions';
  18. const TARGET = 'agent-node-lj-test';
  19. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  20. const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
  21. async function prov(id) {
  22. const r = await fetch(F, {
  23. method: 'POST',
  24. headers: { 'Content-Type': 'application/json' },
  25. body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }),
  26. });
  27. const j = await r.json();
  28. const d = j.result ?? j;
  29. if (!d?.accessToken) throw new Error('provision 失败');
  30. return d;
  31. }
  32. const p = await prov(TARGET);
  33. const a = await prov(TARGET);
  34. const mx = await import('matrix-js-sdk');
  35. const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId });
  36. await c.startClient({ initialSyncLimit: 1 });
  37. await sleep(3000);
  38. const hs = p.homeserver.replace(/\/$/, '');
  39. const auth = { Authorization: `Bearer ${p.accessToken}` };
  40. const room = (
  41. await c.createRoom({
  42. invite: [a.agentMxId],
  43. name: `typing-order-${Date.now()}`,
  44. preset: mx.Preset.TrustedPrivateChat,
  45. })
  46. ).room_id;
  47. for (let i = 0; i < 30; i++) {
  48. await sleep(2000);
  49. const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, {
  50. headers: auth,
  51. }).catch(() => null);
  52. const j = r ? await r.json().catch(() => ({})) : {};
  53. if ((j.joined ?? {})[a.agentMxId]) break;
  54. }
  55. const events = [];
  56. const T0 = Date.now();
  57. const rel = (t) => (t - T0) / 1000;
  58. c.on('RoomMember.typing', (ev, m) => {
  59. if (m?.roomId !== room) return;
  60. events.push({ t: Date.now(), kind: 'typing', value: m.typing });
  61. });
  62. c.on('Room.timeline', (ev, rm) => {
  63. if (rm?.roomId !== room) return;
  64. const type = ev.getType();
  65. if (type === 'm.reaction' && ev.getSender() === a.agentMxId) {
  66. const key = ev.getContent()?.['m.relates_to']?.key ?? '';
  67. events.push({ t: Date.now(), kind: 'reaction', value: key });
  68. }
  69. if (type === 'm.room.message' && ev.getSender() === a.agentMxId) {
  70. events.push({ t: Date.now(), kind: 'msg', value: String(ev.getContent()?.body ?? '').slice(0, 40) });
  71. }
  72. });
  73. await c.sendEvent(room, 'm.room.message', {
  74. msgtype: 'm.text',
  75. body: '用一句话回答:3 乘以 7 等于多少?不要调用工具。',
  76. 'm.mentions': { user_ids: [a.agentMxId] },
  77. });
  78. console.log('已发送,监听 100 秒…\n');
  79. await sleep(100_000);
  80. console.log('=== 事件时序(数字 = 距发送的秒数)===');
  81. for (const e of events) {
  82. const tag = e.kind === 'typing' ? `typing=${e.value}` : e.kind === 'reaction' ? `reaction ${e.value}` : `消息「${e.value}」`;
  83. console.log(` +${rel(e.t).toFixed(1)}s ${tag}`);
  84. }
  85. const msgs = events.filter((e) => e.kind === 'msg');
  86. const lastMsg = msgs.at(-1);
  87. const typingFalse = events.filter((e) => e.kind === 'typing' && e.value === false);
  88. const typingTrue = events.filter((e) => e.kind === 'typing' && e.value === true);
  89. console.log('\n=== 判定 ===');
  90. console.log(`typing=true ${typingTrue.length} 次`);
  91. console.log(`typing=false ${typingFalse.length} 次`);
  92. console.log(`agent 消息 ${msgs.length} 条`);
  93. if (lastMsg && typingFalse.length) {
  94. const lastFalse = typingFalse.at(-1);
  95. const diff = rel(lastFalse.t) - rel(lastMsg.t);
  96. console.log(`\n最后一条消息在 +${rel(lastMsg.t).toFixed(1)}s,最后一个 typing=false 在 +${rel(lastFalse.t).toFixed(1)}s`);
  97. console.log(diff >= 0 ? `✅ false 晚于末条消息 ${diff.toFixed(1)}s(正确)` : `❌ false 早于末条消息 ${(-diff).toFixed(1)}s(仍然抢跑)`);
  98. } else if (msgs.length && !typingFalse.length) {
  99. console.log('\n⚠️ 没有任何 typing=false —— 插件/adapter 都没发结束信号');
  100. } else {
  101. console.log('\n⚠️ 数据不足,无法判定');
  102. }
  103. const rej = events.filter((e) => e.kind === 'reaction');
  104. console.log(`\n另:✅/❌ reaction ${rej.length} 次 ${rej.length ? '(这本身也是可靠的结束信号)' : ''}`);
  105. process.exit(0);