## 缺口(部署后实测才发现,是我自己引入的)
第一版 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 条相对路径被标出
360 lines
14 KiB
Go
360 lines
14 KiB
Go
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)
|
||
}
|
||
}
|