用户 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 并对主题断言。
282 lines
9.7 KiB
Go
282 lines
9.7 KiB
Go
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@.项目定位")
|
||
}
|
||
}
|