import { resolveAgentState, isAgentUsable, agentLayers, sessionStateOf, sessionRunning } from './agent-state'; import { Agent } from './api/models'; /** * 状态派生单测(2026-11-07) * * 核心是**优先级**:异常 > 实时 > 容器自报。 */ function agent(over: Partial = {}): Agent { return { id: 'a1', name: 'lj-test', status: 'idle', matrixReady: true, channels: { matrixConfig: { channelState: 'connected' } }, ...over, } as Agent; } describe('resolveAgentState', () => { it('默认空闲(中性色,不用警告色)', () => { const v = resolveAgentState({ agent: agent() }); expect(v.state).toBe('idle'); expect(v.text).toBe('空闲'); expect(v.tone).toBe('neutral'); expect(v.busy).toBe(false); }); it('上报器自说自话 → 降级为空闲(不显示假的「正在执行」)', () => { /* * 容器硬编码 `"status": "running"` + `"currentAction": "reporter heartbeat @ …"`, * 用户明明没和它对话。前端识别这种自指,降级显示空闲。 */ const v = resolveAgentState({ agent: agent({ status: 'running', currentAction: 'reporter heartbeat @ 2026-11-07T11:16:35', }), }); expect(v.state).toBe('idle'); expect(v.text).toBe('空闲'); }); it('有真实任务时即便 action 含 reporter 字样也不降级(防误伤)', () => { const v = resolveAgentState({ agent: agent({ status: 'running', currentTask: '整理周报', currentAction: 'reporter heartbeat', }), }); expect(v.state).toBe('running'); }); it('正常的 running(有中文 action)不降级', () => { const v = resolveAgentState({ agent: agent({ status: 'running', currentAction: '正在生成报表' }), }); expect(v.state).toBe('running'); }); it('心跳过期 → 离线(即使 status 还是 running)', () => { /* * 真实数据(2026-11-07):两个容器 heartbeatAgeSec=3914610(45 天), * status 却仍是 running —— 不看心跳就会把早已消失的容器显示成绿色运行中。 */ const v = resolveAgentState({ agent: agent({ status: 'running', heartbeatAgeSec: 3_914_610 }), }); expect(v.state).toBe('offline'); expect(v.tone).toBe('danger'); }); it('心跳新鲜 → 按 status 正常显示', () => { const v = resolveAgentState({ agent: agent({ status: 'running', heartbeatAgeSec: 56 }) }); expect(v.state).toBe('running'); }); /* * 阈值 2026-11-09 用户裁决改为 80 分钟: * 容器上报周期是 1 小时 + 0~15 分钟随机抖动,卡着 1 小时判会频繁闪红。 */ it('心跳超过 80 分钟判离线,80 分钟以内仍算「能连」', () => { expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 81 * 60 }) }).state).toBe('offline'); expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 75 * 60 }) }).state).toBe('idle'); }); it('没有心跳字段时回落到 heartbeatAt', () => { const old = new Date('2026-09-23T03:56:21.507Z').toISOString(); const v = resolveAgentState({ agent: agent({ status: 'running', heartbeatAt: old }), now: Date.parse('2026-11-07T12:00:00Z'), }); expect(v.state).toBe('offline'); }); it('两者都没有时不判离线(旧数据兼容)', () => { expect(resolveAgentState({ agent: agent({ status: 'running' }) }).state).toBe('running'); }); it('容器上报 running → 正在执行', () => { const v = resolveAgentState({ agent: agent({ status: 'running' }) }); expect(v.state).toBe('running'); expect(v.text).toBe('正在执行'); }); it('实时译文中 → 正在回复,覆盖 idle', () => { const v = resolveAgentState({ agent: agent({ status: 'idle' }), phase: 'replying' }); expect(v.state).toBe('replying'); expect(v.text).toBe('正在回复…'); expect(v.tone).toBe('success'); expect(v.busy).toBe(true); }); it('实时思考中 → 思考中', () => { const v = resolveAgentState({ agent: agent(), phase: 'thinking' }); expect(v.state).toBe('thinking'); expect(v.busy).toBe(true); }); it('实时态也覆盖 running(回答优先于泛泛的执行中)', () => { const v = resolveAgentState({ agent: agent({ status: 'running' }), phase: 'replying' }); expect(v.state).toBe('replying'); }); it('**异常优先于实时态**:已离线即便在 typing 也显示离线', () => { const v = resolveAgentState({ agent: agent({ matrixReady: false }), phase: 'replying', }); expect(v.state).toBe('offline'); expect(v.tone).toBe('danger'); expect(v.busy).toBe(false); }); it('status=error → 异常', () => { const v = resolveAgentState({ agent: agent({ status: 'error' }), phase: 'thinking' }); expect(v.state).toBe('error'); expect(v.text).toBe('异常'); }); it('status=stopped → 离线', () => { expect(resolveAgentState({ agent: agent({ status: 'stopped' }) }).state).toBe('offline'); }); it('phase=done / failed / idle 不覆盖容器自报', () => { expect(resolveAgentState({ agent: agent({ status: 'running' }), phase: 'done' }).state).toBe( 'running', ); expect(resolveAgentState({ agent: agent(), phase: 'failed' }).state).toBe('idle'); }); it('无 agent → 离线', () => { expect(resolveAgentState({ agent: null }).state).toBe('offline'); }); }); describe('isAgentUsable', () => { it('异常与离线不可用', () => { expect(isAgentUsable(resolveAgentState({ agent: agent({ status: 'error' }) }))).toBe(false); expect(isAgentUsable(resolveAgentState({ agent: agent({ matrixReady: false }) }))).toBe(false); }); it('空闲 / 运行中 / 正在回复均可用', () => { expect(isAgentUsable(resolveAgentState({ agent: agent() }))).toBe(true); expect(isAgentUsable(resolveAgentState({ agent: agent({ status: 'running' }) }))).toBe(true); expect(isAgentUsable(resolveAgentState({ agent: agent(), phase: 'replying' }))).toBe(true); }); }); /** * 三层状态(2026-11-09 用户裁决)。 * * 用户明确要求:「拆成『连接』和『执行』两个状态」。 * 实测问题:同一容器 PC 显示「不在线」、移动端显示「在线·空闲」,而它其实能正常回复。 */ describe('agentLayers(三层:存活 / 连接 / 执行)', () => { const NOW = 1_800_000_000_000; it('没有 agent → 全部不可用', () => { const v = agentLayers(null, NOW); expect(v.alive).toBe('dead'); expect(v.link).toBe('unready'); expect(v.busy).toBeFalse(); }); it('🔴 心跳 45 天但 status=running → 判已离线(不能信 status)', () => { const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 3_914_610 }), NOW); expect(v.alive).withContext('必须死').toBe('dead'); expect(v.busy).withContext('死了不可能在干活').toBeFalse(); expect(v.text).toBe('已离线'); }); /* * 阈值按用户裁决改为 1 小时粒度(2026-11-09): * 容器改成 1 小时 + 随机错峰上报,不再需要「2 分钟 / 10 分钟」这种精细判据。 */ it('心跳 100 分钟 + 无任务描述 → 可疑(状态未同步)', () => { const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 100 * 60 }), NOW); expect(v.alive).toBe('stale'); expect(v.text).toBe('状态未同步'); }); it('🔴 心跳 100 分钟但有真实任务描述 → 仍算在干活(2026-11-10)', () => { /* * 上报是小时级的,干活时错过一轮很常见。 * 内层(任务描述)明确知道它在忙,不该因为「没上报」就标未同步。 */ const v = agentLayers( agent({ status: 'running', heartbeatAgeSec: 100 * 60, currentTask: '写周报' }), NOW, ); expect(v.alive).toBe('alive'); expect(v.text).toBe('正在执行'); }); it('心跳 100 分钟且是上报器自指 → 不因「有描述」而豁免', () => { const v = agentLayers( agent({ status: 'running', heartbeatAgeSec: 100 * 60, currentTask: 'periodic status reporter' }), NOW, ); expect(v.alive).withContext('自指不算干活').toBe('stale'); expect(v.text).toBe('状态未同步'); }); it('🔴 心跳 75 分钟 → 仍算存活(上报周期 1h+0~15min,不能卡着 1 小时判死)', () => { const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 75 * 60 }), NOW); expect(v.alive).toBe('alive'); expect(v.text).toBe('空闲'); }); it('心跳新鲜 + running → 正在执行', () => { const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 30 }), NOW); expect(v.alive).toBe('alive'); expect(v.link).toBe('ready'); expect(v.busy).toBeTrue(); expect(v.text).toBe('正在执行'); }); it('心跳新鲜 + idle → 空闲', () => { const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 30 }), NOW); expect(v.busy).toBeFalse(); expect(v.text).toBe('空闲'); }); it('🔴 存活但未接通 → 显示「未接通」,不再让各端各说各话', () => { const v = agentLayers(agent({ matrixReady: false, heartbeatAgeSec: 30 }), NOW); expect(v.alive).toBe('alive'); expect(v.link).toBe('unready'); expect(v.text).toBe('未接通'); }); it('上报器自说自话的 running 不算在干活', () => { const v = agentLayers( agent({ status: 'running', heartbeatAgeSec: 30, currentAction: 'reporter heartbeat @ 12:00', currentTask: 'periodic status reporter (xlosi3m46q)', }), NOW, ); expect(v.busy).toBeFalse(); }); it('心跳时间戳形式也能判(没有 heartbeatAgeSec 时回落 heartbeatAt)', () => { const v = agentLayers( agent({ status: 'running', heartbeatAt: new Date(NOW - 30_000).toISOString() }), NOW, ); expect(v.alive).toBe('alive'); expect(v.text).toBe('正在执行'); }); }); /** * 会话级状态匹配(2026-11-09 方案 A)。 * * 容器上报 `status` 只有全局一个值,同一容器多会话会互相串状态。 * 容器改为在 `extra.currentSessions` 里按 session_key 上报。 */ describe('sessionStateOf / sessionRunning(会话级状态)', () => { const APP_SID = '$approot_mw7l1ezj_wnuym1jt:fmode.cn'; const SESSIONS = [ { sessionKey: 'agent:main:matrix:dm:!room1:other_thread_xxx', status: 'idle' as const }, { sessionKey: `agent:main:matrix:dm:!room1:${APP_SID}`, status: 'running' as const }, ]; it('能从容器 sessionKey 里匹配到当前会话', () => { const s = sessionStateOf(SESSIONS, APP_SID); expect(s?.sessionKey).toContain('wnuym1jt'); expect(s?.status).toBe('running'); }); it('只为**当前**会话报在跑,别的会话不影响', () => { expect(sessionRunning(SESSIONS, APP_SID)).toBeTrue(); expect(sessionRunning(SESSIONS, '$approot_zzz_other:fmode.cn')).toBeUndefined(); }); it('容器没上报会话列表 → undefined(调用方回落全局 status)', () => { expect(sessionRunning(undefined, APP_SID)).toBeUndefined(); }); it('没打开会话 → undefined', () => { expect(sessionRunning(SESSIONS, null)).toBeUndefined(); }); /* * 🔴 陈旧的 idle 不能盖过「用户刚发出」(2026-11-12 用户实测)。 * * 现象:左侧对话条目「点开就停,不点开就转」。 * 根因之一:容器 `currentSessions` 里留着上一轮收尾时写的 `idle` * (实测 turnStartAt→updatedAt 仅差 1 秒),它的 updatedAt **早于**本轮提问。 * 若直接采信这条 idle,刚发出的会话一下都不转。 */ describe('陈旧 idle 与本地「刚发出」的先后', () => { const NOW = 1_800_000_000_000; const staleIdle = [ { sessionKey: APP_SID, status: 'idle' as const, updatedAt: NOW - 30_000 }, ]; it('idle 早于本地发出时刻 → 不采信(返回 undefined)', () => { expect(sessionRunning(staleIdle, APP_SID, null, null, NOW, NOW - 5_000)).toBeUndefined(); }); it('idle 晚于本地发出时刻 → 正常判空闲', () => { expect(sessionRunning(staleIdle, APP_SID, null, null, NOW, NOW - 60_000)).toBeFalse(); }); it('不传本地时刻 → 保持原行为(直接判空闲)', () => { expect(sessionRunning(staleIdle, APP_SID, null, null, NOW)).toBeFalse(); }); }); /* * 🔴 roomId 退路(2026-11-11 实测加了这条)。 * * 容器实测:`state.db` 里 Matrix 会话的 `thread_id` 大量为 NULL(本月 46%), * 于是 `build_session_key()` 拼不出 thread 段,只能退到 `chat_id`(roomId)。 * 而 `chat_id` 实测 100% 稳定(212 条全是 `!` 开头,同房间跨 22 天不变)。 * * 所以匹配必须支持「sessionKey 里只有 roomId」的情况,否则会话级状态永远不显示。 */ describe('roomId 退路', () => { const NO_THREAD = [ { sessionKey: 'agent:main:matrix:dm:!roomA', status: 'running' as const }, { sessionKey: 'agent:main:matrix:dm:!roomB', status: 'idle' as const }, ]; it('🔴 thread_id 为 NULL → 用 roomId 匹配上', () => { expect(sessionRunning(NO_THREAD, APP_SID, '!roomA')).toBeTrue(); expect(sessionRunning(NO_THREAD, APP_SID, '!roomB')).toBeFalse(); }); it('不传 roomId → 保持原行为(只按 thread 匹配,匹配不到返回 undefined)', () => { expect(sessionRunning(NO_THREAD, APP_SID)).toBeUndefined(); }); it('thread 精确命中优先于 roomId(一房多会话不串)', () => { const mixed = [ { sessionKey: 'agent:main:matrix:dm:!roomA', status: 'idle' as const }, { sessionKey: `agent:main:matrix:dm:!roomA:${APP_SID}`, status: 'running' as const }, ]; // thread 命中 running 的那条,而不是 roomA 的 idle expect(sessionStateOf(mixed, APP_SID, '!roomA')?.status).toBe('running'); }); }); /* * 🔴 房间号**不得**子串匹配(2026-11-12 用户实测事故)。 * * 老实现用 `sessionKey.includes(roomId)`:51 条会话共享同一个 `!roomId` 时, * 容器为**某一条**上报 running,其余所有会话都会命中 → 「明明没回复也在转」。 * * 这些用例锁住新判据,防止有人图省事再改回子串匹配。 */ describe('房间号匹配不得传染同房间的其他会话', () => { it('🔴 `!roomA:别的会话` 不得命中 !roomA 的会话', () => { const other = [ { sessionKey: '!roomA:thread_of_another_session', status: 'running' as const }, ]; expect(sessionStateOf(other, APP_SID, '!roomA')).toBeUndefined(); }); it('🔴 前缀相似的房间号不得命中(!roomAxxx ≠ !roomA)', () => { const other = [{ sessionKey: '!roomAxxx', status: 'running' as const }]; expect(sessionStateOf(other, APP_SID, '!roomA')).toBeUndefined(); }); it('纯房间号上报仍可命中(一房间一会话的典型形态)', () => { const pure = [{ sessionKey: '!roomA', status: 'running' as const }]; expect(sessionStateOf(pure, APP_SID, '!roomA')?.status).toBe('running'); }); it('带结构前缀的房间号可命中(容器 agent:main:matrix:dm:!roomA)', () => { const prefixed = [{ sessionKey: 'agent:main:matrix:dm:!roomA', status: 'running' as const }]; expect(sessionStateOf(prefixed, APP_SID, '!roomA')?.status).toBe('running'); }); it('🔥 同房间两条记录:只命中精确的那条,不串到另一条', () => { const two = [ { sessionKey: '!roomA:other', status: 'running' as const }, { sessionKey: '!roomA', status: 'idle' as const }, ]; // 本会话 → 命中 idle 那条,而**不是** other 的 running expect(sessionStateOf(two, APP_SID, '!roomA')?.status).toBe('idle'); }); }); }); /* * 🔴 多人共用容器的隔离(2026-11-11)。 * * 容器可能被同公司多人共享,`currentSessions` 是容器级的一个列表。 * A 在跑任务时,B 打开自己的会话不该看到「正在回复」。 * * 依据:容器实测 `state.db.sessions` 表本来就有 `user_id` 列, * 上报时会补到每条记录的 `userId` 字段。 */ describe('多人共用容器:userId 隔离', () => { const APP_SID = '$approot_me:fmode.cn'; const ROOM = '!sameRoom:fmode.cn'; it('🔴 别人在跑、我在空闲 → 不得误判成我在跑', () => { const sessions = [ { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const }, { sessionKey: '!sameRoom:fmode.cn', userId: '@me:fmode.cn', status: 'idle' as const }, ]; // 不传 userId:roomId 命中第一条(running)→ 误判 expect(sessionRunning(sessions, APP_SID, ROOM)).toBeTrue(); // 传了 userId:精确到我的那条 → idle expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeFalse(); }); it('我在跑时能正确命中', () => { const sessions = [ { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'idle' as const }, { sessionKey: '!sameRoom:fmode.cn', userId: '@me:fmode.cn', status: 'running' as const }, ]; expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue(); }); it('容器没上报 userId(老容器)→ 保持原行为,不因过滤而丢失', () => { const sessions = [{ sessionKey: '!sameRoom:fmode.cn', status: 'running' as const }]; expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue(); }); it('🔴 只有别人的会话 → 不回落全集,返回 undefined(不知道)', () => { /* * 初版写成「过滤后为空就回落到全集」,结果别人的 running 被判成我在跑, * 隔离等于没做。自动化验证 ⑤ 抓到了这个。 * * 正确语义:currentSessions 里没有我这条 = 容器没报我的状态 = 不知道, * 由调用方回落成「在线」,而不是拿别人的状态冒充。 */ const sessions = [ { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const }, ]; expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeUndefined(); // 不过滤时仍能命中(老行为,供对比) expect(sessionRunning(sessions, APP_SID, ROOM)).toBeTrue(); }); }); /* * 🔴 `running` 也需要保质期(2026-11-11 用户实测)。 * * 现象:容器上报本会话 running,但 turn 早已结束,界面永久「正在处理」+ 左侧一直转圈。 * 根因:`currentSessions` 只在 turn 首尾写;漏写结束那一次就永久卡住。 */ describe('running 状态过期保护', () => { const NOW = 1_800_000_000_000; const sid = '$approot_x:fmode.cn'; it('🔴 updatedAt 超过 10 分钟没变 → 不再相信 running', () => { const sessions = [ { sessionKey: '!r', status: 'running' as const, updatedAt: NOW - 11 * 60_000 }, ]; expect(sessionRunning(sessions, sid, '!r', null, NOW)).withContext('过期就该判不知道').toBeUndefined(); }); it('updatedAt 在 10 分钟内 → 仍然相信', () => { const sessions = [ { sessionKey: '!r', status: 'running' as const, updatedAt: NOW - 2 * 60_000 }, ]; expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeTrue(); }); it('没有 updatedAt(老容器)→ 不做过期判定,保持原行为', () => { const sessions = [{ sessionKey: '!r', status: 'running' as const }]; expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeTrue(); }); it('idle 不受过期逻辑影响', () => { const sessions = [{ sessionKey: '!r', status: 'idle' as const, updatedAt: NOW - 99 * 60_000 }]; expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeFalse(); }); });