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 并对主题断言。
This commit is contained in:
2026-09-15 11:51:58 +08:00
parent 38b0e07415
commit 4d9b47e50e
2 changed files with 134 additions and 1 deletions

View File

@ -137,6 +137,29 @@ func Recipients(ctx context.Context, m Mail) {
}
}
// 回信地址的 path 位:**Agent 才有工作目录**。
//
// 原先这里硬编码空串,理由是「path 的语义是发件人该在哪儿干活,而人没有工作目录」。
// 那个理由只对**人**成立 —— 发件方是 Agent 时,空 path 就把它的工作目录丢了,
// 于是同一个地址出现两种口径:
// 这里(插件印给模型看的回信地址):`dsh@.鸿蒙客户端与-WebUI-界面对齐`
// suggest_address / 邮件表的 from_workspace:`dsh@/home/program/agentmail.鸿蒙…`
//
// 2026-09-15 用户直接从通知里看出了不对(「这个地址明显核心拼错了」)。
// 后果不只是难看:插件把 reply_address 原样印给模型,模型照它回信时,
// 收信方的 path 位就空了 —— 而 path 是「去哪个目录干活」的唯一提示,
// 插件只能自己拼临时目录(同一封邮件的每一方都落在不同空目录里)。
//
// 人的地址保持无 path(那是「人没有工作目录」的正确表达)。
replyPath := ""
if human, err := repo.IsHumanUser(ctx, replyTo); err == nil && !human {
replyPath = repo.SessionWorkspaceOf(ctx, m.SessionID)
if replyPath == replyTo {
// 历史脏数据:Agent 名曾被当成路径存下来,那不是地址(同 forward.go 的 senderAddress)
replyPath = ""
}
}
payload := func(role, workspace, forName string) map[string]interface{} {
p := map[string]interface{}{
"mail_id": m.MailID.String(),
@ -160,7 +183,7 @@ func Recipients(ctx context.Context, m Mail) {
// 地址里没有 path 不会丢工作目录 —— 收信方回信时,发信端会用
// `sessions.workspace` 把它补齐(见 handler/mail.go 的“收件方是
// Agent 且地址里没写 path”那段),所以这里不必也不应该自己填。
"reply_address": models.FormatAddress(replyTo, "", alias),
"reply_address": models.FormatAddress(replyTo, replyPath, alias),
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
"self_address": models.FormatAddress(forName, workspace, alias),
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的

View File

@ -169,3 +169,113 @@ func TestRecipients_PlainSessionNoPlatformID(t *testing.T) {
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@.项目定位")
}
}