feat: agent 邮件寻址能力全面补齐 + .new 别名替换

## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -0,0 +1,283 @@
package repo
import (
"context"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 这一组测试守的是一条不变量:**`.new` 建出来的会话必须立刻可寻址**。
//
// 破坏方式很隐蔽 —— 邮件照样送达、收件人照样能回复那一封,只有「指名投进同一条
// 会话」这个动作静默失败(`FindNamedSessionFor` 查不到未命名会话),再发一次
// `.new` 就多一条平行会话。所以这里的断言都落在「事后能不能按别名找回来」上,
// 而不是「有没有报错」。
func TestAutoAliasForCombinesNameAndSubject(t *testing.T) {
// 名字与主题都要在:只用主题时「服务恢复验证」这类通用主题会跨 Agent 撞名,
// 只用名字则同一个 Agent 的会话全叫 dsh-2、dsh-3,看不出在聊什么。
got := AutoAliasFor("dsh", "重构导入路径")
if got != "dsh-重构导入路径" {
t.Fatalf("want dsh-重构导入路径, got %q", got)
}
}
func TestAutoAliasForStripsAddressingChars(t *testing.T) {
// 别名要参与 name@path.session 的切分,含 . / @ 或空白会让地址解析歧义。
// 主题里的 Markdown 与标点噪声([联调]、—、:)也不该变成一串破折号。
cases := []struct{ in, want string }{
{"[联调] llmsproxy / ModelRouter — 请提供部署现状", "x-联调-llmsproxy-ModelRouter-请提供部署现状"},
{"a.b.c", "x-a-b-c"},
{"has spaces", "x-has-spaces"},
{"user@host", "x-user-host"},
{"Re: 服务恢复验证", "x-Re-服务恢复验证"},
}
for _, c := range cases {
got := AutoAliasFor("x", c.in)
if got != c.want {
t.Errorf("AutoAliasFor(x, %q) = %q, want %q", c.in, got, c.want)
}
if strings.ContainsAny(got, ". \t/@") {
t.Errorf("别名 %q 含寻址保留字符,会破坏地址解析", got)
}
}
}
func TestAutoAliasForAvoidsReservedNew(t *testing.T) {
// "new" 是 session 位的保留字。别名若正好是它,`x@/p.new` 就同时是
// 「投进这条会话」与「再建一条」两种意思。
if got := AutoAliasFor("", "new"); got == "new" {
t.Fatal("别名不得为保留字 new")
}
}
func TestAutoAliasForEmptyMaterial(t *testing.T) {
// 素材里一个可用字符都没有时返回空串,交由 EnsureSessionAlias 走 uuid 兜底,
// 而不是在这里编造一个名字。
if got := AutoAliasFor("", "···"); got != "" {
t.Fatalf("want empty, got %q", got)
}
}
func TestAutoAliasForTruncatesAtByteLimit(t *testing.T) {
// session_alias 是 VARCHAR(128),而中文主题很容易超;按字节截断时
// 不能把多字节字符切坏(切坏后写库会得到非法 UTF-8)。
got := AutoAliasFor("bot", strings.Repeat("中", 200))
if len(got) > aliasMaxBytes {
t.Fatalf("别名 %d 字节,超过上限 %d", len(got), aliasMaxBytes)
}
if !utf8Valid(got) {
t.Fatal("截断切坏了多字节字符")
}
}
func utf8Valid(s string) bool {
for _, r := range s {
if r == '\uFFFD' {
return false
}
}
return true
}
func TestEnsureSessionAliasMakesNewSessionAddressable(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 复刻 `.new` 的真实路径:CreateSession(alias=nil) —— 别名是 NULL。
sid, err := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
if err != nil {
t.Fatalf("建会话: %v", err)
}
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
// 命名前:按别名找不回来(这正是线上那条断链)
if _, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", "dsh-重构导入路径"); err == nil {
t.Fatal("未命名会话竟然能按别名找到,测试前提不成立")
}
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
if err != nil {
t.Fatalf("命名: %v", err)
}
if alias == "" {
t.Fatal("别名为空")
}
// 命名后:收件方能指名投回这条会话,而不是又开一条
got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias)
if err != nil {
t.Fatalf("按别名寻址: %v", err)
}
if got != sid {
t.Fatalf("别名 %q 指向 %s,应指向 %s", alias, got, sid)
}
}
func TestEnsureSessionAliasKeepsExistingName(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 调用方显式命名过(发信时传了 session_alias),标记为 manual。
// 自动命名绝不能覆盖它 —— 人记住的地址不该下一秒失效。
want := "llmsproxy-joint"
sid, err := CreateSession(ctx, &want, "dsh", "联调", "/home/program/llmsproxy")
if err != nil {
t.Fatalf("建会话: %v", err)
}
got, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("opencode", "别的主题"))
if err != nil {
t.Fatalf("命名: %v", err)
}
if got != want {
t.Fatalf("已有别名被改写成 %q,应保持 %q", got, want)
}
}
func TestEnsureSessionAliasIsIdempotent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 默认会话路径上 EnsureSessionAlias 会被每封邮件调用一次
// (会话可能是刚建的也可能是复用的),因此重复调用必须返回同一个名字。
sid, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
first, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
if err != nil {
t.Fatalf("首次命名: %v", err)
}
second, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
if err != nil {
t.Fatalf("二次命名: %v", err)
}
if first != second {
t.Fatalf("重复调用给出两个别名: %q vs %q", first, second)
}
}
func TestEnsureSessionAliasSuffixesOnCollision(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 同一个 Agent + 同一主题会反复出现(「服务恢复验证」发两次)。
// 别名全局唯一(负责寻址),撞名必须让位而不是报错 —— 发信不该因为
// 主题重复而失败。
want := AutoAliasFor("dsh", "服务恢复验证")
a, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
aliasA, err := EnsureSessionAlias(ctx, a, want)
if err != nil {
t.Fatalf("首个会话命名: %v", err)
}
b, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
aliasB, err := EnsureSessionAlias(ctx, b, want)
if err != nil {
t.Fatalf("第二个会话命名: %v", err)
}
if aliasA == aliasB {
t.Fatalf("两条会话拿到同一个别名 %q", aliasA)
}
if aliasB != want+"-2" {
t.Fatalf("撞名后缀应为 %s-2,实际 %q", want, aliasB)
}
// 两个别名各自指向自己那条会话,没有相互覆盖
for alias, expect := range map[string]uuid.UUID{aliasA: a, aliasB: b} {
var got uuid.UUID
err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&got)
if err != nil {
t.Fatalf("查别名 %q: %v", alias, err)
}
if got != expect {
t.Errorf("别名 %q 指向 %s,应指向 %s", alias, got, expect)
}
}
}
func TestEnsureSessionAliasFallsBackToUUID(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 主题与名字都拿不出可用字符时(AutoAliasFor 返回空串),
// 仍必须得到一个能寻址的别名 —— 丑名字远胜于没有名字。
sid, _ := CreateSession(ctx, nil, "admin", "···", "/tmp/ws")
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("", "···"))
if err != nil {
t.Fatalf("兜底命名: %v", err)
}
if alias == "" {
t.Fatal("兜底后别名仍为空")
}
if !strings.HasPrefix(alias, "session-") {
t.Fatalf("兜底别名应形如 session-xxxxxxxx,实际 %q", alias)
}
}
func TestEnsureSessionAliasSurfacesInSuggestions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 补全候选只收「有别名的非归档会话」(`session_alias IS NOT NULL AND <> ''`)。
// 自动命名的另一半价值就在这里:命名前这条会话在人类的三段式补全里
// 也是不可见的,人同样只能靠回复某封邮件才能续谈。
seedUser(t, ctx, "admin")
sid, _ := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
before, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
if err != nil {
t.Fatalf("补全(命名前): %v", err)
}
for _, c := range before {
if c.Source == "mail" {
t.Fatalf("未命名会话不该出现在补全里,却拿到 %+v", c)
}
}
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
if err != nil {
t.Fatalf("命名: %v", err)
}
after, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
if err != nil {
t.Fatalf("补全(命名后): %v", err)
}
found := false
for _, c := range after {
if c.Alias == alias {
found = true
}
}
if !found {
t.Fatalf("命名后 %q 仍未出现在补全候选里: %+v", alias, after)
}
}
// seedMailForSession 往会话里塞一封邮件。
// FindNamedSessionFor 与 SuggestSessionCandidates 都要求「该收件人参与过」,
// 只建会话不建邮件的话两者都查不到,测试会得出错误结论。
func seedMailForSession(t *testing.T, sid uuid.UUID, from, to, workspace string) {
t.Helper()
if _, err := CreateMail(context.Background(), sid, nil,
from, "", to, workspace, "主题", "正文", nil); err != nil {
t.Fatalf("seed mail: %v", err)
}
}
// seedUser 插一个用户。SuggestSessionCandidates 的可见性条件要查 users 表。
func seedUser(t *testing.T, ctx context.Context, username string) {
t.Helper()
_, err := db.DB.ExecContext(ctx,
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ($1, $1, 'x', 'admin')`, username)
if err != nil {
t.Fatalf("seed user %s: %v", username, err)
}
}