Files
MailUI4Agents/server/internal/repo/permission_deadline_test.go
JianFeeeee 1619399470 fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。

实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
  gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
  而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。

三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。

改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
  单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
  status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
  用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
  写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。

★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。

判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。

线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** 
2026-09-13 14:25:44 +08:00

142 lines
4.7 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/models"
)
// 待决权限的**失效时刻**必须在每条读路径上都透出。
//
// # 为什么值得钉一个测试
//
// 2026-09-11 端到端实测:人在界面上批准了一条待办,接口回 200但那件事什么都没
// 发生。原因是提出询问的桥在回合超时(默认 10 分钟)后拆掉了 worker 与它的决策
// 路由表,此后到达的决策只会作为通知投递给 Agent不恢复当时那次工具调用。
//
// 而数据库里那条 permission_requests 的 result 永远是 NULL —— 界面据此把它渲染成
// 「待决策」,于是一条早就没人等的待办会永远挂在授权页上,人点了也白点。
//
// 服务端不必替插件宣布裁决(那是否还在等是插件进程内的状态),但它必须给出
// **这条待办什么时候算过期**,否则任何客户端都无从显示。这个测试锁的就是这件事:
// 不是「某个字段存在于结构体」,而是「五个读函数都把它带出来了」——
// 漏掉任何一个读路径都不会报错,字段只会静默变成零值。
func TestPermissionDeadlineVisibleOnEveryReadPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-d", "等一个决策", "")
if err != nil {
t.Fatal(err)
}
pendingID, err := CreatePermissionMail(ctx, sid, "agent-d", "alice",
"删除 build/", "rm -rf build/", []string{"同意", "拒绝"}, "permission", false)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, pendingID, sid, "agent-d",
"删除 build/", []string{"同意", "拒绝"}, "rm -rf build/", "permission", false); err != nil {
t.Fatal(err)
}
// 同一会话里再放一条**已决策**的待办:它不该带失效时刻 ——
// 已决策的不再是待办,给它一个过期时间只会让界面把历史记录也标成过期。
settledID, err := CreatePermissionMail(ctx, sid, "agent-d", "alice",
"读取配置", "cat cfg", []string{"同意", "拒绝"}, "permission", false)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, settledID, sid, "agent-d",
"读取配置", []string{"同意", "拒绝"}, "cat cfg", "permission", false); err != nil {
t.Fatal(err)
}
if _, err := DecidePermission(ctx, settledID, "alice", "同意"); err != nil {
t.Fatal(err)
}
assertPending := func(what string, m *models.Mail) {
t.Helper()
if m == nil {
t.Fatalf("%s 返回 nil", what)
}
if m.PermissionExpiresAt == nil {
t.Fatalf("%s待决权限没有失效时刻字段没进该读路径时会是 nil"+
"界面就只能永远显示「待决策」)", what)
}
want := models.PermissionDeadline(m.CreatedAt)
if !m.PermissionExpiresAt.Equal(want) {
t.Errorf("%s失效时刻 = %v期望 %v= created_at + 等待窗口)",
what, m.PermissionExpiresAt, want)
}
}
// 1) GetMailByID —— 单封详情。
got, err := GetMailByID(ctx, pendingID)
if err != nil {
t.Fatal(err)
}
assertPending("GetMailByID", got)
// 2) ListInbox —— 授权页列表的来源。
inbox, err := ListInbox(ctx, "alice", "all", 50)
if err != nil {
t.Fatal(err)
}
if !assertDeadlineIn(inbox, pendingID, t) {
t.Error("ListInbox 未带出待决权限的失效时刻")
}
// 3) GetSessionMails —— 会话视图。
sessMails, err := GetSessionMails(ctx, sid)
if err != nil {
t.Fatal(err)
}
if !assertDeadlineIn(sessMails, pendingID, t) {
t.Error("GetSessionMails 未带出待决权限的失效时刻")
}
// 4) GetSessionMailByID —— 会话内的单封。
one, err := GetSessionMailByID(ctx, sid, pendingID)
if err != nil {
t.Fatal(err)
}
assertPending("GetSessionMailByID", one)
// 5) ListSentBy —— Agent 发出的那侧。
sent, err := ListSentBy(ctx, "agent-d", 50)
if err != nil {
t.Fatal(err)
}
if !assertDeadlineIn(sent, pendingID, t) {
t.Error("ListSentBy 未带出待决权限的失效时刻")
}
// 反向断言:已决策的那条不该有失效时刻。
decided, err := GetMailByID(ctx, settledID)
if err != nil {
t.Fatal(err)
}
if decided.PermissionExpiresAt != nil {
t.Errorf("已决策的权限邮件不该带失效时刻,实际 %v", decided.PermissionExpiresAt)
}
}
// assertDeadlineIn 在列表里找那封邮件并校验失效时刻。
func assertDeadlineIn(mails []models.Mail, id interface{ String() string }, t *testing.T) bool {
t.Helper()
for i := range mails {
if mails[i].ID.String() != id.String() {
continue
}
m := &mails[i]
if m.PermissionExpiresAt == nil {
return false
}
return m.PermissionExpiresAt.Equal(models.PermissionDeadline(m.CreatedAt))
}
t.Fatalf("列表里找不到邮件 %s", id)
return false
}