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

254 lines
7.0 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"
"database/sql"
"errors"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 停用是可逆的「归档」,不是删除。这组测试钉住三件事:
// 停用后从候选里消失、密钥被撤销、重新注册不能复活它。
func TestSetAgentDisabledHidesFromCandidates(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for _, n := range []string{"keeper", "goner"} {
if err := CreateOrUpdateAgent(ctx, n, "s", "test", nil); err != nil {
t.Fatalf("注册 %s: %v", n, err)
}
}
if _, err := SetAgentDisabled(ctx, "goner", true); err != nil {
t.Fatalf("停用: %v", err)
}
// 默认列表地址补全、GET /agents、可授权范围都走这条不含已停用的
got, err := ListAgents(ctx, "")
if err != nil {
t.Fatalf("ListAgents: %v", err)
}
names := map[string]bool{}
for _, a := range got {
names[a.Name] = true
}
if names["goner"] {
t.Error("已停用的 Agent 仍出现在默认列表里 —— 人会把任务派给一个不会响应的地址")
}
if !names["keeper"] {
t.Error("停用一个把别的也弄没了")
}
// statusFilter="all" 时要能看到 —— 那是管理页恢复它的唯一入口
all, err := ListAgents(ctx, "all")
if err != nil {
t.Fatalf("ListAgents(all): %v", err)
}
found := false
for _, a := range all {
if a.Name == "goner" {
found = true
if a.Status != "disabled" {
t.Errorf("状态应为 disabled实际 %q", a.Status)
}
}
}
if !found {
t.Error("statusFilter=all 也看不到已停用的,就再也无法恢复它了")
}
}
func TestSetAgentDisabledRevokesKeys(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
admin := seedAdminForTest(t, ctx)
for i := 0; i < 2; i++ {
if _, err := CreateAgentKey(ctx, "bot", "permanent", "k", 0, admin, ""); err != nil {
t.Fatalf("建密钥: %v", err)
}
}
revoked, err := SetAgentDisabled(ctx, "bot", true)
if err != nil {
t.Fatalf("停用: %v", err)
}
if revoked != 2 {
t.Errorf("应撤销 2 把密钥,实际 %d", revoked)
}
keys, err := ListAgentKeys(ctx, "bot")
if err != nil {
t.Fatalf("ListAgentKeys: %v", err)
}
if len(keys) != 0 {
t.Errorf("停用后仍留着 %d 把密钥 —— 插件还能用它调 /mail/send"+
"停用的语义是「不再参与工作」而不只是「不出现在补全里」", len(keys))
}
}
func TestDisabledAgentCannotReRegister(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("首次注册: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
// 插件启动时会重新注册。不拒的话 status 被写回 online停用等于没做。
err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil)
if !errors.Is(err, ErrAgentDisabled) {
t.Fatalf("已停用的 Agent 重新注册应当被拒,实际 err=%v", err)
}
disabled, err := AgentDisabled(ctx, "bot")
if err != nil {
t.Fatalf("AgentDisabled: %v", err)
}
if !disabled {
t.Error("注册尝试把停用状态冲掉了")
}
}
func TestHeartbeatDoesNotReviveDisabledAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
// 心跳是 30 秒一次的。不排除 disabled 的话停用最多维持半分钟。
if _, err := HeartbeatAgent(ctx, "bot"); err != nil {
t.Fatalf("心跳本身不该报错: %v", err)
}
disabled, _ := AgentDisabled(ctx, "bot")
if !disabled {
t.Error("心跳把已停用的 Agent 改回在线了")
}
}
func TestRestoreAgentGoesOfflineNotOnline(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", false); err != nil {
t.Fatalf("恢复: %v", err)
}
all, _ := ListAgents(ctx, "all")
for _, a := range all {
if a.Name != "bot" {
continue
}
// 恢复成 online 会让界面显示一个其实没在跑的 Agent 为在线;
// 它是否真的活着由下一次心跳决定。
if a.Status != "offline" {
t.Errorf("恢复后应为 offline实际 %q", a.Status)
}
}
// 恢复后能重新注册
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Errorf("恢复后应当能重新注册: %v", err)
}
}
func TestSetAgentDisabledUnknownAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, err := SetAgentDisabled(ctx, "nope", true)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("停用不存在的 Agent 应回 ErrNoRows实际 %v", err)
}
}
// 停用不得动邮件与会话 —— 往来里有一半是人自己写的。
func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
sid, err := CreateSession(ctx, nil, "bot", "一件事", "/tmp/ws")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if _, err := CreateMail(ctx, sid, nil,
"human", "", "bot", "/tmp/ws", "主题", "正文", nil); err != nil {
t.Fatalf("建邮件: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
mails, err := ListInbox(ctx, "bot", "all", 10)
if err != nil {
t.Fatalf("ListInbox: %v", err)
}
if len(mails) != 1 {
t.Errorf("停用把邮件删了:剩 %d 封。那些往来里有一半是人自己写的", len(mails))
}
}
// 模型范围与平台会话镜像也保留:恢复后不必重配。
func TestSetAgentDisabledKeepsModelScope(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
if err := SetAllowedModels(ctx, "bot", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
t.Fatalf("设范围: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
allowed, err := ListAllowedModels(ctx, "bot")
if err != nil {
t.Fatalf("ListAllowedModels: %v", err)
}
if len(allowed) != 1 {
t.Errorf("停用把模型范围清了,恢复后管理员得重配一遍:%+v", allowed)
}
}
// seedAdminForTest 插一个管理员并返回它的 user_idCreateAgentKey 要 created_by
func seedAdminForTest(t *testing.T, ctx context.Context) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('key-admin', 'Admin', 'x', 'admin') RETURNING user_id`).Scan(&id)
if err != nil {
t.Fatalf("seed admin: %v", err)
}
return id
}