loop-guard.service.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import { signal } from '@angular/core';
  2. import { TestBed } from '@angular/core/testing';
  3. import { Agent, Message } from './api/models';
  4. import { ChatService } from './chat.service';
  5. import { LiveService } from './live.service';
  6. import { LoopGuardService } from './loop-guard.service';
  7. /**
  8. * 回环守卫(运行时)。
  9. *
  10. * 纯判定逻辑在 loop-guard.spec.ts 已覆盖;这里测**运行时接线**:
  11. * ① 消息变化后是否重新判定
  12. * ② 人类发言是否解除暂停
  13. * ③ 换会话是否清空状态
  14. * ④ off 模式是否真的不检测
  15. */
  16. describe('LoopGuardService', () => {
  17. let svc!: LoopGuardService;
  18. const MY = '@me:fmode.cn';
  19. const AGENT = '@a:fmode.cn';
  20. /**
  21. * 当前会话标识。
  22. *
  23. * 🔴 必须是**真信号**:`paused` / `pauseDetail` 是 computed,
  24. * 只会在依赖的信号变化时重算。用 jasmine spy 返回可变值时
  25. * computed 不会失效,断言会读到旧缓存(2026-11-09 踩坑)。
  26. */
  27. const sessionSig = signal<string | null>('s1');
  28. const chatStub = {
  29. messages: jasmine.createSpy('messages').and.returnValue([] as Message[]),
  30. agentId: jasmine.createSpy('agentId').and.returnValue('a1'),
  31. sessionId: () => sessionSig(),
  32. provisionInfo: { userMxId: MY, agentMxId: AGENT } as { userMxId?: string; agentMxId?: string } | null,
  33. };
  34. /** 切换当前会话(守卫只认 `chat.sessionId()` 这一个来源) */
  35. function setSession(id: string): void {
  36. sessionSig.set(id);
  37. // 信号变化后让守卫重算一次(生产里由 effect 自动做)
  38. svc.reevaluate();
  39. }
  40. function msg(sender: 'me' | 'agent', text: string, i: number): Message {
  41. return {
  42. id: `m${i}`,
  43. conversationId: 'c',
  44. sender,
  45. kind: 'text',
  46. text,
  47. ts: new Date(Date.now() - (100 - i) * 1000).toISOString(),
  48. state: 'sent',
  49. };
  50. }
  51. beforeEach(() => {
  52. sessionSig.set('s1');
  53. chatStub.messages.calls.reset(); chatStub.agentId.calls.reset();
  54. chatStub.messages.and.returnValue([]);
  55. chatStub.agentId.and.returnValue('a1');
  56. chatStub.provisionInfo = { userMxId: MY, agentMxId: AGENT };
  57. TestBed.resetTestingModule();
  58. TestBed.configureTestingModule({
  59. providers: [
  60. LoopGuardService,
  61. LiveService,
  62. { provide: ChatService, useValue: chatStub },
  63. { provide: 'FmodeApi', useValue: {} },
  64. ],
  65. });
  66. // LiveService 需要 FmodeApi,这里用 override 简化
  67. TestBed.overrideProvider(LiveService as never, { useValue: { agents: () => [], tick: () => 0 } } as never);
  68. svc = TestBed.inject(LoopGuardService);
  69. });
  70. it('🔴 默认是 off 模式(2026-11-13 用户裁决:误报太多,彻底关闭)', () => {
  71. expect(svc.mode()).toBe('off');
  72. expect(svc.paused()).toBeFalse();
  73. });
  74. it('默认 off 时,即使连续 20 条相同内容也不暂停', () => {
  75. chatStub.messages.and.returnValue(
  76. Array.from({ length: 20 }, (_, i) => msg('agent', '收到', i)),
  77. );
  78. svc.reevaluate();
  79. expect(svc.paused()).toBeFalse();
  80. });
  81. it('off 模式 → 永远不暂停', () => {
  82. chatStub.messages.and.returnValue(
  83. Array.from({ length: 20 }, (_, i) => msg('agent', '收到', i)),
  84. );
  85. svc.setMode('off');
  86. expect(svc.paused()).toBeFalse();
  87. });
  88. it('🔴 连续重复消息 → 判定暂停', () => {
  89. chatStub.messages.and.returnValue(
  90. Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)),
  91. );
  92. svc.setMode('standard');
  93. expect(svc.paused()).toBeTrue();
  94. expect(svc.pauseDetail()).toContain('连续');
  95. });
  96. it('🔴 人类发言后 → resume 清除暂停', () => {
  97. chatStub.messages.and.returnValue(
  98. Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)),
  99. );
  100. svc.setMode('standard');
  101. expect(svc.paused()).toBeTrue();
  102. svc.resume();
  103. expect(svc.paused()).toBeFalse();
  104. expect(svc.pauseDetail()).toBe('');
  105. });
  106. it('内容各不相同的连续消息 → 不暂停(在干活)', () => {
  107. chatStub.messages.and.returnValue([
  108. msg('agent', '我先备份配置文件', 0),
  109. msg('agent', '已备份到 bak 目录', 1),
  110. msg('agent', '正在修改白名单', 2),
  111. msg('agent', 'diff 确认只改了一行', 3),
  112. msg('agent', '重启 gateway 完成', 4),
  113. msg('agent', '验证通过', 5),
  114. ]);
  115. svc.setMode('standard');
  116. expect(svc.paused()).toBeFalse();
  117. });
  118. it('没有打开会话 → 不暂停', () => {
  119. chatStub.agentId.and.returnValue(null);
  120. chatStub.messages.and.returnValue(
  121. Array.from({ length: 10 }, (_, i) => msg('agent', '收到', i)),
  122. );
  123. svc.setMode('standard');
  124. expect(svc.paused()).toBeFalse();
  125. });
  126. /*
  127. * 🔴 会话隔离(2026-11-09)。
  128. *
  129. * 原实现 `dismissedFor` 存的是 agentId,同一个容器的两个会话
  130. * 之间切换不会清暂停态 → 上个会话的判定串到下一个会话。
  131. */
  132. describe('🔴 按会话隔离', () => {
  133. /*
  134. * 🔴 显式打开检测(2026-11-13)。
  135. * 默认模式已改为 `off`(误报太多),而本组用例测的是
  136. * 「暂停态是否按会话隔离」——与默认值无关,必须自己开起来。
  137. */
  138. beforeEach(() => svc.setMode('standard'));
  139. /** s1:回环中 */
  140. const LOOPING = () => Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i));
  141. /** s2:正常对话,不该暂停 */
  142. const NORMAL = () => [
  143. msg('agent', '我先看一下配置文件', 0),
  144. msg('agent', '改好了,重启验证', 1),
  145. msg('me', '好的', 2),
  146. ];
  147. it('同 agentId 切到另一个会话 → 前一会话的暂停态不显示', () => {
  148. chatStub.messages.and.returnValue(LOOPING());
  149. setSession('s1');
  150. expect(svc.paused()).toBeTrue();
  151. chatStub.messages.and.returnValue(NORMAL());
  152. setSession('s2');
  153. expect(svc.paused()).withContext('s2 不应继承 s1 的暂停态').toBeFalse();
  154. });
  155. it('两个会话各自独立:切回 s1 仍是暂停', () => {
  156. chatStub.messages.and.returnValue(LOOPING());
  157. setSession('s1');
  158. chatStub.messages.and.returnValue(NORMAL());
  159. setSession('s2');
  160. expect(svc.paused()).toBeFalse();
  161. chatStub.messages.and.returnValue(LOOPING());
  162. setSession('s1');
  163. expect(svc.paused()).withContext('切回来应恢复暂停').toBeTrue();
  164. });
  165. it('resume() 只解除当前会话,不影响别的会话', () => {
  166. chatStub.messages.and.returnValue(LOOPING());
  167. setSession('s1');
  168. expect(svc.paused()).toBeTrue();
  169. setSession('s2'); svc.resume();
  170. expect(svc.paused()).withContext('s2 本来就没暂停').toBeFalse();
  171. setSession('s1');
  172. expect(svc.paused()).withContext('s1 不该被 s2 的 resume 解除').toBeTrue();
  173. });
  174. it('🔴 provisionInfo 为空时仍能正确判定(不依赖 mxId)', () => {
  175. chatStub.provisionInfo = null;
  176. chatStub.messages.and.returnValue(
  177. Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)),
  178. );
  179. setSession('s1');
  180. svc.setMode('standard');
  181. expect(svc.paused()).toBeTrue();
  182. });
  183. it('🔴 我方在多个会话各发一句「你好」→ 不暂停', () => {
  184. chatStub.messages.and.returnValue([
  185. msg('me', '你好', 0),
  186. msg('me', '你好', 1),
  187. msg('me', '你好', 2),
  188. ]);
  189. setSession('s1');
  190. svc.setMode('standard');
  191. expect(svc.paused()).toBeFalse();
  192. });
  193. });
  194. });