## 别名替换(让 .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),未手工拼写
284 lines
9.7 KiB
Go
284 lines
9.7 KiB
Go
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)
|
||
}
|
||
}
|