## 主体是 gofmt
`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `// x` 规范成 `//\tx`。
功能零变化。
## 一处实质:init.sql 补 parent_session_id
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
TEXT REFERENCES sessions(session_id)`
远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。
- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)
## 新增 handler/ratelimit_test.go(162 行)
速率限制的判据。此前这一块没有独立判据。
## 验证(本机实跑,非推断)
go build ./... 通过
go vet ./internal/{handler,repo,db,sse}/ 通过
go test -count=1(handler/repo/db/sse/blob)
handler 41.3s ok
repo 101.9s ok
db 1.1s ok
sse 0.01s ok
blob 0.08s ok
`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
225 lines
8.7 KiB
Go
225 lines
8.7 KiB
Go
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("阈值必须为正,否则守卫恒真或恒假")
|
||
}
|
||
}
|
||
|
||
/*
|
||
★ 2026-09-30 缺陷(dsh,生产实测 `harmony-push-真机验证` 会话 0094eee5):
|
||
|
||
插件代劳的邮件(失败退信 / 权限询问 / 总结转发,走 relay:"summary" 免配额通道)
|
||
被 CountTrailingAgentPingPong 一并计入「Agent 互发」计数。它同样满足
|
||
「发件方 Agent + 收件方 Agent + 无人类」,于是:
|
||
|
||
① 退信把计数顶到 8 ⇒ 闸被**插件自己**触发;
|
||
② 闸在 send 入口、先于 relay 分支 ⇒ 退信本身也被 403;
|
||
③ 退信发不出去 ⇒ 发件人只看到「模型未产生回复」(桥的 sendFailureReply
|
||
同样发不出,真因被吞);
|
||
④ 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ **会话永久死锁**:
|
||
模型回信 → 403 → 退信也 403 → 发件人重发 → 模型再回 → 再 403。
|
||
生产上该会话 09-29 22:01 至 09-30 10:11 被 403 拒了 7 次。
|
||
|
||
正确形状:relay 邮件归 CountTrailingRelayHops(阈值 5,更严)管,
|
||
不挤占「模型主动互发」的额度(阈值 8)。两条闸独立计数、互不侵占。
|
||
*/
|
||
func seedPingPongRelay(t *testing.T, sessionID uuid.UUID, from, to string) {
|
||
t.Helper()
|
||
seedPingPong(t, sessionID, from, to) // 复用同一套时间戳纪律(见其注释)
|
||
// 给刚插入的那封登记 relayed_mails(服务端成功路径由 ClaimRelay+BindRelayMail 做)
|
||
var mailID string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT mail_id FROM mails WHERE session_id=$1 ORDER BY created_at DESC LIMIT 1`,
|
||
sessionID).Scan(&mailID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`INSERT INTO relayed_mails (agent_name, relay_key, mail_id, kind)
|
||
VALUES ($1, $2, $3, 'summary')`, from, "test-key-"+mailID, mailID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestAgentPingPongSkipsRelayMails(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
sid, err := CreateSession(ctx, nil, "human", "回路", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 3 封模型主动互发 + 3 封插件代劳(relay)+ 2 封模型主动互发
|
||
seedPingPong(t, sid, "pi", "dsh")
|
||
seedPingPong(t, sid, "dsh", "pi")
|
||
seedPingPong(t, sid, "pi", "dsh")
|
||
seedPingPongRelay(t, sid, "homeagent", "dsh") // 退信 1
|
||
seedPingPongRelay(t, sid, "homeagent", "dsh") // 退信 2
|
||
seedPingPongRelay(t, sid, "homeagent", "dsh") // 退信 3
|
||
seedPingPong(t, sid, "dsh", "pi")
|
||
seedPingPong(t, sid, "pi", "dsh")
|
||
|
||
// 缺陷版本会数出 8(3+3+2)⇒ 触发上限;修复后 relay 不计数 ⇒ 5。
|
||
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 5 {
|
||
t.Fatalf("relay 邮件不应计入 Agent 互发数:应 5,实际 %d(err=%v)", n, err)
|
||
}
|
||
}
|
||
|
||
func TestAgentPingPongStillBreaksOnHumanAfterRelay(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
sid, err := CreateSession(ctx, nil, "human", "回路", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
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)
|
||
}
|
||
|
||
// relay 群里夹一封人类参与的信 ⇒ 从它往后数
|
||
seedPingPongRelay(t, sid, "homeagent", "dsh")
|
||
seedPingPongRelay(t, sid, "homeagent", "dsh")
|
||
seedPingPong(t, sid, "jianf", "pi") // 人类发
|
||
seedPingPong(t, sid, "pi", "dsh")
|
||
|
||
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 1 {
|
||
t.Fatalf("人类参与仍应打断连续性(relay 在更早处):应 1,实际 %d(err=%v)", n, err)
|
||
}
|
||
}
|