Files
MailUI4Agents/server/internal/mcp/relay_test.go
JianFeeeee a6b7994a45 feat(mcp)★★: 自动转发通道 —— relay/relay_key,并修掉一个从未生效的幂等文案
用户 2026-10-04:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。

## 为什么 MCP 侧需要显式上报,而插件侧不用

  插件(pi / dsh / homeagent)有 `session.idle` 钩子:
    宿主进程在自己会话 idle 时读到最后一条回复 → 自动 send_mail(relay=…)
  MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
    不知道「这一轮处理完了」,也不会主动调 send_mail。
    ⇒ 宿主必须显式上报,服务端才可能自动转发。

  所以新增 `send_mail(relay="summary", relay_key="<宿主消息 id>")`:
  relay=summary 免配额(服务端既有逻辑),relay_key 提供幂等。

## 顺带修掉一个从未生效的幂等文案(既有缺陷)

    MCP 侧读:str(payload, "duplicate_relay")
    服务端返:{"status": "duplicate_relay", "detail": "该上游消息已转发过…"}

  **字段名对不上 ⇒ 那段逻辑从来没生效过。**

  后果对 MCP 接入端尤其致命:它的自动转发正是靠 relay_key 幂等兜底,
  而宿主重试时看到的是「已发送」(无 mail_id、无任何异常),
  于是继续重试 —— 每一轮都在消耗一次往返。

  现在文案是:
    该上游消息(relay_key: xxx)已转发过,本次调用未产生新邮件 —— 不需要重试。

## 判据(3 格,2 个变异都转红)

- send_mail 暴露 relay / relay_key(去掉透传 → 转红)
- relay 非空而 relay_key 为空必须被拒
- 同一 relay_key 第二次转发报重复,且**只有一封真邮件**

## 判据自己错了两次

1. `TestRelayRequiresKey` 红着但**原因不对**:没建收件人,400 发生在
   「收件人不存在」那一步,掩盖了 parseRelay 的校验。
2. 幂等那格被我自己 `Skip` 了 —— `.relay-idem` 会话不存在,
   于是「幂等没被击穿」的假绿。改为 `.new` 并**禁止 Skip**:
   幂等是自动转发不重投的唯一保证,它绿着才敢让宿主上报。
   (判据不可达 = 描述的状态不存在。)

## 双向对齐判据立刻证明了价值

加了 relay/relay_key 却没登记,它当场红:

    ★ send_mail 的参数 "relay" 既不在插件版、也没登记为 MCP 额外

若它不存在,加了新参数就会静默通过,而「这个能力是有意加的还是漏登记的」
正是以后没人能回答的问题。relay 登记为「额外」而非「缺失」——
插件侧走 session.idle,不需要这条参数。

14 包全绿。
2026-10-04 17:08:15 +08:00

125 lines
4.8 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 mcp
/*
MCP 自动转发通道的判据(2026-10-04)。
★ 用户要的:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。
## 为什么 MCP 侧需要显式上报,而插件侧不用
插件(pi / dsh / homeagent)有 `session.idle` 钩子:
宿主进程在自己的会话里 idle 时读到最后一条回复 → 自动调 send_mail(relay=…)
MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
它不知道「这一轮处理完了」,也不会主动调 send_mail。
⇒ 宿主必须显式上报,服务端才可能自动转发。
## 幂等靠什么
`relay_key` 由**宿主**生成(模型伪造不出),服务端 `relayed_mails`
有唯一约束 ⇒ 同一 id 只能转发一次,重试拿 duplicate_relay 而不重复投递。
与各桥的 relay 幂等语义一致(见 handler/mail.go 的注释)。
*/
import (
"context"
"github.com/agentmail/gateway/internal/db"
"strings"
"testing"
)
func TestSendMailExposesRelayParams(t *testing.T) {
s := NewServer(nil)
NewTools().RegisterAll(s)
props, _ := s.tools["send_mail"].Schema().InputSchema["properties"].(map[string]any)
for _, k := range []string{"relay", "relay_key"} {
if _, has := props[k]; !has {
t.Errorf("★ send_mail 缺参数 %q —— MCP 接入端无法走免配额转发通道,"+
"「自动转发最后一条」在这条路上不成立", k)
}
}
}
// relay 非空时必须带 relay_key,否则幂等无从谈起。
func TestRelayRequiresKey(t *testing.T) {
setupDB(t)
// ★ 收件人必须是真实用户。
// 初版没建 gui-lab,于是请求在「收件人不存在」那一步就 400 了,
// 而 parseRelay 的 400 也被算成 200 分支之外 —— 判据红着但原因不对。
// 修正后仍红,才说明「relay 缺 key 没被拒」是真的。
if _, err := mustSeedHuman(t, "gui-lab"); err != nil {
t.Fatalf("建收件人: %v", err)
}
s := newTestServer()
// 无 relay_key 就该被服务端拒(400),而不是静默当成普通发信
code, msg := call(t, s, "pi", "send_mail", map[string]any{
"to": "gui-lab@/tmp.new", "subject": "缺 relay_key", "body": "x",
"relay": "summary",
})
txt, _ := toolText(t, msg)
if !strings.Contains(strings.ToLower(txt), "relay_key") &&
!strings.Contains(strings.ToLower(txt), "relay key") {
t.Errorf("★ relay 非空而 relay_key 为空,服务端应明确报错;实际 %d %q", code, txt)
}
}
// relay_key 幂等:同一 key 转发两次,第二次必须是 duplicate 而非新邮件。
func TestRelayKeyIsIdempotent(t *testing.T) {
setupDB(t)
if _, err := mustSeedHuman(t, "gui-lab"); err != nil {
t.Fatalf("建收件人: %v", err)
}
s := newTestServer()
const key = "mcp-relay-idem-1"
_, first := call(t, s, "pi", "send_mail", map[string]any{
"to": "gui-lab@/tmp.new", "subject": "转给人类", "body": "本轮总结",
"relay": "summary", "relay_key": key,
})
txt1, _ := toolText(t, first)
if strings.Contains(txt1, "执行失败") {
// ★ 不许 Skip。Skip 会让这一格在 CI 上悄悄消失,而幂等是
// 「自动转发」不重投的唯一保证 —— 它绿着才敢让宿主上报。
// (初版这里 Skip 过一轮:.relay-idem 会话不存在 ⇒
// 「幂等没被击穿」的假绿。判据不可达 = 描述的状态不存在。)
t.Fatalf("relay 转发不该失败:%s", txt1)
}
_, second := call(t, s, "pi", "send_mail", map[string]any{
"to": "gui-lab@/tmp.new", "subject": "转给人类", "body": "本轮总结",
"relay": "summary", "relay_key": key,
})
txt2, _ := toolText(t, second)
if !strings.Contains(txt2, "duplicate") && !strings.Contains(txt2, "已转发") {
t.Errorf("★ 同一 relay_key 第二次转发应报重复,实际:%s", txt2)
}
// ★ 只有一封真邮件(否则是重复投递,而那正是要防的)。
// 按**收件人**计数,不按会话别名 —— `.new` 每次会建新会话,
// 按别名查会得到 0,那与「幂等被击穿」是两回事,不能混。
var n int
if err := dbCountMailsTo(t, "gui-lab", &n); err != nil {
t.Fatalf("计数: %v", err)
}
if n != 1 {
t.Errorf("★ 幂等被击穿:同一 relay_key 发给同一收件人产生了 %d 封邮件", n)
}
_ = context.Background()
}
// mustSeedHuman 建一个真实人类用户(relay 免配额的条件是收件人是人)。
func mustSeedHuman(t *testing.T, username string) (bool, error) {
t.Helper()
_, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (username, password_hash, role) VALUES ($1,'x','user')
ON CONFLICT (username) DO NOTHING`, username)
return true, err
}
// dbCountMailsTo 数发给某收件人的邮件数。
func dbCountMailsTo(t *testing.T, to string, n *int) error {
t.Helper()
return db.DB.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM mails WHERE to_name = $1`, to).Scan(n)
}