|
|
@@ -0,0 +1,143 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+/**
|
|
|
+ * 统一状态裁决 · 自动化验证(2026-11-11)
|
|
|
+ *
|
|
|
+ * 目的:在**真实容器数据**上验证 App 的状态判定,而不是只看单测。
|
|
|
+ *
|
|
|
+ * 覆盖 5 个核心场景:
|
|
|
+ * ① 本会话在跑 → 显示「正在回复」
|
|
|
+ * ② 本会话空闲、别的会话在跑 → 显示「在线」(不串台)
|
|
|
+ * ③ 收到 ✅ reaction → 立刻收尾
|
|
|
+ * ④ 容器没上报会话级状态 → 只显示在线/离线,不猜
|
|
|
+ * ⑤ 多人共用:别人在跑不影响我
|
|
|
+ *
|
|
|
+ * 用法:node scripts/verify-unified-state.mjs
|
|
|
+ */
|
|
|
+import { readFileSync } from 'node:fs';
|
|
|
+import { homedir } from 'node:os';
|
|
|
+import { join } from 'node:path';
|
|
|
+
|
|
|
+const F = 'https://server.fmode.cn/api/functions';
|
|
|
+const AGENT = 'agent-node-lj-test';
|
|
|
+const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 复刻 App 侧 `resolveSessionPhase()` 的优先级。
|
|
|
+ * 🔴 这段必须与 `src/app/core/agent-liveness.ts` 保持一致,
|
|
|
+ * 脚本只是把同一套规则搬到 Node 里跑真实数据。
|
|
|
+ */
|
|
|
+function resolveSessionPhase({ turnEnded, sessionRunning, typing, turnLive }) {
|
|
|
+ if (turnEnded) return { busy: false, reason: 'reaction' };
|
|
|
+ if (sessionRunning === true) return { busy: true, phase: 'replying', reason: 'session' };
|
|
|
+ if (sessionRunning === false) return { busy: false, reason: 'session' };
|
|
|
+ if (typing) return { busy: true, phase: 'thinking', reason: 'typing' };
|
|
|
+ if (turnLive) return { busy: true, phase: 'replying', reason: 'turn' };
|
|
|
+ return { busy: false, reason: 'idle' };
|
|
|
+}
|
|
|
+
|
|
|
+/** 复刻 `sessionStateOf()`:userId 收窄 → thread 根 → roomId */
|
|
|
+function sessionRunning(sessions, appSessionId, roomId, myUserId) {
|
|
|
+ if (!sessions?.length || !appSessionId) return undefined;
|
|
|
+ const core = appSessionId.split(':')[0];
|
|
|
+ const reportedUsers = sessions.some((s) => !!s.userId);
|
|
|
+ const pool =
|
|
|
+ myUserId && reportedUsers
|
|
|
+ ? sessions.filter((s) => !s.userId || s.userId === myUserId)
|
|
|
+ : sessions;
|
|
|
+ /* 过滤后为空 = 容器没报我的状态 = 不知道,不回落到全集 */
|
|
|
+ if (!pool.length) return undefined;
|
|
|
+ const byThread = pool.find((s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId));
|
|
|
+ const hit = byThread ?? (roomId ? pool.find((s) => s.sessionKey.includes(roomId)) : undefined);
|
|
|
+ return hit ? hit.status === 'running' : undefined;
|
|
|
+}
|
|
|
+
|
|
|
+async function readAgent() {
|
|
|
+ const r = await fetch(F, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ id: 'xlosi3m46q', token: tok, params: { agentId: AGENT } }),
|
|
|
+ });
|
|
|
+ const j = await r.json();
|
|
|
+ const a = j.agent ?? j.result ?? j;
|
|
|
+ const ex = typeof a.extra === 'string' ? JSON.parse(a.extra) : a.extra;
|
|
|
+ return { status: a.status, sessions: ex?.currentSessions ?? [] };
|
|
|
+}
|
|
|
+
|
|
|
+let pass = 0;
|
|
|
+let fail = 0;
|
|
|
+function check(name, actual, expected) {
|
|
|
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
|
+ console.log(` ${ok ? '✅' : '❌'} ${name}`);
|
|
|
+ if (!ok) console.log(` 期望 ${JSON.stringify(expected)} / 实际 ${JSON.stringify(actual)}`);
|
|
|
+ ok ? pass++ : fail++;
|
|
|
+}
|
|
|
+
|
|
|
+console.log('=== 读取容器真实上报 ===\n');
|
|
|
+const d = await readAgent();
|
|
|
+console.log('全局 status =', d.status);
|
|
|
+console.log('currentSessions =', d.sessions.length, '条');
|
|
|
+for (const s of d.sessions) {
|
|
|
+ console.log(` ${String(s.status).padEnd(8)} ${s.sessionKey} userId=${s.userId ?? '(无)'}`);
|
|
|
+}
|
|
|
+
|
|
|
+const running = d.sessions.find((s) => s.status === 'running');
|
|
|
+const idle = d.sessions.find((s) => s.status === 'idle');
|
|
|
+const myMx = d.sessions[0]?.userId ?? '@xhvrjtmwqh:fmode.cn';
|
|
|
+const MY_SID = '$approot_selftest_abc:fmode.cn';
|
|
|
+
|
|
|
+console.log('\n=== 场景验证 ===\n');
|
|
|
+
|
|
|
+console.log('① 本会话在跑 → 正在回复');
|
|
|
+{
|
|
|
+ const room = running?.sessionKey;
|
|
|
+ if (room) {
|
|
|
+ const sr = sessionRunning([running], MY_SID, room, myMx);
|
|
|
+ const r = resolveSessionPhase({ turnEnded: false, sessionRunning: sr, typing: false, turnLive: false });
|
|
|
+ check('本会话 running → 忙碌', r.busy, true);
|
|
|
+ check('归类为 session 信号', r.reason, 'session');
|
|
|
+ } else {
|
|
|
+ console.log(' ⏭ 当前没有 running 会话,跳过(可发一条长任务后重跑)');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+console.log('\n② 本会话空闲、别的会话在跑 → 在线(不串台)');
|
|
|
+{
|
|
|
+ if (idle && running) {
|
|
|
+ const sr = sessionRunning([idle, running], MY_SID, idle.sessionKey, myMx);
|
|
|
+ const r = resolveSessionPhase({ turnEnded: false, sessionRunning: sr, typing: true, turnLive: true });
|
|
|
+ check('别的在跑不算我在跑', r.busy, false);
|
|
|
+ check('typing 残留也不能翻盘', r.reason, 'session');
|
|
|
+ } else {
|
|
|
+ console.log(' ⏭ 需要同时存在 running 和 idle 两条,跳过');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+console.log('\n③ 收到 ✅ reaction → 立刻收尾');
|
|
|
+{
|
|
|
+ const r = resolveSessionPhase({ turnEnded: true, sessionRunning: true, typing: true, turnLive: true });
|
|
|
+ check('reaction 一票否决', r.busy, false);
|
|
|
+ check('归类为 reaction', r.reason, 'reaction');
|
|
|
+}
|
|
|
+
|
|
|
+console.log('\n④ 容器没上报会话级状态 → 不猜');
|
|
|
+{
|
|
|
+ const r = resolveSessionPhase({ turnEnded: false, sessionRunning: undefined, typing: false, turnLive: false });
|
|
|
+ check('无信号 → 空闲', r.busy, false);
|
|
|
+ check('不拿全局 status 冒充', r.reason, 'idle');
|
|
|
+}
|
|
|
+
|
|
|
+console.log('\n⑤ 多人共用:别人的会话不影响我');
|
|
|
+{
|
|
|
+ const other = d.sessions.find((s) => s.userId && s.userId !== myMx);
|
|
|
+ if (other) {
|
|
|
+ const sr = sessionRunning([other], MY_SID, other.sessionKey, myMx);
|
|
|
+ check('别人的 running 不算我的', sr, undefined);
|
|
|
+ } else {
|
|
|
+ console.log(' ⏭ 当前库里只有本人会话,用构造数据验证');
|
|
|
+ const fake = [{ sessionKey: '!shared', userId: '@other:fmode.cn', status: 'running' }];
|
|
|
+ check('别人的 running 不算我的', sessionRunning(fake, MY_SID, '!shared', myMx), undefined);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+console.log(`\n=== 结果:${pass} 通过 / ${fail} 失败 ===`);
|
|
|
+process.exit(fail ? 1 : 0);
|