agent-state.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { resolveAgentState, isAgentUsable, agentLayers, sessionStateOf, sessionRunning } from './agent-state';
  2. import { Agent } from './api/models';
  3. /**
  4. * 状态派生单测(2026-11-07)
  5. *
  6. * 核心是**优先级**:异常 > 实时 > 容器自报。
  7. */
  8. function agent(over: Partial<Agent> = {}): Agent {
  9. return {
  10. id: 'a1',
  11. name: 'lj-test',
  12. status: 'idle',
  13. matrixReady: true,
  14. channels: { matrixConfig: { channelState: 'connected' } },
  15. ...over,
  16. } as Agent;
  17. }
  18. describe('resolveAgentState', () => {
  19. it('默认空闲(中性色,不用警告色)', () => {
  20. const v = resolveAgentState({ agent: agent() });
  21. expect(v.state).toBe('idle');
  22. expect(v.text).toBe('空闲');
  23. expect(v.tone).toBe('neutral');
  24. expect(v.busy).toBe(false);
  25. });
  26. it('上报器自说自话 → 降级为空闲(不显示假的「正在执行」)', () => {
  27. /*
  28. * 容器硬编码 `"status": "running"` + `"currentAction": "reporter heartbeat @ …"`,
  29. * 用户明明没和它对话。前端识别这种自指,降级显示空闲。
  30. */
  31. const v = resolveAgentState({
  32. agent: agent({
  33. status: 'running',
  34. currentAction: 'reporter heartbeat @ 2026-11-07T11:16:35',
  35. }),
  36. });
  37. expect(v.state).toBe('idle');
  38. expect(v.text).toBe('空闲');
  39. });
  40. it('有真实任务时即便 action 含 reporter 字样也不降级(防误伤)', () => {
  41. const v = resolveAgentState({
  42. agent: agent({
  43. status: 'running',
  44. currentTask: '整理周报',
  45. currentAction: 'reporter heartbeat',
  46. }),
  47. });
  48. expect(v.state).toBe('running');
  49. });
  50. it('正常的 running(有中文 action)不降级', () => {
  51. const v = resolveAgentState({
  52. agent: agent({ status: 'running', currentAction: '正在生成报表' }),
  53. });
  54. expect(v.state).toBe('running');
  55. });
  56. it('心跳过期 → 离线(即使 status 还是 running)', () => {
  57. /*
  58. * 真实数据(2026-11-07):两个容器 heartbeatAgeSec=3914610(45 天),
  59. * status 却仍是 running —— 不看心跳就会把早已消失的容器显示成绿色运行中。
  60. */
  61. const v = resolveAgentState({
  62. agent: agent({ status: 'running', heartbeatAgeSec: 3_914_610 }),
  63. });
  64. expect(v.state).toBe('offline');
  65. expect(v.tone).toBe('danger');
  66. });
  67. it('心跳新鲜 → 按 status 正常显示', () => {
  68. const v = resolveAgentState({ agent: agent({ status: 'running', heartbeatAgeSec: 56 }) });
  69. expect(v.state).toBe('running');
  70. });
  71. /*
  72. * 阈值 2026-11-09 用户裁决改为 80 分钟:
  73. * 容器上报周期是 1 小时 + 0~15 分钟随机抖动,卡着 1 小时判会频繁闪红。
  74. */
  75. it('心跳超过 80 分钟判离线,80 分钟以内仍算「能连」', () => {
  76. expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 81 * 60 }) }).state).toBe('offline');
  77. expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 75 * 60 }) }).state).toBe('idle');
  78. });
  79. it('没有心跳字段时回落到 heartbeatAt', () => {
  80. const old = new Date('2026-09-23T03:56:21.507Z').toISOString();
  81. const v = resolveAgentState({
  82. agent: agent({ status: 'running', heartbeatAt: old }),
  83. now: Date.parse('2026-11-07T12:00:00Z'),
  84. });
  85. expect(v.state).toBe('offline');
  86. });
  87. it('两者都没有时不判离线(旧数据兼容)', () => {
  88. expect(resolveAgentState({ agent: agent({ status: 'running' }) }).state).toBe('running');
  89. });
  90. it('容器上报 running → 正在执行', () => {
  91. const v = resolveAgentState({ agent: agent({ status: 'running' }) });
  92. expect(v.state).toBe('running');
  93. expect(v.text).toBe('正在执行');
  94. });
  95. it('实时译文中 → 正在回复,覆盖 idle', () => {
  96. const v = resolveAgentState({ agent: agent({ status: 'idle' }), phase: 'replying' });
  97. expect(v.state).toBe('replying');
  98. expect(v.text).toBe('正在回复…');
  99. expect(v.tone).toBe('success');
  100. expect(v.busy).toBe(true);
  101. });
  102. it('实时思考中 → 思考中', () => {
  103. const v = resolveAgentState({ agent: agent(), phase: 'thinking' });
  104. expect(v.state).toBe('thinking');
  105. expect(v.busy).toBe(true);
  106. });
  107. it('实时态也覆盖 running(回答优先于泛泛的执行中)', () => {
  108. const v = resolveAgentState({ agent: agent({ status: 'running' }), phase: 'replying' });
  109. expect(v.state).toBe('replying');
  110. });
  111. it('**异常优先于实时态**:已离线即便在 typing 也显示离线', () => {
  112. const v = resolveAgentState({
  113. agent: agent({ matrixReady: false }),
  114. phase: 'replying',
  115. });
  116. expect(v.state).toBe('offline');
  117. expect(v.tone).toBe('danger');
  118. expect(v.busy).toBe(false);
  119. });
  120. it('status=error → 异常', () => {
  121. const v = resolveAgentState({ agent: agent({ status: 'error' }), phase: 'thinking' });
  122. expect(v.state).toBe('error');
  123. expect(v.text).toBe('异常');
  124. });
  125. it('status=stopped → 离线', () => {
  126. expect(resolveAgentState({ agent: agent({ status: 'stopped' }) }).state).toBe('offline');
  127. });
  128. it('phase=done / failed / idle 不覆盖容器自报', () => {
  129. expect(resolveAgentState({ agent: agent({ status: 'running' }), phase: 'done' }).state).toBe(
  130. 'running',
  131. );
  132. expect(resolveAgentState({ agent: agent(), phase: 'failed' }).state).toBe('idle');
  133. });
  134. it('无 agent → 离线', () => {
  135. expect(resolveAgentState({ agent: null }).state).toBe('offline');
  136. });
  137. });
  138. describe('isAgentUsable', () => {
  139. it('异常与离线不可用', () => {
  140. expect(isAgentUsable(resolveAgentState({ agent: agent({ status: 'error' }) }))).toBe(false);
  141. expect(isAgentUsable(resolveAgentState({ agent: agent({ matrixReady: false }) }))).toBe(false);
  142. });
  143. it('空闲 / 运行中 / 正在回复均可用', () => {
  144. expect(isAgentUsable(resolveAgentState({ agent: agent() }))).toBe(true);
  145. expect(isAgentUsable(resolveAgentState({ agent: agent({ status: 'running' }) }))).toBe(true);
  146. expect(isAgentUsable(resolveAgentState({ agent: agent(), phase: 'replying' }))).toBe(true);
  147. });
  148. });
  149. /**
  150. * 三层状态(2026-11-09 用户裁决)。
  151. *
  152. * 用户明确要求:「拆成『连接』和『执行』两个状态」。
  153. * 实测问题:同一容器 PC 显示「不在线」、移动端显示「在线·空闲」,而它其实能正常回复。
  154. */
  155. describe('agentLayers(三层:存活 / 连接 / 执行)', () => {
  156. const NOW = 1_800_000_000_000;
  157. it('没有 agent → 全部不可用', () => {
  158. const v = agentLayers(null, NOW);
  159. expect(v.alive).toBe('dead');
  160. expect(v.link).toBe('unready');
  161. expect(v.busy).toBeFalse();
  162. });
  163. it('🔴 心跳 45 天但 status=running → 判已离线(不能信 status)', () => {
  164. const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 3_914_610 }), NOW);
  165. expect(v.alive).withContext('必须死').toBe('dead');
  166. expect(v.busy).withContext('死了不可能在干活').toBeFalse();
  167. expect(v.text).toBe('已离线');
  168. });
  169. /*
  170. * 阈值按用户裁决改为 1 小时粒度(2026-11-09):
  171. * 容器改成 1 小时 + 随机错峰上报,不再需要「2 分钟 / 10 分钟」这种精细判据。
  172. */
  173. it('心跳 100 分钟 + 无任务描述 → 可疑(状态未同步)', () => {
  174. const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 100 * 60 }), NOW);
  175. expect(v.alive).toBe('stale');
  176. expect(v.text).toBe('状态未同步');
  177. });
  178. it('🔴 心跳 100 分钟但有真实任务描述 → 仍算在干活(2026-11-10)', () => {
  179. /*
  180. * 上报是小时级的,干活时错过一轮很常见。
  181. * 内层(任务描述)明确知道它在忙,不该因为「没上报」就标未同步。
  182. */
  183. const v = agentLayers(
  184. agent({ status: 'running', heartbeatAgeSec: 100 * 60, currentTask: '写周报' }),
  185. NOW,
  186. );
  187. expect(v.alive).toBe('alive');
  188. expect(v.text).toBe('正在执行');
  189. });
  190. it('心跳 100 分钟且是上报器自指 → 不因「有描述」而豁免', () => {
  191. const v = agentLayers(
  192. agent({ status: 'running', heartbeatAgeSec: 100 * 60, currentTask: 'periodic status reporter' }),
  193. NOW,
  194. );
  195. expect(v.alive).withContext('自指不算干活').toBe('stale');
  196. expect(v.text).toBe('状态未同步');
  197. });
  198. it('🔴 心跳 75 分钟 → 仍算存活(上报周期 1h+0~15min,不能卡着 1 小时判死)', () => {
  199. const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 75 * 60 }), NOW);
  200. expect(v.alive).toBe('alive');
  201. expect(v.text).toBe('空闲');
  202. });
  203. it('心跳新鲜 + running → 正在执行', () => {
  204. const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 30 }), NOW);
  205. expect(v.alive).toBe('alive');
  206. expect(v.link).toBe('ready');
  207. expect(v.busy).toBeTrue();
  208. expect(v.text).toBe('正在执行');
  209. });
  210. it('心跳新鲜 + idle → 空闲', () => {
  211. const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 30 }), NOW);
  212. expect(v.busy).toBeFalse();
  213. expect(v.text).toBe('空闲');
  214. });
  215. it('🔴 存活但未接通 → 显示「未接通」,不再让各端各说各话', () => {
  216. const v = agentLayers(agent({ matrixReady: false, heartbeatAgeSec: 30 }), NOW);
  217. expect(v.alive).toBe('alive');
  218. expect(v.link).toBe('unready');
  219. expect(v.text).toBe('未接通');
  220. });
  221. it('上报器自说自话的 running 不算在干活', () => {
  222. const v = agentLayers(
  223. agent({
  224. status: 'running',
  225. heartbeatAgeSec: 30,
  226. currentAction: 'reporter heartbeat @ 12:00',
  227. currentTask: 'periodic status reporter (xlosi3m46q)',
  228. }),
  229. NOW,
  230. );
  231. expect(v.busy).toBeFalse();
  232. });
  233. it('心跳时间戳形式也能判(没有 heartbeatAgeSec 时回落 heartbeatAt)', () => {
  234. const v = agentLayers(
  235. agent({ status: 'running', heartbeatAt: new Date(NOW - 30_000).toISOString() }),
  236. NOW,
  237. );
  238. expect(v.alive).toBe('alive');
  239. expect(v.text).toBe('正在执行');
  240. });
  241. });
  242. /**
  243. * 会话级状态匹配(2026-11-09 方案 A)。
  244. *
  245. * 容器上报 `status` 只有全局一个值,同一容器多会话会互相串状态。
  246. * 容器改为在 `extra.currentSessions` 里按 session_key 上报。
  247. */
  248. describe('sessionStateOf / sessionRunning(会话级状态)', () => {
  249. const APP_SID = '$approot_mw7l1ezj_wnuym1jt:fmode.cn';
  250. const SESSIONS = [
  251. { sessionKey: 'agent:main:matrix:dm:!room1:other_thread_xxx', status: 'idle' as const },
  252. { sessionKey: `agent:main:matrix:dm:!room1:${APP_SID}`, status: 'running' as const },
  253. ];
  254. it('能从容器 sessionKey 里匹配到当前会话', () => {
  255. const s = sessionStateOf(SESSIONS, APP_SID);
  256. expect(s?.sessionKey).toContain('wnuym1jt');
  257. expect(s?.status).toBe('running');
  258. });
  259. it('只为**当前**会话报在跑,别的会话不影响', () => {
  260. expect(sessionRunning(SESSIONS, APP_SID)).toBeTrue();
  261. expect(sessionRunning(SESSIONS, '$approot_zzz_other:fmode.cn')).toBeUndefined();
  262. });
  263. it('容器没上报会话列表 → undefined(调用方回落全局 status)', () => {
  264. expect(sessionRunning(undefined, APP_SID)).toBeUndefined();
  265. });
  266. it('没打开会话 → undefined', () => {
  267. expect(sessionRunning(SESSIONS, null)).toBeUndefined();
  268. });
  269. /*
  270. * 🔴 陈旧的 idle 不能盖过「用户刚发出」(2026-11-12 用户实测)。
  271. *
  272. * 现象:左侧对话条目「点开就停,不点开就转」。
  273. * 根因之一:容器 `currentSessions` 里留着上一轮收尾时写的 `idle`
  274. * (实测 turnStartAt→updatedAt 仅差 1 秒),它的 updatedAt **早于**本轮提问。
  275. * 若直接采信这条 idle,刚发出的会话一下都不转。
  276. */
  277. describe('陈旧 idle 与本地「刚发出」的先后', () => {
  278. const NOW = 1_800_000_000_000;
  279. const staleIdle = [
  280. { sessionKey: APP_SID, status: 'idle' as const, updatedAt: NOW - 30_000 },
  281. ];
  282. it('idle 早于本地发出时刻 → 不采信(返回 undefined)', () => {
  283. expect(sessionRunning(staleIdle, APP_SID, null, null, NOW, NOW - 5_000)).toBeUndefined();
  284. });
  285. it('idle 晚于本地发出时刻 → 正常判空闲', () => {
  286. expect(sessionRunning(staleIdle, APP_SID, null, null, NOW, NOW - 60_000)).toBeFalse();
  287. });
  288. it('不传本地时刻 → 保持原行为(直接判空闲)', () => {
  289. expect(sessionRunning(staleIdle, APP_SID, null, null, NOW)).toBeFalse();
  290. });
  291. });
  292. /*
  293. * 🔴 roomId 退路(2026-11-11 实测加了这条)。
  294. *
  295. * 容器实测:`state.db` 里 Matrix 会话的 `thread_id` 大量为 NULL(本月 46%),
  296. * 于是 `build_session_key()` 拼不出 thread 段,只能退到 `chat_id`(roomId)。
  297. * 而 `chat_id` 实测 100% 稳定(212 条全是 `!` 开头,同房间跨 22 天不变)。
  298. *
  299. * 所以匹配必须支持「sessionKey 里只有 roomId」的情况,否则会话级状态永远不显示。
  300. */
  301. describe('roomId 退路', () => {
  302. const NO_THREAD = [
  303. { sessionKey: 'agent:main:matrix:dm:!roomA', status: 'running' as const },
  304. { sessionKey: 'agent:main:matrix:dm:!roomB', status: 'idle' as const },
  305. ];
  306. it('🔴 thread_id 为 NULL → 用 roomId 匹配上', () => {
  307. expect(sessionRunning(NO_THREAD, APP_SID, '!roomA')).toBeTrue();
  308. expect(sessionRunning(NO_THREAD, APP_SID, '!roomB')).toBeFalse();
  309. });
  310. it('不传 roomId → 保持原行为(只按 thread 匹配,匹配不到返回 undefined)', () => {
  311. expect(sessionRunning(NO_THREAD, APP_SID)).toBeUndefined();
  312. });
  313. it('thread 精确命中优先于 roomId(一房多会话不串)', () => {
  314. const mixed = [
  315. { sessionKey: 'agent:main:matrix:dm:!roomA', status: 'idle' as const },
  316. { sessionKey: `agent:main:matrix:dm:!roomA:${APP_SID}`, status: 'running' as const },
  317. ];
  318. // thread 命中 running 的那条,而不是 roomA 的 idle
  319. expect(sessionStateOf(mixed, APP_SID, '!roomA')?.status).toBe('running');
  320. });
  321. });
  322. /*
  323. * 🔴 房间号**不得**子串匹配(2026-11-12 用户实测事故)。
  324. *
  325. * 老实现用 `sessionKey.includes(roomId)`:51 条会话共享同一个 `!roomId` 时,
  326. * 容器为**某一条**上报 running,其余所有会话都会命中 → 「明明没回复也在转」。
  327. *
  328. * 这些用例锁住新判据,防止有人图省事再改回子串匹配。
  329. */
  330. describe('房间号匹配不得传染同房间的其他会话', () => {
  331. it('🔴 `!roomA:别的会话` 不得命中 !roomA 的会话', () => {
  332. const other = [
  333. { sessionKey: '!roomA:thread_of_another_session', status: 'running' as const },
  334. ];
  335. expect(sessionStateOf(other, APP_SID, '!roomA')).toBeUndefined();
  336. });
  337. it('🔴 前缀相似的房间号不得命中(!roomAxxx ≠ !roomA)', () => {
  338. const other = [{ sessionKey: '!roomAxxx', status: 'running' as const }];
  339. expect(sessionStateOf(other, APP_SID, '!roomA')).toBeUndefined();
  340. });
  341. it('纯房间号上报仍可命中(一房间一会话的典型形态)', () => {
  342. const pure = [{ sessionKey: '!roomA', status: 'running' as const }];
  343. expect(sessionStateOf(pure, APP_SID, '!roomA')?.status).toBe('running');
  344. });
  345. it('带结构前缀的房间号可命中(容器 agent:main:matrix:dm:!roomA)', () => {
  346. const prefixed = [{ sessionKey: 'agent:main:matrix:dm:!roomA', status: 'running' as const }];
  347. expect(sessionStateOf(prefixed, APP_SID, '!roomA')?.status).toBe('running');
  348. });
  349. it('🔥 同房间两条记录:只命中精确的那条,不串到另一条', () => {
  350. const two = [
  351. { sessionKey: '!roomA:other', status: 'running' as const },
  352. { sessionKey: '!roomA', status: 'idle' as const },
  353. ];
  354. // 本会话 → 命中 idle 那条,而**不是** other 的 running
  355. expect(sessionStateOf(two, APP_SID, '!roomA')?.status).toBe('idle');
  356. });
  357. });
  358. });
  359. /*
  360. * 🔴 多人共用容器的隔离(2026-11-11)。
  361. *
  362. * 容器可能被同公司多人共享,`currentSessions` 是容器级的一个列表。
  363. * A 在跑任务时,B 打开自己的会话不该看到「正在回复」。
  364. *
  365. * 依据:容器实测 `state.db.sessions` 表本来就有 `user_id` 列,
  366. * 上报时会补到每条记录的 `userId` 字段。
  367. */
  368. describe('多人共用容器:userId 隔离', () => {
  369. const APP_SID = '$approot_me:fmode.cn';
  370. const ROOM = '!sameRoom:fmode.cn';
  371. it('🔴 别人在跑、我在空闲 → 不得误判成我在跑', () => {
  372. const sessions = [
  373. { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const },
  374. { sessionKey: '!sameRoom:fmode.cn', userId: '@me:fmode.cn', status: 'idle' as const },
  375. ];
  376. // 不传 userId:roomId 命中第一条(running)→ 误判
  377. expect(sessionRunning(sessions, APP_SID, ROOM)).toBeTrue();
  378. // 传了 userId:精确到我的那条 → idle
  379. expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeFalse();
  380. });
  381. it('我在跑时能正确命中', () => {
  382. const sessions = [
  383. { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'idle' as const },
  384. { sessionKey: '!sameRoom:fmode.cn', userId: '@me:fmode.cn', status: 'running' as const },
  385. ];
  386. expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
  387. });
  388. it('容器没上报 userId(老容器)→ 保持原行为,不因过滤而丢失', () => {
  389. const sessions = [{ sessionKey: '!sameRoom:fmode.cn', status: 'running' as const }];
  390. expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
  391. });
  392. it('🔴 只有别人的会话 → 不回落全集,返回 undefined(不知道)', () => {
  393. /*
  394. * 初版写成「过滤后为空就回落到全集」,结果别人的 running 被判成我在跑,
  395. * 隔离等于没做。自动化验证 ⑤ 抓到了这个。
  396. *
  397. * 正确语义:currentSessions 里没有我这条 = 容器没报我的状态 = 不知道,
  398. * 由调用方回落成「在线」,而不是拿别人的状态冒充。
  399. */
  400. const sessions = [
  401. { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const },
  402. ];
  403. expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeUndefined();
  404. // 不过滤时仍能命中(老行为,供对比)
  405. expect(sessionRunning(sessions, APP_SID, ROOM)).toBeTrue();
  406. });
  407. });
  408. /*
  409. * 🔴 `running` 也需要保质期(2026-11-11 用户实测)。
  410. *
  411. * 现象:容器上报本会话 running,但 turn 早已结束,界面永久「正在处理」+ 左侧一直转圈。
  412. * 根因:`currentSessions` 只在 turn 首尾写;漏写结束那一次就永久卡住。
  413. */
  414. describe('running 状态过期保护', () => {
  415. const NOW = 1_800_000_000_000;
  416. const sid = '$approot_x:fmode.cn';
  417. it('🔴 updatedAt 超过 10 分钟没变 → 不再相信 running', () => {
  418. const sessions = [
  419. { sessionKey: '!r', status: 'running' as const, updatedAt: NOW - 11 * 60_000 },
  420. ];
  421. expect(sessionRunning(sessions, sid, '!r', null, NOW)).withContext('过期就该判不知道').toBeUndefined();
  422. });
  423. it('updatedAt 在 10 分钟内 → 仍然相信', () => {
  424. const sessions = [
  425. { sessionKey: '!r', status: 'running' as const, updatedAt: NOW - 2 * 60_000 },
  426. ];
  427. expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeTrue();
  428. });
  429. it('没有 updatedAt(老容器)→ 不做过期判定,保持原行为', () => {
  430. const sessions = [{ sessionKey: '!r', status: 'running' as const }];
  431. expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeTrue();
  432. });
  433. it('idle 不受过期逻辑影响', () => {
  434. const sessions = [{ sessionKey: '!r', status: 'idle' as const, updatedAt: NOW - 99 * 60_000 }];
  435. expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeFalse();
  436. });
  437. });