死锁现场: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 子测试。
120 lines
3.8 KiB
Go
120 lines
3.8 KiB
Go
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 → dsh,dsh 触发权限询问
|
||
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)
|
||
}
|
||
}
|