Commit Graph

2 Commits

Author SHA1 Message Date
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
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