Files
MailUI4Agents/server/internal/repo/session_scope_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

239 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/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
/*
会话维度:一个 Agent 同时在多条会话里干活时,**读一条不能动另一条**。
# 用户报的缺陷
「不同 session 的 agent 都可以看到全部邮件」—— 原先 `read_inbox` 按 **Agent** 列,
并且把列出来的都标成已读 ⇒ A 会话的 worker 会把 B 会话的未读标掉。
平时看不出来(SSE 事件在途时队列兜着),但桥重启/漏事件之后的补投判据是
`?status=unread` —— 被别人标掉的那封信**再也不会补投**。这不是"少看一封",
是静默丢信。现场实例:另一条会话的来信被我在读自己收件箱时标成已读
(`mail_reads` 里那行的 reader=pi、时间就是那次读取的时刻)。
# 判据要两侧都验
只验"收窄后看不到别人的"是不够的:把列表直接返回空也能过。所以同时验
"不带会话时两条都看得到"(反向对照),以及"全标已读"两个变体。
*/
// seedMailInSession 在指定会话里造一封给 recipient 的未读邮件。
func seedMailInSession(t *testing.T, sessionID uuid.UUID, recipient string) uuid.UUID {
t.Helper()
var id uuid.UUID
if err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO mails (session_id, from_name, to_name, subject, body)
VALUES ($1, 'sender', $2, 's', 'b') RETURNING mail_id`,
sessionID, recipient).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
func twoSessionsWithUnread(t *testing.T, agent string) (uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
s1, err := CreateSession(ctx, nil, "human", "会话一", "")
if err != nil {
t.Fatal(err)
}
s2, err := CreateSession(ctx, nil, "human", "会话二", "")
if err != nil {
t.Fatal(err)
}
return s1, s2, seedMailInSession(t, s1, agent), seedMailInSession(t, s2, agent)
}
func TestInboxListIsScopedBySession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot")
// 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到
all, err := ListInbox(ctx, "bot", "unread", "", 50)
if err != nil {
t.Fatal(err)
}
if !hasMail(all, m1) || !hasMail(all, m2) {
t.Fatalf("不带会话时应当两条都列出来(%d 条)", len(all))
}
// ★ 收窄到会话一:只有它那条
scoped, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1)
if err != nil {
t.Fatal(err)
}
if !hasMail(scoped, m1) {
t.Fatal("会话一的邮件应当在会话一的列表里")
}
if hasMail(scoped, m2) {
t.Fatal("★ 会话二的邮件不该出现在会话一的列表里(用户报的缺陷)")
}
// 会话二同理(别只验一侧 —— 方向反了也能"过")
scoped2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2)
if err != nil {
t.Fatal(err)
}
if hasMail(scoped2, m1) || !hasMail(scoped2, m2) {
t.Fatalf("会话二的列表应只含会话二的邮件")
}
}
func TestMarkAllReadCanBeScopedToSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
s1, s2, _, _ := twoSessionsWithUnread(t, "bot")
// 只标会话一
n, err := MarkAllInboxReadForSession(ctx, "bot", "", s1)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("应当只标掉 1 封,实际 %d", n)
}
left, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 {
t.Fatalf("★ 会话二的未读必须还在(实际剩 %d 条)—— 被跨会话标掉就是静默丢信", len(left))
}
// 反向对照:不带会话时两条都会被标掉
if _, err := MarkAllInboxReadFor(ctx, "bot", ""); err != nil {
t.Fatal(err)
}
if again, _ := ListInbox(ctx, "bot", "unread", "", 50); len(again) != 0 {
t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again))
}
}
func TestScopedCountsMatchScopedList(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
s1, s2, _, _ := twoSessionsWithUnread(t, "bot")
// 会话内的未读数(会话列表徽标用)与"只列这条会话"的口径必须一致,
// 否则界面上会出现"徽标 2、列表 1"这种自相矛盾。
n1, err := CountUnreadInSession(ctx, "bot", s1)
if err != nil {
t.Fatal(err)
}
list1, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1)
if err != nil {
t.Fatal(err)
}
if n1 != len(list1) || n1 != 1 {
t.Fatalf("会话一:计数 %d、列表 %d,期望都是 1", n1, len(list1))
}
_ = s2
}
func hasMail(list []models.Mail, id uuid.UUID) bool {
for _, m := range list {
if m.ID == id {
return true
}
}
return false
}
// ─── 补拉路径的档位契约(2026-09-14) ───
//
// 桥的离线补投走 `GET /mail/inbox`(repo 层就是 `ListInboxScoped`)。
// 事故:插件以为这条路径**拿不到**会话档位,于是 `mailToEvent` 只搬了 8 个字段,
// 补投的邮件让 worker 回落默认 workspace 档 —— 一条 full 档会话被误拦一整轮。
// 实际上服务端的行**一直带着**档位(SQL 里有 `JOIN sessions s` +
// `COALESCE(NULLIF(s.permission_mode,”),'workspace')`,`models.Mail.PermissionMode`
// 的注释也写明补拉路径必须有它)。这条判据把"服务端确实给了"钉死:
// 谁哪天把这个 JOIN 去掉,插件侧就会静默降级,而单看插件测试是发现不了的。
func TestInboxRowsCarrySessionPermissionMode(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "bot", 50)
sid := createTestSession(t, ctx, "bot", "/tmp/ws")
if _, err := SetSessionPermissionMode(ctx, sid, "full"); err != nil {
t.Fatal(err)
}
seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("应当恰好一条未读,实际 %d 条", len(rows))
}
if rows[0].PermissionMode != "full" {
t.Fatalf("补拉路径必须带上会话档位:want full, got %q", rows[0].PermissionMode)
}
if rows[0].PermissionEnforcement == "" {
t.Fatal("permission_enforcement 也要带上(空值会让插件无从判断强制力)")
}
// 反向对照:档位没设过的会话落到 workspace(**不是** full)——
// 缺字段时"猜宽"就是提权,所以默认值这一侧也要验。
other := createTestSession(t, ctx, "bot", "/tmp/ws2")
seedMailInSession(t, other, "bot")
rows2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, other)
if err != nil {
t.Fatal(err)
}
for _, m := range rows2 {
if m.PermissionMode != "workspace" {
t.Fatalf("没有档位记录的会话应当回落 workspace,got %q", m.PermissionMode)
}
}
}
// 回信地址:补拉路径也要有(与 PermissionMode 同一个理由 —— 少了它,
// 补投进来的邮件拿不到回信地址,插件只能自己拼,拼错就是静默开新会话)。
func TestInboxRowsCarryReplyAddress(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "bot", 50)
// 会话带别名(回信地址里应当出现别名;没有别名的会话只写发件人)
alias := "harmony-ui-alignment"
sid, err := CreateSession(ctx, nil, "sender-agent", "test subject", "/tmp/ws")
if err != nil {
t.Fatal(err)
}
if _, err := db.DB.ExecContext(ctx, `UPDATE sessions SET session_alias = $1 WHERE session_id = $2`, alias, sid); err != nil {
t.Fatal(err)
}
seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("应当恰好一条未读,实际 %d 条", len(rows))
}
got := rows[0].ReplyAddress
want := models.FormatAddress(rows[0].FromName, "", alias)
if got != want {
t.Fatalf("回信地址要与 SSE 载荷同形:want %q, got %q", want, got)
}
// 反面:地址里**不该**出现 path 位(与 notify 的注释同一取舍:人没有工作目录)
if strings.Count(got, "@") != 1 {
t.Fatalf("回信地址形状不对:%q", got)
}
}