Files
MailUI4Agents/gateway/internal/repo/relayhops_test.go
JianFeeeee f321380fa3 fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## relay 死循环防护(两道防线)

### 主防线:免配额只给发往人类的 relay(handler/mail.go)

原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。
问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何
一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟
缩到 5 秒,且用了 37 封才烧掉 4/20 预算。

改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。

顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键
已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。

### 兜底:hop_limit 列接通(repo/relayhops.go)

schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。
CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay
邮件即停(中间有一封自主发信或人类插话就归零)。

5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立

## DSH 工作区注册修复

问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自
resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>),
而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝,
且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。

修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd,
resume 路径下是持久化 header 里那个。

## homeagent 插件:11 工具齐平 opencode

tools.go 新增:read_mail / forward_mail / suggest_address /
list_contacts / session_participants / read_thread / connect_to_server
+ handleConnectToServer(注册到 Gateway 前先用候选坐标试注册,
成功才写回 p.gwURL/p.key,失败不破坏原配置)

关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName
(AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。
它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于
注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in
里显式设 AGENTMAIL_AGENT_NAME=homeagent。

## 三插件补齐 connect_to_server

之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时,
opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。

DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL
与 client.agentKey 当场生效。

pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect
工具直接用。

# 测试

relayhops_test.go 5 例
opencode 172 / dsh 188 / pi 214 全绿
check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
2026-09-03 15:01:52 +08:00

127 lines
3.9 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/google/uuid"
)
// 连续 relay 跳数上限守的是一个真实事故:会话 f3d824cedsh 与 opencode 联调
// llmsproxy共 41 封,最后一封人类意图的邮件之后每一封都是 relay:summary
// 间隔从 15 分钟一路缩到 5 秒。双方都没有「决定继续」,因为双方都不在做决定 ——
// 发信是插件代劳的,而免配额通道让整个回路里没有任何一处在计数。
func TestTrailingRelayHopsEmptySession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
n, err := CountTrailingRelayHops(ctx, sid)
if err != nil {
t.Fatalf("空会话应正常返回: %v", err)
}
if n != 0 {
t.Fatalf("空会话跳数应为 0实为 %d", n)
}
}
func TestTrailingRelayHopsCountsOnlyRelay(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "混合", "/tmp/ws")
// 人类发一封(非 relay
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
// 插件连续转发三封
for i := 0; i < 3; i++ {
seedRelayMail(t, ctx, sid, "dsh", "opencode")
}
n, err := CountTrailingRelayHops(ctx, sid)
if err != nil {
t.Fatalf("数跳数: %v", err)
}
if n != 3 {
t.Fatalf("尾部连续 relay 应为 3实为 %d", n)
}
}
func TestTrailingRelayHopsResetsOnAutonomousSend(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 「连续」是这条规则的关键:中间只要有一封自主发信(模型真的决定说什么)
// 或人类插话,计数就归零。否则正常的「模型回一封、插件补一封总结」
// 会被误判成回路。
sid, _ := CreateSession(ctx, nil, "admin", "打断", "/tmp/ws")
for i := 0; i < 4; i++ {
seedRelayMail(t, ctx, sid, "dsh", "opencode")
}
// 模型亲手发了一封 —— 链条到此为止
mustMail(t, sid, "dsh", "", "opencode", "/tmp/ws", nil)
seedRelayMail(t, ctx, sid, "opencode", "dsh")
n, err := CountTrailingRelayHops(ctx, sid)
if err != nil {
t.Fatalf("数跳数: %v", err)
}
if n != 1 {
t.Fatalf("自主发信之后只剩 1 跳,实为 %d", n)
}
}
func TestTrailingRelayHopsReachesLimit(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 攒到上限:此时 handler 应当拒绝下一封 relay。
sid, _ := CreateSession(ctx, nil, "admin", "到顶", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
for i := 0; i < MaxRelayHops(); i++ {
seedRelayMail(t, ctx, sid, "dsh", "opencode")
}
n, _ := CountTrailingRelayHops(ctx, sid)
if n < MaxRelayHops() {
t.Fatalf("应达到上限 %d实为 %d", MaxRelayHops(), n)
}
}
func TestTrailingRelayHopsIsPerSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一条会话的回路不该影响另一条:两个 Agent 在 A 会话里刷爆了,
// B 会话的正常自动转发仍应放行。
a, _ := CreateSession(ctx, nil, "admin", "A", "/tmp/a")
b, _ := CreateSession(ctx, nil, "admin", "B", "/tmp/b")
for i := 0; i < 5; i++ {
seedRelayMail(t, ctx, a, "dsh", "opencode")
}
seedRelayMail(t, ctx, b, "dsh", "admin")
na, _ := CountTrailingRelayHops(ctx, a)
nb, _ := CountTrailingRelayHops(ctx, b)
if na != 5 || nb != 1 {
t.Fatalf("跳数应按会话独立计A=%d期望 5B=%d期望 1", na, nb)
}
}
// seedRelayMail 建一封走 relay 通道的邮件(同时占幂等键并关联 mail_id
// 复刻 handler.SendMail 的真实写入顺序。
func seedRelayMail(t *testing.T, ctx context.Context, sid uuid.UUID, from, to string) {
t.Helper()
key := "relay-" + uuid.NewString()
if err := ClaimRelay(ctx, from, key, "summary"); err != nil {
t.Fatalf("占幂等键: %v", err)
}
mid, err := CreateMail(ctx, sid, nil, from, "", to, "", "Re: 主题", "正文", nil)
if err != nil {
t.Fatalf("建邮件: %v", err)
}
if err := BindRelayMail(ctx, from, key, mid); err != nil {
t.Fatalf("关联 relay: %v", err)
}
}