Files
MailUI4Agents/server/internal/mcp/rename_proposal_test.go
JianFeeeee 560c462768 feat(mcp): GET /api/v1/mcp —— 投递侧事件流(让接入方被动收信,不用轮询)
## 这半边解决什么

工具面(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 格全绿
2026-10-02 15:37:34 +08:00

285 lines
11 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
/*
改名提议的判据。
# 为什么这一层要单独钉
标记格式是**服务端正则的镜像**(`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)
}
}