用户报的那句 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** ✅
142 lines
4.7 KiB
Go
142 lines
4.7 KiB
Go
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
|
||
}
|