|
|
@@ -0,0 +1,245 @@
|
|
|
+import {
|
|
|
+ LOOP_GUARD_PRESETS,
|
|
|
+ LoopMessage,
|
|
|
+ configOf,
|
|
|
+ detectLoop,
|
|
|
+ similarity,
|
|
|
+} from './loop-guard';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 自动对话回环检测。
|
|
|
+ *
|
|
|
+ * 阈值来自 2026-10-21 的实测数据(两个容器互相对话 28.6 分钟 / 96 条消息 /
|
|
|
+ * 最长连续 57 条无人类介入),不是拍脑袋定的。
|
|
|
+ */
|
|
|
+describe('loop-guard', () => {
|
|
|
+ const HUMAN = '@xhvrjtmwqh:fmode.cn';
|
|
|
+ const A = '@k0nrevcfik:fmode.cn';
|
|
|
+ const B = '@zgdcmgnqcd:fmode.cn';
|
|
|
+ const isHuman = (s: string) => s === HUMAN;
|
|
|
+
|
|
|
+ function msg(sender: string, body: string, ts: number): LoopMessage {
|
|
|
+ return { sender, body, ts };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 造一串连续 agent 消息(模拟互相「收到」「还在处理」) */
|
|
|
+ function agentBurst(n: number, startTs = 1_000_000, humanFirst = false): LoopMessage[] {
|
|
|
+ const out: LoopMessage[] = [];
|
|
|
+ if (humanFirst) out.push(msg(HUMAN, '你们互相测一下', startTs));
|
|
|
+ const phrases = [
|
|
|
+ '收到',
|
|
|
+ '还在处理中,请稍等',
|
|
|
+ '对方回了,我继续盯着',
|
|
|
+ '再等一轮',
|
|
|
+ '结果出来了',
|
|
|
+ ];
|
|
|
+ for (let i = 0; i < n; i++) {
|
|
|
+ out.push(msg(i % 2 ? A : B, phrases[i % phrases.length] + i, startTs + 1000 * (i + 1)));
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ describe('预设与配置', () => {
|
|
|
+ it('off 模式返回 null(不检测)', () => {
|
|
|
+ expect(configOf('off')).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('三档阈值递增', () => {
|
|
|
+ expect(LOOP_GUARD_PRESETS.strict.maxStreak).toBeLessThan(
|
|
|
+ LOOP_GUARD_PRESETS.standard.maxStreak,
|
|
|
+ );
|
|
|
+ expect(LOOP_GUARD_PRESETS.standard.maxStreak).toBeLessThan(
|
|
|
+ LOOP_GUARD_PRESETS.loose.maxStreak,
|
|
|
+ );
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('① 连续数 + 内容重复(必须同时满足)', () => {
|
|
|
+ /**
|
|
|
+ * 🔴 规则修正(2026-10-21 实测)
|
|
|
+ *
|
|
|
+ * 起初「连续 5 条 agent」就触发,结果误伤了正常干活:
|
|
|
+ * 用户让容器改白名单,它连发 7 条(改配置/diff/重启/验证/汇报),
|
|
|
+ * 内容各不相同 —— 这是执行过程,不是回环。
|
|
|
+ *
|
|
|
+ * 现在的判据:连续数达标 **且** 内容在原地打转。
|
|
|
+ */
|
|
|
+
|
|
|
+ it('🔴 连续 57 条且内容重复(实测回环)→ 判定回环', () => {
|
|
|
+ const msgs = agentBurst(57);
|
|
|
+ const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, 1_000_000 + 60_000);
|
|
|
+ expect(v.loop).toBeTrue();
|
|
|
+ expect(v.streak).toBe(57);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('🔴 连续 7 条但内容各不相同(正常执行任务)→ **不触发**', () => {
|
|
|
+ const t0 = 1_000_000;
|
|
|
+ const work = [
|
|
|
+ [A, '我先备份配置文件'],
|
|
|
+ [A, '已备份到 .env.bak-p0-20261021'],
|
|
|
+ [A, '现在修改 MATRIX_ALLOWED_USERS 这一行'],
|
|
|
+ [A, 'diff 确认只改了 552 行'],
|
|
|
+ [A, '正在重启 gateway'],
|
|
|
+ [A, '重启完成,PID 已变'],
|
|
|
+ [A, 'post-restart 验证通过'],
|
|
|
+ ] as [string, string][];
|
|
|
+ const msgs = work.map(([sender, body], i) => msg(sender, body, t0 + i * 10_000));
|
|
|
+ const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 70_000);
|
|
|
+ expect(v.streak).toBe(7);
|
|
|
+ expect(v.loop)
|
|
|
+ .withContext('执行任务的连续消息不应被判为回环')
|
|
|
+ .toBeFalse();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('只有 3 条不同内容 → 标准模式不触发(连续与重复都未达)', () => {
|
|
|
+ const t0 = 1_100_000;
|
|
|
+ const msgs = [
|
|
|
+ msg(A, '表单校验写好了', t0),
|
|
|
+ msg(B, '接口加了参数校验', t0 + 5_000),
|
|
|
+ msg(A, '前端已经接上了', t0 + 10_000),
|
|
|
+ ];
|
|
|
+ expect(detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 15_000).loop).toBeFalse();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('🔴 人类说过话后连续数是重新计算的(复读场景)', () => {
|
|
|
+ // 人类先说一句,然后两个容器开始原地打转
|
|
|
+ const t0 = 1_050_000;
|
|
|
+ const msgs: LoopMessage[] = [msg(HUMAN, '你们对接一下接口', t0)];
|
|
|
+ for (let i = 0; i < 10; i++) {
|
|
|
+ msgs.push(msg(i % 2 ? A : B, '收到', t0 + (i + 1) * 5_000));
|
|
|
+ }
|
|
|
+ const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 60_000);
|
|
|
+ expect(v.streak).toBe(10);
|
|
|
+ expect(v.loop).toBeTrue();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('人类在中间插话 → 连续数归零', () => {
|
|
|
+ const msgs = [
|
|
|
+ ...agentBurst(4),
|
|
|
+ msg(HUMAN, '停一下', 1_010_000),
|
|
|
+ ...agentBurst(2, 1_020_000),
|
|
|
+ ];
|
|
|
+ const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard);
|
|
|
+ expect(v.streak).toBe(2);
|
|
|
+ expect(v.loop).toBeFalse();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('严格模式阈值更低:同样 3 条重复内容触发', () => {
|
|
|
+ const t0 = 1_200_000;
|
|
|
+ const msgs = [A, B, A].map((s, i) => msg(s, '收到', t0 + i * 5_000));
|
|
|
+ expect(detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.strict, t0 + 15_000).loop).toBeTrue();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('② 速率检测', () => {
|
|
|
+ it('一分钟内 15 条(阈值 10)→ 触发', () => {
|
|
|
+ const now = 2_000_000;
|
|
|
+ // 连续数故意做小,确保是靠速率触发
|
|
|
+ const msgs: LoopMessage[] = [];
|
|
|
+ for (let i = 0; i < 15; i++) msgs.push(msg(A, `消息${i}`, now - 50_000 + i * 1000));
|
|
|
+ const v = detectLoop(msgs, isHuman, { maxStreak: 100, maxPerMinute: 10, repeatWindow: 99 }, now);
|
|
|
+ expect(v.loop).toBeTrue();
|
|
|
+ expect(v.reason).toBe('rate');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('一分钟内 5 条 → 不触发(隔离连续数规则,只测速率)', () => {
|
|
|
+ const now = 2_000_000;
|
|
|
+ const msgs: LoopMessage[] = [];
|
|
|
+ for (let i = 0; i < 5; i++) msgs.push(msg(A, `消息${i}`, now - 20_000 + i * 1000));
|
|
|
+ // maxStreak 设很大:确保不是被连续数规则误触发
|
|
|
+ const v = detectLoop(msgs, isHuman, { maxStreak: 100, maxPerMinute: 10, repeatWindow: 99 }, now);
|
|
|
+ expect(v.loop).toBeFalse();
|
|
|
+ expect(v.perMinute).toBe(5);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('③ 内容重复检测', () => {
|
|
|
+ it('🔴 连续「收到」「收到。」「收到!」→ 判为复读', () => {
|
|
|
+ const msgs = [
|
|
|
+ msg(A, '收到', 1),
|
|
|
+ msg(B, '收到。', 2),
|
|
|
+ msg(A, '收到!', 3),
|
|
|
+ ];
|
|
|
+ const v = detectLoop(msgs, isHuman, { maxStreak: 99, maxPerMinute: 99, repeatWindow: 3 });
|
|
|
+ expect(v.loop).toBeTrue();
|
|
|
+ expect(v.reason).toBe('repeat');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('内容各不相同的三条 → 不触发', () => {
|
|
|
+ const msgs = [
|
|
|
+ msg(A, '我把登录页的样式调好了', 1),
|
|
|
+ msg(B, '后端接口我加了校验', 2),
|
|
|
+ msg(A, '那我把前端接上', 3),
|
|
|
+ ];
|
|
|
+ const v = detectLoop(msgs, isHuman, { maxStreak: 99, maxPerMinute: 99, repeatWindow: 3 });
|
|
|
+ expect(v.loop).toBeFalse();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('边界', () => {
|
|
|
+ it('空消息列表 → 不触发', () => {
|
|
|
+ const v = detectLoop([], isHuman, LOOP_GUARD_PRESETS.standard);
|
|
|
+ expect(v.loop).toBeFalse();
|
|
|
+ expect(v.streak).toBe(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('off 模式 → 永远不触发(用户明确选择不限)', () => {
|
|
|
+ const v = detectLoop(agentBurst(100), isHuman, null);
|
|
|
+ expect(v.loop).toBeFalse();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('全是人类消息 → 连续数为 0', () => {
|
|
|
+ const msgs = [msg(HUMAN, 'a', 1), msg(HUMAN, 'b', 2)];
|
|
|
+ expect(detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard).streak).toBe(0);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('similarity 工具', () => {
|
|
|
+ it('完全相同 → 1', () => {
|
|
|
+ expect(similarity('收到', '收到')).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('完全不相关 → 接近 0', () => {
|
|
|
+ expect(similarity('登录页样式', '后端接口校验')).toBeLessThan(0.2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('标点差异不影响相似度', () => {
|
|
|
+ expect(similarity('收到', '收到。')).toBeGreaterThan(0.9);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('🔴 真实协作场景(防止误伤正常对话)', () => {
|
|
|
+ it('人类下达任务、两容器交替干活、人类偶尔确认 → 不触发', () => {
|
|
|
+ const t0 = 5_000_000;
|
|
|
+ const seq: [string, string][] = [
|
|
|
+ [HUMAN, '登录页做好了吗,前端和后端对接一下'],
|
|
|
+ [A, '我这边表单校验写好了'],
|
|
|
+ [B, '接口我加了 /api/login,参数是 phone 和 code'],
|
|
|
+ [A, '收到,我把请求接上'],
|
|
|
+ [B, '联调通过,你可以试一下'],
|
|
|
+ [HUMAN, '好,我看看'],
|
|
|
+ [A, '顺便把错误提示也补了'],
|
|
|
+ ];
|
|
|
+ const msgs = seq.map(([sender, body], i) => msg(sender, body, t0 + i * 30_000));
|
|
|
+ // 逐条喂入,任何一步都不应触发
|
|
|
+ for (let i = 1; i <= msgs.length; i++) {
|
|
|
+ const v = detectLoop(msgs.slice(0, i), isHuman, LOOP_GUARD_PRESETS.standard, msgs[i - 1].ts);
|
|
|
+ expect(v.loop)
|
|
|
+ .withContext(`第 ${i} 条被误判:${v.detail}`)
|
|
|
+ .toBeFalse();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('连续 5 条但每条内容不同(在干活)→ 不触发', () => {
|
|
|
+ const t0 = 6_000_000;
|
|
|
+ const five = [A, B, A, B, A].map((s, i) => msg(s, `第${i}条不同的工作内容`, t0 + i * 10_000));
|
|
|
+ expect(detectLoop(five, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 50_000).loop).toBeFalse();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('连续 5 条且内容重复(在打转)→ 触发', () => {
|
|
|
+ const t0 = 6_100_000;
|
|
|
+ const five = [A, B, A, B, A].map((s, i) => msg(s, '收到', t0 + i * 10_000));
|
|
|
+ expect(detectLoop(five, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 50_000).loop).toBeTrue();
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|