Files
MailUI4Agents/server/internal/handler/address_flatten_test.go
JianFeeeee d1099526ad fix(寻址): flatten 的候选**逐条**标注 —— 第一版把 §C 噪声放进了新端点
## 缺口(部署后实测才发现,是我自己引入的)

第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:

    222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
    而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出

那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。

只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。

## 修法

`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。

marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:

    同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
    而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
    改一处忘另一处就会出现这种自相矛盾,且没有任何报错。

## 判据(2 格)

    TestFlattenAnnotatesEachCandidate  桥内部目录/相对路径能分类 + 带说明;
                                        真工作区不得被误标(否则全是噪声)
    TestClassifyPathAgreesWithRepo     handler 与 repo 口径必须逐条一致

## 顺带

第一版 flatten 本身已验证有效(生产实测):

    flatten=1 → 222 条候选、66 个工作区
    /home/program/agentmail 125 条 · /root 16 条 · root 2 条
    ⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」

    path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
2026-10-02 16:06:00 +08:00

360 lines
14 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"
"github.com/agentmail/gateway/internal/repo"
)
// 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
}
/*
★ 逐候选标注(2026-10-02 部署后实测发现的缺口,我引入的)。
第一版 flatten 只在响应的 `paths[]` 里标注。实测 222 条候选里 37 条(16%)
落在桥内部目录,而标注在另一个数组 —— 模型必须自己把 candidates 与 paths
对照才认得出。那就是「§C 噪声淹没信号」换个位置复活:新端点把噪声一起放大了。
*/
func TestFlattenAnnotatesEachCandidate(t *testing.T) {
kind, note := repo.ClassifyPathKind("/root/.pi/mail-sessions/abc"), repo.ClassifyPathNote("/root/.pi/mail-sessions/abc")
if kind != "bridge-internal" {
t.Errorf("★ 桥内部目录必须能分类出来,实际 %q", kind)
}
if note == "" {
t.Error("必须带说明")
}
// 反向对照:真工作区不得被标
if k := repo.ClassifyPathKind("/home/program/agentmail"); k != "workspace" {
t.Errorf("真实工作区被误标 %q", k)
}
if n := repo.ClassifyPathNote("/home/program/agentmail"); n != "" {
t.Errorf("真工作区不该带 note(否则全是噪声):%q", n)
}
// 相对路径:分类仍是 workspace,但 note 必须点明它与 /root 的区别
if k := repo.ClassifyPathKind("root"); k != "workspace" {
t.Errorf("相对路径的 kind 应仍是 workspace,实际 %q", k)
}
if n := repo.ClassifyPathNote("root"); n == "" {
t.Error("★ 相对路径必须带 note —— 它与 /root 是两个不同工作区")
}
}
// handler 与 repo 共用同一批 marker(两处各写一份时,改一处忘另一处
// ⇒ 同一目录在 path 列表标「工作区」、在候选列表没标,而两者同一次返回)。
func TestClassifyPathAgreesWithRepo(t *testing.T) {
for _, p := range []string{
"/root/.pi/mail-sessions/abc", "/mail-sessions/x", "/.dsh/y",
"/home/program/agentmail", "root", "/.zcode/mail-sessions/z",
} {
if got, want := classifyPath(p).Kind, repo.ClassifyPathKind(p); got != want {
t.Errorf("path=%q:handler 说 %q,repo 说 %q —— 两处口径必须一致", p, got, want)
}
}
}
// ★ 逐候选必须自带标注 —— 不能只靠响应里那个 paths[] 数组。
//
// 实测(2026-10-02):flatten 一次给 222 条候选,其中 37 条(16%)落在桥的
// 内部会话目录。若标注只在 paths[] 里,模型必须自己把 candidates 与 paths
// 两个数组对照才认得出 —— 那就是 §C「噪声淹没信号」换个位置复活。
//
// 判据两侧都钉:有标注 / 反向对照(真工作区不该被误标)。
func TestFlattenCandidatesCarryTheirOwnPathFlags(t *testing.T) {
setupHandlerTestDB(t)
seedPeerSession(t, "pi", "/root/.pi/mail-sessions/abc-123", "桥内部的一条")
seedPeerSession(t, "pi", "/home/program/agentmail", "真实工作区")
rec := doSuggest(t, "dsh", "pi", true)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
}
var got struct {
Candidates []struct {
Alias string `json:"alias"`
Path string `json:"path"`
PathKind string `json:"path_kind"`
PathNote string `json:"path_note"`
IsAbsolutePath bool `json:"is_absolute_path"`
}
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("响应不是 JSON:%v", err)
}
if len(got.Candidates) < 2 {
t.Fatalf("应有两个工作区的候选,实际 %d", len(got.Candidates))
}
var sawInternal, sawWorkspace bool
for _, c := range got.Candidates {
if strings.Contains(c.Path, "/mail-sessions/") {
sawInternal = true
if c.PathKind != "bridge-internal" {
t.Errorf("★ 桥内部目录候选必须自带 path_kind,实际 %q(路径 %s)", c.PathKind, c.Path)
}
if c.PathNote == "" {
t.Error("★ 必须自带 path_note(模型扫一眼就要能跳过它)")
}
} else {
sawWorkspace = true
if c.PathKind == "bridge-internal" {
t.Errorf("真实工作区被误标为 bridge-internal:%s", c.Path)
}
if !c.IsAbsolutePath {
t.Errorf("绝对路径候选的 is_absolute_path 应为 true:%s", c.Path)
}
}
}
if !sawInternal || !sawWorkspace {
t.Errorf("夹具没造出两类路径(internal=%v workspace=%v)", sawInternal, sawWorkspace)
}
}