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 + ``}, {"正文", "fix-a", "", "正文" + NL + ``}, // 理由里的双引号要去掉(HTML 注释里没有转义机制) {"正文", "fix-a", `他说"好"`, "正文" + NL + ``}, } 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