Files
MailUI4Agents/server/internal/handler/mail_cooldown_test.go
JianFeeeee 4d8165fde9 fix(回路): Agent↔Agent 加 2h 冷静期;冷却期内 relay 不自动重投递
缺口:maxAgentPingPong=8 撞闸后**计数永不回落** —— 只有人类插话才归零。
旧文案把出路指向「请由人类插一句话」,而那条线索上常常**根本没有人类**
(2026-10-01 报告:agent 一封都发不出,且那条线索无人在场)。

修法(两条要求分别落地):
① 2h 恢复机制:撞闸即写入 session_agent_locks(落库,内存态一重启就"恢复",
   且多副本各算各的);到期自动放行,人类插话立刻解锁(优先于到期)。
② 锁定期间的邮件不自动重投递:冷却期内 relay 直接 403 丢弃 ——
   **不排队、不占幂等键、不入库**。排队会在 2h 后一次性灌回去,
   那等于把刚压住的回路换个更糟的形状放出来。

实测踩到的三个坑(都被判据抓住):
- `VALUES ($1,$2,$2,...)` 让 until_at 复用 locked_at 的 $2 ⇒ 锁诞生即过期
- driver 以 UTC 扫回 DATETIME,而 time.Now() 是本地(HKT+8) ⇒ 差 8h > 2h 的一半 ⇒ 锁形同虚设
- 只查**收件方**是否人类,漏了**发件方** —— 而「人类插话解锁」的常态就是
  人回信、收件方仍是 Agent ⇒ 人插话反被自己写的闸 403 拦下,那条出路根本不存在
- SQLite 没有 GREATEST;在 DATETIME 上按**字符串**比大小 ⇒ CASE 也会错。
  改为「已有锁一律不碰 until_at」。

判据:repo 6 格(含"读失败不得读成未锁定"—— 最初 0 格能抓,变异测试补的)
+ handler 4 格。三个变异全部经得起(且每��都先确认变异编译通过再数红格 ——
本轮多次 grep 得 0 实际是 build failed,测试压根没跑)。
2026-10-01 19:41:06 +08:00

212 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
/*
Agent↔Agent 回路的**冷静期**在 handler 上的行为(2026-10-01)
# 两句话需求,落在两处
① 「agent 互发应当有 2h 恢复机制」
→ 撞 maxAgentPingPong=8 时**开始**冷静期,到期自动放行,不依赖人。
② 「锁定期间的邮件不自动重投递」
→ 冷静期内 **relay 邮件直接丢弃**(不排队、到时不补投)。
# 为什么 ② 必须单独钉
"不自动重投递"有三种可能的实现形状,观测上**不等价**:
· 直接拒(丢弃) ← 本次要求的
· 拒但入队、到期补投 ← 看着一样,其实把回路攒到 2h 后一次性放出来
· 收下但不投递 ← 库里多一封信,统计与将来的人工检查都被污染
所以这一格钉的是**库里没有这封信**,而不只是"接口返回了错误"。
*/
// lockSession 造一个「两个 Agent 互发已达上限、会话已进冷静期」的会话。
func lockSession(t *testing.T) (uuid.UUID, string) {
t.Helper()
ctx := context.Background()
// Agent 用 repo.CreateOrUpdateAgent 建(真实列是 secret/host_url,
// 不是 agent_key —— 我第一版照想象写了列名,被 schema 打回)。
// ⚠ **不要**把 pinger/ponger 建成了 user。
//
// 我第一版顺手调了 mustPermissionUser,结果 IsHumanUser("ponger")=true
// ⇒ 闸按"收件方是人类"正确放行 ⇒ 判据红。
// 闸没问题,是我的靶子造错了人:这两个必须是 Agent。
for _, u := range []string{"pinger", "ponger"} {
if err := repo.CreateOrUpdateAgent(ctx, u, "k-"+u, "test", nil); err != nil {
t.Fatalf("建 agent %s: %v", u, err)
}
}
sid, err := repo.CreateSession(ctx, nil, "pinger", "回路", "")
if err != nil {
t.Fatalf("建会话: %v", err)
}
// ⚠ 必须有一封**已存在的信**才能 reply_to 进去。
//
// 我第一版直接用 `ponger@.new` 投递:`.new` 会**另建一条会话**,
// 于是锁在 sid 上、请求却落在另一条会话上 ⇒ 一路 200,
// 判据看着像"闸没生效"。真实闸没问题,是我的靶子打偏了。
if _, err := repo.CreateMail(ctx, sid, nil, "ponger", "", "pinger", "",
"起个头", "在吗", nil); err != nil {
t.Fatalf("预置一封信: %v", err)
}
var mailID string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_id FROM mails WHERE session_id = $1 ORDER BY created_at DESC LIMIT 1`,
sid).Scan(&mailID); err != nil {
t.Fatal(err)
}
if _, err := repo.SetSessionLock(ctx, sid, 8, "test"); err != nil {
t.Fatalf("进入冷静期: %v", err)
}
return sid, mailID
}
// countMails 数会话里的邮件数。判据用它对比**前后的变化**:
// lockSession 自己会预置一封「起头信」(没有它 reply_to 无处可落),
// 所以基线不是 0。
func countMails(t *testing.T, sessionID uuid.UUID) int {
t.Helper()
var n int
if err := db.DB.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// postSend 以某个 agent 的身份发一封信,返回响应体。
func postSend(t *testing.T, agent, to, body, relayKey, replyTo string) *httptest.ResponseRecorder {
t.Helper()
ctx := context.Background()
if err := repo.CreateOrUpdateAgent(ctx, agent, "k-"+agent, "test", nil); err != nil {
t.Fatalf("注册 %s: %v", agent, err)
}
// relay 非空时服务端要求 relay_key 齐备("给了 relay_key 却没给 relay 类型"
// —— 我第一版只给了 relay_key,被这道前置校验先打回,看不到真正的闸)。
relay := ""
if relayKey != "" {
relay = "summary"
}
form := strings.NewReader(fmt.Sprintf(
`{"to":%q,"subject":"s","body":%q,"relay_key":%q,"relay":%q,"reply_to":%q}`,
to, body, relayKey, relay, replyTo))
req := httptest.NewRequest("POST", "/api/v1/mail", form)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, agent))
rr := httptest.NewRecorder()
SendMail(rr, req)
return rr
}
// ① 冷静期内:Agent 的自主回信被拒,且**给出的是可执行的出路**。
func TestCooldownBlocksAgentSendAndStatesRecovery(t *testing.T) {
setupPermissionHandlerDB(t)
sid, mailID := lockSession(t)
baseCount := countMails(t, sid)
rr := postSend(t, "pinger", "ponger@", "还在互相确认吗", "", mailID)
if rr.Code != http.StatusForbidden {
t.Fatalf("冷静期内应 403,实际 %d:%s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
// 文案必须说清「多久后自动恢复」——旧文案只教「请人类插话」,
// 而这条线索上常常根本没有人类(那正是原缺陷)。
if !strings.Contains(body, "冷静期") {
t.Fatalf("错误文案应说明这是冷静期:%s", body)
}
if !strings.Contains(body, "恢复") {
t.Fatalf("★ 文案必须给出恢复出路(自动恢复 or 人类插话):%s", body)
}
if n := countMails(t, sid); n != baseCount {
t.Fatalf("★ 被拒的信不得入库(否则它会被补投):%d → %d 封", baseCount, n)
}
}
// ② 冷静期内:relay(自动转发)**丢弃**,不排队不补投。
func TestCooldownDiscardsRelayWithoutQueueing(t *testing.T) {
setupPermissionHandlerDB(t)
sid, mailID := lockSession(t)
baseCount := countMails(t, sid)
rr := postSend(t, "pinger", "ponger@", "自动转发", "relay-key-1", mailID)
if rr.Code != http.StatusForbidden {
t.Fatalf("冷静期内 relay 应 403,实际 %d:%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "已丢弃") {
t.Fatalf("★ 文案必须明说「已丢弃」(否则发件方以为会补投而等待):%s", rr.Body.String())
}
// 关键:库里既没有邮件,**也没有留下待补投的占位**
if n := countMails(t, sid); n != baseCount {
t.Fatalf("★ relay 不得入库(否则 2h 后会被一次性灌回去):%d → %d 封", baseCount, n)
}
var keys int
if err := db.DB.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM relayed_mails WHERE relay_key = $1`, "relay-key-1").Scan(&keys); err != nil {
t.Fatal(err)
}
if keys != 0 {
t.Fatalf("★ 不得占用 relay 幂等键(占着会让重试拿到 duplicate 却从未发出):%d 行", keys)
}
}
// ③ 人类插话立刻恢复 —— 冷静期不是死等 2 小时。
func TestHumanPostClearsCooldownImmediately(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
mustPermissionUser(t, "human1")
sid, mailID := lockSession(t)
// 人类在**同一会话**里插一封(非 relay)—— 必须 reply_to 进去:
// 用 .new 会另建一条会话,解锁的就不是被锁的那条了(我第一版的错)。
req := httptest.NewRequest("POST", "/api/v1/mail",
strings.NewReader(fmt.Sprintf(
`{"to":"ponger@","subject":"人来了","body":"停一下","reply_to":%q}`, mailID)))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, "human1"))
rr := httptest.NewRecorder()
SendMail(rr, req)
if rr.Code/100 != 2 {
t.Fatalf("人类插话本身不应被拦:%d %s", rr.Code, rr.Body.String())
}
if _, locked, _ := repo.SessionLockOf(ctx, sid); locked {
t.Fatal("★ 人类参与后必须立刻解锁(不必等 2 小时)")
}
}
// ④ 收件方是人类时**永远不该进**回路计数/冷静期。
func TestHumanRecipientNeverLocked(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
mustPermissionUser(t, "human1")
rr := postSend(t, "pinger", "human1@.new", "请教一下", "", "")
if rr.Code/100 != 2 {
t.Fatalf("发往人类不应被回路闸拦:%d %s", rr.Code, rr.Body.String())
}
var locks int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM session_agent_locks`).Scan(&locks); err != nil {
t.Fatal(err)
}
if locks != 0 {
t.Fatalf("★ 发往人类不得产生锁(人类在回路里正是我们要的):%d 行", locks)
}
}