verify-unified-state.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. #!/usr/bin/env node
  2. /**
  3. * 统一状态裁决 · 自动化验证(2026-11-11)
  4. *
  5. * 目的:在**真实容器数据**上验证 App 的状态判定,而不是只看单测。
  6. *
  7. * 覆盖 5 个核心场景:
  8. * ① 本会话在跑 → 显示「正在回复」
  9. * ② 本会话空闲、别的会话在跑 → 显示「在线」(不串台)
  10. * ③ 收到 ✅ reaction → 立刻收尾
  11. * ④ 容器没上报会话级状态 → 只显示在线/离线,不猜
  12. * ⑤ 多人共用:别人在跑不影响我
  13. *
  14. * 用法:node scripts/verify-unified-state.mjs
  15. */
  16. import { readFileSync } from 'node:fs';
  17. import { homedir } from 'node:os';
  18. import { join } from 'node:path';
  19. const F = 'https://server.fmode.cn/api/functions';
  20. const AGENT = 'agent-node-lj-test';
  21. const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
  22. /**
  23. * 复刻 App 侧 `resolveSessionPhase()` 的优先级。
  24. * 🔴 这段必须与 `src/app/core/agent-liveness.ts` 保持一致,
  25. * 脚本只是把同一套规则搬到 Node 里跑真实数据。
  26. */
  27. function resolveSessionPhase({ turnEnded, sessionRunning, typing, turnLive }) {
  28. if (turnEnded) return { busy: false, reason: 'reaction' };
  29. if (sessionRunning === true) return { busy: true, phase: 'replying', reason: 'session' };
  30. if (sessionRunning === false) return { busy: false, reason: 'session' };
  31. if (typing) return { busy: true, phase: 'thinking', reason: 'typing' };
  32. if (turnLive) return { busy: true, phase: 'replying', reason: 'turn' };
  33. return { busy: false, reason: 'idle' };
  34. }
  35. /** 复刻 `sessionStateOf()`:userId 收窄 → thread 根 → roomId */
  36. function sessionRunning(sessions, appSessionId, roomId, myUserId) {
  37. if (!sessions?.length || !appSessionId) return undefined;
  38. const core = appSessionId.split(':')[0];
  39. const reportedUsers = sessions.some((s) => !!s.userId);
  40. const pool =
  41. myUserId && reportedUsers
  42. ? sessions.filter((s) => !s.userId || s.userId === myUserId)
  43. : sessions;
  44. /* 过滤后为空 = 容器没报我的状态 = 不知道,不回落到全集 */
  45. if (!pool.length) return undefined;
  46. const byThread = pool.find((s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId));
  47. const hit = byThread ?? (roomId ? pool.find((s) => s.sessionKey.includes(roomId)) : undefined);
  48. return hit ? hit.status === 'running' : undefined;
  49. }
  50. async function readAgent() {
  51. const r = await fetch(F, {
  52. method: 'POST',
  53. headers: { 'Content-Type': 'application/json' },
  54. body: JSON.stringify({ id: 'xlosi3m46q', token: tok, params: { agentId: AGENT } }),
  55. });
  56. const j = await r.json();
  57. const a = j.agent ?? j.result ?? j;
  58. const ex = typeof a.extra === 'string' ? JSON.parse(a.extra) : a.extra;
  59. return { status: a.status, sessions: ex?.currentSessions ?? [] };
  60. }
  61. let pass = 0;
  62. let fail = 0;
  63. function check(name, actual, expected) {
  64. const ok = JSON.stringify(actual) === JSON.stringify(expected);
  65. console.log(` ${ok ? '✅' : '❌'} ${name}`);
  66. if (!ok) console.log(` 期望 ${JSON.stringify(expected)} / 实际 ${JSON.stringify(actual)}`);
  67. ok ? pass++ : fail++;
  68. }
  69. console.log('=== 读取容器真实上报 ===\n');
  70. const d = await readAgent();
  71. console.log('全局 status =', d.status);
  72. console.log('currentSessions =', d.sessions.length, '条');
  73. for (const s of d.sessions) {
  74. console.log(` ${String(s.status).padEnd(8)} ${s.sessionKey} userId=${s.userId ?? '(无)'}`);
  75. }
  76. const running = d.sessions.find((s) => s.status === 'running');
  77. const idle = d.sessions.find((s) => s.status === 'idle');
  78. const myMx = d.sessions[0]?.userId ?? '@xhvrjtmwqh:fmode.cn';
  79. const MY_SID = '$approot_selftest_abc:fmode.cn';
  80. console.log('\n=== 场景验证 ===\n');
  81. console.log('① 本会话在跑 → 正在回复');
  82. {
  83. const room = running?.sessionKey;
  84. if (room) {
  85. const sr = sessionRunning([running], MY_SID, room, myMx);
  86. const r = resolveSessionPhase({ turnEnded: false, sessionRunning: sr, typing: false, turnLive: false });
  87. check('本会话 running → 忙碌', r.busy, true);
  88. check('归类为 session 信号', r.reason, 'session');
  89. } else {
  90. console.log(' ⏭ 当前没有 running 会话,跳过(可发一条长任务后重跑)');
  91. }
  92. }
  93. console.log('\n② 本会话空闲、别的会话在跑 → 在线(不串台)');
  94. {
  95. if (idle && running) {
  96. const sr = sessionRunning([idle, running], MY_SID, idle.sessionKey, myMx);
  97. const r = resolveSessionPhase({ turnEnded: false, sessionRunning: sr, typing: true, turnLive: true });
  98. check('别的在跑不算我在跑', r.busy, false);
  99. check('typing 残留也不能翻盘', r.reason, 'session');
  100. } else {
  101. console.log(' ⏭ 需要同时存在 running 和 idle 两条,跳过');
  102. }
  103. }
  104. console.log('\n③ 收到 ✅ reaction → 立刻收尾');
  105. {
  106. const r = resolveSessionPhase({ turnEnded: true, sessionRunning: true, typing: true, turnLive: true });
  107. check('reaction 一票否决', r.busy, false);
  108. check('归类为 reaction', r.reason, 'reaction');
  109. }
  110. console.log('\n④ 容器没上报会话级状态 → 不猜');
  111. {
  112. const r = resolveSessionPhase({ turnEnded: false, sessionRunning: undefined, typing: false, turnLive: false });
  113. check('无信号 → 空闲', r.busy, false);
  114. check('不拿全局 status 冒充', r.reason, 'idle');
  115. }
  116. console.log('\n⑤ 多人共用:别人的会话不影响我');
  117. {
  118. const other = d.sessions.find((s) => s.userId && s.userId !== myMx);
  119. if (other) {
  120. const sr = sessionRunning([other], MY_SID, other.sessionKey, myMx);
  121. check('别人的 running 不算我的', sr, undefined);
  122. } else {
  123. console.log(' ⏭ 当前库里只有本人会话,用构造数据验证');
  124. const fake = [{ sessionKey: '!shared', userId: '@other:fmode.cn', status: 'running' }];
  125. check('别人的 running 不算我的', sessionRunning(fake, MY_SID, '!shared', myMx), undefined);
  126. }
  127. }
  128. console.log(`\n=== 结果:${pass} 通过 / ${fail} 失败 ===`);
  129. process.exit(fail ? 1 : 0);