Files
MailUI4Agents/server/internal/notify/notify_test.go
JianFeeeee 4d9b47e50e fix(notify): 回信地址的 path 位按「Agent 有工作目录、人没有」给 —— 用户从通知里看出的地址不一致
用户 2026-09-15 原话:「这个地址明显核心拼错了」。实情是**同一个地址有两种口径**:

  插件印给模型看的(new_mail 的 reply_address):dsh@.鸿蒙客户端与-WebUI-界面对齐   ← 空 path
  suggest_address / 邮件表的 from_workspace:     dsh@/home/program/agentmail.鸿蒙…  ← 有 path

根因在 notify/mail.go:`reply_address` 的 path 位硬编码成空串,注释给的理由
("path 的语义是发件人该在哪儿干活,而人没有工作目录")**只对人成立**。
发件方是 Agent 时,空 path 把它的工作目录丢了,而插件会把 reply_address 原样印给
模型、模型照它回信 —— 收信方的 path 位就空了,插件只能自己拼临时目录
(同一封信的每个参与方落在不同空目录里,正是"未分组"那类症状的源头)。

修:path 只看**发件身份**是不是人(repo.IsHumanUser),不是人就用会话工作目录
(repo.SessionWorkspaceOf),并沿用 forward.go 的同一条守卫:`path == 名字`
是历史脏数据(agent 名曾被当路径存过),丢弃。

判据(新增,两侧都写 + 变异验证过):
- Agent 发件方 → reply_address 必须带 path;
- 人发件方 → 必须**不**带(给人写工作目录是另一种错地址)。
两次变异(退回空串 / 不分人机都给)都必须变红,实测都红。

测试夹具踩了一个坑并记在注释里:attach 的闭包总从头扫、返回第一帧,
同一个客户端连收两封时会读到上一封 —— 所以新加了 attachLast 并对主题断言。
2026-09-15 11:51:58 +08:00

282 lines
9.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 notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
func setupTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
func seedAgent(t *testing.T, name string) {
t.Helper()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`,
name); err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
// attach 挂一个真实的 SSE 客户端并返回「读出这个 Agent 收到的 new_mail payload」的闭包。
//
// 走真实的 sse.Default 而不是替换发送函数:要验的正是「谁收到什么」,
// 而分发逻辑就在 Manager 里 —— 把它换掉等于不验。
func attach(t *testing.T, agentName string) func() map[string]any {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/events/stream", nil)
c := sse.Default.AddClient(rec, req, agentName, "")
if c == nil {
t.Fatalf("AddClient(%s) 返回 nil", agentName)
}
t.Cleanup(func() { sse.Default.RemoveClient(c.ID) })
return func() map[string]any {
// SSE 帧形如 `id: N\nevent: new_mail\ndata: {…}\n\n`
for _, frame := range strings.Split(rec.Body.String(), "\n\n") {
if !strings.Contains(frame, "event: new_mail") {
continue
}
for _, line := range strings.Split(frame, "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m); err == nil {
return m
}
}
}
return nil
}
}
// seedAdopted 建一条接管了 owner 的平台会话的本侧会话。
func seedAdopted(t *testing.T, owner, platformID, workspace string) uuid.UUID {
t.Helper()
ctx := context.Background()
if err := repo.ReplacePlatformSessions(ctx, owner, []repo.PlatformSession{
{PlatformID: platformID, Workspace: workspace, Slug: "项目定位", Title: "项目定位"},
}); err != nil {
t.Fatalf("ReplacePlatformSessions: %v", err)
}
id, err := repo.AdoptPlatformSession(ctx, owner, platformID, "项目定位", workspace, "项目定位")
if err != nil {
t.Fatalf("AdoptPlatformSession: %v", err)
}
return id
}
// platform_session_id 只该发给归属方。
//
// 生产事故:会话接管了 pi 的 `01a05a5e-…`,而那封邮件抄送了 dsh。DSH 收到同一个
// id,在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件),
// 于是按 N-8 抛「平台侧会话已删」——邮件静默消失,日志里一个字都没有。
func TestRecipients_PlatformIDOnlyToOwner(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
seedAgent(t, "dsh")
readPi, readDsh := attach(t, "pi"), attach(t, "dsh")
sessionID := seedAdopted(t, "pi", "pid-pi-1", "/w")
Recipients(context.Background(), Mail{
SessionID: sessionID,
MailID: uuid.New(),
From: "jianf",
To: models.Address{Name: "pi", Path: "/w"},
CC: []models.Address{{Name: "dsh", Path: "/w"}},
Subject: "任务",
})
pi, dsh := readPi(), readDsh()
if pi == nil {
t.Fatal("归属方 pi 没收到 new_mail")
}
if dsh == nil {
t.Fatal("抄送方 dsh 没收到 new_mail(抄送方必须单独推)")
}
if v := pi["platform_session_id"]; v != "pid-pi-1" {
t.Errorf("归属方 pi 的 platform_session_id = %v, want pid-pi-1", v)
}
if v := dsh["platform_session_id"]; v != "" {
t.Errorf("抄送方 dsh 的 platform_session_id = %v, want 空串(那是 pi 的会话文件)", v)
}
}
// 归属方在抄送位上也要拿到:归属与收件角色无关。
func TestRecipients_PlatformIDToOwnerEvenAsCC(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
seedAgent(t, "dsh")
readPi, readDsh := attach(t, "pi"), attach(t, "dsh")
sessionID := seedAdopted(t, "pi", "pid-pi-2", "/w")
Recipients(context.Background(), Mail{
SessionID: sessionID,
MailID: uuid.New(),
From: "jianf",
To: models.Address{Name: "dsh", Path: "/w"},
CC: []models.Address{{Name: "pi", Path: "/w"}},
Subject: "任务",
})
if v := readPi()["platform_session_id"]; v != "pid-pi-2" {
t.Errorf("抄送位上的归属方 pi = %v, want pid-pi-2", v)
}
if v := readDsh()["platform_session_id"]; v != "" {
t.Errorf("主收件人 dsh = %v, want 空串", v)
}
}
// 普通(非接管)会话:谁都不该拿到 platform id。
func TestRecipients_PlainSessionNoPlatformID(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
readPi := attach(t, "pi")
id, err := repo.CreateSession(context.Background(), nil, "pi", "普通", "/w")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
Recipients(context.Background(), Mail{
SessionID: id, MailID: uuid.New(), From: "jianf",
To: models.Address{Name: "pi", Path: "/w"}, Subject: "任务",
})
if v := readPi()["platform_session_id"]; v != "" {
t.Errorf("普通会话 = %v, want 空串", v)
}
}
// seedHuman 建一个人类用户(IsHumanUser 判的就是这张表)。
func seedHuman(t *testing.T, name string) {
t.Helper()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (username, display_name, password_hash) VALUES ($1, $1, 'x')`,
name); err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
}
/*
回信地址的 path 位:**Agent 有工作目录,人没有**。
用户 2026-09-15 从通知里直接看出了不对:「这个地址明显核心拼错了」。
实情是同一个地址有两种口径:
插件印给模型看的(本函数产出的 reply_address):dsh@.鸿蒙客户端与-WebUI-界面对齐 ← 空 path
suggest_address / 邮件表的 from_workspace: dsh@/home/program/agentmail.鸿蒙… ← 有 path
根因:`reply_address` 的 path 硬编码成空串,注释给的理由("path 的语义是发件人该在哪
干活,而人没有工作目录")**只对人成立**。发件方是 Agent 时,空 path 把它的工作目录丢了 ——
而插件会把 reply_address 原样印给模型,模型照它回信时收信方 path 位就是空的,
插件只能自己拼临时目录(同一封信的每个参与方落在不同空目录里)。
判据两侧都写:Agent 发件方必须带 path;人发件方必须不带(人确实没有工作目录)。
只验一侧的话,"永远带上会话工作目录"那种写法也能过 —— 那会把人类地址写成
`jianf@/home/program/agentmail.…`,是另一种错地址。
*/
// attachLast 与 attach 同样挂真实 SSE 客户端,但读的是**最后一帧** new_mail。
//
// 为什么需要它:同一个客户端在一次测试里会接连收到多封(本用例要打两次
// Recipients),而 attach 的闭包总是从头扫、返回第一帧 —— 第二次读还是上一封,
// 于是判据会在错误的样本上通过或失败(这个坑是本用例第一次跑就踩到的)。
func attachLast(t *testing.T, agentName string) func() map[string]any {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/events/stream", nil)
c := sse.Default.AddClient(rec, req, agentName, "")
if c == nil {
t.Fatalf("AddClient(%s) 返回 nil", agentName)
}
t.Cleanup(func() { sse.Default.RemoveClient(c.ID) })
return func() map[string]any {
var last map[string]any
for _, frame := range strings.Split(rec.Body.String(), "\n\n") {
if !strings.Contains(frame, "event: new_mail") {
continue
}
for _, line := range strings.Split(frame, "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m); err == nil {
last = m
}
}
}
return last
}
}
func TestRecipients_ReplyAddressCarriesAgentWorkspaceOnly(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
seedAgent(t, "dsh")
seedHuman(t, "jianf")
readPi := attachLast(t, "pi")
// seedAdopted 建的会话,别名固定为「项目定位」
sessionID := seedAdopted(t, "pi", "pid-ws-1", "/home/program/agentmail")
// ① 发件方是 Agent(dsh):回信地址必须带上它的工作目录
Recipients(context.Background(), Mail{
SessionID: sessionID,
MailID: uuid.New(),
From: "dsh",
To: models.Address{Name: "pi", Path: "/home/program/agentmail"},
Subject: "地址一致性",
})
piFrame := readPi()
if piFrame["subject"] != "地址一致性" {
t.Fatalf("读到的帧不是这一轮的(subject=%v)——夹具又错了", piFrame["subject"])
}
got := piFrame["reply_address"]
if got != "dsh@/home/program/agentmail.项目定位" {
t.Errorf("Agent 发件方的 reply_address = %q, want %q(path 是「去哪个目录干活」的唯一提示)",
got, "dsh@/home/program/agentmail.项目定位")
}
// ② 发件方是人(jianf):不带 path —— 人没有工作目录
Recipients(context.Background(), Mail{
SessionID: sessionID,
MailID: uuid.New(),
From: "jianf",
To: models.Address{Name: "pi", Path: "/home/program/agentmail"},
Subject: "地址一致性(人)",
})
piFrame = readPi()
if piFrame["subject"] != "地址一致性(人)" {
t.Fatalf("读到的帧不是人类那一轮的(subject=%v)", piFrame["subject"])
}
got = piFrame["reply_address"]
if got != "jianf@.项目定位" {
t.Errorf("人类发件方的 reply_address = %q, want %q(给人写工作目录是错地址)",
got, "jianf@.项目定位")
}
}