Files
MailUI4Agents/server/internal/repo/readstate_test.go
JianFeeeee 453f451fbb fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务
用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
2026-09-13 22:47:25 +08:00

243 lines
7.9 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"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
已读语义:**按读者**记录,而不是邮件行上的一个全局列。
2026-09-13 线上实测:`jianf → dsh` 那封打了招呼、抄送 pi/opencode/zcode/homeagent 的
邮件里pi 最先回复 ⇒ 它读过了 ⇒ 这封对 dsh 也变成 `read` ⇒ dsh 的
`read_inbox`(默认 unread返回空它只能按提示词里的 mail_id 直接读,并在回信里
写下"收件箱列表未展示它,直接按 mail_id 读取成功"。
同一封邮件**对每个读者各自算未读**,是这一组判据要钉住的不变量。
*/
// unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。
func unreadList(t *testing.T, reader string) []uuid.UUID {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "unread", 50)
if err != nil {
t.Fatal(err)
}
out := []uuid.UUID{}
for _, m := range mails {
out = append(out, m.ID)
}
return out
}
func hasID(list []uuid.UUID, id uuid.UUID) bool {
for _, x := range list {
if x == id {
return true
}
}
return false
}
// statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。
func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "all", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
return m.Status
}
}
t.Fatalf("读者 %s 的收件箱里没有 %s", reader, id)
return ""
}
func TestReadStateIsPerRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一封主收件人 alice、抄送 bob 的邮件 —— 正是线上那封信的形状
id := seedMailTo(t, "alice", "bob")
// 初始:两边都未读
if n, _ := CountUnread(ctx, "alice"); n != 1 {
t.Fatalf("alice 初始未读 = %d期望 1", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("bob 初始未读 = %d期望 1", n)
}
// alice 读掉它
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatal(err)
}
// ★ 核心判据alice 的未读清了bob 的**必须还在**
if n, _ := CountUnread(ctx, "alice"); n != 0 {
t.Fatalf("alice 读过后未读 = %d期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n)
}
if !hasID(unreadList(t, "bob"), id) {
t.Fatal("★ bob 的 unread 收件箱里必须还能看到这封信")
}
if hasID(unreadList(t, "alice"), id) {
t.Fatal("alice 自己读过了unread 收件箱不该再有它")
}
// 同一封邮件,两个读者看到的状态不同 —— 这就是"按读者"的直接证据
if got := statusSeenBy(t, "alice", id); got != "read" {
t.Fatalf("alice 看到的 status = %q期望 read", got)
}
if got := statusSeenBy(t, "bob", id); got != "unread" {
t.Fatalf("★ bob 看到的 status = %q期望 unread同一封邮件按读者算", got)
}
// 反向对照bob 也读掉 → 两边都清
if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
t.Fatalf("bob 读过后未读 = %d期望 0", n)
}
}
// CountUnreadInSession 是会话列表徽标用的,同样必须按读者算。
func TestCountUnreadInSessionIsPerRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnreadInSession(ctx, "alice", sid); n != 0 {
t.Fatalf("alice 会话内未读 = %d期望 0", n)
}
if n, _ := CountUnreadInSession(ctx, "bob", sid); n != 1 {
t.Fatalf("★ bob 会话内未读 = %d期望 1徽标不能因为别人读过就归零", n)
}
}
// 批量标已读Agent 的 /mail/read同样只影响调用者自己。
func TestBulkMarkReadOnlyAffectsCaller(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ 批量标已读后 bob 未读 = %d期望 1", n)
}
// 全部标已读(不带 id也只能标自己收件箱里的
if _, err := MarkAllInboxReadFor(ctx, "alice"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ 全标已读后 bob 未读 = %d期望 1", n)
}
}
// 归档是**会话级**的全局状态归档后对所有人都不可见unread 与 all 都不给),
// 而且不能因为"没人读过"就把它算成未读。
func TestArchivedStaysArchivedForEveryone(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := ArchiveSession(ctx, sid); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
t.Fatal("归档邮件的 status 是 archived不该出现在 unread 收件箱")
}
// ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在
// `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。
mails, err := ListInbox(ctx, "bob", "all", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
t.Fatal("归档会话里的邮件不该出现在收件箱列表all 也不该有)")
}
}
}
// 权限决策把邮件记成**决策人**已读:同一条线索上其他收件人不受影响。
func TestPermissionDecisionMarksOnlyDecider(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一封 alice 收、bob 抄送的权限邮件
id := seedMailTo(t, "alice", "bob")
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, kind)
SELECT mail_id, session_id, 'bot', 'q', '["同意","拒绝"]', 'permission' FROM mails WHERE mail_id = $1`,
id); err != nil {
t.Fatal(err)
}
if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "alice"); n != 0 {
t.Fatalf("决策人 alice 未读 = %d期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ bob 未读 = %d期望 1决策是 alice 做的,不该替他标记已读)", n)
}
}
// 决策回执必须与普通邮件区分开:桥靠这个类型判断"这是控制面回执,不是新任务"
// (漏了它,同一封决策就会被当成新邮件再起一轮 —— 2026-09-13 线上缺陷的第二半)。
func TestDecisionMailCarriesTypeAndNote(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
req := seedMailTo(t, "alice", "")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, req).Scan(&sid); err != nil {
t.Fatal(err)
}
const note = "我说了让你拉取仓库到program下你听不懂吗"
id, err := CreateDecisionMail(ctx, sid, req, "jianf", "alice", "拒绝", note)
if err != nil {
t.Fatal(err)
}
var mtype, body string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_type, body FROM mails WHERE mail_id = $1`, id).Scan(&mtype, &body); err != nil {
t.Fatal(err)
}
if mtype != "permission_decision" {
t.Fatalf("决策邮件 mail_type = %q期望 permission_decision桥靠它区分「回执」与「新任务」", mtype)
}
if !strings.Contains(body, note) {
t.Fatalf("决策邮件正文必须带备注,实际:%q", body)
}
}