Files
MailUI4Agents/gateway/internal/repo/nearesthuman_test.go
JianFeeeee edb4bd94fd fix(permission): 权限决策者必须是人类,顺会话树查找
死锁现场:pi 的权限邮件 to_name = "pi"(决策者被解析成 Agent 自己),
SendToUser 找不到收件人 —— 请求发出去后无人可答,会话无声挂死。

新增 repo.NearestHumanInThread 三级查找:会话 owner → 最近人类发件人 →
最近人类收件/抄送方。发件人优先于收件人:主动说话的人更可能在关注这条线索。

不用递归 CTE 沿 parent_mail_id 上溯而按会话扫:权限请求常常不挂父邮件,
上溯会在第一跳就断。

没有人类可路由时**自动拒绝并给 Agent 可执行的替代建议**,而不是转给管理员
—— 管理员对这条 Agent 链的上下文一无所知,收到也无法判断。
409 返回结构化的 error/detail/suggestion/decider_was。

nearesthuman_test.go 4 子测试。
2026-09-03 21:08:39 +08:00

120 lines
3.8 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 repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/db"
)
// 权限询问的决策者必须是人Agent 收不到 SendToUser桥的 await 永不 resolve。
// 生产事故pi 把任务派给自己的另一条会话 → 那条会话要跑 bash → 权限邮件发给 "pi"
// → pi 不是人类用户 → 整条会话永久阻塞。
//
// 修法是顺着会话的邮件链上溯找最近的人类 —— 派活的人才是该点头的人。
func TestNearestHumanInThread(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
mustExec(t, ctx, `INSERT INTO users (username, display_name, password_hash, role)
VALUES ('alice','Alice','x','user')`)
for _, a := range []string{"opencode", "dsh", "pi"} {
if err := CreateOrUpdateAgent(ctx, a, "s", "test", nil); err != nil {
t.Fatalf("注册 %s: %v", a, err)
}
}
t.Run("沿链上溯找到派活的人", func(t *testing.T) {
sid, err := CreateSession(ctx, nil, "opencode", "任务链", "/home")
if err != nil {
t.Fatalf("建会话: %v", err)
}
// alice → opencode → dshdsh 触发权限询问
m1, err := CreateMail(ctx, sid, nil, "alice", "", "opencode", "", "任务", "请帮忙", nil)
if err != nil {
t.Fatalf("封1: %v", err)
}
m2, err := CreateMail(ctx, sid, &m1, "opencode", "", "dsh", "", "转派", "你来看", nil)
if err != nil {
t.Fatalf("封2: %v", err)
}
if _, err := CreateMail(ctx, sid, &m2, "dsh", "", "opencode", "", "进展", "做了一半", nil); err != nil {
t.Fatalf("封3: %v", err)
}
for _, agent := range []string{"dsh", "opencode"} {
got, err := NearestHumanInThread(ctx, sid, agent)
if err != nil {
t.Fatalf("%s: %v", agent, err)
}
if got != "alice" {
t.Errorf("%s 触发权限时应路由到 alice得到 %q", agent, got)
}
}
})
t.Run("全 Agent 链返回空串", func(t *testing.T) {
// 没有人类参与的链条:调用方据此拒绝请求,而不是转给一个
// 对上下文一无所知的管理员。
sid, err := CreateSession(ctx, nil, "opencode", "纯 Agent", "/tmp")
if err != nil {
t.Fatalf("建会话: %v", err)
}
m1, err := CreateMail(ctx, sid, nil, "opencode", "", "dsh", "", "干活", "go", nil)
if err != nil {
t.Fatalf("封1: %v", err)
}
if _, err := CreateMail(ctx, sid, &m1, "dsh", "", "opencode", "", "好", "ok", nil); err != nil {
t.Fatalf("封2: %v", err)
}
got, err := NearestHumanInThread(ctx, sid, "dsh")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("链上没有人类时应返回空串,得到 %q", got)
}
})
t.Run("skipAgent 是自己时不会把自己当人", func(t *testing.T) {
// pi 给自己的另一条会话派活正是事故场景:链上只有 pi 一个名字。
sid, err := CreateSession(ctx, nil, "pi", "自派", "/home")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if _, err := CreateMail(ctx, sid, nil, "pi", "", "pi", "", "拆任务", "自己干", nil); err != nil {
t.Fatalf("封1: %v", err)
}
got, err := NearestHumanInThread(ctx, sid, "pi")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("自派链上没有人类,应返回空串,得到 %q", got)
}
})
t.Run("空会话不报错", func(t *testing.T) {
sid, err := CreateSession(ctx, nil, "opencode", "空", "/tmp")
if err != nil {
t.Fatalf("建会话: %v", err)
}
got, err := NearestHumanInThread(ctx, sid, "opencode")
if err != nil {
t.Fatalf("空会话应返回空串而非报错,得到 err=%v", err)
}
if got != "" {
t.Errorf("空会话应返回空串,得到 %q", got)
}
})
}
func mustExec(t *testing.T, ctx context.Context, q string, args ...any) {
t.Helper()
if _, err := db.DB.ExecContext(ctx, q, args...); err != nil {
t.Fatalf("exec %s: %v", q, err)
}
}