Files
MailUI4Agents/server/internal/repo/readstate_test.go
JianFeeeee 7634be8966 fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)
用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
2026-09-26 07:44:33 +08:00

305 lines
11 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)
}
}
// ★ 空读者必须报错,不许兜底成某个读者(pi 2026-09-14)。
//
// 这条约定原先只活在注释里:HTTP 层取 `user.Username`(不是请求参数),所以线上不会传空,
// 但 repo 函数本身接受空串 —— 将来新调用方传 `""` 就会写一行 `reader_name=”` 的垃圾:
// 它不属于任何人,却会让"未读"统计出偏差,而且没有任何东西会红。
// 判据要点:不仅"不许插垃圾行",而且**必须返回错误**(静默忽略同样是一种兜底)。
func TestMarkMailReadRejectsEmptyReader(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "")
for _, empty := range []string{"", " "} {
if err := MarkMailRead(ctx, id, empty); err == nil {
t.Fatalf("空 reader(%q)必须报错 —— 否则会静默写入不属于任何人的已读行", empty)
}
}
var n int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM mail_reads WHERE mail_id = $1`, id).Scan(&n); err != nil {
t.Fatalf("查 mail_reads: %v", err)
}
if n != 0 {
t.Fatalf("空 reader 不该写入任何 mail_reads 行,实际写了 %d 行", n)
}
// 正例:正常读者仍然能标记(别把守卫写成"一律拒绝")
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatalf("正常读者不该被守卫拦下:%v", err)
}
}
/*
* ★ 读侧空读者:**必须报错**,不许静默算出一个数(pi 2026-09-14 裁定 §2)。
*
* 写侧那条守卫挡住了"写入不属于任何人的已读行";读侧是另一半,而且更隐蔽:
* 空串作为**过滤条件**时 `NOT EXISTS(... reader_name = '')` 恒真 ⇒
* 所有邮件都被算成未读(用户看到"全都没读"),而 `read` 过滤恒空 ——
* 不报错、不留坏数据,只有一个错数字。所以这里两条都要钉:
* ① 空 reader 的两个入口都返回错误;② 报错时**不返回任何数**(不是"返回 0 顺带报错")。
*/
func TestReadSideRejectsEmptyReader(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedMailTo(t, "alice", "")
for _, empty := range []string{"", " "} {
if n, err := CountUnread(ctx, empty, ""); err == nil {
t.Fatalf("CountUnread(%q) 必须报错,实际返回 %d —— 空读者会静默算出错误的未读数", empty, n)
}
if _, err := ListInbox(ctx, empty, "all", "", 50); err == nil {
t.Fatalf("ListInbox(%q) 必须报错 —— 空读者是过滤条件,不能兜底", empty)
}
if n, err := CountUnreadInSession(ctx, empty, uuid.Nil); err == nil {
t.Fatalf("CountUnreadInSession(%q) 必须报错,实际返回 %d", empty, n)
}
}
// 正例:正常读者照常工作(别把守卫写成"一律拒绝")
if n, err := CountUnread(ctx, "alice", ""); err != nil || n != 1 {
t.Fatalf("正常读者 alice 的未读数应为 1,实际 %d(err=%v)", n, err)
}
}