diff --git a/server/internal/handler/address_flatten_test.go b/server/internal/handler/address_flatten_test.go new file mode 100644 index 0000000..f9af2e0 --- /dev/null +++ b/server/internal/handler/address_flatten_test.go @@ -0,0 +1,257 @@ +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 +} diff --git a/server/internal/handler/agent_discovery.go b/server/internal/handler/agent_discovery.go index 6840a78..834f4b2 100644 --- a/server/internal/handler/agent_discovery.go +++ b/server/internal/handler/agent_discovery.go @@ -280,6 +280,9 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.URL.Query().Get("name")) path := strings.TrimSpace(r.URL.Query().Get("path")) + // flatten=1:一次给出该 name 的**全部**可投递地址(各 path 下的会话都带上 + // 自己的 path 与完整地址)。见下方那一段的说明。 + flatten := r.URL.Query().Get("flatten") == "1" || r.URL.Query().Get("flatten") == "true" if name == "" { agents, err := repo.ListAgents(r.Context(), "") @@ -304,11 +307,52 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) { return } + // flatten:把三段补全压成一次调用。 + // + // ★ 2026-10-02(DSH 侧报告 A):原来的形状构成一个闭合的环 —— + // 给 name 只给 path,要再调一次才知道有哪些会话;而要枚举会话又必须先 + // 知道 path,path 却只能猜(且 `root` 与 `/root` 会静默落到不同工作区)。 + // 报告实测:投 `pi@root` 返回 200,落进一条标题为「拓展坞实测硬件正常…」 + // 的无关会话 —— 投递成功,所以调用方不知道自己投错了。 + // + // 可见性口径**不放宽**:与下面 path+name 那一支完全一致 + //(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限。 + if flatten { + if _, ok := agentScope(w, r, agentName); !ok { + return + } + cands, err := repo.SuggestAddressesForPeer(r.Context(), agentName, name) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list addresses") + return + } + addresses := make([]string, 0, len(cands)) + for _, c := range cands { + addresses = append(addresses, c.Address) + } + JSON(w, http.StatusOK, map[string]any{ + "kind": "session_flat", + "addresses": emptySlice(addresses), + "candidates": emptySlice(cands), + // paths 一并给回:调用方若要按 path 逐个展开,不必再调一次。 + "paths": pathCandidates(mustPaths(r, name)), + }) + return + } + if path == "" { paths, _ := repo.SuggestPaths(r.Context(), name) JSON(w, http.StatusOK, map[string]any{ "kind": "path", "suggestions": emptySlice(paths), + // ★ 2026-10-02(DSH 侧报告 B/C):path 候选里混着**桥的内部会话目录** + // (如 /root/.pi/mail-sessions/),实测 64 条候选里 33 条是它。 + // 而 `root` 与 `/root` 在数据里真的是两个不同工作区(实测 1 条 vs 17 条), + // 外观只差一个斜杠。调用方无从区分,选中即静默投进错误线索。 + // + // 所以这里**标注**而不是隐藏:隐藏会让人以为那些工作区不存在, + // 而它们确实是某些会话的真实 cwd(只是不该出现在「工作区」候选里)。 + "paths": pathCandidates(paths), }) return } @@ -497,3 +541,71 @@ func participantsOf(m *models.Mail, alias string) []map[string]any { } return out } + +// pathCandidate 是一个 path 候选,外加**它是不是真工作区**的标注。 +// +// ★ 2026-10-02(DSH 侧报告 B/C)。为什么必须标注而不是直接过滤: +// +// /root/.pi/mail-sessions/ 这类**桥内部目录**不是工作区, +// 但它确实是某些会话的真实 cwd +// (报告实测:投 pi@root 落进的那条会话 +// workspace='root',uuid 正是其中一个) +// 滤掉它 ⇒ 那些会话在这个 name 下彻底不可见,调用方连"存在这样的线索"都不知道 +// 留下不标 ⇒ 64 条候选里 33 条是噪声,模型选中即静默投错 +// +// 所以两条信息都给:suggestions 保持原样(向后兼容,各桥按它取 path), +// paths[] 里带 kind 标注,让调用方能降权或跳过。 +type pathCandidate struct { + Path string `json:"path"` + // Kind 是 "workspace" 或 "bridge-internal"。 + Kind string `json:"kind"` + // Note 只在 kind != "workspace" 时给,一句话说清它是什么。 + Note string `json:"note,omitempty"` + // IsAbsolute 标出**相对路径**。`root` 与 `/root` 在数据里是两个不同工作区, + // 而外观只差一个开头的斜杠 —— 三维地址的 path 位会被原样当作 cwd。 + IsAbsolute bool `json:"is_absolute"` +} + +// bridgeInternalMarkers 是各桥把「会话存储」放在哪的痕迹。 +// +// 这些目录不是工作区,而是桥为每条会话建的落地点。它们出现在 path 候选里 +// 是因为 mails.to_workspace 记的就是**投递时的 path 位**,而模型发信时如果 +// 猜了这类目录,信真的会落在那里(于是那条会话的 cwd 就成了它)。 +var bridgeInternalMarkers = []string{ + "/.pi/mail-sessions/", + "/mail-sessions/", + "/.agentmail/sessions/", + "/.zcode/mail-sessions/", + "/.dsh/", +} + +func classifyPath(p string) pathCandidate { + c := pathCandidate{Path: p, Kind: "workspace", IsAbsolute: strings.HasPrefix(p, "/")} + for _, m := range bridgeInternalMarkers { + if strings.Contains(p, m) { + c.Kind = "bridge-internal" + c.Note = "这是 Agent 桥的内部会话存储目录,不是项目工作区;投到这里的信会把会话 cwd 变成它" + break + } + } + if !c.IsAbsolute { + c.Note = strings.TrimSpace(c.Note + " 另:这是相对路径,与 /" + strings.TrimPrefix(p, "/") + " 是两个不同工作区") + } + return c +} + +func pathCandidates(paths []string) []pathCandidate { + out := make([]pathCandidate, 0, len(paths)) + for _, p := range paths { + out = append(out, classifyPath(p)) + } + return out +} + +func mustPaths(r *http.Request, name string) []string { + paths, err := repo.SuggestPaths(r.Context(), name) + if err != nil { + return nil + } + return paths +} diff --git a/server/internal/mcp/support.go b/server/internal/mcp/support.go index 289b00a..297715f 100644 --- a/server/internal/mcp/support.go +++ b/server/internal/mcp/support.go @@ -231,6 +231,61 @@ func summarizeUpload(payload map[string]any) string { func summarizeSuggest(payload map[string]any) string { kind := str(payload, "kind") + + // flatten 出来的形状(2026-10-02 寻址报告 A):addresses + candidates。 + // 必须单独渲染 —— 它没有 suggestions 字段,走下面的分支只会回一句 + // 「(没有 session_flat 建议)」,模型拿不到任何地址,等于白问。 + if kind == "session_flat" { + cands, _ := payload["candidates"].([]any) + if len(cands) == 0 { + return "没有可列出的会话(对方可能从未与你通信过)。" + + "若要开新线索,用 send_mail 指定 **.new** 结尾的地址 —— " + + "但请先确认工作区:root 与 /root 是两个不同工作区。" + } + var b strings.Builder + fmt.Fprintf(&b, "%d 条可投递地址(每条自带工作区,直接放进 send_mail 的 to):\n\n", len(cands)) + for _, it := range cands { + m, _ := it.(map[string]any) + flag := "" + if numberOf(m["unread"]) > 0 { + flag = fmt.Sprintf(" ★%d 未读", int(numberOf(m["unread"]))) + } + fmt.Fprintf(&b, "· %s\n 工作区:%s\n 标题:%s%s\n", + str(m, "address"), str(m, "path"), + firstNonEmpty(str(m, "title"), "(无标题)"), flag) + } + if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 { + if notes := pathWarnings(paths); notes != "" { + b.WriteString("\n⚠ 工作区候选里的坑(别选它们):\n" + notes) + } + } + return b.String() + } + + // path 形状:把「这不是工作区」与「这是相对路径」标出来。 + if kind == "path" { + if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 { + var b strings.Builder + b.WriteString("可选工作目录:\n") + for _, it := range paths { + m, _ := it.(map[string]any) + mark := "" + if str(m, "kind") == "bridge-internal" { + mark = " ⚠ 桥内部目录,不是项目工作区" + } + if note := str(m, "note"); note != "" && mark == "" { + mark = " ⚠ " + note + } + fmt.Fprintf(&b, "· %s%s\n", str(m, "path"), mark) + } + if notes := pathWarnings(paths); notes != "" { + b.WriteString("\n" + notes) + } + b.WriteString("\n不知道该用哪个 → 加 flatten=true 一次拿到该收件人的全部可投递地址。") + return b.String() + } + } + suggestions, _ := payload["suggestions"].([]any) if len(suggestions) == 0 { return fmt.Sprintf("(没有%s建议)", kind) @@ -370,3 +425,37 @@ func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[ "description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))), }, "" } + +// pathWarnings 汇总「path 候选里哪些不该选」。 +// +// 为什么值得单独做(2026-10-02 寻址报告 B/C 实测):64 条候选里 33 条是 +// 桥的内部会话目录,且 root 与 /root 外观只差一个斜杠却是两个不同工作区。 +// 混在列表里给模型挑,选中即**静默**投进错误线索(投递返回 200)。 +func pathWarnings(paths []any) string { + var internal, relative []string + for _, it := range paths { + m, _ := it.(map[string]any) + if str(m, "kind") == "bridge-internal" { + internal = append(internal, str(m, "path")) + } else if ok, isBool := m["is_absolute"].(bool); isBool && !ok { + relative = append(relative, str(m, "path")) + } + } + var b strings.Builder + if len(internal) > 0 { + fmt.Fprintf(&b, "· %d 条是 Agent 桥的内部会话目录(投到那里会把会话的工作区变成它):%s\n", + len(internal), firstFew(internal, 3)) + } + if len(relative) > 0 { + fmt.Fprintf(&b, "· %d 条是相对路径(与同名绝对路径是两个不同工作区):%s\n", + len(relative), firstFew(relative, 3)) + } + return b.String() +} + +func firstFew(items []string, n int) string { + if len(items) <= n { + return strings.Join(items, ", ") + } + return strings.Join(items[:n], ", ") + fmt.Sprintf(" …(共 %d 条)", len(items)) +} diff --git a/server/internal/mcp/tool_impls.go b/server/internal/mcp/tool_impls.go index 690e709..9443e69 100644 --- a/server/internal/mcp/tool_impls.go +++ b/server/internal/mcp/tool_impls.go @@ -276,18 +276,30 @@ func (t *Tools) SuggestAddress() Tool { return fnTool{ schema: ToolSchema{ Name: "suggest_address", - Description: "查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;" + - "name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。", + Description: "查询可用的收件人地址,用于精准发信。" + + "★ **发信前必须先问它**,不要凭记忆拼 `name@path.session` —— 拼错不会报错," + + "只会静默投进另一条会话(实测:`pi@root` 与 `pi@/root` 是两个不同工作区," + + "选错后投递返回 200 但落进无关线索)。三种用法:" + + "只给 name(列出该 name 用过的工作目录,并标注哪些是桥内部目录、哪些是相对路径);" + + "给 name + flatten=true(**一次拿到该 name 的全部可投递地址**,含各自的工作区 —— " + + "不知道在哪个工作区时用这个);" + + "给 name + path(列该工作区下可续谈的会话)。", Annotations: readOnly, InputSchema: objSchema(map[string]any{ - "name": strProp("收件人名;留空则列出所有候选收件人"), - "path": strProp("工作目录;与 name 同时给出才列会话"), + "name": strProp("收件人名;留空则列出所有候选收件人"), + "path": strProp("工作目录;与 name 同时给出才列会话"), + "flatten": boolProp("★ 只给 name 时置 true:一次返回该 name 的全部可投递地址(含各自工作区)"), }), }, run: func(ctx context.Context, args map[string]any) (string, error) { + flat := "0" + if argBool(args, "flatten") { + flat = "1" + } target := withQuery("/api/v1/agent/contacts/suggest", map[string]string{ - "name": strings.TrimSpace(argStr(args, "name")), - "path": strings.TrimSpace(argStr(args, "path")), + "flatten": flat, + "name": strings.TrimSpace(argStr(args, "name")), + "path": strings.TrimSpace(argStr(args, "path")), }) code, payload, raw := invoke(ctx, handler.AgentSuggestAddress, newRequest(ctx, http.MethodGet, target, nil)) return render("suggest_address", code, payload, raw) diff --git a/server/internal/repo/platform_sessions.go b/server/internal/repo/platform_sessions.go index 58c54d1..41a047d 100644 --- a/server/internal/repo/platform_sessions.go +++ b/server/internal/repo/platform_sessions.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "sort" "strings" "time" @@ -176,6 +177,12 @@ type SessionCandidate struct { Source string `json:"source"` // Unread 仅 mail 来源有意义 Unread int `json:"unread,omitempty"` + // LastActivity 是这条会话最近一次有邮件的时间(UTC)。 + // + // 为什么加它:2026-10-02 的 A 项要「一次列全部可投递地址」,而候选可能 + // 上百条 —— 没有时间序,模型无从判断该续哪一条,只能按返回顺序取第一条。 + // 它也是排序键(见 SuggestAddressesForPeer)。 + LastActivity time.Time `json:"last_activity,omitempty"` } // SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。 @@ -214,7 +221,8 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin COALESCE(s.subject, ''), COALESCE(s.platform_id, ''), (SELECT COUNT(*) FROM mails u - WHERE u.session_id = s.session_id AND u.status = 'unread') + WHERE u.session_id = s.session_id AND u.status = 'unread'), + s.updated_at FROM sessions s WHERE s.session_alias IS NOT NULL AND s.session_alias <> '' AND s.status <> 'archived' @@ -247,7 +255,16 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin for rows.Next() { var alias, title, pid string var unread int - if err := rows.Scan(&alias, &title, &pid, &unread); err != nil { + // 直接扫成 time.Time:repo.go:1702 的 ListContactsFor 对同一列就是这么读的。 + // + // ★ 这里**不能**用 SQL 的 COALESCE 兜底(实测踩到): + // `COALESCE(s.updated_at, '0001-01-01 00:00:00+00')` 让驱动返回 **string**, + // 扫进 time.Time 报 "unsupported Scan" ⇒ 本行 `return out, err` ⇒ + // **整个候选列表变空**(实测一条都列不出)。 + // 那个错误信息里完全没有线索指向「是你加的 COALESCE 害的」。 + // 改成扫进 sql.NullTime:NULL 就是零值,其余交给驱动(同 ListContactsFor)。 + var updated sql.NullTime + if err := rows.Scan(&alias, &title, &pid, &unread, &updated); err != nil { return out, err } if alias == "" { @@ -256,6 +273,7 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin seen[alias] = len(out) out = append(out, SessionCandidate{ Alias: alias, Title: title, Source: "mail", Unread: unread, + LastActivity: updated.Time, }) if pid != "" { seen["pid:"+pid] = len(out) - 1 @@ -277,7 +295,7 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin mirrorArgs = append(mirrorArgs, *onlyWorkspace) } prows, err := db.DB.QueryContext(ctx, ` - SELECT slug, title, platform_id + SELECT slug, title, platform_id, updated_at FROM agent_platform_sessions WHERE agent_name = $1 AND slug <> '' @@ -294,7 +312,10 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin for prows.Next() { var slug, title, pid string - if err := prows.Scan(&slug, &title, &pid); err != nil { + // 同上:不用 COALESCE 兜底(会让驱动返回 string 而扫不进 time.Time)。 + // ORDER BY 仍用 COALESCE(updated_at, reported_at) —— 那是排序,不进结果集。 + var seenAt sql.NullTime + if err := prows.Scan(&slug, &title, &pid, &seenAt); err != nil { break } if slug == "" { @@ -316,7 +337,15 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin continue } seen[slug] = len(out) - out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"}) + out = append(out, SessionCandidate{ + Alias: slug, Title: title, Source: "platform", + LastActivity: func() time.Time { + if seenAt.Valid { + return seenAt.Time + } + return time.Time{} + }(), + }) } return out, nil } @@ -484,3 +513,85 @@ func PlatformSessionFor(ctx context.Context, sessionID uuid.UUID) (platformID, o } return pid, fromAgent } + +// AddressedCandidate 是一条**带完整三维地址**的会话候选。 +// +// 与 SessionCandidate 的差别:多一个 Path,且 Alias/Path 组合出来就是可直接 +// 塞进 send_mail 的 to —— 调用方不必自己拼(拼错就是本文件头记录的 +// 「猜错比报错更糟」)。 +type AddressedCandidate struct { + SessionCandidate + // Path 是这条会话**自己**的工作区。必须带出来:同一个 name 在不同 path 下 + // 是不同的会话集合,而 `root` 与 `/root` 在数据里真的是两个不同工作区。 + Path string `json:"path"` + // Address 是可直接投递的三维地址(name@path.alias)。 + Address string `json:"address"` + // PathSource 说明这个 path 是从哪学来的(投递历史 / 平台镜像 / 注册自报), + // 供调用方判断可信度与新鲜度。 + PathSource string `json:"path_source,omitempty"` +} + +// SuggestAddressesForPeer 一次列出「我能投递的、属于 peerName 的全部地址」。 +// +// # 为什么需要它(2026-10-02,DSH 侧报告的 A 项) +// +// 三段式寻址 `name@path.session` 里,session 段是**人的寻址入口**,而枚举它 +// 必须先知道 path —— 但 path 恰恰是调用方无从得知的:SuggestPaths 给出的 +// 是「历史上被投递过的全部路径」,包含 `root` 与 `/root` 这种只差一个斜杠、 +// 却是两个不同工作区的值(实测 `/root` 下 17 条会话、`root` 下 1 条)。 +// +// 原形状因此构成一个闭合的环: +// +// 给 name → 只给 path(要再调一次才知道有哪些会话) +// 给 name+path → 给会话别名(但 path 得先猜对) +// +// 报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题为 +// 「拓展坞实测硬件正常…」的无关会话 —— 它的 workspace 恰好是 `root`, +// 而那正是 pi 桥内部会话目录 `/root/.pi/mail-sessions/` 里的一个 uuid。 +// 投递成功 ⇒ 调用方不知道自己投错了。 +// +// # 可见性口径:与 SuggestSessionCandidates 完全一致 +// +// 「我参与过 + 与该 name 匹配」。**不放宽** —— 报告本身也确认问题不在权限 +// (同一批数据给了 path 就能列出 17 条,说明数据是可得的)。 +// +// # 为什么按 path 分组返回而不是嵌套 +// +// 嵌套(name → path → sessions)更整齐,但调用方要发一封「不知道在哪个 +// path」的信时仍要自己遍历全部组。平铺一次给全,每个候选自带 address —— +// 一次调用就能拿到全部可投递地址,模型不必做「先猜 path 再枚举」的两步。 +// +// 排序:先按最近活动倒序,再按 path、同序别名为稳定次序 —— +// 让「刚聊过的那条」排在前面(与 SuggestPaths 同取向)。 +func SuggestAddressesForPeer(ctx context.Context, forUser, peerName string) ([]AddressedCandidate, error) { + paths, err := SuggestPaths(ctx, peerName) + if err != nil { + return nil, err + } + out := []AddressedCandidate{} + seen := map[string]bool{} // path|alias 去重 + for _, p := range paths { + cands, err := SuggestSessionCandidates(ctx, forUser, peerName, p) + if err != nil { + // 单个 path 失败不该让整次调用失败 —— 少几个候选, + // 比一个都拿不到有用得多(调用方仍可对每个 path 重试)。 + continue + } + for _, c := range cands { + key := p + "|" + c.Alias + if seen[key] { + continue + } + seen[key] = true + out = append(out, AddressedCandidate{ + SessionCandidate: c, + Path: p, + Address: models.FormatAddress(peerName, p, c.Alias), + }) + } + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].LastActivity.After(out[j].LastActivity) + }) + return out, nil +}