用户问:「你之前不是说你已经处理了不同 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` 核对。
153 lines
4.6 KiB
Go
153 lines
4.6 KiB
Go
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
|
||
}
|