Files
MailUI4Agents/server/internal/handler/address_flatten_test.go
JianFeeeee 1b810a4898 fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑
## 起因

DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。
三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它
必须先知道 path —— 但 path 恰恰是调用方无从得知的:

    给 name      → 只给 path(要再调一次才知道有哪些会话)
    给 name+path → 给会话别名(但 path 得先猜对)

于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题
为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。

## 修法

**① A 项:`flatten=1` 一次给出全部可投递地址**

`SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带
`path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼,
拼错就是那个「猜错比报错更糟」。

可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name
匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。

按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信
仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。

**② B/C 项:标注而非隐藏**

`paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。

选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`)
确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是
其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条
是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。

`suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序
(刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。

## ★★ 顺带修掉一个生产级缺陷(实测撞出来的)

给 `SessionCandidate` 加 `LastActivity` 时用了:

    COALESCE(s.updated_at, '0001-01-01 00:00:00+00')

COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan`
⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。

生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。
而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」——
本次是我自己加的列触发的,排查花了几步。

改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。
注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。

## MCP 侧同步

`suggest_address` 加 `flatten` 参数,且**渲染必须单独写**:
flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句
「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。

path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径
(`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。

## 判据(8 格)

含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、
「两个工作区都要出现」(原形状缺的就是这一维)、
「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、
「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。

**变异验证**:

    COALESCE 兜底(那个真 bug)          → 红 1 ✓
    flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path")

## 实测校准了一处报告里的数字

报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** ——
服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是
「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建,
恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。

§E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。

全量 14 包绿。
2026-10-02 15:59:56 +08:00

258 lines
9.4 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 handler
/*
`flatten` 与 path 标注的判据(2026-10-02,DSH 侧寻址报告 A/B/C)。
# 为什么这些判据形状是「实测出来的」而不是「想出来的」
报告的复现里有一条关键差异:它写「近似写法返回 0 条」,实测返回 **1 条**,
内容是 `new` —— 服务端在任何 path 下都追加的新建占位(见 agent_discovery.go
里那句「new 总在最后」)。所以:
path=/root → 17 条真实会话
path=/root/ → 1 条: ['new'] ← 不是「空」,而是「看起来像出路」
这比报 0 更危险:调用方看到「只有 new 可选」就会顺手新建,于是**恰好落进
它猜错的那个工作区**。这一格就是钉住这个形状 —— 它决定了修复必须
让「path 不对」这件事**看起来像失败**,而不是像一条出路。
*/
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
)
// pathCandidates 的形状判据:桥内部目录必须被标出来,且 suggestions 原样保留。
func TestClassifyPathMarksBridgeInternal(t *testing.T) {
c := classifyPath("/root/.pi/mail-sessions/4c78e966-d231-44e2-9a1f-2c733d3c5e19")
if c.Kind != "bridge-internal" {
t.Errorf("★ 桥内部目录必须被标出,否则它与真工作区无法区分:%+v", c)
}
if c.Note == "" {
t.Error("必须给一句说明(调用方要据此降权)")
}
// 真工作区不得被误标
w := classifyPath("/home/program/agentmail")
if w.Kind != "workspace" {
t.Errorf("真实工作区被误标为 %q", w.Kind)
}
if w.Note != "" {
t.Errorf("真实工作区不该带 note(否则全是噪声):%q", w.Note)
}
}
func TestClassifyPathMarksRelativePath(t *testing.T) {
// `root` 与 `/root` 在数据里是两个不同工作区(实测 1 条 vs 17 条),
// 而外观只差一个开头的斜杠。必须标出来,否则调用方会当成同一个。
c := classifyPath("root")
if c.IsAbsolute {
t.Error("root 不是绝对路径")
}
if c.Note == "" {
t.Error("★ 相对路径必须标注 —— 它与 /root 是两个不同工作区,选错即静默投错")
}
if !strings.Contains(c.Note, "/root") {
t.Errorf("说明里要点明它与 /root 的区别,实际 %q", c.Note)
}
// 反向对照:绝对路径**不该**带相对路径说明(它没有歧义)。
// (这一行最初写反了 —— 断言 Note=="" 为失败,于是把正确行为当成 bug。)
if a := classifyPath("/root"); !a.IsAbsolute {
t.Errorf("/root 是绝对路径:%+v", a)
} else if a.Note != "" {
t.Errorf("/root 无歧义,不该带相对路径说明:%q", a.Note)
}
}
func TestPathCandidatesKeepsOriginalList(t *testing.T) {
in := []string{"/root", "root", "/root/.pi/mail-sessions/abc"}
out := pathCandidates(in)
if len(out) != len(in) {
t.Fatalf("数量必须一致(向后兼容:suggestions 仍按原样给)")
}
for i := range in {
if out[i].Path != in[i] {
t.Errorf("第 %d 条 path 变了:%q → %q", i, in[i], out[i].Path)
}
}
// 顺序也不能变 —— SuggestPaths 是按最近使用倒序排的(刚用过的那个
// 几乎总是下一封想用的),排序被打乱就等于让模型取第一条 = 最老的那个。
}
// flatten 端到端:一次给出全部可投递地址,且每个候选自带完整 address。
func TestFlattenListsAddressesWithOwnPath(t *testing.T) {
setupHandlerTestDB(t)
seedPeerSession(t, "pi", "/home/program/agentmail", "架构讨论")
seedPeerSession(t, "pi", "/root", "虚拟机架构")
rec := doSuggest(t, "dsh", "pi", true)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
}
var got struct {
Kind string `json:"kind"`
Addresses []string
Candidates []struct {
Alias string `json:"alias"`
Path string `json:"path"`
Address string `json:"address"`
Source string `json:"source"`
}
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("响应不是合法 JSON:%v", err)
}
if got.Kind != "session_flat" {
t.Errorf("kind=%q", got.Kind)
}
if len(got.Candidates) < 2 {
t.Fatalf("两个不同工作区下的会话都该列出,实际 %d 条:%+v", len(got.Candidates), got.Candidates)
}
// 每个候选的 address 必须与 path 一致 —— 这正是「猜 path 会静默投错」的解药:
// 调用方不必自己拼,拼错就落进了别的会话。
pathsSeen := map[string]bool{}
for _, c := range got.Candidates {
if c.Address == "" {
t.Errorf("候选 %q 缺 address(调用方就得自己拼 ⇒ 拼错即静默投错)", c.Alias)
continue
}
want := c.Path + "." + c.Alias
if !strings.HasSuffix(c.Address, want) {
t.Errorf("★ address %q 与候选自身 path/alias 不一致(应含 %q)", c.Address, want)
}
pathsSeen[c.Path] = true
}
if len(pathsSeen) < 2 {
t.Errorf("★ 两个工作区都该出现 —— 这正是原形状缺的那一维。实际:%v", pathsSeen)
}
}
// path 不对时必须「看起来像失败」,不能像一条出路。
//
// 报告实测 path=/root/ 只给出 ['new'],于是调用方顺手新建 ——
// 恰好落进它猜错的那个工作区。flatten 这一支不能重复这个形状。
func TestFlattenDoesNotOfferNewWhenNoRealSessions(t *testing.T) {
setupHandlerTestDB(t)
// 一个从未与我往来的 name ⇒ 没有任何真实会话
rec := doSuggest(t, "dsh", "从未通信的-agent", true)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP %d", rec.Code)
}
var got struct {
Candidates []struct {
Alias string `json:"alias"`
Source string `json:"source"`
}
}
_ = json.Unmarshal(rec.Body.Bytes(), &got)
for _, c := range got.Candidates {
if c.Alias == "new" {
t.Errorf("★ flatten 里不得把「新建」混在候选里 —— " +
"没有真实会话时它看起来像出路,调用方会顺着它投进猜错的工作区")
}
}
}
// 不带 flatten 时行为一字未变(各桥与 WebUI 都走这一支)。
func TestSuggestWithoutFlattenUnchanged(t *testing.T) {
setupHandlerTestDB(t)
seedPeerSession(t, "pi", "/home/program/agentmail", "架构讨论")
rec := doSuggest(t, "dsh", "pi", false)
var got struct {
Kind string `json:"kind"`
Suggestions []string `json:"suggestions"`
Addresses []string `json:"addresses"`
Paths []struct {
Path string `json:"path"`
Kind string `json:"kind"`
} `json:"paths"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got.Kind != "path" {
t.Errorf("不带 flatten 时 kind 应仍是 path(向后兼容),实际 %q", got.Kind)
}
if len(got.Suggestions) == 0 {
t.Error("suggestions 必须照旧")
}
// paths 是**新增**字段,不替换 suggestions
if len(got.Paths) != len(got.Suggestions) {
t.Errorf("paths 与 suggestions 应一一对应:%d vs %d", len(got.Paths), len(got.Suggestions))
}
}
// ---- 夹具 ----
func setupHandlerTestDB(t *testing.T) {
t.Helper()
db.Close()
path := filepath.Join(t.TempDir(), "addr-flatten.db")
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移测试库: %v", err)
}
t.Cleanup(db.Close)
// 两个 Agent(caller / peer),seedPeerSession 要往里写邮件
for _, n := range []string{"dsh", "pi", "从未通信的-agent"} {
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform, default_rounds)
VALUES ($1, 'x', 'test', 50)`, n); err != nil {
t.Fatalf("建 Agent %s: %v", n, err)
}
}
}
// seedPeerSession 造一条「我与 peer 在 ws 下往来过」的会话 ——
// 这是 SuggestSessionCandidates 能看到它的前提(EXISTS 那条子查询)。
func seedPeerSession(t *testing.T, peer, ws, title string) {
t.Helper()
ctx := context.Background()
// alias 由调用方显式给:生产库有 idx_sessions_path_alias_uniq,
// 而「从 ws 推导 alias」在两个 ws 下可能撞车(/root → s-root 与
// 另一个空 ws 都落到同一个),撞了 t.Fatalf 会把整格变成 setup 失败 ——
// 那看起来像功能坏了,其实只是夹具推导有歧义。
alias := title
var sid string
if err := db.DB.QueryRowContext(ctx,
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
VALUES ($1,$2,'active',$3,$1) RETURNING session_id`,
alias, title, ws).Scan(&sid); err != nil {
t.Fatalf("建会话: %v", err)
}
// ★ 收件人必须是 peer:SuggestPaths 来源 1 是
// `WHERE to_name = $1`(peerName)—— 给 dsh→dsh 它什么也看不见。
// (最初写成 to_name='dsh',于是 paths 与 candidates 全空,
// 一度看起来像代码缺陷;其实是夹具没照 SQL 的形状造数据。)
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO mails (session_id, from_name, from_workspace, to_workspace, to_name, subject, body, status)
VALUES ($1,'dsh',$3,$3,$2,$4,'x','unread')`, sid, peer, ws, title); err != nil {
t.Fatalf("建邮件: %v", err)
}
}
func doSuggest(t *testing.T, caller, name string, flatten bool) *httptest.ResponseRecorder {
t.Helper()
target := "/api/v1/agent/contacts/suggest?name=" + name
if flatten {
target += "&flatten=1"
}
r := httptest.NewRequest(http.MethodGet, target, nil)
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, caller))
rec := httptest.NewRecorder()
AgentSuggestAddress(rec, r)
return rec
}