## 这半边解决什么
工具面(POST)只解决「接入方**问**」。这一条解决「服务端**说**」:
邮件投递时把 new_mail / session_update 推给接入方,让它**拉起对话** ——
与各桥靠 /api/v1/events/stream 收信是同一件事,只是方言不同:
桥: id: 7\nevent: new_mail\ndata: {…}\n\n
MCP: {"jsonrpc":"2.0","method":"notifications/message","params":{…}}
## 为什么复用 sse.Manager 而不是另起一套
Manager 里那些东西**都是踩过坑才对的**:writeMu 串行化(2026-09-28 -race
实测 http.ResponseWriter 并发写会把 JSON 劈成半截,800 帧只切出 459 个完整)、
Last-Event-ID 回放(宁可重复也不丢失)、心跳(反代按空闲 30-58s 掐连接)、
环形缓冲上限、断线清理。复制一份等于把那些坑再踩一遍,
而两边的修复从此各走各的。
代价是 `sse.Client` 多了一个可选 `Frame` 钩子:
**nil = AgentMail 原格式,各桥与 WebUI 行为一字未变**(默认值即历史行为)。
## ★ 回放是第三条写路径,漏了就只在断线时现形
`Send` / `SendWithID` / `replay` 是三条写 Res 的路径。原先**三条都把格式写死**,
只改前两条的话:MCP 客户端**平时**一切正常,只有带 `Last-Event-ID` 重连时
才会收到一批自己解不开的帧 —— 同一个连接上两种方言。
判据 `TestCustomFrameAppliesToReplayToo` 专门钉这条,并带反向对照
(nil 帧必须回落 AgentMail 格式)。
`Frame` 必须在**注册时**传入(`AddClientWithFrame`),不能事后设 ——
回放发生在「先写响应、再注册」的前半段,事后设只影响之后推来的事件。
原先 `AddClient` 保留为薄封装,各桥与 WebUI 调用点一字未改。
## 判据(6 格)
Frame 是 JSON-RPC 2.0 通知 + 帧完整性(单事件、\n\n 结尾)
payload 原样嵌入(不是 JSON 字符串)—— 再 marshal 会让客户端解析两次
event_id / event_type 必带(前者是 Last-Event-ID 续传的依据)
Accept 判定(含 q 值、大小写)
匿名 GET → 401(不能变成静默的匿名订阅)
缺 Accept → 406(接错的客户端会静默收不到东西)
## 顺带修:TestAdvanceRecurrenceLunar 的时区缺陷(★ 今天第三次假红)
全量测试红了,查下来是**我今天早些时候改判据时引入的**,与本次改动无关。
农历换算必须按**本地公历日**算(`AdvanceRecurrence` 里那句
`eventTime.In(time.Local)` 就是这条规则)。库里读回的 EventTime 是 **UTC**
(DSN 用 `_timezone=UTC`),UTC 比本地晚 8 小时,跨零点时农历日差一天:
start (Local) = 2026-10-04 农历日 24
after (UTC) = 2026-11-01 16:00 农历日 23 ← 断言没换算时区(错)
after.In(Local) = 2026-11-02 00:00 农历日 24 ← 正确
服务端代码一直是对的,是判据没照做。失败信息里现在打印时区,
免得下次要重新推导一遍。变异验证:去掉 `.In(time.Local)` → 红 1 ✓
(这条判据是农历的第三次假红了:3459605「断言要求不存在的农历日」、
今天早些「起点写死日期 + advanceToFuture 跳过过期月份」、现在「没换算时区」——
三次都是判据自己写错,代码三次都对。它依赖 Local 时区与「今天」,
天生脆弱,值得记着。)
## 验证
go test ./... 14 包全绿
go test ./internal/sse/ 含新判据绿
go test ./internal/mcp/ 6 格新判据 + 原 19 格全绿
285 lines
11 KiB
Go
285 lines
11 KiB
Go
package mcp
|
||
|
||
/*
|
||
改名提议的判据。
|
||
|
||
# 为什么这一层要单独钉
|
||
|
||
标记格式是**服务端正则的镜像**(`server/internal/handler/rename_proposal.go` 的
|
||
`renameProposalRe`)。格式不一致的后果是**静默**的:
|
||
|
||
邮件照常发出 → 服务端正则匹配不上 → 提议凭空消失
|
||
→ 模型以为自己提过了 → 下一封邮件用那个不存在的别名寻址 → 404
|
||
|
||
所以这里不只验「能拼出标记」,还验**与 JS 版逐字节相同**、且**能被服务端正则
|
||
真的解出来**(直接调用服务端那个正则,不是另写一个)。
|
||
*/
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
)
|
||
|
||
// 服务端的正则,在这里 import 而不是复制 —— 复制一份就等于放弃了「镜像」的意义。
|
||
// 这里直接用 handler 包里的那个(它是导出的)。
|
||
func serverExtract(body string) (alias, reason string, ok bool) {
|
||
for _, m := range renameProposalRe.FindAllStringSubmatch(body, -1) {
|
||
if len(m) >= 2 {
|
||
return m[1], func() string {
|
||
if len(m) > 2 {
|
||
return m[2]
|
||
}
|
||
return ""
|
||
}(), true
|
||
}
|
||
}
|
||
return "", "", false
|
||
}
|
||
|
||
func TestRenameProposalRoundTripsThroughServerRegex(t *testing.T) {
|
||
body, ok := appendRenameProposal("干完了,根因是登录态泄漏。", "fix-login-leak", "定位到泄漏点在 cookie 过期判断")
|
||
if !ok {
|
||
t.Fatal("合法别名必须被接受")
|
||
}
|
||
alias, reason, found := serverExtract(body)
|
||
if !found {
|
||
t.Fatalf("★ 服务端正则必须能解出标记(解不出 = 提议静默消失):body=%q", body)
|
||
}
|
||
if alias != "fix-login-leak" {
|
||
t.Errorf("alias=%q", alias)
|
||
}
|
||
if reason != "定位到泄漏点在 cookie 过期判断" {
|
||
t.Errorf("reason=%q", reason)
|
||
}
|
||
}
|
||
|
||
// 与 JS 版逐字节相同 —— 这是「跨语言实现同一份协议」的核心风险。
|
||
func TestRenameProposalMatchesJSByteForByte(t *testing.T) {
|
||
const NL = "\n\n" // 标记前是两个换行
|
||
cases := []struct{ body, alias, reason, want string }{
|
||
{"正文", "fix-a", "理由", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="理由" -->`},
|
||
{"正文", "fix-a", "", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" -->`},
|
||
// 理由里的双引号要去掉(HTML 注释里没有转义机制)
|
||
{"正文", "fix-a", `他说"好"`, "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="他说好" -->`},
|
||
}
|
||
for _, c := range cases {
|
||
got, ok := appendRenameProposal(c.body, c.alias, c.reason)
|
||
if !ok {
|
||
t.Fatalf("case %+v 应被接受", c)
|
||
}
|
||
if got != c.want {
|
||
t.Errorf("★ 与 JS 版不一致\n Go: %q\n JS: %q", got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 非法别名:原样返回、不追加标记,并如实告诉模型「没提交」。
|
||
func TestRenameProposalRejectsIllegalAlias(t *testing.T) {
|
||
for _, alias := range []string{"", " ", "new", "has.dot", "has space", "has/slash", "has@at", `has"quote`} {
|
||
body, ok := appendRenameProposal("正文", alias, "理由")
|
||
if ok {
|
||
t.Errorf("★ 非法别名 %q 被接受了(会发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记)", alias)
|
||
}
|
||
if body != "正文" {
|
||
t.Errorf("别名 %q 非法时正文必须原样返回,实际 %q", alias, body)
|
||
}
|
||
// 空别名 = 根本没提议 ⇒ 不该有说明(与 JS 版一致)。
|
||
// 非空但不合法 ⇒ 必须说明「未提交」,否则模型以为自己提过了。
|
||
note := renameProposalNote("", alias, false)
|
||
trimmed := strings.TrimSpace(alias)
|
||
if trimmed != "" && note == "" {
|
||
t.Errorf("★ 别名 %q 不合法时必须给模型一句「未提交」的说明", alias)
|
||
}
|
||
if trimmed == "" && note != "" {
|
||
t.Errorf("别名是空的(没提议)时不该有说明,实际 %q", note)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回显必须用**服务端给的**别名:normalizeAlias 改过之后,本地值是不存在的名字。
|
||
func TestRenameProposalNoteUsesServerAlias(t *testing.T) {
|
||
// 服务端规范化了(new → session-new 之类)
|
||
note := renameProposalNote("fixed-login", "fix登录", true)
|
||
if !strings.Contains(note, "fixed-login") {
|
||
t.Errorf("★ 必须回显服务端返回的别名,实际 %q", note)
|
||
}
|
||
if !strings.Contains(note, "规范化") {
|
||
t.Errorf("本地值与服务端值不同时必须说明被规范化了,实际 %q", note)
|
||
}
|
||
// 必须说「等用户确认」—— 不说模型会以为已经生效,接着用新别名发信(那个别名还不存在)
|
||
if !strings.Contains(note, "确认") {
|
||
t.Errorf("必须说明要等人确认,实际 %q", note)
|
||
}
|
||
}
|
||
|
||
// 三种情形各有各的话,不能串。
|
||
func TestRenameProposalNoteThreeOutcomes(t *testing.T) {
|
||
if n := renameProposalNote("", "", false); n != "" {
|
||
t.Errorf("没有提议时不该有说明,实际 %q", n)
|
||
}
|
||
if n := renameProposalNote("", "fix-a", true); !strings.Contains(n, "未被服务端接受") {
|
||
t.Errorf("标记发出但服务端没回值 ⇒ 应说未被接受,实际 %q", n)
|
||
}
|
||
if n := renameProposalNote("fix-a", "fix-a", true); strings.Contains(n, "规范化") {
|
||
t.Errorf("两端一致时不该提规范化,实际 %q", n)
|
||
}
|
||
}
|
||
|
||
// ★ 与 JS 版的合法判据一致:超长别名拒绝。
|
||
func TestRenameProposalRejectsOverlongAlias(t *testing.T) {
|
||
long := strings.Repeat("a", 129)
|
||
if _, ok := appendRenameProposal("正文", long, ""); ok {
|
||
t.Error("★ 超 128 字节的别名必须拒绝(服务端是 VARCHAR(128))")
|
||
}
|
||
if _, ok := appendRenameProposal("正文", strings.Repeat("a", 128), ""); !ok {
|
||
t.Error("正好 128 字节应当接受")
|
||
}
|
||
}
|
||
|
||
// ---- 工具层接线 ----
|
||
|
||
// ★ 这一格钉的是「参数真的接上了」:上一版三个参数都缺(propose_alias、
|
||
//
|
||
// offset、session_alias),而工具照常工作 —— 缺参数不会报错,只会让模型
|
||
// 以为能力不存在。
|
||
func TestToolsExposeParamsOtherBridgesHave(t *testing.T) {
|
||
s := NewServer(nil)
|
||
NewTools().RegisterAll(s)
|
||
// 必须与 plugins/*/lib 那一侧逐字一致的参数
|
||
want := map[string][]string{
|
||
"send_mail": {"to", "subject", "body", "cc", "reply_to", "session_alias", "attachment_ids", "propose_alias", "propose_reason"},
|
||
"read_thread": {"mail_id", "offset", "session_id"},
|
||
"forward_mail": {"mail_id", "to", "comment", "subject", "cc", "session_alias", "session_id"},
|
||
"read_inbox": {"workspace", "status", "limit", "session_id"},
|
||
"read_mail": {"mail_id", "session_id"},
|
||
"suggest_address": {"name", "path"},
|
||
"list_contacts": {"limit"},
|
||
"upload_attachment": {"file_path", "filename"},
|
||
"download_attachment": {"attachment_id", "save_path"},
|
||
"session_participants": {"session_id"},
|
||
// connect_to_server **有意**无参:网关内建端点已认证,改坐标是部署动作,
|
||
// 不该由一次工具调用触发(桥侧那个能改是因为它是局外进程)。
|
||
}
|
||
for tool, keys := range want {
|
||
sch, ok := s.tools[tool]
|
||
if !ok {
|
||
t.Errorf("没有工具 %s", tool)
|
||
continue
|
||
}
|
||
props, _ := sch.Schema().InputSchema["properties"].(map[string]any)
|
||
for _, k := range keys {
|
||
if _, has := props[k]; !has {
|
||
t.Errorf("★ %s 缺参数 %q —— 与其它桥不一致,模型会以为该能力不存在", tool, k)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var _ = fmt.Sprintf
|
||
|
||
// ─── GET /mcp 事件流(投递/拉起对话)────────────────────────
|
||
|
||
/*
|
||
★ 方言必须是 JSON-RPC 通知。
|
||
|
||
这一格存在的理由:Frame 决定了接入方能不能解析。写错了不会报错 ——
|
||
连接是活的、字节在流动,只是客户端**解不开**而默默丢弃每一个事件。
|
||
那种失败看起来完全像「没有人给我发信」。
|
||
*/
|
||
func TestFrameRendersJSONRPCNotification(t *testing.T) {
|
||
out := Frame("7", "new_mail", []byte(`{"mail_id":"m1","subject":"你好"}`))
|
||
|
||
if !strings.HasPrefix(out, `{"jsonrpc":"2.0","method":"notifications/message"`) {
|
||
t.Fatalf("必须是 JSON-RPC 2.0 通知:%q", out)
|
||
}
|
||
if !strings.HasSuffix(out, "\n\n") {
|
||
t.Errorf("SSE 帧必须以空行结尾(否则客户端不认为事件结束):%q", out)
|
||
}
|
||
// payload 必须**原样嵌入**而不是被转义成字符串 ——
|
||
// 否则客户端要解析两次,长度还翻倍。
|
||
if !strings.Contains(out, `"payload":{"mail_id":"m1","subject":"你好"}`) {
|
||
t.Errorf("payload 应原样嵌入(不是 JSON 字符串):%q", out)
|
||
}
|
||
// 反向对照:转义版本(错误实现)不该出现
|
||
if strings.Contains(out, `"payload":"{`) {
|
||
t.Errorf("★ payload 被转义成字符串了:%q", out)
|
||
}
|
||
}
|
||
|
||
func TestFrameCarriesEventTypeAndID(t *testing.T) {
|
||
out := Frame("42", "session_update", []byte(`{}`))
|
||
if !strings.Contains(out, `"event_id":"42"`) {
|
||
t.Errorf("必须带 event_id(客户端据此做 Last-Event-ID 续传):%q", out)
|
||
}
|
||
if !strings.Contains(out, `"event_type":"session_update"`) {
|
||
t.Errorf("必须带 event_type(否则客户端分不清新邮件与会话更新):%q", out)
|
||
}
|
||
}
|
||
|
||
// 单行 + 空行分隔:SSE 的帧完整性靠它,多一个换行会把一条拆成两条。
|
||
func TestFrameIsSingleEvent(t *testing.T) {
|
||
out := Frame("1", "new_mail", []byte(`{}`))
|
||
if strings.Count(out, "\n\n") != 1 {
|
||
t.Errorf("一个事件只能有一个空行分隔:%q", out)
|
||
}
|
||
if strings.Contains(strings.TrimSuffix(out, "\n\n"), "\n\n") {
|
||
t.Errorf("事件体内不得出现空行:%q", out)
|
||
}
|
||
}
|
||
|
||
// Accept 判定:必须真的要求 text/event-stream。
|
||
func TestAcceptsEventStream(t *testing.T) {
|
||
cases := []struct {
|
||
accept string
|
||
want bool
|
||
}{
|
||
{"text/event-stream", true},
|
||
{"text/event-stream, application/json", true},
|
||
{"text/event-stream;q=0.9", true},
|
||
{"TEXT/EVENT-STREAM", true},
|
||
{"application/json", false},
|
||
{"", false},
|
||
{"text/plain", false},
|
||
}
|
||
for _, c := range cases {
|
||
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
|
||
if c.accept != "" {
|
||
r.Header.Set("Accept", c.accept)
|
||
}
|
||
if got := acceptsEventStream(r); got != c.want {
|
||
t.Errorf("Accept=%q 期望 %v 收到 %v", c.accept, c.want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 未认证的 GET 必须拒绝(不能变成一处静默的匿名订阅)。
|
||
func TestGetRequiresAgentIdentity(t *testing.T) {
|
||
s := newTestServer()
|
||
rec := httptest.NewRecorder()
|
||
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
|
||
r.Header.Set("Accept", "text/event-stream")
|
||
// 故意不注入身份
|
||
s.HandleGET(rec, r)
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Errorf("匿名订阅必须 401,收到 %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// 不带 Accept 的 GET 必须 406(接错的客户端会静默收不到东西)。
|
||
func TestGetRequiresEventStreamAccept(t *testing.T) {
|
||
s := newTestServer()
|
||
rec := httptest.NewRecorder()
|
||
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
|
||
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, "probe"))
|
||
s.HandleGET(rec, r)
|
||
if rec.Code != http.StatusNotAcceptable {
|
||
t.Errorf("缺 Accept 应 406,收到 %d", rec.Code)
|
||
}
|
||
}
|