Files
MailUI4Agents/server/internal/mcp/rename_proposal_test.go
JianFeeeee 5e312c6f5f feat(mcp): 补齐与四桥的三个参数缺口 —— 改名提议 / 线索翻页 / 转发命名
## 起因

做 MCP 与各桥的**参数级**对照(不是数量级)时,发现三处缺口。上一轮我
说过其中两处「服务端没有」—— **那是错的**,是我没查就下的结论:

| 缺口 | 真相 |
|---|---|
| `send_mail` 缺 `propose_alias` / `propose_reason` | ✅ 真缺口,且**不需要服务端字段** |
| `read_thread` 缺 `offset` | 服务端**早已支持**(`thread.go` 的 `intQuery(r,"offset",…)`),我漏传 |
| `forward_mail` 缺 `session_alias` / `session_id` | 服务端**早已支持**(`forward.go:33`),我漏传 |

三处都是「接上就行」,没有一处需要改服务端。

## ① 改名提议:为什么不是加个字段

`/mail/send` **没有** `propose_alias` 字段 —— 提议是**搭在正文里**发出去的:

    <!-- agentmail:rename-session alias="fix-login-leak" reason="定位到泄漏点" -->

服务端用正则摘出来、把标记从入库正文剥掉、把规范化后的别名回填到响应的
`rename_proposed`。载体选 HTML 注释的三个理由见 `lib/rename-proposal.js`:
react-markdown 默认不解析 raw HTML(没剥掉也不破版)、纯文本客户端里一行不碍事、
不与 Markdown 语法冲突。

所以在 Go 侧复刻了 `lib/rename-proposal.js`(三方插件共用那份)的三段逻辑:
`isProposableAlias` / `appendRenameProposal` / `renameProposalNote`。

## ★ 这一层的真正风险:跨语言镜像

格式差一个空格(或把双引号写成单引号),服务端正则就匹配不上,而**失败是
静默**的:邮件照常发出、提议凭空消失、模型以为自己提过了、下一封拿那个不存在的
别名寻址 → 404。

判据因此钉两件事:

- **能被服务端那个正则真的解出来** —— 直接 import `renameProposalRe`,
  不是另写一个(复制一份就放弃了「镜像」的意义)。
- **与 JS 版逐字节相同** —— 三个用例(含「理由里的双引号要去掉」)逐字符对照。

## ②③ 线索翻页与转发命名

`read_thread` 透传 `offset`(长线索不再只能拿首段);`forward_mail` 透传
`session_alias`(给转发出的新会话命名)与 `session_id`(与其它读端点一样过
`agentScope` 收窄)。

## 一处**故意**与桥不同的差异

`connect_to_server` 在桥侧有 `gateway_url` / `key_token`,MCP 侧保持无参 ——
网关内建端点**已认证**,改坐标是部署动作,不该由一次工具调用触发(桥侧能改是
因为它是局外进程)。判据里为此写了注释,防止将来有人"顺手补齐"。

## 判据(rename_proposal_test.go)

参数级对照那格钉「与其它桥逐字一致」——**缺参数不会报错**,只会让模型以为
该能力不存在,属静默缺陷。

**变异验证**:

    删掉 propose_alias 两行(回到缺口态)    → 红 1 ✓
    标记少一对引号(跨语言镜像写错)         → 红 2 ✓
    非法别名也追加标记(静默丢弃的来源)     → 红 2 ✓

第三条最要紧:别名不合法时**必须**不追加标记,否则发出一个服务端匹配得上却被
`validateSessionAlias` 拒掉的标记 —— 失败仍然是静默的。

## 两次判据自身缺陷(都记下来)

1. 「与 JS 版逐字节相同」那格最初用 Go 字符串字面量写期望值,`\n` 成了字面两字符
   ⇒ 判据错报红。代码是对的,判据错了。
2. 变异脚本只切掉 `strProp(…)` 的**第一行**、续行留在原地 ⇒ schema 仍合法 ⇒
   「0 红」。**没有采信那个 0**,改用完整锚点重测才拿到正确的红 1。

全量 14 包绿。
2026-10-02 14:37:09 +08:00

180 lines
7.1 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 (
"fmt"
"strings"
"testing"
)
// 服务端的正则,在这里 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