test-multi-session.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. #!/usr/bin/env node
  2. /**
  3. * 多会话并发测试(2026-11-11 用户要求)
  4. *
  5. * 目的:同一容器同时开两个会话并发提问,观察:
  6. * ① 回复是否会串台(A 会话收到 B 的回答)
  7. * ② 回复是否丢失(只回了一句「我先去查」就没下文)
  8. * ③ typing 结束信号是按会话发还是按容器发
  9. * ④ extra.currentSessions 能否区分两个会话的运行态
  10. *
  11. * 用法:node scripts/test-multi-session.mjs
  12. */
  13. import { readFileSync } from 'node:fs';
  14. import { homedir } from 'node:os';
  15. import { join } from 'node:path';
  16. const F = 'https://server.fmode.cn/api/functions';
  17. const TARGET = 'agent-node-lj-test';
  18. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  19. const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
  20. async function prov(id) {
  21. const r = await fetch(F, {
  22. method: 'POST',
  23. headers: { 'Content-Type': 'application/json' },
  24. body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }),
  25. });
  26. const j = await r.json();
  27. const d = j.result ?? j;
  28. if (!d?.accessToken) throw new Error('provision 失败: ' + JSON.stringify(j).slice(0, 200));
  29. return d;
  30. }
  31. /** 读云函数,看 currentSessions 有没有区分两个会话 */
  32. async function readAgentStatus() {
  33. const r = await fetch(F, {
  34. method: 'POST',
  35. headers: { 'Content-Type': 'application/json' },
  36. body: JSON.stringify({ id: 'xlosi3m46q', token: tok, params: { agentId: TARGET } }),
  37. });
  38. const j = await r.json();
  39. const d = j.result ?? j;
  40. const ex = d?.extra;
  41. const parsed = typeof ex === 'string' ? safeJson(ex) : ex;
  42. return {
  43. status: d?.status,
  44. currentTask: d?.currentTask,
  45. currentSessions: parsed?.currentSessions,
  46. };
  47. }
  48. function safeJson(s) {
  49. try {
  50. return JSON.parse(s);
  51. } catch {
  52. return null;
  53. }
  54. }
  55. const p = await prov(TARGET);
  56. const a = await prov(TARGET);
  57. const mx = await import('matrix-js-sdk');
  58. const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId });
  59. await c.startClient({ initialSyncLimit: 1 });
  60. await sleep(3000);
  61. const hs = p.homeserver.replace(/\/$/, '');
  62. const auth = { Authorization: `Bearer ${p.accessToken}` };
  63. async function makeRoom(tag) {
  64. const room = (
  65. await c.createRoom({
  66. invite: [a.agentMxId],
  67. name: `${tag}-${Date.now()}`,
  68. preset: mx.Preset.TrustedPrivateChat,
  69. })
  70. ).room_id;
  71. for (let i = 0; i < 30; i++) {
  72. await sleep(2000);
  73. const r = await fetch(
  74. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,
  75. { headers: auth },
  76. ).catch(() => null);
  77. const j = r ? await r.json().catch(() => ({})) : {};
  78. if ((j.joined ?? {})[a.agentMxId]) return room;
  79. }
  80. throw new Error(`房间 ${tag} 容器未进房`);
  81. }
  82. /** 事件收集器:按房间记录 agent 消息 / typing / 自身消息 */
  83. const log = { A: [], B: [], typing: [] };
  84. const T0 = Date.now();
  85. const rel = (t) => `+${((t - T0) / 1000).toFixed(1)}s`;
  86. let roomA, roomB;
  87. c.on('Room.timeline', (ev, rm) => {
  88. const rid = rm?.roomId;
  89. if (rid !== roomA && rid !== roomB) return;
  90. const tag = rid === roomA ? 'A' : 'B';
  91. if (ev.getType() !== 'm.room.message') return;
  92. const mine = ev.getSender() === p.userMxId;
  93. if (mine) return;
  94. const body = String(ev.getContent()?.body ?? '');
  95. log[tag].push({ at: Date.now(), body });
  96. console.log(`[${rel(Date.now())}] 房间${tag} agent: ${JSON.stringify(body.slice(0, 70))}`);
  97. });
  98. c.on('RoomMember.typing', (ev, m) => {
  99. if (m?.roomId !== roomA && m?.roomId !== roomB) return;
  100. const tag = m.roomId === roomA ? 'A' : 'B';
  101. log.typing.push({ at: Date.now(), tag, typing: m.typing });
  102. console.log(`[${rel(Date.now())}] 🔵 房间${tag} typing=${m.typing}`);
  103. });
  104. console.log('\n=== 建两个会话 ===');
  105. roomA = await makeRoom('multiA');
  106. roomB = await makeRoom('multiB');
  107. console.log('房间 A =', roomA);
  108. console.log('房间 B =', roomB);
  109. console.log('\n=== 并发发送(间隔 0.5 秒)===');
  110. await c.sendEvent(roomA, 'm.room.message', {
  111. msgtype: 'm.text',
  112. body: '请详细介绍一下杭州西湖的历史,分 5 点说,每点写 2 句话。',
  113. 'm.mentions': { user_ids: [a.agentMxId] },
  114. });
  115. await sleep(500);
  116. await c.sendEvent(roomB, 'm.room.message', {
  117. msgtype: 'm.text',
  118. body: '请列出中国的 5 个一线城市,每个城市说一句特色。',
  119. 'm.mentions': { user_ids: [a.agentMxId] },
  120. });
  121. console.log('两条已发送');
  122. /* 期间每 15 秒记录一次容器上报的 currentSessions */
  123. console.log('\n=== 周期观察容器上报 ===');
  124. for (let i = 0; i < 10; i++) {
  125. await sleep(15000);
  126. const st = await readAgentStatus().catch(() => null);
  127. if (st) {
  128. console.log(
  129. `[${rel(Date.now())}] 容器上报 status=${st.status} currentTask=${JSON.stringify((st.currentTask || '').slice(0, 40))} sessions=${JSON.stringify(st.currentSessions)}`,
  130. );
  131. }
  132. // 两个房间都超过 60 秒没动静就提前结束
  133. const lastA = log.A.at(-1)?.at ?? T0;
  134. const lastB = log.B.at(-1)?.at ?? T0;
  135. if (Date.now() - lastA > 60000 && Date.now() - lastB > 60000 && log.A.length && log.B.length) {
  136. console.log('两边都静默超过 60 秒,提前结束观察');
  137. break;
  138. }
  139. }
  140. console.log('\n================ 结果 ================');
  141. for (const tag of ['A', 'B']) {
  142. console.log(`\n【房间 ${tag}】agent 消息 ${log[tag].length} 条`);
  143. log[tag].forEach((m, i) => console.log(` ${i + 1}. ${JSON.stringify(m.body.slice(0, 80))}`));
  144. }
  145. console.log(`\n【typing 事件】共 ${log.typing.length} 条`);
  146. const byTag = { A: log.typing.filter((t) => t.tag === 'A'), B: log.typing.filter((t) => t.tag === 'B') };
  147. for (const tag of ['A', 'B']) {
  148. const arr = byTag[tag];
  149. console.log(` 房间${tag}: ${arr.map((t) => `${rel(t.at)}=${t.typing}`).join(' | ') || '无'}`);
  150. }
  151. /* 串台检测:B 的问题里有「一线城市」,A 的回答不该出现它 */
  152. const crossA = log.A.filter((m) => m.body.includes('一线城市'));
  153. const crossB = log.B.filter((m) => m.body.includes('西湖'));
  154. console.log('\n【串台检测】');
  155. console.log(' A 里出现 B 的内容:', crossA.length ? `❌ ${crossA.length} 条` : '✅ 无');
  156. console.log(' B 里出现 A 的内容:', crossB.length ? `❌ ${crossB.length} 条` : '✅ 无');
  157. console.log('\n【回复完整性】');
  158. for (const tag of ['A', 'B']) {
  159. const err = await probeRoom(tag === 'A' ? roomA : roomB);
  160. console.log(` 房间${tag} 原始事件数: ${err}`);
  161. }
  162. /** 直接读 timeline,确认有没有 App 没收到的事件 */
  163. async function probeRoom(roomId) {
  164. const r = await fetch(
  165. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=100`,
  166. { headers: auth },
  167. );
  168. const j = await r.json();
  169. const bodies = (j.chunk ?? [])
  170. .filter((e) => e.type === 'm.room.message' && e.sender === a.agentMxId)
  171. .reverse();
  172. console.log(` ─ 房间 ${roomId.slice(-8)} timeline 里 agent 消息 ${bodies.length} 条:`);
  173. bodies.forEach((e) => console.log(` ${JSON.stringify(String(e.content?.body ?? '').slice(0, 70))}`));
  174. return bodies.length;
  175. }
  176. process.exit(0);