package repo import ( "context" "testing" "time" "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) /* ★ 第三道防线:数**无人决策的 Agent↔Agent 连续往返**(与 relay 无关)。 # 这个缺陷的形状(2026-09-26 生产实测) `pi ↔ dsh` 一天 175 封、正文 612KB,其中 **relay = 0**:全部是模型**主动** 调 send_mail。而既有两道防线都只覆盖 `relay != ""`: · 会话预算 —— relay 才扣 · maxRelayHops —— `if relay != "" {` 根本没进 · 插件自动转发守卫 —— 日志明说"本轮不自动转发",守卫正常,但不管模型主动发 ⇒ 三条全绕开。这三条判据钉住第四道的**判据形状**(连续性、人类参与即归零), 而不是钉"有没有这个函数"。 */ /* seedPingPong 造一封 Agent 之间的信。 ★ **必须显式给 created_at,且逐封递增。** `mails.created_at` 的默认值是 `CURRENT_TIMESTAMP`(**秒**精度),而 `CountTrailingAgentPingPong` 按 `created_at DESC, mail_id DESC` 从后往前扫。 同一秒内插的多封,排序实际由随机 UUID 决定 ⇒ 连续段数**不确定**。 我第一版就是这么写的,当场被这条判据自己抓到:同一段数据一次读出 2、一次读出 1 ("人类插话后应只数它之后的 2 封,实际 1")。这与本仓记录过的老坑同一个形状 ("SQLite 时间戳只有秒精度:同秒多封排序不确定")。 生产侧不受影响:`mails` 的 INSERT 显式传 `NOW()`(微秒)。这里补上同样的事。 */ var pingPongSeq int func seedPingPong(t *testing.T, sessionID uuid.UUID, from, to string) { t.Helper() pingPongSeq++ // 以固定基准 + 递增秒数,确保先后顺序与插入顺序一致 ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(pingPongSeq) * time.Second) if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO mails (session_id, from_name, to_name, subject, body, created_at) VALUES ($1, $2, $3, 'pingpong', 'b', $4)`, sessionID, from, to, ts); err != nil { t.Fatal(err) } } func TestAgentPingPongCountsConsecutive(t *testing.T) { setupTestDB(t) ctx := context.Background() sid, err := CreateSession(ctx, nil, "human", "回路", "") if err != nil { t.Fatal(err) } // 三个来回 = 6 封,全是 Agent↔Agent(from/to 都不是人类用户名) for i := 0; i < 3; i++ { seedPingPong(t, sid, "pi", "dsh") seedPingPong(t, sid, "dsh", "pi") } if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 6 { t.Fatalf("三个来回应为 6,实际 %d(err=%v)", n, err) } } // ★ 核心语义:**人类插一句话,计数归零**。 // // 这一条决定了这个判据会不会误伤正常用法: // · 人类说一句 → Agent 回十封 → 人类再一句 ⇒ 每次都在阈值以下 // · 没有人说话 → 一直涨 ⇒ 到阈值拒绝 // 与 maxRelayHops 的"连续"同构 —— 两条防线在这个语义上必须一致, // 否则同一个现象在两处得到不同的结论。 func TestAgentPingPongResetsOnHuman(t *testing.T) { setupTestDB(t) ctx := context.Background() sid, err := CreateSession(ctx, nil, "human", "回路", "") if err != nil { t.Fatal(err) } /* ★ 判据依赖 "jianf 是人类" —— 而测试库是空的(`setupTestDB` 只跑迁移, 不建用户)。不种的话 `IsHumanUser` 查不到,这封信会被当成 Agent 发的, 于是一条本应绿的判据变红。 我第一版就漏了这一步,当场被这条判据自己抓到("应只数 2 封,实际 3")。 ⇒ 判据里任何"某名字是人类/Agent"的前提都必须**显式种下**, 不能靠库里碰巧有。 */ if _, err := db.DB.ExecContext(ctx, `INSERT INTO users (username, display_name, password_hash, role) VALUES ('jianf', 'jianf', 'x', 'admin')`); err != nil { t.Fatal(err) } // 先造一封人类的信(计数应当从它之后才开始) seedPingPong(t, sid, "jianf", "pi") seedPingPong(t, sid, "pi", "dsh") seedPingPong(t, sid, "dsh", "pi") if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 2 { t.Fatalf("人类插话后应只数它之后的 2 封,实际 %d(err=%v)", n, err) } // 人类**收**信也算打断(to 是人类)—— 不是只认发件方 seedPingPong(t, sid, "pi", "jianf") if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 0 { t.Fatalf("人类收信也应打断连续性(应 0),实际 %d(err=%v)", n, err) } } // 反向对照:**没有人类参与**时计数不该被重置 —— 否则这道防线永远不触发 // (把"人类参与才 reset"实现成"每封都 reset",上面那条仍会绿)。 func TestAgentPingPongNotResetByAgents(t *testing.T) { setupTestDB(t) ctx := context.Background() sid, err := CreateSession(ctx, nil, "human", "回路", "") if err != nil { t.Fatal(err) } seedPingPong(t, sid, "pi", "dsh") seedPingPong(t, sid, "opencode", "homeagent") // 换一对 Agent 也仍是"无人决策" seedPingPong(t, sid, "dsh", "pi") if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 3 { t.Fatalf("纯 Agent 往返应连续计数(应 3),实际 %d(err=%v)", n, err) } if MaxAgentPingPong() <= 0 { t.Fatal("阈值必须为正,否则守卫恒真或恒假") } }