Files
MailUI4Agents/server/internal/repo/session_scope_test.go
JianFeeeee 552fbc731e fix(inbox): read_inbox 按会话收窄 —— 修「不同 session 的 agent 都能看到全部邮件」
用户问:「你之前不是说你已经处理了不同 session 的 agent 都可以看到全部邮件的
问题了吗?」——**我得先纠正事实:上一轮我只做了诊断并问要不要动手,没有实施。**
这是我的表述问题(把"已定位并给了方案"说成了像"已处理")。现在实施。

## 缺陷

`read_inbox` 是**按 Agent** 的:列的是该 Agent 的全部未读(含别的会话的来信),
并按契约把列出来的都标成已读 ⇒ A 会话的 worker 标掉 B 会话的未读。平时看不出来
(SSE 事件在途时队列兜着),但桥重启/漏事件后的补投判据是 `?status=unread` ——
被标掉的那封**再也不会补投** ⇒ 静默丢信。现场实例:另一条会话的来信在
`mail_reads` 里的 reader=pi、时间正是我读自己收件箱的那一刻。

## 改动

- **网关**:`GET /mail/inbox` 与 `POST /mail/read` 支持可选 `session_id`。
  不带 = 旧语义(整个 Agent 的收件箱,浏览器/脚本仍可用);带了就只在这条会话内
  列与标。`ListInbox` / `MarkAllInboxReadFor` 保持原签名并委托给新变体 ——
  老调用点一个都不用改。
- **pi 桥**:`read_inbox` 把自己那条会话拼进 URL(worker 通过闭包把**邮件会话 id**
  递给工具,而不是在启动时取快照)。

## 判据

- repo 三条:列表按会话收窄(含"不带会话时两条都在"的反向对照)、
  ★"标会话 A 不动会话 B"、会话内计数与列表口径一致(否则界面会出现"徽标 2、列表 1")。
- handler/网关:非法 `session_id` ⇒ 400(不静默忽略)。
- pi 接线三条(URL 拼了收窄、worker 递了 id、判据自检:旧写法必须判红)。
- 线上只读 E2E:两条真实会话 A/B 列表**无交集**、不带会话能列出全部、非法 id 400。

## 过程中测试当场抓到"只改了一半"

`MarkAllInboxReadForSession` 里插 `mail_reads` 的语句我加了会话条件,
**刷新冗余列的 UPDATE 忘了加** ⇒ 返回的"标掉几封"变成 2(应 1)。
判据一眼看出来了 —— 这类"改一半"正是这次要防的。

## 范围(诚实说明)

另外四家桥(dsh/opencode/zcode/homeagent)的 `read_inbox` 工具签名里**没有会话上下文**
(`execute(args)` / `execute(args, ctx)` 各不相同),要按各自框架的上下文 API 接线,
不是一行改动 ⇒ **未做**,列为待办(位置已定位)。所以:pi 上这个缺陷已消除,
另外四家仍在。

## 部署

网关已部署并线上验证;pi 桥的部署**延迟到本轮结束后 150 秒**执行
(重启 pi 桥会掐掉我自己这一轮 —— 之前真发生过),日志
`/var/log/agentmail-pi-redeploy.log`,可用 `node deploy/check-deploy-drift.mjs` 核对。
2026-09-14 09:19:25 +08:00

153 lines
4.6 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"
"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
}