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 全绿。
This commit is contained in:
2026-09-26 07:44:33 +08:00
parent b85f2b26f0
commit 7634be8966
22 changed files with 957 additions and 179 deletions

View File

@ -25,6 +25,11 @@ type registerRequest struct {
//
// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。
type heartbeatRequest struct {
// Workspace 是调用方当前所处的工作区(绝对路径)——**必需**。
//
// 心跳返回的 pending_mails 按它算:那是桥的补投判据,混了别的工作区
// 就会让桥反复重放(见 handler 里那段说明)。
Workspace string `json:"workspace"`
// PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。
//
// 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的
@ -170,17 +175,29 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
return
}
/*
★ pending_mails 是**全局**未读数(跨工作区),而收件箱接口是 worker 级
(要求 workspace)。两者不矛盾,是不同层的东西 —— 见 UnreadWorkspaces 注释。
但只有总数,桥不知道去**哪个**工作区补投(它此前调的是不带收窄的
`/mail/inbox`,于是会跨工作区重放)。⇒ 一并给出有未读的工作区清单,
桥逐个去读。这也是"心跳返回什么才够用"的答案。
*/
pendingWorkspaces, _ := repo.UnreadWorkspaces(r.Context(), agentName)
// 宽容解码(心跳是全站唯一一处容忍未知字段的端点,理由见下面的说明)。
var req heartbeatRequest
var unknownFields []string
if r.ContentLength > 0 {
unknownFields, _ = DecodeLenient(r, &req)
}
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
// 不该因为上报体格式不对就把 Agent 判成离线。
//
// 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容
// 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 ——
// 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。
var req heartbeatRequest
var unknownFields []string
if r.ContentLength > 0 {
unknownFields, _ = DecodeLenient(r, &req)
}
syncedSessions := -1 // -1 = 本次未上报
if req.PlatformSessions != nil {
if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil {
@ -216,9 +233,10 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
}
resp := map[string]interface{}{
"status": "ok",
"pending_mails": pending,
"stats": stats,
"pending_workspaces": pendingWorkspaces,
"status": "ok",
"pending_mails": pending,
"stats": stats,
}
if syncedSessions >= 0 {
resp["platform_sessions_synced"] = syncedSessions

View File

@ -583,6 +583,35 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
}
}
/*
★ workspace **必需**(用户裁定:「不带 workspace 是错误发件格式,直接退回!」)。
# 为什么是 400 而不是"退回旧语义"
旧语义(不带 = 列该 Agent 全部)正是缺陷本身:`pi` 在 mc 工作区读收件箱
拿到 200 封,191 封属于 agentmail 工作区 ⇒ 它照着那些信改 agentmail 的代码。
留着旧语义当兜底,等于给这个缺陷留一条随时会被走上去的路。
三维地址 `name@path.session` 的 path 位本来就该参与寻址 —— 收件箱侧此前
完全没用它。现在要求调用方说清自己在哪个工作区,服务端据此收窄。
★ 为什么服务端**不该**自己去推调用方的工作区:
它是每个 worker 的 cwd(会随回合变),服务端拿不到可靠来源。
让调用方声明、服务端只当过滤条件用(不做鉴权依据)——
与 session_id 那条同构:声明错了只影响自己看到什么,越权不了别人。
*/
workspace := strings.TrimSpace(r.URL.Query().Get("workspace"))
if workspace == "" {
Error(w, http.StatusBadRequest,
"缺少 workspace:收件箱按工作区收窄(三维地址 name@path.session 的 path 位)。"+
"请带上你所处工作区的绝对路径,例如 &workspace=/home/program/agentmail")
return
}
if !strings.HasPrefix(workspace, "/") {
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+workspace)
return
}
// 可选会话收窄:桥的 read_inbox 会带上自己那条会话。
//
// 不带 = 整个 Agent 的收件箱(旧语义,浏览器/脚本仍可用);带了就只列这条线索 ——
@ -598,7 +627,7 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
sessionID = id
}
mails, err := repo.ListInboxScoped(r.Context(), agentName, status, limit, sessionID)
mails, err := repo.ListInboxScoped(r.Context(), agentName, status, workspace, limit, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
@ -609,7 +638,9 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), agentName)
// ★ total 必须用**同一个收窄口径**,否则列表与计数自相矛盾
// (桥的补投判据就是 pending_mails = 这个数)。
total, _ := repo.CountUnreadScoped(r.Context(), agentName, workspace, sessionID)
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
@ -698,6 +729,10 @@ func parseInt(s string) (int, error) {
type markReadRequest struct {
// SessionID 可选:带了就只在这条会话内标(见 GetInbox 里那段说明)。
SessionID string `json:"session_id"`
// Workspace **必需**(仅"全部标掉"那条路):只标这个工作区的信。
// 带上 mail_ids 时不要求 —— 那些 id 是调用方从**已收窄的**列表里原样拿回来的,
// 服务端按 id 逐个判归属即可(MarkMailsReadFor 只标真发给它的)。
Workspace string `json:"workspace"`
// MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。
MailIDs []string `json:"mail_ids"`
}
@ -728,8 +763,26 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
// 不给 id 就把收件箱里全部未读标掉。
// 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。
if len(req.MailIDs) == 0 {
/*
★ workspace **必需**,与 GET /mail/inbox 同一条规矩。
这条路口径更宽("全部标掉"),缺了工作区会把**别的工作区的未读也标掉** ——
而"被别人标掉"正是那封静默丢信的旧缺陷(read_inbox 按会话收窄那次修的
就是它,见 session_scope_test.go)。收窄到工作区,同一类错误就没法再发生。
*/
ws := strings.TrimSpace(req.Workspace)
if ws == "" {
Error(w, http.StatusBadRequest,
"缺少 workspace:全部标已读必须指明工作区(否则会标掉别的工作区的未读)。"+
"若是按 id 标记,请直接传 mail_ids")
return
}
if !strings.HasPrefix(ws, "/") {
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws)
return
}
var sessionID uuid.UUID
scope := "all"
scope := "workspace"
if raw := strings.TrimSpace(req.SessionID); raw != "" {
id, perr := uuid.Parse(raw)
if perr != nil {
@ -738,7 +791,7 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
}
sessionID, scope = id, "session"
}
n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, sessionID)
n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, ws, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to mark read")
return

View File

@ -218,7 +218,7 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
}
}
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
mails, err := repo.ListInbox(r.Context(), user.Username, status, "", limit)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
@ -229,7 +229,7 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), user.Username)
total, _ := repo.CountUnread(r.Context(), user.Username, "")
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),

View File

@ -394,6 +394,17 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) {
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
"session_id": perm.SessionID.String(),
}
/*
★ workspace:插件用它去收窄"等人期间新到的邮件"(那是一次 /mail/inbox 读,
而收件箱接口要求 workspace,缺了 400)。
为什么服务端给而不是让插件推:插件重启后待决映射丢光(上面那段说的),
此时它手上只有这个事件 —— 从事件里推不出工作区。而服务端**知道**
(`perm.SessionID` → `sessions.workspace`),一处给对,三平台都能用。
*/
if ws := repo.SessionWorkspaceOf(r.Context(), perm.SessionID); ws != "" {
payload["workspace"] = ws
}
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
payload["relay_key"] = key
payload["relay_kind"] = kind

View File

@ -64,7 +64,7 @@ func AgentAuth(next http.Handler) http.Handler {
http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
return
}
repo.HeartbeatAgent(r.Context(), agentName)
repo.TouchAgentLastSeen(r.Context(), agentName)
ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
next.ServeHTTP(w, r.WithContext(ctx))
return
@ -83,7 +83,7 @@ func AgentAuth(next http.Handler) http.Handler {
return
}
repo.HeartbeatAgent(r.Context(), agent.Name)
repo.TouchAgentLastSeen(r.Context(), agent.Name)
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@ -205,7 +205,7 @@ func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
t.Fatalf("停用: %v", err)
}
mails, err := ListInbox(ctx, "bot", "all", 10)
mails, err := ListInbox(ctx, "bot", "all", "", 10)
if err != nil {
t.Fatalf("ListInbox: %v", err)
}

View File

@ -49,7 +49,7 @@ func measureMailStatusDebt(t *testing.T) (outstanding bool, detail, derived stri
if err != nil {
t.Fatal(err)
}
list, err := ListInbox(ctx, "alice", "all", 50)
list, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil {
t.Fatal(err)
}

View File

@ -125,7 +125,7 @@ func TestMarkAllInboxReadForSkipsArchivedAndOthers(t *testing.T) {
db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid)
db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid)
n, err := MarkAllInboxReadFor(ctx, "bot")
n, err := MarkAllInboxReadFor(ctx, "bot", "")
if err != nil {
t.Fatal(err)
}

View File

@ -80,7 +80,7 @@ func TestPermissionDeadlineVisibleOnEveryReadPath(t *testing.T) {
assertPending("GetMailByID", got)
// 2) ListInbox —— 授权页列表的来源。
inbox, err := ListInbox(ctx, "alice", "all", 50)
inbox, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil {
t.Fatal(err)
}

View File

@ -78,7 +78,7 @@ func TestPermissionFieldsVisibleOnEveryReadPath(t *testing.T) {
assertQuestion("GetMailByID", got)
// 2) ListInbox —— 授权页的列表来源。
inbox, err := ListInbox(ctx, "alice", "all", 50)
inbox, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil {
t.Fatal(err)
}

View File

@ -23,7 +23,7 @@ import (
// unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。
func unreadList(t *testing.T, reader string) []uuid.UUID {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "unread", 50)
mails, err := ListInbox(context.Background(), reader, "unread", "", 50)
if err != nil {
t.Fatal(err)
}
@ -46,7 +46,7 @@ func hasID(list []uuid.UUID, id uuid.UUID) bool {
// statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。
func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "all", 50)
mails, err := ListInbox(context.Background(), reader, "all", "", 50)
if err != nil {
t.Fatal(err)
}
@ -67,10 +67,10 @@ func TestReadStateIsPerRecipient(t *testing.T) {
id := seedMailTo(t, "alice", "bob")
// 初始:两边都未读
if n, _ := CountUnread(ctx, "alice"); n != 1 {
if n, _ := CountUnread(ctx, "alice", ""); n != 1 {
t.Fatalf("alice 初始未读 = %d,期望 1", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("bob 初始未读 = %d,期望 1", n)
}
@ -80,10 +80,10 @@ func TestReadStateIsPerRecipient(t *testing.T) {
}
// ★ 核心判据:alice 的未读清了,bob 的**必须还在**
if n, _ := CountUnread(ctx, "alice"); n != 0 {
if n, _ := CountUnread(ctx, "alice", ""); n != 0 {
t.Fatalf("alice 读过后未读 = %d,期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n)
}
if !hasID(unreadList(t, "bob"), id) {
@ -105,7 +105,7 @@ func TestReadStateIsPerRecipient(t *testing.T) {
if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("bob 读过后未读 = %d,期望 0", n)
}
}
@ -141,15 +141,15 @@ func TestBulkMarkReadOnlyAffectsCaller(t *testing.T) {
if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ 批量标已读后 bob 未读 = %d,期望 1", n)
}
// 全部标已读(不带 id)也只能标自己收件箱里的
if _, err := MarkAllInboxReadFor(ctx, "alice"); err != nil {
if _, err := MarkAllInboxReadFor(ctx, "alice", ""); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ 全标已读后 bob 未读 = %d,期望 1", n)
}
}
@ -169,7 +169,7 @@ func TestArchivedStaysArchivedForEveryone(t *testing.T) {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
@ -177,7 +177,7 @@ func TestArchivedStaysArchivedForEveryone(t *testing.T) {
}
// ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在
// `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。
mails, err := ListInbox(ctx, "bob", "all", 50)
mails, err := ListInbox(ctx, "bob", "all", "", 50)
if err != nil {
t.Fatal(err)
}
@ -204,10 +204,10 @@ func TestPermissionDecisionMarksOnlyDecider(t *testing.T) {
if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "alice"); n != 0 {
if n, _ := CountUnread(ctx, "alice", ""); n != 0 {
t.Fatalf("决策人 alice 未读 = %d,期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
if n, _ := CountUnread(ctx, "bob", ""); n != 1 {
t.Fatalf("★ bob 未读 = %d,期望 1(决策是 alice 做的,不该替他标记已读)", n)
}
}
@ -286,10 +286,10 @@ func TestReadSideRejectsEmptyReader(t *testing.T) {
seedMailTo(t, "alice", "")
for _, empty := range []string{"", " "} {
if n, err := CountUnread(ctx, empty); err == nil {
if n, err := CountUnread(ctx, empty, ""); err == nil {
t.Fatalf("CountUnread(%q) 必须报错,实际返回 %d —— 空读者会静默算出错误的未读数", empty, n)
}
if _, err := ListInbox(ctx, empty, "all", 50); err == nil {
if _, err := ListInbox(ctx, empty, "all", "", 50); err == nil {
t.Fatalf("ListInbox(%q) 必须报错 —— 空读者是过滤条件,不能兜底", empty)
}
if n, err := CountUnreadInSession(ctx, empty, uuid.Nil); err == nil {
@ -298,7 +298,7 @@ func TestReadSideRejectsEmptyReader(t *testing.T) {
}
// 正例:正常读者照常工作(别把守卫写成"一律拒绝")
if n, err := CountUnread(ctx, "alice"); err != nil || n != 1 {
if n, err := CountUnread(ctx, "alice", ""); err != nil || n != 1 {
t.Fatalf("正常读者 alice 的未读数应为 1,实际 %d(err=%v)", n, err)
}
}

View File

@ -55,7 +55,73 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor
return err
}
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
/*
UnreadWorkspaces 列出"这个 Agent 在哪些工作区有未读"。
# 为什么要它,而不是让心跳收一个 workspace 参数
心跳是**进程级**的(一个桥进程同时服务所有工作区),而收件箱是**worker 级**的
(每个 worker 手上只有一封信,信封上有明确的 path 位)。在进程级强制要求
workspace 是概念错配 —— 它没有一个"我的工作区"可言。
但 `pending_mails` 是桥的补投判据:若它是一个跨工作区的总数,桥就不知道该去
**哪个工作区**补投。⇒ 心跳返回这个清单,桥逐个工作区去读(见 catchUp)。
这同时修掉一个隐蔽问题:补投原先调 `/mail/inbox?status=unread`(不带收窄),
按当时的语义会列出**所有工作区**的未读并逐封重放 —— 在 mc 干活时会去补投
agentmail 的信。
*/
func UnreadWorkspaces(ctx context.Context, agentName string) ([]string, error) {
if err := requireReader(agentName); err != nil {
return nil, err
}
rows, err := db.DB.QueryContext(ctx, `
SELECT s.workspace, COUNT(*) AS n
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND `+unreadFor("$1")+`
AND s.status <> 'archived'
-- ★ 只报**可寻址**的工作区(绝对路径)。
--
-- 历史库里存在 workspace 为相对路径的行(早期以 "pi@root" 寻址留下的
-- 测试会话)。收件箱接口要求绝对路径,把它们放进清单只会在桥侧撞 400
-- —— 我实测就撞到了:"补投工作区 root 失败: HTTP 400"。
--
-- 过滤放在这里而不是让桥去试错:这个清单的语义是"**能去补投**的工作区",
-- 列出不可寻址的等于给调用方递一个注定失败的任务。
AND s.workspace LIKE '/%'
GROUP BY s.workspace
ORDER BY n DESC`, agentName)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var ws string
var n int
if err := rows.Scan(&ws, &n); err != nil {
return nil, err
}
out = append(out, ws)
}
return out, rows.Err()
}
/*
TouchAgentLastSeen 只刷新"我还活着",**不**算未读数。
# 为什么要把它拆出来
原先 middleware 在**每一个**已认证请求上都调 HeartbeatAgent(它会顺手算
`CountUnread` 并丢掉返回值)—— 那是白算一次全表扫描,而且现在 `CountUnread`
还需要工作区,而 middleware 那一层拿不到(请求体还没解析)。
⇒ 拆成两件事:心跳副作用(只更新 last_seen)留在 middleware;
"这个工作区还有多少未读"由心跳 handler 按请求体里的 workspace 算。
*/
func TouchAgentLastSeen(ctx context.Context, agentName string) error {
// 只把【非停用】的 Agent 标成在线。
//
// 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online
@ -64,10 +130,21 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
`UPDATE agents SET last_seen = NOW(), status = 'online'
WHERE agent_name = $1 AND status <> 'disabled'`,
agentName)
if err != nil {
return err
}
// HeartbeatAgent 刷新在线状态并返回**该 Agent 的未读总数**。
//
// ★ 这里是**全局**口径(跨工作区),与 ListInboxScoped 不同 —— 原因见
//
// UnreadWorkspaces 上面那段:心跳是进程级,收件箱是 worker 级。
// 桥拿到这个总数后,用 `pending_workspaces` 清单逐工作区去补投,
// 两边合起来才是"有没有信、在哪个工作区"。
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
if err := TouchAgentLastSeen(ctx, agentName); err != nil {
return 0, err
}
return CountUnread(ctx, agentName)
return CountUnread(ctx, agentName, "")
}
// AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false ——
@ -504,6 +581,44 @@ func requireReader(reader string) error {
return nil
}
/*
checkWorkspace 校验 workspace 的形状(**允许为空**)。
# 空与非空各是什么语义(两条不同的入口,别混)
· **非空** = 只列该工作区的信。**Agent 侧必须非空**(Handler 层强制)。
· **空** = 不过滤,列该名字的全部。**人类侧就是这个**:一个人跨工作区,
WebUI 把结果按 `session_workspace` **分组显示**(`MailList.tsx:167`),
而不是只给一个工作区 —— 强行让人也带工作区,等于让人在多个工作区之间反复切。
⇒「必需」这条约束放在 **Handler**(`GetInbox` / `MarkInboxRead` / `HeartbeatAgent`),
不是这里:它是**接口契约**,不是数据层不变量。放 repo 会让人类那条路也没法用,
而人类侧并没有"我处在哪个工作区"这个概念。
# 为什么 Agent 必须带工作区
Agent 的收件箱原先只按名字过滤(`WHERE m.to_name = $1`),于是 `pi` 这个名字下
**所有工作区**的信混成一个池子。生产实测:在 `mc` 工作区干活的 pi 读收件箱拿到
200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改
agentmail 的代码,把手上 mc 的活丢在一边(用户当场问「你怎么干着干着修
agentmail 去了?」)。
三维地址是 `name@path.session` —— **path 位本来就该参与寻址**。收件箱侧此前
完全没用它,那不是"另一种语义",是没兑现契约。
*/
func checkWorkspace(workspace string) error {
w := strings.TrimSpace(workspace)
if w == "" {
return nil // 人类侧:不过滤,由 WebUI 按工作区分组显示
}
// 绝对路径:相对路径在服务端无法解释,且不同调用方 cwd 不同 ⇒ 拼出来必然对不上。
if !strings.HasPrefix(w, "/") {
return fmt.Errorf("workspace 必须是绝对路径,收到 %q", w)
}
return nil
}
// unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。
func unreadFor(arg string) string {
return `(m.status <> 'archived' AND NOT EXISTS (
@ -595,15 +710,23 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error {
原函数保持原语义(不带会话 = 整个 Agent 的收件箱),新增带会话的变体 ——
老调用点一个都不用改。
*/
func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) {
return ListInboxScoped(ctx, agentName, status, limit, uuid.Nil)
func ListInbox(ctx context.Context, agentName, status, workspace string, limit int) ([]models.Mail, error) {
return ListInboxScoped(ctx, agentName, status, workspace, limit, uuid.Nil)
}
// ListInboxScoped 与 ListInbox 相同,但 `sessionID` 非零时只列该会话的邮件。
func ListInboxScoped(ctx context.Context, agentName, status string, limit int, sessionID uuid.UUID) ([]models.Mail, error) {
//
// `workspace` 非空时收窄到该工作区(Agent 侧 Handler 强制必填;人类侧为空):只列属于该工作区的会话里的信。
// 判据用 `s.workspace`(会话的权威工作区)而不是 `m.to_workspace`:
// 后者是**这封信**的信封字段,可能是抄送、可能是历史遗留;而"这条线索属于哪个
// 工作区"是会话的属性,一处定死才不会两种答案。
func ListInboxScoped(ctx context.Context, agentName, status, workspace string, limit int, sessionID uuid.UUID) ([]models.Mail, error) {
if err := requireReader(agentName); err != nil {
return nil, err
}
if err := checkWorkspace(workspace); err != nil {
return nil, err
}
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
@ -618,7 +741,15 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND s.status <> 'archived'`
// ★ 工作区收窄(必需):`pi` 在两个工作区各有一条收件箱,互不可见。
//
// 用 EXISTS 而不是再 JOIN 一次 sessions:s 已经在上面 JOIN 过了,
// 这里直接把条件写进 WHERE 即可(同一条 s)。
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
if sessionID != uuid.Nil {
// 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明)
args = append(args, sessionID)
@ -633,7 +764,8 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
} else if status == "read" {
q += ` AND NOT ` + unreadFor("$1") + ` AND m.status <> 'archived'`
} else {
q += ` AND m.status = $2`
// ★ 占位符必须是**动态序号**:$2 现在被 workspace 占了。
q += fmt.Sprintf(` AND m.status = $%d`, len(args)+1)
args = append(args, status)
}
}
@ -685,19 +817,41 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
return mails, rows.Err()
}
func CountUnread(ctx context.Context, agentName string) (int, error) {
func CountUnread(ctx context.Context, agentName, workspace string) (int, error) {
return CountUnreadScoped(ctx, agentName, workspace, uuid.Nil)
}
// CountUnreadScoped 与 CountUnread 相同,但 `sessionID` 非零时只数那条会话。
//
// ★ 必须与 ListInboxScoped **同一个收窄口径**:桥的补投判据是
//
// `pending_mails = CountUnread` —— 两者口径不一致时,列表看不到的信会一直
// 被算成"还有未读",桥每次心跳都重放一遍(这是设计文档里记过的那个坑)。
func CountUnreadScoped(ctx context.Context, agentName, workspace string, sessionID uuid.UUID) (int, error) {
if err := requireReader(agentName); err != nil {
return 0, err
}
if err := checkWorkspace(workspace); err != nil {
return 0, err
}
var count int
err := db.DB.QueryRowContext(ctx, `
q := `
SELECT COUNT(*)
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND `+unreadFor("$1")+`
AND s.status <> 'archived'
`, agentName).Scan(&count)
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND ` + unreadFor("$1") + `
AND s.status <> 'archived'`
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
if sessionID != uuid.Nil {
args = append(args, sessionID)
q += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
}
err := db.DB.QueryRowContext(ctx, q, args...).Scan(&count)
return count, err
}
@ -1822,21 +1976,31 @@ func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (i
//
// 排除已归档会话:那些邮件在收件箱里根本看不到,
// 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。
func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) {
return MarkAllInboxReadForSession(ctx, recipient, uuid.Nil)
func MarkAllInboxReadFor(ctx context.Context, recipient, workspace string) (int, error) {
return MarkAllInboxReadForSession(ctx, recipient, workspace, uuid.Nil)
}
// MarkAllInboxReadForSession 只标掉某条会话里发给 recipient 的未读。
//
// 为什么需要:Agent 的「不给 mail_ids,全部标掉」在会话驱动的 worker 里会跨会话
// 误伤(见 ListInboxScoped 上面那段说明)。不带 sessionID(uuid.Nil)时是旧语义。
func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID uuid.UUID) (int, error) {
scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')`
func MarkAllInboxReadForSession(ctx context.Context, recipient, workspace string, sessionID uuid.UUID) (int, error) {
if err := checkWorkspace(workspace); err != nil {
return 0, err
}
// ★ 工作区收窄:与 ListInboxScoped **同一个谓词** —— 列表看不到的信却被
// "全部标掉"标掉,就是静默丢信(session_scope_test.go 记过这个形状)。
wsFilter := ""
args := []any{recipient}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
wsFilter = fmt.Sprintf(" AND workspace = $%d", len(args))
}
scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)`
if sessionID != uuid.Nil {
scope += ` AND m.session_id = $2`
args = append(args, sessionID)
scope += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
}
if err := markReadFor(ctx, recipient, scope, args...); err != nil {
return 0, err
@ -1847,11 +2011,14 @@ func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID
upd := `UPDATE mails SET status = 'read'
WHERE status = 'unread'
AND (to_name = $1 OR ` + db.CCHas("cc_list", 1) + `)
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')`
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)`
updArgs := []any{recipient}
if strings.TrimSpace(workspace) != "" {
updArgs = append(updArgs, workspace)
}
if sessionID != uuid.Nil {
upd += ` AND session_id = $2`
updArgs = append(updArgs, sessionID)
upd += fmt.Sprintf(` AND session_id = $%d`, len(updArgs))
}
res, err := db.DB.ExecContext(ctx, upd, updArgs...)
if err != nil {

View File

@ -62,7 +62,7 @@ func TestInboxListIsScopedBySession(t *testing.T) {
s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot")
// 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到
all, err := ListInbox(ctx, "bot", "unread", 50)
all, err := ListInbox(ctx, "bot", "unread", "", 50)
if err != nil {
t.Fatal(err)
}
@ -71,7 +71,7 @@ func TestInboxListIsScopedBySession(t *testing.T) {
}
// ★ 收窄到会话一:只有它那条
scoped, err := ListInboxScoped(ctx, "bot", "unread", 50, s1)
scoped, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1)
if err != nil {
t.Fatal(err)
}
@ -83,7 +83,7 @@ func TestInboxListIsScopedBySession(t *testing.T) {
}
// 会话二同理(别只验一侧 —— 方向反了也能"过")
scoped2, err := ListInboxScoped(ctx, "bot", "unread", 50, s2)
scoped2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2)
if err != nil {
t.Fatal(err)
}
@ -98,14 +98,14 @@ func TestMarkAllReadCanBeScopedToSession(t *testing.T) {
s1, s2, _, _ := twoSessionsWithUnread(t, "bot")
// 只标会话一
n, err := MarkAllInboxReadForSession(ctx, "bot", s1)
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)
left, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2)
if err != nil {
t.Fatal(err)
}
@ -114,10 +114,10 @@ func TestMarkAllReadCanBeScopedToSession(t *testing.T) {
}
// 反向对照:不带会话时两条都会被标掉
if _, err := MarkAllInboxReadFor(ctx, "bot"); err != nil {
if _, err := MarkAllInboxReadFor(ctx, "bot", ""); err != nil {
t.Fatal(err)
}
if again, _ := ListInbox(ctx, "bot", "unread", 50); len(again) != 0 {
if again, _ := ListInbox(ctx, "bot", "unread", "", 50); len(again) != 0 {
t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again))
}
}
@ -133,7 +133,7 @@ func TestScopedCountsMatchScopedList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
list1, err := ListInboxScoped(ctx, "bot", "unread", 50, s1)
list1, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1)
if err != nil {
t.Fatal(err)
}
@ -172,7 +172,7 @@ func TestInboxRowsCarrySessionPermissionMode(t *testing.T) {
}
seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid)
rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid)
if err != nil {
t.Fatal(err)
}
@ -190,7 +190,7 @@ func TestInboxRowsCarrySessionPermissionMode(t *testing.T) {
// 缺字段时"猜宽"就是提权,所以默认值这一侧也要验。
other := createTestSession(t, ctx, "bot", "/tmp/ws2")
seedMailInSession(t, other, "bot")
rows2, err := ListInboxScoped(ctx, "bot", "unread", 50, other)
rows2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, other)
if err != nil {
t.Fatal(err)
}
@ -219,7 +219,7 @@ func TestInboxRowsCarryReplyAddress(t *testing.T) {
}
seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid)
rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid)
if err != nil {
t.Fatal(err)
}

View File

@ -4,60 +4,153 @@ import (
"context"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
工作区维度:一个 Agent 同时服务**所有**工作区,所以"参与过"不等于"该看"。
工作区维度:**同一个 Agent 名字在不同工作区各有独立收件箱。**
# 用户报的缺陷
# 用户报的缺陷(12 天前就提过,这轮才真修)
「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」
「不同 session 的 agent 都可以看到全部邮件」—— `552fbc7` 修了 **session_id** 那一维,
但**工作区**那一维从没做。生产实测(2026-09-26):在 `mc` 工作区干活的 pi 读收件箱
拿到 200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改
agentmail 的代码,把手上 mc 的活丢在一边。用户当场问「你怎么干着干着修 agentmail 去了?」
根因不是某处漏了一个 WHERE,而是**隔离单位选的是 Agent**:
三维地址是 `name@path.session`。**path 位本来就该参与寻址**,收件箱侧此前完全没用它。
- `AgentCanAccessSession(agentName, sid)` 判的是「这个 Agent 名出现在这条会话的
from/to/cc 里」;
- 而 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace`
决定),于是同一个 agent `pi` 既"参与过" agentmail 的会话、也"参与过"
TrueAgent 的会话 —— 两个工作区之间没有任何边界。
# 判据要两侧都验
现场证据:`mail_reads` 里 2026-09-14 08:11–09:19 有 8 次"同一瞬间读了多个不同工作区
的会话"(最典型 08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),
而那正是按 Agent 整表读的特征。更要紧的是 `mail_reads` 只记 `reader_name`,
**没有"读的人当时在哪个工作区"这一列** —— 这类越界读在数据上与正常读无法区分。
# 判据两侧都验
只验"跨工作区被拒"是不够的:把函数写成永远拒绝也能过。所以同时验
- 同工作区必须放行(否则等于把所有 Agent 都锁死);
- 未声明 scope 时的旧语义(迁移期妥协,必须明确写下来,不能靠"没人测"存在);
- 拒绝的**原因**要分得清(没参与过 vs 跨工作区)—— 否则调用方无法自查;
- 列表类接口的反向对照:不带收窄时两条会话都在(证明收窄真的在起作用)。
只验「带工作区 A 时看不到 B 的信」不够 —— 把列表直接返回空也能过。
所以同时验反向对照:「不带工作区时两条都看得到」(人类侧语义)。
两侧一起才证明是**收窄**而不是**清空**。
*/
func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID {
func seedMailInWorkspace(t *testing.T, workspace, recipient string) uuid.UUID {
t.Helper()
id, err := CreateSession(context.Background(), nil, "human", title, ws)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "ws-"+workspace, workspace)
if err != nil {
t.Fatalf("create session(%s): %v", title, err)
t.Fatal(err)
}
var id uuid.UUID
if err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, to_workspace)
VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`,
sid, recipient, workspace).Scan(&id); err != nil {
t.Fatal(err)
}
seedMailInSession(t, id, agent)
return id
}
func TestListContactsWithEmptyScopeStillWorks(t *testing.T) {
func TestInboxListIsScopedByWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sessionIn(t, "pi", "/home/program/agentmail", "随便一条线索")
all, err := ListContactsFor(ctx, "", false)
const wsA = "/home/program/agentmail"
const wsB = "/home/newqqagent/plugindev/mc"
a := seedMailInWorkspace(t, wsA, "pi")
b := seedMailInWorkspace(t, wsB, "pi")
// ① 带工作区 A ⇒ 只有 A 那封
onlyA, err := ListInbox(ctx, "pi", "all", wsA, 50)
if err != nil {
t.Fatalf("scope 为空(管理员看全部)不该报错:%v", err)
t.Fatal(err)
}
if len(all) != 1 {
t.Fatalf("应当列出 1 条(实际 %d 条)", len(all))
if len(onlyA) != 1 || onlyA[0].ID != a {
t.Fatalf("工作区 A 应收窄到 1 封(A 那封),实际 %d 封", len(onlyA))
}
// ② 带工作区 B ⇒ 只有 B 那封(**反向对照**:证明不是"总是返回第一封")
onlyB, err := ListInbox(ctx, "pi", "all", wsB, 50)
if err != nil {
t.Fatal(err)
}
if len(onlyB) != 1 || onlyB[0].ID != b {
t.Fatalf("工作区 B 应收窄到 1 封(B 那封),实际 %d 封", len(onlyB))
}
// ③ 不带工作区(人类侧语义)⇒ 两封都看得到
// ★ 这一条是"收窄 vs 清空"的分水岭:少了它,把 ① 实现成 return nil 也能过。
all, err := ListInbox(ctx, "pi", "all", "", 50)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("不带工作区应看到 2 封(人类跨工作区),实际 %d 封", len(all))
}
// ④ 未读数同样按工作区算 —— 它是桥的补投判据 pending_mails。
// 口径不一致时,桥会认为"还有信没投"并反复重放。
nA, err := CountUnread(ctx, "pi", wsA)
if err != nil {
t.Fatal(err)
}
nB, err := CountUnread(ctx, "pi", wsB)
if err != nil {
t.Fatal(err)
}
nAll, err := CountUnread(ctx, "pi", "")
if err != nil {
t.Fatal(err)
}
if nA != 1 || nB != 1 || nAll != 2 {
t.Fatalf("未读数应为 A=1 B=1 全部=2,实际 A=%d B=%d 全部=%d", nA, nB, nAll)
}
}
// 「全部标已读」也必须按工作区收窄 —— 否则标掉别的工作区的未读 = 静默丢信
// (session_scope_test.go 记过这个形状)。
func TestMarkAllReadIsScopedByWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const wsA = "/home/program/agentmail"
const wsB = "/home/newqqagent/plugindev/mc"
seedMailInWorkspace(t, wsA, "pi")
seedMailInWorkspace(t, wsB, "pi")
n, err := MarkAllInboxReadForSession(ctx, "pi", wsA, uuid.Nil)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("标已读应只影响工作区 A 的 1 封,实际 %d 封", n)
}
// A 的标掉了
if left, _ := CountUnread(ctx, "pi", wsA); left != 0 {
t.Fatalf("工作区 A 应已清空,实际剩 %d 封未读", left)
}
// B 的**必须还在**(这是本判据的重点)
if left, _ := CountUnread(ctx, "pi", wsB); left != 1 {
t.Fatalf("工作区 B 的未读被误标了 —— 这是静默丢信(应剩 1 封,实际 %d)", left)
}
// 反向对照:不带工作区时全标掉(人类侧)
if _, err := MarkAllInboxReadForSession(ctx, "pi", "", uuid.Nil); err != nil {
t.Fatal(err)
}
if left, _ := CountUnread(ctx, "pi", ""); left != 0 {
t.Fatalf("不带工作区应全部标掉,实际剩 %d 封", left)
}
}
// workspace 的形状校验:非空时必须是绝对路径。
// 相对路径在服务端无法解释(不同调用方 cwd 不同 ⇒ 拼出来必然对不上),
// 而"对不上"的表现是**静默返回空收件箱** —— 那比报错难查得多。
func TestWorkspaceMustBeAbsolute(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedMailInWorkspace(t, "/home/program/agentmail", "pi")
for _, bad := range []string{"relative/path", "./here", "~/home"} {
if _, err := ListInbox(ctx, "pi", "all", bad, 50); err == nil {
t.Fatalf("ListInbox 对相对路径 %q 必须报错,实际通过(会静默返回空收件箱)", bad)
}
if _, err := CountUnread(ctx, "pi", bad); err == nil {
t.Fatalf("CountUnread 对相对路径 %q 必须报错", bad)
}
}
}