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 包绿。
This commit is contained in:
2026-10-02 15:59:56 +08:00
parent 560c462768
commit 1b810a4898
5 changed files with 592 additions and 11 deletions

View File

@ -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
}

View File

@ -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/<uuid>),实测 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/<uuid> 这类**桥内部目录**不是工作区,
// 但它确实是某些会话的真实 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
}

View File

@ -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))
}

View File

@ -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)

View File

@ -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>` 里的一个 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
}