| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- import { resolveAgentState, isAgentUsable, agentLayers, sessionStateOf, sessionRunning } from './agent-state';
- import { Agent } from './api/models';
- /**
- * 状态派生单测(2026-11-07)
- *
- * 核心是**优先级**:异常 > 实时 > 容器自报。
- */
- function agent(over: Partial<Agent> = {}): 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();
- });
- });
|