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 }