Files
MailUI4Agents/gateway/internal/repo/participants_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

171 lines
6.1 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"
"testing"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// 参与方列表要回答的是「这条线索上现在有谁、用什么地址找到他、谁还没开口」。
// 三个问题里每一个都曾经答错过:
// - 有谁sessions 表只有 from_agent 一个名字,抄送方与转发引入的人都不在里面
// - 什么地址:拿 from_workspace 当 path 会拼出 dsh@dsh.alias 这种投不出去的东西
// - 谁还没回:只留最后一个身份的话,既发过信又被抄送过的人会被算成纯配合方
func TestSessionParticipantsIncludesCC(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "抄收联调", "/home/program/llmsproxy")
// 复刻线上那封admin 主发 dsh抄送 opencode
mustMail(t, sid, "admin", "", "dsh", "/home/program/llmsproxy",
[]models.Address{{Name: "opencode", Path: "/home", Session: "new", Raw: "opencode@/home.new"}})
parts, err := SessionParticipants(ctx, sid)
if err != nil {
t.Fatalf("列参与方: %v", err)
}
byName := map[string]Participant{}
for _, p := range parts {
byName[p.Name] = p
}
for _, want := range []string{"admin", "dsh", "opencode"} {
if _, ok := byName[want]; !ok {
t.Errorf("参与方缺 %s实得 %+v", want, parts)
}
}
// 抄送方的 path 必须是它自己那个地址的 path 位,不是主收件人的。
// 用错的后果:对方在别人的工作目录里开会话。
if got := byName["opencode"].Path; got != "/home" {
t.Errorf("opencode 的 path = %q应为 /home它自己地址的 path 位)", got)
}
if got := byName["dsh"].Path; got != "/home/program/llmsproxy" {
t.Errorf("dsh 的 path = %q应为 /home/program/llmsproxy", got)
}
}
func TestSessionParticipantsSenderPathStaysEmpty(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "回信", "/tmp/ws")
// Agent 回信时 from_workspace 存的是 Agent 名而非路径(历史遗留)。
// 若把它当 path地址会拼成 dsh@dsh.alias —— 投不出去。
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
parts, _ := SessionParticipants(ctx, sid)
for _, p := range parts {
if p.Name == "dsh" && p.Path == "dsh" {
t.Fatal("发件人的 path 取了 from_workspace那列存的是 Agent 名),会拼出无效地址")
}
}
}
func TestSessionParticipantsMergesRoles(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "往返", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) // admin=from, dsh=to
mustMail(t, sid, "dsh", "dsh", "admin", "", nil) // dsh=from, admin=to
parts, _ := SessionParticipants(ctx, sid)
for _, p := range parts {
if len(p.Roles) != 2 {
t.Errorf("%s 的 roles = %v双方都该同时有 from 与 to", p.Name, p.Roles)
}
}
}
func TestSessionParticipantsCountsOnlySends(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "谁还没回", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
[]models.Address{{Name: "opencode", Path: "/tmp/ws", Raw: "opencode@/tmp/ws"}})
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
parts, _ := SessionParticipants(ctx, sid)
got := map[string]int{}
for _, p := range parts {
got[p.Name] = p.MailCount
}
// MailCount 只数「作为发件人」的邮件:抄送方 opencode 一封都没发,
// 计数为 0 正是「还没开口的人」这个判断的依据。
if got["opencode"] != 0 {
t.Errorf("opencode 只被抄送未发信MailCount 应为 0实为 %d", got["opencode"])
}
if got["admin"] != 1 || got["dsh"] != 1 {
t.Errorf("admin/dsh 各发过一封,实为 %d/%d", got["admin"], got["dsh"])
}
}
func TestSessionParticipantsKeepsFirstSeenOrder(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "顺序", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
parts, _ := SessionParticipants(ctx, sid)
// 按首次出现排序,让主收件人稳定排在抄送方之前 ——
// 模型据此判断谁是负责人、谁是配合方;按名字排序会丢掉这个信息。
want := []string{"admin", "dsh", "opencode"}
if len(parts) != len(want) {
t.Fatalf("参与方数量 %d期望 %d: %+v", len(parts), len(want), parts)
}
for i, w := range want {
if parts[i].Name != w {
t.Errorf("第 %d 位是 %s期望 %s", i, parts[i].Name, w)
}
}
}
func TestSessionParticipantsPrefersFirstNonEmptyPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "改指", "/tmp/a")
// 同一个人先被抄送到 /home后被主发到 /tmp/b。
// 保留首个非空值,与排序口径一致,也避免一封转发把地址改指到别处。
mustMail(t, sid, "admin", "", "dsh", "/tmp/a",
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
mustMail(t, sid, "admin", "", "opencode", "/tmp/b", nil)
parts, _ := SessionParticipants(ctx, sid)
for _, p := range parts {
if p.Name == "opencode" && p.Path != "/home" {
t.Fatalf("opencode 的 path = %q应保持首次出现的 /home", p.Path)
}
}
}
func TestSessionParticipantsEmptySession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 会话刚建、还没有邮件。返回空列表而不是报错:
// 调用方拿到空表能正常渲染「暂无参与方」,拿到 error 只能整个失败。
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
parts, err := SessionParticipants(ctx, sid)
if err != nil {
t.Fatalf("空会话应正常返回: %v", err)
}
if len(parts) != 0 {
t.Fatalf("空会话应无参与方,实得 %+v", parts)
}
}
func mustMail(t *testing.T, sid uuid.UUID, from, fromWS, to, toWS string, cc []models.Address) {
t.Helper()
if _, err := CreateMail(context.Background(), sid, nil,
from, fromWS, to, toWS, "主题", "正文", cc); err != nil {
t.Fatalf("建邮件: %v", err)
}
}