Files
MailUI4Agents/gateway/internal/repo/autoalias_test.go
JianFeeeee e6fd2fafdc 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),未手工拼写
2026-09-03 12:09:12 +08:00

284 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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