import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Agent, Message } from './api/models'; import { ChatService } from './chat.service'; import { LiveService } from './live.service'; import { LoopGuardService } from './loop-guard.service'; /** * 回环守卫(运行时)。 * * 纯判定逻辑在 loop-guard.spec.ts 已覆盖;这里测**运行时接线**: * ① 消息变化后是否重新判定 * ② 人类发言是否解除暂停 * ③ 换会话是否清空状态 * ④ off 模式是否真的不检测 */ describe('LoopGuardService', () => { let svc!: LoopGuardService; const MY = '@me:fmode.cn'; const AGENT = '@a:fmode.cn'; /** * 当前会话标识。 * * 🔴 必须是**真信号**:`paused` / `pauseDetail` 是 computed, * 只会在依赖的信号变化时重算。用 jasmine spy 返回可变值时 * computed 不会失效,断言会读到旧缓存(2026-11-09 踩坑)。 */ const sessionSig = signal('s1'); const chatStub = { messages: jasmine.createSpy('messages').and.returnValue([] as Message[]), agentId: jasmine.createSpy('agentId').and.returnValue('a1'), sessionId: () => sessionSig(), provisionInfo: { userMxId: MY, agentMxId: AGENT } as { userMxId?: string; agentMxId?: string } | null, }; /** 切换当前会话(守卫只认 `chat.sessionId()` 这一个来源) */ function setSession(id: string): void { sessionSig.set(id); // 信号变化后让守卫重算一次(生产里由 effect 自动做) svc.reevaluate(); } function msg(sender: 'me' | 'agent', text: string, i: number): Message { return { id: `m${i}`, conversationId: 'c', sender, kind: 'text', text, ts: new Date(Date.now() - (100 - i) * 1000).toISOString(), state: 'sent', }; } beforeEach(() => { sessionSig.set('s1'); chatStub.messages.calls.reset(); chatStub.agentId.calls.reset(); chatStub.messages.and.returnValue([]); chatStub.agentId.and.returnValue('a1'); chatStub.provisionInfo = { userMxId: MY, agentMxId: AGENT }; TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ LoopGuardService, LiveService, { provide: ChatService, useValue: chatStub }, { provide: 'FmodeApi', useValue: {} }, ], }); // LiveService 需要 FmodeApi,这里用 override 简化 TestBed.overrideProvider(LiveService as never, { useValue: { agents: () => [], tick: () => 0 } } as never); svc = TestBed.inject(LoopGuardService); }); it('🔴 默认是 off 模式(2026-11-13 用户裁决:误报太多,彻底关闭)', () => { expect(svc.mode()).toBe('off'); expect(svc.paused()).toBeFalse(); }); it('默认 off 时,即使连续 20 条相同内容也不暂停', () => { chatStub.messages.and.returnValue( Array.from({ length: 20 }, (_, i) => msg('agent', '收到', i)), ); svc.reevaluate(); expect(svc.paused()).toBeFalse(); }); it('off 模式 → 永远不暂停', () => { chatStub.messages.and.returnValue( Array.from({ length: 20 }, (_, i) => msg('agent', '收到', i)), ); svc.setMode('off'); expect(svc.paused()).toBeFalse(); }); it('🔴 连续重复消息 → 判定暂停', () => { chatStub.messages.and.returnValue( Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)), ); svc.setMode('standard'); expect(svc.paused()).toBeTrue(); expect(svc.pauseDetail()).toContain('连续'); }); it('🔴 人类发言后 → resume 清除暂停', () => { chatStub.messages.and.returnValue( Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)), ); svc.setMode('standard'); expect(svc.paused()).toBeTrue(); svc.resume(); expect(svc.paused()).toBeFalse(); expect(svc.pauseDetail()).toBe(''); }); it('内容各不相同的连续消息 → 不暂停(在干活)', () => { chatStub.messages.and.returnValue([ msg('agent', '我先备份配置文件', 0), msg('agent', '已备份到 bak 目录', 1), msg('agent', '正在修改白名单', 2), msg('agent', 'diff 确认只改了一行', 3), msg('agent', '重启 gateway 完成', 4), msg('agent', '验证通过', 5), ]); svc.setMode('standard'); expect(svc.paused()).toBeFalse(); }); it('没有打开会话 → 不暂停', () => { chatStub.agentId.and.returnValue(null); chatStub.messages.and.returnValue( Array.from({ length: 10 }, (_, i) => msg('agent', '收到', i)), ); svc.setMode('standard'); expect(svc.paused()).toBeFalse(); }); /* * 🔴 会话隔离(2026-11-09)。 * * 原实现 `dismissedFor` 存的是 agentId,同一个容器的两个会话 * 之间切换不会清暂停态 → 上个会话的判定串到下一个会话。 */ describe('🔴 按会话隔离', () => { /* * 🔴 显式打开检测(2026-11-13)。 * 默认模式已改为 `off`(误报太多),而本组用例测的是 * 「暂停态是否按会话隔离」——与默认值无关,必须自己开起来。 */ beforeEach(() => svc.setMode('standard')); /** s1:回环中 */ const LOOPING = () => Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)); /** s2:正常对话,不该暂停 */ const NORMAL = () => [ msg('agent', '我先看一下配置文件', 0), msg('agent', '改好了,重启验证', 1), msg('me', '好的', 2), ]; it('同 agentId 切到另一个会话 → 前一会话的暂停态不显示', () => { chatStub.messages.and.returnValue(LOOPING()); setSession('s1'); expect(svc.paused()).toBeTrue(); chatStub.messages.and.returnValue(NORMAL()); setSession('s2'); expect(svc.paused()).withContext('s2 不应继承 s1 的暂停态').toBeFalse(); }); it('两个会话各自独立:切回 s1 仍是暂停', () => { chatStub.messages.and.returnValue(LOOPING()); setSession('s1'); chatStub.messages.and.returnValue(NORMAL()); setSession('s2'); expect(svc.paused()).toBeFalse(); chatStub.messages.and.returnValue(LOOPING()); setSession('s1'); expect(svc.paused()).withContext('切回来应恢复暂停').toBeTrue(); }); it('resume() 只解除当前会话,不影响别的会话', () => { chatStub.messages.and.returnValue(LOOPING()); setSession('s1'); expect(svc.paused()).toBeTrue(); setSession('s2'); svc.resume(); expect(svc.paused()).withContext('s2 本来就没暂停').toBeFalse(); setSession('s1'); expect(svc.paused()).withContext('s1 不该被 s2 的 resume 解除').toBeTrue(); }); it('🔴 provisionInfo 为空时仍能正确判定(不依赖 mxId)', () => { chatStub.provisionInfo = null; chatStub.messages.and.returnValue( Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)), ); setSession('s1'); svc.setMode('standard'); expect(svc.paused()).toBeTrue(); }); it('🔴 我方在多个会话各发一句「你好」→ 不暂停', () => { chatStub.messages.and.returnValue([ msg('me', '你好', 0), msg('me', '你好', 1), msg('me', '你好', 2), ]); setSession('s1'); svc.setMode('standard'); expect(svc.paused()).toBeFalse(); }); }); });