39 Commits
Author SHA1 Message Date
AskaEth dfac06bbeb docs(agents): 记下 internal/webui/dist/.gitkeep 占位不可删(干净 checkout 可构建的前提)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-25 00:09:56 +08:00
AskaEth 210c8b999f Merge branch 'feat/m33-testhealth-fix-test-double-races-and' into dev 2026-10-24 23:41:40 +08:00
AskaEth f56d74c363 test(channelbridge): 准入三条用例改按「这条消息续出的臂」断言(M33 r2 复审 p2)
TestCoalesceSystemNoticeDoesNotJoinBatch 仍在用位置索引 d[1]:waitTimerCalls 只保证
「至少 2 次请求」,不保证顺序 —— 加压下会拿到 m1/m2 那次 2s 的续臂(实测 -race
-count=200 有 2/200 假红,空载测不出来)。

改成与兄弟用例同一口径,但把相位做成确定的:新增 sendSettledAndDuration ——
投递后等 worker 把这条收进本批、**为它**续好臂(按请求序号认领),再返回那次续臂的
时长。仅用 latestDuration 仍有缝:worker 可能在帧/通知之后才补上一条消息的续臂,
最新那次就成了 2s。三条准入用例(action 帧 / 系统通知 / 聊天消息对照组)统一改用它。

断言口径未变:仍然断言「系统通知/action 帧不重置静默窗口(剩余 500ms)」、
「聊天消息重置成完整 2s」;waitTimerCalls 语义、请求次数均未改。

证据:-race -count=400 连跑 3 次(1200 次)全绿、DATA RACE=0;
兄弟两条 -count=200 全绿;整包 -race -count=3 全绿。
2026-10-24 23:36:41 +08:00
AskaEth 1ae9158250 test(channelbridge): 窗口夹具按截止时刻推钟 + 去掉无法证实的替身锁(M33 r1 复审)
P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态):
- coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d,
  推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并
  按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、
  绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。
- 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle):
  时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。
- sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker
  放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。
- fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致
  推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。
- 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库;
  准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设);
  stale 用例两条都投完再推时钟(车道不会在两条之间退休)。

P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器,
退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径
(b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑
(含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁
并补 -race 用例。

证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮
18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿;
全树 go build/vet/test -count=1 全绿。
2026-10-24 23:18:45 +08:00
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00
AskaEth 20cf3e0a93 Merge branch 'feat/m32-subagentbackfill-deliver-subagent-re' into dev 2026-10-24 22:11:16 +08:00
AskaEth da6a365222 tower: snapshot of uncommitted base checkout changes (worktree wt-33) 2026-10-24 22:03:13 +08:00
AskaEth 3acc81cec8 feat(gateway): 子代理结果回填父会话(系统注记)+ 记忆抽取排除(M32)
断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成
WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮
看不到,于是「她拍了个子代理出去,但没有下文」。

回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言):
- 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅
  `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头
  并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在;
- 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`),
  不是「她说过的话」。

落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空
(注记不属于任何角色、也不属于任何一路回合)。

记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰
`memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker
同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM
提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。

可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与
「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者
顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。

测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文
有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线;
结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多
一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。

不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、
不注入人设、不改 Docs(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00
AskaEth bd49080bd1 tower: snapshot of uncommitted base checkout changes (worktree wt-32) 2026-10-24 21:51:07 +08:00
AskaEth 0fe88a44c0 Merge branch 'feat/m23-coalesce-silent-window-batching-and' into dev 2026-10-24 21:32:29 +08:00
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00
AskaEth ca8577f05b feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
2026-10-24 21:11:22 +08:00
AskaEth 679c8abf1d Merge branch 'feat/m30-mcporigin-stamp-tool-origin-and-grou' into dev 2026-10-24 20:36:47 +08:00
AskaEth c608dd4afb fix(gateway): 卸载拒绝话术按接入路径分流(.env 接入的 server 不再指向 MCP 页)
复审 M30-p2-1(r1):`plugin_uninstall_scope` 的 400 一律让主人「去『MCP 服务器』页
卸载」,但 `.env` 的 MCP_SERVERS_JSON 启动期接入的 server **不经**插件管理器、
不在 plugins.json 里,MCP 页(= Manager.Statuses()/List())根本看不到它 ⇒ 那条指引
对该路径是假指引。

- 新增 `plugin.(Manager) MCPServerName`:`mcp:<server>` 注册键 → server 名的**唯一**
  解析口(前缀只在 manager.go 出现一次),网关不再自己拼前缀。
- `serverScopedUninstall` 改为 `*Server` 方法,文案经 `uninstallPointer` 分流:
  · 管理器托管(落在 plugins.json,判据 `Manager.List()`)⇒ 保留「请在『MCP 服务器』
    页启停或卸载该 server」;
  · 否则 ⇒ 明说该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入、面板不管理它,
    并给出可操作步骤(改 .env 里那一项:删掉或设 enabled=false,改完需重启生效)。
  接入路径判据来自**管理器持久清单**,不是名字前缀/transport 推断。
- `mcp:<server>` 登记条目同样分流(此前也一律指向 MCP 页)。

测试(gateway):plugins.json 路径断言含「MCP 服务器」页字样且不提 MCP_SERVERS_JSON;
.env 路径(工具与登记条目各一条)断言含 MCP_SERVERS_JSON 且**不含**「MCP 服务器」
字样;三条均断言 400 + `plugin_uninstall_scope` 不变、被拒后条目仍在。
修前红:两条 .env 用例(见回报);修后绿。回归 go build/vet/test 全绿。
2026-10-24 20:32:36 +08:00
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」

归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。

后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
  OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
  MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
  重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
  transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
  (plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
  server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。

前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
  transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
  「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
  不再静默丢弃(design/09 §5.2.1 红线③)。

零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。

测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
2026-10-24 20:21:22 +08:00
AskaEth fd8a62edf5 Merge branch 'feat/lanequeue-decouple-inbound-turns-and-add' into dev 2026-10-24 20:17:17 +08:00
AskaEth e3e8878ead fix(channelbridge): 车道键与会话键同源 + 主人私聊溢出区保序(M22 复审)
复审 r1 的 P1-1 被证实:车道键(a.Name()+ConversationKey,含频道与账号维度)与会话键
(platform \x00 channelID)是两套没有共同来源的判据,于是 kook 同一 guild 的两个 text
channel、以及同平台两个账号对同一对端,都会得到「一个 session、两条车道、两个回合并发」
—— 两条 run 共享 sessionID,checkpoint 行 id 互相覆盖 / flow 桶被先收尾者删掉。

- P1-1 车道键与会话键同源:`internal/channel/session` 导出 ChannelKey / Key(channelID
  与原缓存键派生**原样**导出,manager 内部改为走同一个 sessionKey,语义一字未改);
  `laneKey` 现在逐字是 `session.Key(msg)`(主人私聊仍是 `main|web` 这个目的地键),
  **去掉 a.Name() 维度**。同平台多账号对同一对端因此共用一条车道(它们是同一个 session),
  出站路由不受影响:回复取每条消息自己的 platform/ChannelID 与它自己的适配器。
- P2-1 溢出区保序:`spill` 非空后新条目一律追加 spill、不再回写 `ch`,修掉复审实测的
  `[m1 m2 m3 m5 m4]` 超车(现为到达顺序)。
- P2-2 口径修正:lane_test 文件头改指 `TestLaneRejectsNewestWhenFull`(语义是「门前拒收
  最新、队列不动」);laneKey 注释改写「多账号必须共用一条车道」并写明取舍。
- P2-3 生产可见性:丢弃 WARN 带上 `dropped_stale_total` / `dropped_overflow_total` 累计
  计数(不新开端点);主人私聊积压改成按倍率 16/32/64… 告警,不再只响一次。

断言:TestLaneKeyNeverCollidesAcrossSessions(表驱动,含 kook c1/c2@同 guild、
telegram conf0/conf1@同对端两行 + 不同群仍并行的控制行)、
TestLaneOwnerPrivateSpillKeepsArrivalOrder(逐条放行,钉住到达顺序)、
internal/channel/session/keys_test.go(键派生与会话缓存键形状)。
2026-10-24 20:08:28 +08:00
AskaEth 7ea2850f18 Merge branch 'feat/m24-flowgate-move-review-off-admission-p' into dev 2026-10-24 20:00:57 +08:00
AskaEth 187eb327d6 Merge branch 'feat/mcppanel-mcp-server-management-page-and' into dev 2026-10-24 19:58:37 +08:00
AskaEth cfb8700b61 Merge branch 'feat/m26-webuidist-make-go-build-work-in-clea' into dev 2026-10-24 19:54:10 +08:00
AskaEth 2142de92f2 fix(plugin): 拒收 env/args 的非字符串值 + 保留键收成内置 id 集合 + 启用落盘失败文案
r1 复审 P2-1 + 两条顺手件。

P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。

顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。

顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。

测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
2026-10-24 19:53:38 +08:00
AskaEth a25e0c8870 test(channelbridge): 锁住审查解析的接线(BOM 韧性必须在生产侧生效)
- 新增 TestParseKeepIndices_ToleratesBOMWiring:喂对端**原样字节**(\xef\xbb\xbf + JSON 数组)
  给生产路径的 parseKeepIndices,必须解析成功且升序;同时断言夹具真的以 BOM 开头、且裸
  json.Unmarshal 在它上面仍然失败 —— 把委派改回裸 json.Unmarshal 即红。
- 新增 TestParseKeepIndices_StillFiltersAfterDelegation:委派之后过滤口径不变(越界/负数/
  重复编号丢弃、结果升序)。
- 新增 TestNewLLMReviewer_ToleratesBOMOutput:走整条生产路径(NewLLMReviewer 产出的
  Reviewer),回带 BOM 时给出保留编号而不是退回 FIFO。
- 注释里写明 M24 已确认的语义代价:审查移出准入路径后 review_pruned 不再从门口丢消息
  (41–74 条/日 会进流水线),而 rate_limited 门口丢与 FIFO 上限修剪一字不改(由既有用例钉住)。

只加用例与必要注释(含一个为断言所需的 encoding/json import),未改动既有用例的任何断言。
2026-10-24 19:49:58 +08:00
AskaEth 960d7b46fd Merge branch 'feat/m25-proactiveweb-route-web-proactive-pus' into dev 2026-10-24 19:47:48 +08:00
AskaEth e91b868a5c feat(channelbridge): 入站投递与回水解耦 + 每会话回合车道(M22)
报障:「群聊消息速率限制似乎会让消息一直排队,导致后面才回复前面的话」。
病灶:`onMessage` 在适配器的读循环里**同步**抢全局 16 槽回合信号量,槽满即停止
读 socket(心跳不回、后续帧不解析),跑完一轮再按条重放。

- 解耦:`onMessage` 过完流控闸后只调用 `enqueueInbound` 就返回;全局回合槽
  (maxConcurrent=16)搬到车道 worker 里取,读循环不再等任何一个回合。
- 每会话一条车道(lane.go):键 = `<账号>|<platform>|<channel>|<会话桶>`
  (主人私聊恒为 `main|web`,因为它们都路由进同一个主会话);同会话串行 FIFO、
  不同会话并行;空闲车道退休,表大小只跟活跃会话数相关。
- 陈旧度闸:轮到时已等超 `LaneStaleAfter`(默认 30s,Options 可覆盖)⇒ 丢弃不补答,
  留 WARN + LaneStats 计数 + onRejected 审计。主人私聊与非聊天事件豁免
  (主人 2026-10-24 裁定:宁可晚回,不许不回)。
- 背压有界且诚实:群聊/陌生人满时在门前拒收这一条(队列内容不动,M23 的 takeAll
  依赖它);主人私聊永不拒收,溢到 spill 只排队,越过 ownerLaneBacklogWarn 记 WARN。
- 四条不变量(顺序/一致性/完整性/最新性)在 lane.go 头部写清,lane_test.go 逐条断言;
  TestLaneDeliveryNeverBlocksTheReadLoop 用「槽全满 + 心跳帧」复现旧病灶。

不改「回什么」的语义:flowgate 限流判据、engage/冷避/owner 闸、陌生人 AcquireTurn
全部原样保留;不做静默窗口合并与回合中续跑(M23)。
2026-10-24 19:46:20 +08:00
AskaEth ec6c98d26a fix(web): keep the webui dist placeholder across frontend builds
copy-dist.mjs rm -rf'd internal/webui/dist before copying the SPA in, so
every npm run build deleted the committed .gitkeep and a fresh checkout
went back to failing go:embed. Restore the placeholder after the copy, and
also on a failed copy (an empty dist/ still compiles; a missing dir does
not), so the wipe only ever clears build output.
2026-10-24 19:44:34 +08:00
AskaEth 76d972f0f2 fix(channel/flowgate): 审查移出准入路径、节流覆盖失败路径、BOM 解析韧性
- Admit 不再同步调用审查模型:触发时只把审查**派发**到后台 goroutine,准入只读
  当前已有结论。三重上界:同一适配器至多一次在飞(reviewInFlight)、同一会话每窗口
  至多一次(lastReview)、整个适配器每窗口至多 defaultReviewPerWindow=6 次(reviewHits,
  写成可测断言)。
- st.lastReview 改为在**派发时**置位 ⇒ 成功与失败两条路径同样被节流。修复前它只在
  err == nil 分支置位,而实测 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满后每条入站帧
  都付一次 ~6s 的模型调用(qq-ws/kook-ws/weixin-poll 三路单读 goroutine 全被钉住)。
- 新增 adapter.ParseReviewIndices:容忍 UTF-8 BOM、首尾空白/换行、Markdown 代码围栏、
  数组外解说文字;越界/重复编号照旧丢弃。channelbridge.parseKeepIndices 改为委派它
  (仅函数体 + 注释,未动限流/丢弃/审查触发语义)。
- 语义差异(已写进 flowgate_review.go 文件头):rate_limited 的门口丢与 FIFO 上限
  一字不改;审查修剪降级为后台积压去噪,只修剪它当时看到的那批历史条目 ——
  review_pruned 不再从门口丢消息(实测 41–74 条/日),完整性↑、噪声↑,是否开口仍由
  engage_judge/冷避/owner 闸判(三处判据未动)。
- cmd/cyrene:逐账号启动日志如实报出生效的入口流控(塔裁决 A:零行为改动、不翻默认),
  让「没配流控键的账号闸门仍开着」一眼可见。
- 测试:4s 慢审查替身证明入站帧不被阻塞(变异回同步 ⇒ 红)、节流在失败路径生效
  (摘掉派发时置位 ⇒ 红)、每适配器窗口预算上界(摘掉预算 ⇒ 红)、同适配器单飞
  (摘掉 in-flight ⇒ 红)、在飞期间到达的消息不被连带修剪、带 BOM 字节的夹具
  (夹具去掉 BOM ⇒ 红)。闸门用例 -race 通过。
2026-10-24 19:43:57 +08:00
AskaEth a1d162febf fix(webui): commit dist placeholder so go:embed compiles in clean checkouts
internal/webui/webui.go uses //go:embed all:dist, which fails to compile
when the directory holds zero files. dist/ is gitignored (it is npm build
output), so every fresh clone or worktree could not run go build ./...
until the frontend had been built once.

Track an empty internal/webui/dist/.gitkeep so the embed always has one
matching file. The all: prefix is what makes a dotfile count. Real build
output (index.html, assets/**) stays ignored and is never committed.

The placeholder is forced into the index because .gitignore's dist/ rule
excludes the parent directory with no way to re-include a child.
2026-10-24 19:42:58 +08:00
AskaEth 1d3965aa80 fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)
M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
2026-10-24 19:40:30 +08:00
AskaEth 2421a8dac9 feat(plugin): 组合根装配「MCP 服务器」页(声明 + 动作处理器成对)
- wirePanelHub 在 AttachPanels / declareBuiltinPanels 之后调 AttachMCPPanel:
  一处完成「声明页面」+「登记 7 个动作处理器」,缺任一步就是面板在、动作全 501。
- wire_panels_test 的两条「内置面板恰好 1 条」断言改成按 pluginID 定位
  (web-search/providers),并加钉区块形状(form+table;viewer 只剩 table)。
  接线落地后目录里确实是两条(mcp/10 + web-search/20),旧断言是事实过期而非回归。
2026-10-24 19:38:38 +08:00
AskaEth 4518385f44 Merge branch 'feat/literalnewline-render-literal-backslash' into dev 2026-10-24 19:38:27 +08:00
AskaEth 6b0af81136 fix(turnreply): 围栏闭合串不得短于开启串(复审 p2 收口)
复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。

- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
  (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
  围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
  两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
  主动推送不经 NormalizeParagraphs,另计」。

行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
2026-10-24 19:33:33 +08:00
AskaEth 9654ce8861 feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
2026-10-24 19:31:31 +08:00
AskaEth fa573663c1 fix(turnreply): 字面 \n 渲染成真换行(Web 镜像与 IM 出站同一份)
主人 2026-10-24 报障:QQ 群最新一条 assistant 消息里 6 处字面 `\n`
(反斜杠 + n,不是真换行)原样发出去,用户看到「F\n多利亚」。

落点选在**写入侧共享层** `turnreply.NormalizeParagraphs`(gateway 的
persistTurnReply 与 channelbridge 的 turnVisibleReply 都调它):
IM 的 deliverReply 拿同一个字符串既落库(= Web 主会话镜像)又发气泡,
Web chat 气泡是 whitespace-pre-wrap ⇒ 在共享层渲染,落库/WS/REST/IM
出站与历史一次性对齐;只在 channelbridge 清洗层改会留下「面板上仍是字面 \n」。

- 新增 internal/runtime/turnreply/literal_breaks.go 的 RenderLiteralLineBreaks:
  逐字节扫描,围栏(```)与行内代码(`…` / ``…``)内原样保留,`\n` 与
  `\t`/`\r`/`\uXXXX`、行尾孤立 `\` 一律不动;未闭合围栏保守保护到末尾;幂等。
- paragraph.go 在**围栏 skip 之前**调用它 ⇒ 含围栏的消息里围栏外的 `\n` 也渲染。
- 🔴 未改既有语义:只产出换行字符,绝不产出 <br>/<p> —— <br> 仍是气泡分隔符,
  字面 \n 只做气泡内换行(一条列表不会被拆成 7 条消息)。
- channelbridge 源码零改动。

验证:真机原文(6 处 \n / 5 个 <p>)修前红(摘掉钩子 3 个测试变红)→ 修后绿;
三处变异(去掉围栏保护 / 去掉 \ 配对 / 把渲染挪到围栏 skip 之后)各自变红后还原复绿。
全树 go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包)。
2026-10-24 19:21:50 +08:00
AskaEth 4ed5925296 Merge branch 'feat/memberquiet-cold-shoulder-applies-before' into dev 2026-10-15 19:14:00 +08:00
AskaEth 28967d3435 refactor(channelbridge): 冷避例外②收窄为「真故障/求救」严格子集
塔 2026-10-15 改裁定:例外给了命中词「穿透 30 分钟冷避」的权力,噪声不再只是多回
一句话,而是直接削弱冷避本身 —— 而实测全表 14 条在 598 条真实 user 消息里只命中
1 次,且那次是假阳性(QQ 机器人卡片文案里的 `bug`,而该群本来就在放这种卡片)。

- 新增 coldShoulderPierceKeywords(具名常量,注释写明为什么比全表严、依据即那条
  卡片假阳性)与 hasColdShoulderPierceKeyword:保留 报错/出错/故障/紧急/救命/求助/
  帮帮我/urgent,剔出 ASCII `bug`/`error`/`help`(会命中结构化载荷/URL/卡片)与
  情绪歧义的 `崩溃`/`坏了`/`坏掉`。
- 例外② 改用该子集;hasHighValueKeyword(基线,ruleLightJudge 用)**一字未改**,
  两张表的分工写进注释,并有断言钉住「基线不许被这次收窄波及」。
- 例外矩阵新增三行:机器人卡片含 `bug` ⇒ 不穿透;英文 error/help ⇒ 不穿透;
  情绪「我崩溃了」⇒ 不穿透(三行对上一提交均为红)。第 1/2 行继续用保留词 报错,
  第 3/4/5/6 行原样保留;另加子集单元护栏(正例六条 / 反例六条)。
- 按项目「文件 ≤500 行」把冷避规则簇拆到 engage_coldshoulder.go(engage.go 528→376)。

红前绿证据:把例外判据临时还原成上一提交的 hasHighValueKeyword 全表(与该提交逐字
等价)⇒ 恰好上述三行变红、其余行与护栏保持绿;已还原(无残留)。
已知代价如实登记在案:用英文写的真求救不再穿透 —— 塔裁定以「宁可少穿透」为准;
全表(highValueKeywords)本身的收窄由塔另排切片,不在本片。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包);
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:04:48 +08:00
AskaEth 13a0519115 feat(channelbridge): 冷避期唯一的放行口——高价值关键词穿透(不解除冷却)
塔 2026-10-15 例外口径定稿落地。M18 主体(冷避优先于「@她必回」)保持不动,
本提交只补冷却期内的例外与它的守卫。

- engage.go:`coldShoulderLocked` 在冷却期内放行**高价值关键词**这一条
  (真故障不该被「让她少闲聊」一并压掉),且**不解除冷却**(不动 state / cooldownUntil);
  同句既有负面态度又有高价值关键词时**负面态度优先**(仍进冷却、仍静默);
  例外与顺序写进函数注释,并写明 owner 车道为什么不需要单独写例外(它在
  shouldRespond 第一步就分岔,写在这里是死代码)。文档面 design/18 §5/§9 由塔同步。
- light.go:shouldRespond 的判定顺序与冷避闸注释同步该口径。
- 测试:新增 engage_coldshoulder_exception_test.go(冷却期例外矩阵:真故障放行、
  @她/闲聊不穿透、穿透不解除冷却、同句负面态度优先;以及「冷却之外行为与基线一致」
  的护栏),并按文件 ≤500 行拆出该文件;engage_coldshoulder_test.go 增共享夹具。

红前绿证据(参照 = 本提交的父 M18 提交):例外矩阵两条「真故障放行」行在我的改动
之前为红(冷避闸会把它们静默),其余三行与基线护栏在父提交上已绿,已如实标注为护栏;
「同句负面态度优先」一条另做变异自证(把例外插到负面态度判定之前 ⇒ 该行立刻变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:01:02 +08:00
AskaEth fe53e029ea fix(channelbridge): 冷避优先于「@她必回」——群成员也能让她少说话
M18(主人 2026-10-14 裁决「给」)。病灶是**顺序**:`shouldRespond` 把
`ruleLightJudge`(指向她本人 / 高价值关键词)排在参与度引擎之前,@她的消息
在规则层就 return true,永远走不到 `engageDecision` 的冷避判定——群成员说
「少说点」只换来一句口头答应,行为零变化(2026-10-14 00:27 群 531082168 报障)。

- light.go:新增冷避闸位次 —— 非主人**群**消息在廉价规则之前先过
  `coldShoulderGate`,命中负面态度即进 COOLDOWN、冷却期内一律静默(仍登记)。
  边界:私聊不受影响(冷避是群机制)、owner 车道更早分岔不受影响、
  与是否装配参与度引擎无关。shouldRespond 的判定顺序写进函数注释。
- engage.go:把冷避判据抽成唯一实现 `coldShoulderLocked`(engageDecision 仍调用
  它,作纵深防御),命中时补一条带 source 的 INFO(群 / 发送者 / 冷却到何时)。
- engage.go 词表:依真机原话补「减少文本输出」(覆盖「减少文本输出量」)与
  「文本输出调到最少」;刻意不取「减少 / 输出 / 最少」等短泛词(子串匹配会误伤)。
- 测试:新增 engage_coldshoulder_test.go(主断言修前红/修后绿、登记进上下文的
  端到端守卫、私聊与 owner 边界护栏、词表误伤护栏、INFO 可见性);
  engage_test.go 更新旧口径「@ 打断冷却」为「@ 不得作废冷避」。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(windows 本机
与 darwin/arm64 交叉编译均通过);channelbridge / channel / cmd/cyrene 单独跑亦绿。
2026-10-15 18:55:35 +08:00
AskaEth 6432088586 Merge branch 'feat/addrmember-member-lane-mention-judgment' into dev 2026-10-14 07:26:34 +08:00
AskaEth 5969e700e1 fix(channelbridge): 成员车道「指向她」判据改为 AddressedToMe(@ 别人不再必回)
ruleLightJudge 原判据是 len(msg.Mentions) > 0 —— 只要有任意 @ 就必回,
@ 的是谁不论。2026-10-14 真机病灶:群里 @3889001246、@867587435 两条
都不是 @ 她,她仍逐条回。

改为复用 owner 车道已验证的同一份判据 ownerAddressedToMe
(msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)):
- 已接线渠道(qq-obv11 填了 SelfID/MentionAll/AddressedToMe):只有 @ 她本人 /
  @全体 才必回,@ 别人降级为无结论 → 参与度触发/概率那一档(未触发只登记);
- 未接线渠道(kook/webhook/telegram/weixin_ilink 未填 SelfID):逐字不变,
  仍按旧判据必回(「没有信号」≠「没在对我说话」);
- 判据全部来自结构化字段,绝不匹配正文里的 @<数字>。

口径依据 design/18 §2 触发① 的 2026-10-13 修订(消费点在 M13 只接了 owner 车道)。
2026-10-14 07:21:07 +08:00
105 changed files with 13760 additions and 618 deletions

No files matched your search

+1 -1
View File
@@ -81,6 +81,6 @@
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。 - 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。 - 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。 - 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。 - 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:🔴 **`internal/webui/dist/.gitkeep` 是 `git add -f` 追踪的占位文件,不要删、不要 `git rm --cached`** —— 它让**干净 checkout / 新 worktree** 能直接 `go build ./...`(`//go:embed all:dist` 在空目录上会编译失败);真实产物仍被 `.gitignore` 的 `dist/` 忽略,`frontend/web/copy-dist.mjs` 已在构建末尾保证把它写回来。删掉它会同时打坏编译与前端拷贝链。`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。 - 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。 - 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
+3 -1
View File
@@ -159,7 +159,9 @@
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 | | **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
| **合计** | — | **81** | `14 + 27 + 40`(v0.92) | | **合计** | — | **81** | `14 + 27 + 40`(v0.92) |
**管理路由总数 = 14 + 27 + 40 = 81 条。**(v0.92 起;`14 + 25 + 40 = 79` 是 v0.85–v0.91 的读数) **管理路由总数 = 18 + 43 + 43 = 104 条(2026-10-24 实测,`routes.go` 逐条枚举)。**
> 🔴 **本节计数曾长期偏低,已按实测改正**:`14 + 25 + 40 = 79`(v0.85–v0.91)与 `14 + 27 + 40 = 81`(v0.92)**都对不上代码**。2026-10-24 由复审逐条解析 `routes.go` 得到:**base(含 M22 前)`total=102` → 分组 `18 / 41 / 43`**;**合入 M23(coalesce 两端点)后 `total=104` → 分组 `18 / 43 / 43`**(`writeAuth` 41 → **43**,新增 `GET/PUT /api/v1/admin/channels/coalesce`)。三组口径:`viewerAuth`=18 / `writeAuth`=43 / `ownerAuth`=43,另有 `anyAuth`=3、`unwrapped`=1 未计入本表三段。
> ⚠️ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的条目尚未逐条登记**——本节计数已按实测更新,但**形状级条目仍待补**,见 §7.1。
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。 > **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
+63 -2
View File
@@ -90,11 +90,62 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr) log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
continue continue
} }
logFlowControl(log, built)
out = append(out, built) out = append(out, built)
} }
return out return out
} }
// logFlowControl 逐账号打一条启动日志,如实报出这个账号此刻被限成什么样(M24 裁决 A):
// 闸是否开、每群几条/分、待处理上限、审查触发水位(reviewAt)。
//
// 为什么需要它:namedAdapter 的 Config() 无条件暴露四个流控键(含零值),桥的 cfgInt
// 因此永远认为「键存在」,flowOrDefault 再把 0 映射成保护性默认 —— 于是「一个流控键都
// 没写的账号,闸门仍然是开着的」。这条事实必须一眼可见,否则「群里为什么有消息被丢」
// 只能靠读代码解释。判据:只看这一行日志就能说出这个群的限流/审查额度。
//
// 判定依据是**构建出来的那个适配器本身**,不是重新读一遍配置:非 namedAdapter 的实例
// 不暴露流控键,桥按 flowgate.go「键缺失 ⇒ 该限制关闭」处理;这时必须如实报 off,
// 不能拿保护性默认值假装成「开着」。
func logFlowControl(log *slog.Logger, built adapter.PlatformAdapter) {
if log == nil {
log = slog.Default()
}
log = logger.WithSource(log, "cmd/cyrene/channels")
n, ok := built.(*namedAdapter)
if !ok {
log.Info("channel account ingress flow control",
"account", built.Name(), "gate", "off",
"why", "adapter exposes no flow keys, every limit stays disabled")
return
}
group := flowLimitOrDefault(n.groupMsgPerMin, adapter.FlowLimitDefault)
direct := flowLimitOrDefault(n.directMsgPerMin, adapter.FlowDirectDefault)
pending := flowLimitOrDefault(n.maxMessages, adapter.FlowCapDefault)
log.Info("channel account ingress flow control",
"account", n.Name(), "gate", "on",
"group_msg_per_min", group, "group_msg_per_min_raw", n.groupMsgPerMin,
"direct_msg_per_min", direct, "direct_msg_per_min_raw", n.directMsgPerMin,
"msg_per_min", max(n.msgPerMin, 0),
"max_messages", pending, "max_messages_raw", n.maxMessages,
"review_at", adapter.ReviewAtFor(pending),
"note", "raw 0 (or absent in channels.json) takes the protective default; negative disables")
}
// flowLimitOrDefault 把账号原始值映射为实际生效值:负值关闭该限制、零值取保护性默认、
// 正值固定。它与 internal/services/channelbridge/flowgate.go 的 flowOrDefault 逐字同
// 口径,这里只用于上面那条启动日志 —— 日志必须报生效值,不能报原始值。
func flowLimitOrDefault(v, def int) int {
switch {
case v < 0:
return 0
case v == 0:
return def
default:
return v
}
}
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上 // channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给 // CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底 // runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
@@ -174,8 +225,18 @@ type namedAdapter struct {
denyGroups []string denyGroups []string
allowUsers []string allowUsers []string
denyUsers []string denyUsers []string
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号 // 流控限制(design/12 §6 策略三/四)。**四个键始终经 Config() 暴露,包括零值**:
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。 // 桥的 cfgInt 因此永远认为「键存在」,flowOrDefault 于是把 0 映射成保护性默认
// (每群 6 条/分、待处理上限 50、reviewAt = 40)—— 一个在 channels.json 里一个
// 流控键都没写的账号,闸门**仍然是开着的**(M24 只读勘测实测:现存 qq 账号就是
// 这种状态)。负值经桥映射为「关闭」。
//
// 边界的准确说法(M24):flowgate.go 那句「键缺失 ⇒ 该限制关闭」的向后兼容分支,
// 对 CHANNELS_JSON 账号**不可达** —— config.ChannelAccount 用的是普通 int
// (omitempty),「未配置」与「显式配 0」解出来都是 0,这里无从区分。要区分就得
// 改 config 的类型(*int / 存在性表)或在本题二次解析原始 JSON;而那样做会改掉
// 「闸门默认开还是关」这个产品口径(群聊限流/审查默认从开变关),属主人裁决,
// 不在本处自行翻转。见 M24 回报里的选项 A/B/C。
groupMsgPerMin int groupMsgPerMin int
directMsgPerMin int directMsgPerMin int
msgPerMin int msgPerMin int
+61
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
@@ -114,6 +115,12 @@ func (a *app) wireChannels() error {
ownerLane := runtimeconfig.NewOwnerLaneStore( ownerLane := runtimeconfig.NewOwnerLaneStore(
runtimeconfig.DefaultOwnerLanePath(docDataDir), runtimeconfig.DefaultOwnerLanePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/ownerlane")) logger.WithSource(a.log, "services/channelbridge/ownerlane"))
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
coalesceStore := runtimeconfig.NewCoalesceStore(
runtimeconfig.DefaultCoalescePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/coalesce"))
bridge, berr := channelbridge.New(channelbridge.Options{ bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"), Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop, Loop: a.loop,
@@ -168,6 +175,15 @@ func (a *app) wireChannels() error {
MinReplyGapSec: p.MinReplyGapSec, MinReplyGapSec: p.MinReplyGapSec,
}) })
}, },
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
Coalesce: channelbridge.CoalesceOptions{
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
p := coalesceStore.PolicyFor(laneKey)
return &p
},
},
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。 // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory, Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线 // Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
@@ -207,6 +223,7 @@ func (a *app) wireChannels() error {
fallback: factory.fallback, fallback: factory.fallback,
bridge: bridge, bridge: bridge,
owner: a.owner, owner: a.owner,
coalesce: coalesceStore,
log: a.log, log: a.log,
} }
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载, // 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
@@ -227,11 +244,55 @@ type runtimeChannels struct {
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员 // owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。 // 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile owner *user.OwnerProfile
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
coalesce *runtimeconfig.CoalesceStore
log *slog.Logger log *slog.Logger
} }
func (r *runtimeChannels) Path() string { return r.path } func (r *runtimeChannels) Path() string { return r.path }
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
func (r *runtimeChannels) CoalescePath() string {
if r.coalesce == nil {
return ""
}
return r.coalesce.Path()
}
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
func (r *runtimeChannels) CoalescePresent() bool {
if r.coalesce == nil {
return false
}
return r.coalesce.Present()
}
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
if r.coalesce == nil {
return runtimeconfig.CoalesceFile{}
}
return r.coalesce.Snapshot()
}
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
if r.coalesce == nil {
return false, errors.New("channel coalesce config store not wired")
}
wrote, err := r.coalesce.Apply(patch)
if err != nil {
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
}
if wrote {
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
"path", r.CoalescePath())
}
return wrote, nil
}
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount { func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts() accounts := r.loadAccounts()
r.applyAdminIDs(accounts) r.applyAdminIDs(accounts)
+13
View File
@@ -165,6 +165,12 @@ func (a *app) wireFrontend() error {
// backthink.New 时网关还没组装。 // backthink.New 时网关还没组装。
a.backthink.SetAuditSink(gw) a.backthink.SetAuditSink(gw)
} }
// 子代理结果回填(M32;design/05 §2/§5/§9):网关 **已存在**(回填的实时出口就是
// 它的 `message_persisted` 扇出)之后才能接线。它订阅 `subagent.result`,把后台
// 批次的结论写成父会话的一条 **system 注记**(不冒充 user/assistant、不带 turn_id、
// 绝不进记忆抽取),因此「她拍了个子代理出去,然后没有下文」这件事在历史与下一轮
// 上下文里都有落点。装配细节与降级口径见 wire_subagent_backfill.go。
wireSubagentBackfill(a, gw)
return nil return nil
} }
@@ -203,6 +209,13 @@ func wirePanelHub(a *app) {
}) })
a.plugins.AttachPanels(hub) a.plugins.AttachPanels(hub)
declareBuiltinPanels(hub, log) declareBuiltinPanels(hub, log)
// 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后
// (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。
// 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此
// 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。
if err := a.plugins.AttachMCPPanel(); err != nil {
log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err)
}
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记 // browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。 // 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
// //
+28
View File
@@ -0,0 +1,28 @@
package main
// 本文件只有一件事:给循环的记忆提取器套上「剔除子代理结果注记」这一层,并把这条
// 口径写在一个可测的入口上(M32)。
//
// 为什么要单独一个函数,而不是在 `wire_runtime.go` 的 Options 字面量里就地调一次:
//
// - 它是**红线级**口径(子代理结论绝不进记忆库),值得有一个名字与一个测试,
// 而不是夹在一长串 Options 里的一行调用 —— 那一行被删掉时没有任何东西会红;
// - 组合根里所有「必须套上」的装饰都走这种小函数(同 `a.buildMemoryExtractor()`
// 之于提取器本身),语义在函数名下,接线在唯一一处。
//
// 判据与实现见 `internal/services/gateway/subagent_backfill_extract.go`:元数据
// (role=system + 注记标记),不是提示词。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// loopMemoryExtractor 返回循环实际使用的提取器(`wire_runtime.go` 的
// `loop.Options.Extractor` 就是它):内层是组合根按配置构建的那个(LLM 或确定性),
// 外层保证子代理结果注记**永远不进**抽取输入。
//
// base 为 nil 时返回 nil(未配置提取器的部署逐字不变:循环那边同样是 nil 即跳过)。
func loopMemoryExtractor(base memory.Extractor) memory.Extractor {
return gateway.WithoutSubagentNotes(base)
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// 本文件锁住组合根那一条红线:循环用的提取器**必须**套上「剔除子代理注记」这一层
// (loopMemoryExtractor)。删掉那层装饰,子代理的结论就会被当成对话写进记忆库 ——
// 那条污染线刚查过(84% 记忆来自内部会话),不能再添一条。
//
// 断言的是**组合根这个入口**的语义,因此即使 gateway 那边的装饰器自己写对,把这里
// 换成 `base` 直通也会红。
// noteCaptureStore 是回填落库面的替身:只留下注记正文。
type noteCaptureStore struct {
mu sync.Mutex
rows []history.Message
}
func (s *noteCaptureStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if msg.ID == "" {
msg.ID = "msg-note"
}
s.rows = append(s.rows, msg)
return msg, nil
}
// seenCaptureExtractor 记录内层提取器实际收到的对话。
type seenCaptureExtractor struct {
mu sync.Mutex
seen []model.Message
}
func (e *seenCaptureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.seen = append([]model.Message(nil), conversation...)
return nil, nil
}
// TestLoopMemoryExtractorStripsBackfilledNotes 走真链路取一条注记(回填 → 落库 →
// 转录播种),再把它连同一条主人输入交给组合根返回的提取器:内层必须只看得到主人
// 那一句。
func TestLoopMemoryExtractorStripsBackfilledNotes(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store := &noteCaptureStore{}
bf, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{Bus: b, History: store})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
if err := bf.Handle(context.Background(), bus.Event{
Subject: orchestration.SubagentResultSubject,
Payload: &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Status: "done", Summary: "结论:X 可行",
}},
},
},
}); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(store.rows) != 1 {
t.Fatalf("backfill wrote %d rows, want one system note", len(store.rows))
}
note := transcript.Message(store.rows[0])
if note.Role != model.RoleSystem {
t.Fatalf("seeded note role = %q, want system", note.Role)
}
inner := &seenCaptureExtractor{}
extractor := loopMemoryExtractor(inner)
if extractor == nil {
t.Fatal("loopMemoryExtractor(non-nil) = nil")
}
if _, err := extractor.Extract(context.Background(), []model.Message{
note,
model.TextMessage(model.RoleUser, "接着说"),
}); err != nil {
t.Fatalf("Extract: %v", err)
}
inner.mu.Lock()
seen := inner.seen
inner.mu.Unlock()
if len(seen) != 1 {
t.Fatalf("inner extractor saw %d messages, want only the user turn: the composition root dropped the note filter", len(seen))
}
if !strings.Contains(messageTextOf(seen[0]), "接着说") {
t.Fatalf("inner extractor lost the real dialogue: %+v", seen)
}
}
// TestLoopMemoryExtractorKeepsNil 锁住未配置提取器的部署逐字不变。
func TestLoopMemoryExtractorKeepsNil(t *testing.T) {
if got := loopMemoryExtractor(nil); got != nil {
t.Fatalf("loopMemoryExtractor(nil) = %v, want nil", got)
}
}
// messageTextOf 返回一条模型消息的正文(测试窄视图)。
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
+29 -6
View File
@@ -8,6 +8,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID || // 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
entries[0].Panel.ID != search.PanelIDProviders { // mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries) // 「web-search/providers 在目录里,且形状仍是声明的那一版」。
entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders)
if !ok {
t.Fatalf("内置面板里没有 web-search/providers:%+v", entries)
}
// 形状也钉住:一个 form(写面)+ 一个 table(读面)。
if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm ||
entry.Panel.Sections[1].Type != sdk.SectionTable {
t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections)
} }
for _, name := range search.ActionNames() { for _, name := range search.ActionNames() {
if _, ok := hub.Action(search.PanelPluginID, name); !ok { if _, ok := hub.Action(search.PanelPluginID, name); !ok {
t.Fatalf("内置插件声明的动作 %q 未进白名单", name) t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
} }
} }
// viewer 拿到的必须是裁掉 form 的只读投影。 // viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
viewer, err := hub.List(auth.RoleViewer) viewer, err := hub.List(auth.RoleViewer)
if err != nil { if err != nil {
t.Fatalf("List(viewer): %v", err) t.Fatalf("List(viewer): %v", err)
} }
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 { viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer) if !ok {
t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer)
} }
if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable {
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections)
}
}
// findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。
func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) {
for _, e := range entries {
if e.PluginID == pluginID && e.Panel.ID == panelID {
return e, true
}
}
return paneldecl.Entry{}, false
} }
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。 // TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
+1 -1
View File
@@ -324,7 +324,7 @@ func (a *app) wireRuntime() error {
OnTurnEnd: a.onTurnEnd, OnTurnEnd: a.onTurnEnd,
Memory: store, Memory: store,
Retriever: a.retriever, Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(), Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
Checkpoint: checkpoint, Checkpoint: checkpoint,
Tracer: tracer, Tracer: tracer,
Flow: flowPub, Flow: flowPub,
+80
View File
@@ -0,0 +1,80 @@
package main
// 本文件把**子代理结果回填**接进组合根(M32;design/05 §2/§5/§9)。
//
// 为什么必须在组合根装配,而不是在网关 `New` 里顺手订阅:
//
// - 回填要写的是**父会话的聊天历史**,而网关 New 拿到的 History 与组合根
// `as.history` 是同一个 `*history.Store`,但它此刻还没被 hub 的广播出口包上 ——
// 落库之后必须用**同一个** `message_persisted` 扇出(`Server.BroadcastPersisted`)
// 才能让在线端与刷新/重连的端看到同一行;
// - 与 hub 的分工:hub 只把主题归一成 `subagent_result` 帧(投递),回填订阅者只把
// 结果写成父会话的一行系统注记(落库)。两者订阅**同一个主题**、互不相识,
// 因此以后改帧不会牵连落库,改落库也不会牵连帧。
//
// 订阅用的 ctx 是 `context.Background()`:组合根这一步没有任何长生命周期 ctx 可用
// (`newApp` 的 ctx 只用于构造期的库操作),而进程关停时 `app.close` 会
// `a.bus.Close()` —— 它取消所有订阅的处理器 ctx,因此这里不需要第二个停止开关。
// 这与 hub(`Server.Start` 里自建 ctx 并在 `Close` 取消)是同一取舍。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// backfillWireLogSource 是这条接线自己的日志来源:订阅成功/失败要能按来源定位,
// 而不是混在 `main` 或 `gateway` 的裸日志里(AGENTS §10 的日志来源铁律)。
const backfillWireLogSource = "cmd/cyrene/wire_subagent_backfill"
// wireSubagentBackfill 把「后台子代理结果 → 父会话系统注记」的订阅者接到组合根的
// 进程内总线上(与调度器、网关 hub **同一个** bus 实例)。
//
// 三条降级口径(都**必须可见**,绝不静默):
//
// - 没有 bus 或没有历史存储:这条回填此刻做不了(未接持久化的构建);
// - 订阅失败:总线已关闭或主题非法 —— 那意味着后台结果永远不会回填;
// - 广播出口缺失(网关为 nil):仍然落库(历史是唯一事实来源),只是不推帧。
func wireSubagentBackfill(a *app, gw *gateway.Server) {
log := logger.WithSource(a.log, backfillWireLogSource)
if a.bus == nil || a.history == nil {
log.Warn("subagent backfill not wired: needs both a bus and a history store",
"bus", a.bus != nil, "history", a.history != nil)
return
}
backfill, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{
Bus: a.bus,
History: a.history,
Broadcast: backfillSink(gw),
Log: a.log,
})
if err != nil {
log.Warn("subagent backfill not wired", "err", err)
return
}
if err := backfill.Start(context.Background()); err != nil {
log.Warn("subagent backfill subscribe failed; background results will not reach the parent session",
"err", err)
return
}
log.Info("subagent backfill wired",
"subject", "subagent.result", "session_role", history.RoleSystem)
}
// backfillSink 返回回填的下行出口。网关为 nil 时返回**真 nil 接口**(带类型的 nil
// 指针会让订阅者里的 `!= nil` 判断失效,落库之后去调一个空接收者)。
func backfillSink(gw *gateway.Server) gateway.SubagentNoteSink {
if gw == nil {
return nil
}
return gw
}
// 编译期锁住这条链路的两端:网关仍是回填的下行出口,历史存储仍是它的落库面。
// 任何一边改签名,这里先红,而不是等到运行期回填静默缺席。
var (
_ gateway.SubagentNoteSink = (*gateway.Server)(nil)
_ gateway.SubagentNoteStore = (*history.Store)(nil)
)
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// 本文件锁住 M32 的接线本身:**组合根真的把回填订阅者挂上了那条总线**。
//
// 为什么单独测这一步:这次缺陷的形状就是「所有零件都在、没有一根线」。回填订阅者
// 写对了、总线也发得对,只要 `wireFrontend` 里少掉那一行调用,主人在界面上看到的
// 仍然是「没有下文」—— 而且没有任何测试会红。因此这里断言的是**装配后的行为**:
// 组合根接线 → 真 bus 上发布编排层载荷 → 父会话真的多出一条系统注记行。
// waitForBackfillRow 轮询历史,直到父会话出现一条消息或到期。
func waitForBackfillRow(t *testing.T, st *history.Store, sessionID string) []history.Message {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rows, err := st.Recent(context.Background(), sessionID, 10)
if err != nil {
t.Fatalf("read recent: %v", err)
}
if len(rows) > 0 {
return rows
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("the parent session never received the backfilled note: the composition root did not wire the subscriber")
return nil
}
// TestWireSubagentBackfillPersistsThroughBus 是 ④ 的正面证据:装配 → 发布 → 落行。
func TestWireSubagentBackfillPersistsThroughBus(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
a := &app{bus: b, history: newTestHistory(t), log: testLog()}
wireSubagentBackfill(a, nil)
payload := &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Status: "done", Summary: "结论:X 可行",
}},
},
}
if err := b.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
t.Fatalf("publish: %v", err)
}
rows := waitForBackfillRow(t, a.history, history.MainSessionID)
if len(rows) != 1 {
t.Fatalf("parent session has %d messages, want exactly the one system note: %+v", len(rows), rows)
}
row := rows[0]
if row.Role != history.RoleSystem {
t.Fatalf("backfilled row role = %q, want %q (a sub-agent conclusion is not her own words)", row.Role, history.RoleSystem)
}
if row.TurnID != "" || row.PersonaID != "" {
t.Fatalf("backfilled row = %+v, want no turn_id and no persona_id", row)
}
}
// TestWireSubagentBackfillWithoutHistoryWarns 锁住降级口径:没有历史存储时不装配、
// 不 panic,而是留一条可见的告警(绝不假装回填成立)。
func TestWireSubagentBackfillWithoutHistoryWarns(t *testing.T) {
a := &app{bus: bus.New(), log: testLog()}
wireSubagentBackfill(a, nil) // 没有 history:只允许告警
if err := a.bus.Publish(context.Background(), orchestration.SubagentResultSubject,
&orchestration.SubagentResultEvent{SessionID: history.MainSessionID}); err != nil {
t.Fatalf("publish on an unwired root: %v", err)
}
}
+15 -2
View File
@@ -2,14 +2,27 @@
// the Go package can //go:embed it into the single Cyrene binary. It runs as the // the Go package can //go:embed it into the single Cyrene binary. It runs as the
// last step of `npm run build`. The source dist is the canonical build output, // last step of `npm run build`. The source dist is the canonical build output,
// so it also serves as the external deploy-override directory. // so it also serves as the external deploy-override directory.
import { cp, rm } from "node:fs/promises"; //
import { dirname, resolve } from "node:path"; // The destination is wiped first so assets from an older build cannot linger in
// the binary. internal/webui/dist/.gitkeep is committed because //go:embed
// all:dist fails to compile when the directory holds no files at all — that is
// what makes `go build ./...` work in a clean checkout. The wipe may clear build
// output but never the placeholder, so it is restored on every run, including a
// failed copy: a bare placeholder still compiles, a missing one breaks the build.
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, "dist"); const src = resolve(here, "dist");
const dest = resolve(here, "..", "..", "internal", "webui", "dist"); const dest = resolve(here, "..", "..", "internal", "webui", "dist");
const placeholder = ".gitkeep";
await rm(dest, { recursive: true, force: true }); await rm(dest, { recursive: true, force: true });
try {
await cp(src, dest, { recursive: true }); await cp(src, dest, { recursive: true });
} finally {
await mkdir(dest, { recursive: true });
await writeFile(join(dest, placeholder), "");
}
console.log(`webui: copied ${src} -> ${dest}`); console.log(`webui: copied ${src} -> ${dest}`);
+188 -26
View File
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives"; import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins"; import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
type CapKind = "tool" | "skill" | "channel"; type CapKind = "tool" | "skill" | "channel";
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
channel: "渠道 Channel", channel: "渠道 Channel",
}; };
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
// Channel adapters are not gated by the tool policy (group/stranger markers are a // Channel adapters are not gated by the tool policy (group/stranger markers are a
// tool concept; a channel is configured via its own 渠道管理 page), so the // tool concept; a channel is configured via its own 渠道管理 page), so the
// channel block shows identity + a pointer rather than the policy switches. // channel block shows identity + a pointer rather than the policy switches.
@@ -27,18 +36,17 @@ function channelColumns(): AdminColumn<AdminPluginRecord>[] {
]; ];
} }
/** Columns for a tool / skill row (has environment markers, owner can edit). */ /** The environment-marker switches, shared by tool and skill rows.
function registeredColumns( *
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
function policyColumn(
isOwner: boolean, isOwner: boolean,
busy: string | null, busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void, setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
uninstall: (name: string) => void, ): AdminColumn<AdminPluginRecord> {
): AdminColumn<AdminPluginRecord>[] { return {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "policy", key: "policy",
title: "环境授权(owner)", title: "环境授权(owner)",
render: (p) => ( render: (p) => (
@@ -62,7 +70,21 @@ function registeredColumns(
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null} {busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span> </span>
), ),
}, };
}
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
function registeredColumns(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
uninstall: (name: string) => void,
): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
policyColumn(isOwner, busy, setPolicy),
{ {
key: "actions", key: "actions",
title: "操作", title: "操作",
@@ -75,6 +97,61 @@ function registeredColumns(
]; ];
} }
/** Columns for a tool that belongs to an MCP server.
*
* 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器
* 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为
* 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */
function mcpToolColumns(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{
key: "origin",
title: "来源",
render: (p) => <Tag tone="neutral" title="注册时由后端盖章的上游归属,不从工具名推断">{p.origin?.server || "未知 server"}</Tag>,
},
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
policyColumn(isOwner, busy, setPolicy),
{
key: "actions",
title: "操作",
render: () => <span className="text-xs text-[var(--mu-text-lo)]">{MCP_PAGE_HINT}</span>,
},
];
}
/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */
function mcpServerColumns(): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "登记键", render: (p) => <span className="font-mono text-xs font-medium">{p.name}</span> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "admin",
title: "管理",
render: () => (
<span className="text-xs text-[var(--mu-text-lo)]">
启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略
</span>
),
},
];
}
/** Columns for capability kinds the page does not otherwise model (e.g. iot).
*
* design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */
function otherColumns(): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="neutral">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
];
}
export function PluginsModule(): React.ReactNode { export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []); const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000); useAutoReload(reload, 10000);
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
return groups; return groups;
}, [data]); }, [data]);
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall); // 工具段的两级分节:先按上游 server,再剩「宿主内置」。
const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]);
const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]);
const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]);
const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall);
const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy);
const channelCols = channelColumns(); const channelCols = channelColumns();
const serverCols = mcpServerColumns();
const otherCols = otherColumns();
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="插件与能力" title="插件与能力"
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)" subtitle="已注册能力(工具按上游 MCP server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
/> />
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null} {!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
<Surface className="space-y-3"> <Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">已注册能力</h3> <h3 className="text-sm font-semibold">已注册能力</h3>
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span> <span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 工具按上游 server 分节</span>
</div> </div>
{(["tool", "skill"] as CapKind[]).map((kind) => { <div className="space-y-1.5">
const rows = byKind[kind];
return (
<div key={kind} className="space-y-1.5">
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS[kind]}</Tag> <Tag tone="accent">{KIND_LABELS.tool}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span> <span className="text-xs text-[var(--mu-text-lo)]">
{byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
</span>
</div>
{toolBuckets.mcp.map((group) => (
<div key={group.key} className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 pt-1">
<Tag tone="neutral" title="来源由后端在注册时盖章,不从工具名解析">
{originGroupTitle(group)}
</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">server 级动作见「MCP 服务器」页</span>
</div> </div>
<AdminTable <AdminTable
columns={toolSkillColumns} columns={mcpToolCols}
rows={loading ? null : rows} rows={loading ? null : group.rows}
loading={loading} loading={loading}
error={error} error={error}
emptyTitle={`无${KIND_LABELS[kind]}`} emptyTitle="无工具"
rowKey={(p) => `${kind}:${p.name}`} rowKey={(p) => `mcp:${group.key}:${p.name}`}
/>
</div>
))}
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 pt-1">
<Tag tone="neutral">宿主内置工具</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{toolBuckets.builtin.length} 把</span>
</div>
<AdminTable
columns={toolCols}
rows={loading ? null : toolBuckets.builtin}
loading={loading}
error={error}
emptyTitle="无宿主内置工具"
rowKey={(p) => `tool:${p.name}`}
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS.skill}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.skill.length} 个</span>
</div>
<AdminTable
columns={toolCols}
rows={loading ? null : byKind.skill}
loading={loading}
error={error}
emptyTitle={`无${KIND_LABELS.skill}`}
rowKey={(p) => `skill:${p.name}`}
/> />
</div> </div>
);
})}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
rowKey={(p) => `channel:${p.name}`} rowKey={(p) => `channel:${p.name}`}
/> />
</div> </div>
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台
server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关
(`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="neutral">MCP server 登记</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{mcpServers.length} 台</span>
</div>
<AdminTable
columns={serverCols}
rows={loading ? null : mcpServers}
loading={loading}
error={error}
emptyTitle="无 MCP server 登记(可在「MCP 服务器」页添加)"
rowKey={(p) => `mcpentry:${p.name}`}
/>
</div>
{others.length > 0 ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="neutral">其它能力</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{others.length} 个 · 本页暂无专用视图,如实列出</span>
</div>
<AdminTable
columns={otherCols}
rows={others}
loading={loading}
error={error}
emptyTitle="无其它能力"
rowKey={(p) => `other:${p.name}`}
/>
</div>
) : null}
</Surface> </Surface>
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice> <Notice>
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
</Notice>
</div> </div>
); );
} }
@@ -0,0 +1,343 @@
/**
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
*
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
* 独立的一条消息,绝不拼成一条)。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
*
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
*/
import { useCallback, useEffect, useState } from "react";
import { request } from "../../../lib/api/core";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { useAuditStore } from "../../audit";
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
export interface CoalescePolicyView {
enabled: boolean;
windowMs: number;
maxWaitMs: number;
maxBatchMessages: number;
applyToStrangers: boolean;
strangerWindowMs: number;
}
/** 取值规则(后端 AdminCoalesceRules)。 */
export interface CoalesceRules {
windowMs: [number, number];
maxWaitMs: [number, number];
maxBatchMessages: [number, number];
clamp: string;
flushReasons: string[];
}
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
export interface CoalesceView {
path: string;
present: boolean;
hot: boolean;
default: CoalescePolicyView;
patch: Record<string, unknown>;
conversations: Record<string, CoalescePolicyView>;
rules: CoalesceRules;
ownerPrivate: string;
}
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
export interface CoalesceDraft {
enabled: string;
windowMs: string;
maxWaitMs: string;
maxBatchMessages: string;
applyToStrangers: string;
strangerWindowMs: string;
}
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
export const OWNER_PRIVATE_NOTE =
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
/** 热生效文案。 */
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
export const COALESCE_FIELDS: ReadonlyArray<{
key: keyof CoalesceDraft;
label: string;
kind: "bool" | "int";
hint: string;
}> = [
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
];
/** draftFromView 把生效值铺成草稿。 */
export function draftFromView(view: CoalesceView): CoalesceDraft {
return {
enabled: String(view.default.enabled),
windowMs: String(view.default.windowMs),
maxWaitMs: String(view.default.maxWaitMs),
maxBatchMessages: String(view.default.maxBatchMessages),
applyToStrangers: String(view.default.applyToStrangers),
strangerWindowMs: String(view.default.strangerWindowMs),
};
}
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
export function parseCoalescePatch(
view: CoalesceView,
draft: CoalesceDraft
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
const errors: Record<string, string> = {};
const out: Record<string, number | boolean> = {};
const changed: string[] = [];
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
errors[key] = "要填整数(毫秒/条)";
return null;
}
const n = Number(trimmed);
if (n < lo || n > hi) {
errors[key] = `取值范围 ${lo}–${hi}`;
return null;
}
return n;
};
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
const maxWait = window === null
? null
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
const strangerWindow = intIn(
"strangerWindowMs",
draft.strangerWindowMs,
view.rules.windowMs[0],
view.rules.windowMs[1]
);
const enabled = draft.enabled === "true";
const applyToStrangers = draft.applyToStrangers === "true";
const put = (key: string, next: number | boolean, current: number | boolean): void => {
if (next !== current) {
out[key] = next;
changed.push(key);
}
};
if (window !== null) put("windowMs", window, view.default.windowMs);
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
put("enabled", enabled, view.default.enabled);
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
return { patch: { default: out }, errors, changed };
}
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
export function fetchCoalesce(): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce");
}
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface CoalesceBodyProps {
view: CoalesceView;
draft: CoalesceDraft;
errors: Record<string, string>;
busy: boolean;
receipt: string | null;
savedView: CoalesceView | null;
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
onSave: () => void;
}
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function CoalesceCardBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: CoalesceBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
const inputCls =
"w-36 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
</span>
</div>
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
{COALESCE_FIELDS.map((field) => (
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
{field.kind === "bool" ? (
<select
value={draft[field.key]}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onDraftChange(field.key, e.target.value)}
>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={draft[field.key]}
inputMode="numeric"
className={inputCls}
onChange={(e) => onDraftChange(field.key, e.target.value)}
/>
)}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存窗口设置"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
<div className="font-mono text-[var(--mu-text-lo)]">
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
</div>
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
</div>
) : null}
{Object.keys(view.conversations).length > 0 ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
{Object.entries(view.conversations).map(([key, policy]) => (
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
</div>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
发车原因:{view.rules.flushReasons.join(" / ")}。
</p>
</div>
);
}
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
export function ChannelCoalesceCard(): React.ReactNode {
const [view, setView] = useState<CoalesceView | null>(null);
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return fetchCoalesce()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view || !draft) return;
const check = parseCoalescePatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
if (check.changed.length === 0) {
setReceipt("没有改动(一个键都没写)");
return;
}
setBusy(true);
setFail(null);
saveCoalesce(check.patch)
.then((next) => {
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
log("channels.coalesce", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
log("channels.coalesce", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null || draft === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<CoalesceCardBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -9,6 +9,7 @@ import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils"; import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
import { channelListState, channelStatus } from "./channelStatus"; import { channelListState, channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView"; import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { ChannelCoalesceCard } from "./CoalesceCard";
import type { AdapterNav } from "./useAdapterNav"; import type { AdapterNav } from "./useAdapterNav";
/** Page ① — aggregate all adapters: connection state, message volume, health. /** Page ① — aggregate all adapters: connection state, message volume, health.
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
onOpenList={() => navigate({ kind: "page", page: "list" })} onOpenList={() => navigate({ kind: "page", page: "list" })}
/> />
) : null} ) : null}
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
<ChannelCoalesceCard />
</div> </div>
); );
} }
@@ -0,0 +1,140 @@
// 群聊静默窗口卡(M23)的**真实渲染**测试。
//
// 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里):
// ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关);
// ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**;
// ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。
//
// 跑法(与 MemoryTidyTuningCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \
// && node /tmp/cyrene-coalesce-card.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
COALESCE_FIELDS,
COALESCE_HOT_LABEL,
OWNER_PRIVATE_NOTE,
CoalesceCardBody,
draftFromView,
parseCoalescePatch,
type CoalesceView,
} from "./CoalesceCard";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(代码默认 + 一条会话覆盖)。 */
function fixtureView(overrides: Partial<CoalesceView> = {}): CoalesceView {
return {
path: "/data/config/coalesce.json",
present: true,
hot: true,
default: {
enabled: true,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
patch: {},
conversations: {
"telegram|group:-20": {
enabled: false,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
},
rules: {
windowMs: [300, 15000],
maxWaitMs: [300, 30000],
maxBatchMessages: [1, 50],
clamp: "clamp",
flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"],
},
ownerPrivate: "never",
...overrides,
};
}
function render(view: CoalesceView, draftOverride?: Partial<ReturnType<typeof draftFromView>>): string {
const draft = { ...draftFromView(view), ...(draftOverride ?? {}) };
const check = parseCoalescePatch(view, draft);
return renderToStaticMarkup(
createElement(CoalesceCardBody, {
view,
draft,
errors: check.errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => undefined,
onSave: () => undefined,
})
);
}
function main(): void {
const view = fixtureView();
const html = render(view);
// ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。
for (const field of COALESCE_FIELDS) {
ok(has(html, field.label), `field label missing: ${field.label}`);
ok(has(html, field.hint), `field hint missing for ${field.key}`);
ok(has(html, field.key), `field key not shown for ${field.key}`);
}
ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing");
ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it");
ok(has(html, COALESCE_HOT_LABEL), "hot label missing");
ok(has(html, "/data/config/coalesce.json"), "config path not shown");
ok(has(html, "telegram|group:-20"), "per-conversation override not listed");
ok(has(html, "300–15000ms"), "window rules not shown");
ok(has(html, "quiet"), "flush reasons not shown");
// ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。
const unchanged = parseCoalescePatch(view, draftFromView(view));
ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch");
ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys");
const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" });
ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch");
ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`);
// ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。
const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" });
ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported");
ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported");
ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent");
ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent");
// 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。
const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" });
ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected");
const badHtml = render(view, { windowMs: "10" });
ok(has(badHtml, "取值范围"), "invalid draft must render an inline error");
ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button");
process.stdout.write(`coalesce card render: ${checks} checks passed\n`);
}
main();
@@ -0,0 +1,111 @@
// 「插件与能力」页按上游 server 分组的**纯逻辑**测试(DevDocs/audit/MCP工具按上游分组-2026-10-24.md)。
//
// 锁四件事:
// ① **归属只看 origin,不看名字**:MCP 工具名里没有任何前缀(`mcp:` 是 KindMCP
// 条目的注册键),所以判据必须是后端盖的章 —— 本测试给工具起一个带 `mcp:` 字样的
// 名字但**无 origin**,它必须落进「宿主内置」;
// ② **transport 不编**:后端没报告 transport 时标题省略括号,而不是假设 stdio;
// ③ **空 server 不冒充内置**:报告了 mcp 来源却没说哪台 server 的工具,仍归 MCP 段
// (标题写「未知 MCP server」),不能被并进「宿主内置」—— 那是不同的结论;
// ④ **不得静默丢弃**:`kind:"mcp"` 登记条目与未知 kind(如 iot)都有专门的取值口。
//
// 项目没有前端测试框架,所以这是一个零依赖的 plain-node 检查(同目录既有手法):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/pluginOrigin.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-plugin-origin.test.cjs \
// && node /tmp/cyrene-plugin-origin.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import type { AdminPluginRecord } from "../../lib/types";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
/** 造一条能力记录(origin 只在给定时出现,模拟后端 omitempty)。 */
function rec(
name: string,
kind: string,
origin?: { kind: string; server?: string; transport?: string },
): AdminPluginRecord {
const base: AdminPluginRecord = { name, kind, description: "", allowInGroup: false, allowInStranger: false };
return origin ? { ...base, origin } : base;
}
// ① 两个 server 各两把工具 + 两把内置工具(其中一把名字里带 mcp: 字样的假前缀)。
const rows: AdminPluginRecord[] = [
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("write_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("search", "tool", { kind: "mcp", server: "github", transport: "http" }),
rec("mcp:read_file", "tool"), // 名字像 MCP,但没有来源章 ⇒ 内置
rec("web_search", "tool"),
];
const buckets = toolOriginBuckets(rows);
// ① 归属只看 origin:两个 MCP 分节(按 server 名排序),其余归内置。
same(
buckets.mcp.map((g) => [g.server, g.transport, g.rows.map((r) => r.name)]),
[
["fs", "stdio", ["read_file", "write_file"]],
["github", "http", ["search"]],
],
"MCP 工具按 origin.server 分节(按 server 名排序)",
);
same(
buckets.builtin.map((r) => r.name),
["mcp:read_file", "web_search"],
"无 origin 的归宿主内置 —— 名字里带 mcp: 不算来源",
);
// ② 标题:transport 有则带括号,无则省略(不编 stdio);计数是「把工具」。
same(originGroupTitle(buckets.mcp[0]), "来自 fs(stdio)· 2 把工具", "带 transport 的组标题");
same(
originGroupTitle({ key: "mcp:x", server: "x", transport: "", rows: [rec("a", "tool")] }),
"来自 x · 1 把工具",
"未报告 transport 时省略括号,不编默认值",
);
// ③ 报告了 mcp 却没说哪台 server:仍属 MCP 段,不得冒充内置。
const anonymous = toolOriginBuckets([rec("orphan_tool", "tool", { kind: "mcp" }), rec("web_search", "tool")]);
same(anonymous.mcp.length, 1, "缺 server 名的 MCP 工具仍是 MCP 段的一员");
same(anonymous.mcp[0].server, "", "缺 server 名时 server 留空,不编一个名字");
same(originGroupTitle(anonymous.mcp[0]), "来自 未知 MCP server · 1 把工具", "缺 server 名的标题如实写未知");
same(anonymous.builtin.map((r) => r.name), ["web_search"], "内置桶只装真的没有 mcp 来源的工具");
// ④ 不得静默丢弃:KindMCP 登记条目与未知 kind 各有取值口。
const mixed: AdminPluginRecord[] = [
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("mcp:fs", "mcp"),
rec("kb_search", "skill"),
rec("lamp", "iot"),
rec("kook", "channel"),
];
same(mcpServerEntries(mixed).map((r) => r.name), ["mcp:fs"], "kind:mcp 登记条目要能被页面拿到");
same(otherCapabilityEntries(mixed).map((r) => r.name), ["lamp"], "未知 kind(iot)不得被静默过滤");
same(toolOriginBuckets(mixed).builtin.length, 0, "只有工具行进工具桶(技能/渠道/登记条目都不进)");
// 空输入不炸,且三个出口都是空数组而不是 undefined。
const empty = toolOriginBuckets([]);
same([empty.mcp.length, empty.builtin.length], [0, 0], "空清单得到空结果");
console.log(`pluginOrigin: ${checks} checks passed`);
@@ -0,0 +1,93 @@
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 React 依赖)。
//
// 主人 2026-10-24 的要求:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器
// 分类并与其他插件相对隔离?」结论(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// 归属**只能来自注册时的元数据**(`origin`),不能解析工具名 —— MCP 工具名由 server
// 自己给,宿主不加任何前缀(`mcp:` 是 KindMCP 条目的注册键,不是工具的命名空间)。
//
// 因此本模块只做三件事:
// ① 把 `kind:"tool"` 的行按 `origin.server` 切成分节,无 MCP 来源的归「宿主内置」;
// ② 生成分节标题(`来自 fs(stdio)· 12 把工具`)——transport 未报告时省略括号,
// 绝不编一个默认值;
// ③ 把不再是「工具/技能/渠道」行的事实摆出来(`mcp` 登记条目、其它未知 kind),
// 供页面**如实显示**而不是静默丢弃(design/09 §5.2.1 红线③)。
//
// 这里判的是「呈现归属」,不涉及权限:工具能不能被调用仍只由 tool_policies 的逐工具
// 布尔 + actor 元数据决定,分组既不收窄也不放宽任何权限。
import type { AdminPluginRecord } from "../../lib/types";
/** 一个上游 MCP server 承载的工具分节。 */
export interface ToolOriginGroup {
/** React key:同一 server 的所有工具共用一个。 */
key: string;
/** server 名(plugins.json / MCP_SERVERS_JSON 的 name);空串 = 报告了 MCP 却没说哪台。 */
server: string;
/** stdio / sse / http;空串 = 后端未报告。 */
transport: string;
rows: AdminPluginRecord[];
}
/** 工具段的两级分节结果。 */
export interface ToolOriginBuckets {
/** 按上游 server 分节(按 server 名排序);只含来自 MCP 的工具。 */
mcp: ToolOriginGroup[];
/** 没有 MCP 来源的工具:宿主内置 / 来源未知。 */
builtin: AdminPluginRecord[];
}
/** 页面自己成段渲染的 kind;其余 kind 必须落到「其它能力」段,不得静默丢弃。 */
export const RENDERED_KINDS = ["tool", "skill", "channel", "mcp"] as const;
/** record 是否来自一台 MCP server(判据是后端盖的来源章,不是名字)。 */
export function isMCPOrigin(rec: AdminPluginRecord): boolean {
return rec.origin?.kind === "mcp";
}
/** 把工具行按上游 server 分节;无 MCP 来源的归「宿主内置」桶。
*
* 只收 `kind === "tool"` 的行:技能/渠道/MCP 登记条目由页面自己成段渲染(见
* `RENDERED_KINDS` 与另两个取值口),它们**不是**工具、也不该被算进任何一个工具分节。
* 传整份清单进来是安全的 —— 忽略 ≠ 丢弃,其余 kind 在页面上各有位置。 */
export function toolOriginBuckets(rows: AdminPluginRecord[]): ToolOriginBuckets {
const groups = new Map<string, ToolOriginGroup>();
const builtin: AdminPluginRecord[] = [];
for (const rec of rows) {
if (rec.kind !== "tool") continue;
if (!isMCPOrigin(rec)) {
builtin.push(rec);
continue;
}
const server = (rec.origin?.server ?? "").trim();
const transport = (rec.origin?.transport ?? "").trim();
const key = `mcp:${server}`;
const group = groups.get(key);
if (group) {
group.rows.push(rec);
// 同一 server 的传输形态应当一致;万一不一致,保留先到的那个而不是拼接,
// 免得标题读成「stdio/sse」(拼接会让人以为一台 server 同时两种传输)。
continue;
}
groups.set(key, { key, server, transport, rows: [rec] });
}
const mcp = [...groups.values()].sort((a, b) => (a.server < b.server ? -1 : a.server > b.server ? 1 : 0));
builtin.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
return { mcp, builtin };
}
/** 分节标题:`来自 fs(stdio)· 12 把工具`;transport / server 缺失时如实省略。 */
export function originGroupTitle(group: ToolOriginGroup): string {
const count = `${group.rows.length} 把工具`;
const server = group.server || "未知 MCP server";
return group.transport ? `来自 ${server}(${group.transport})· ${count}` : `来自 ${server} · ${count}`;
}
/** `kind:"mcp"` 的登记条目(每台 server 一条,名字是 `mcp:<server>`)。 */
export function mcpServerEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
return rows.filter((rec) => rec.kind === "mcp");
}
/** 既不是工具/技能/渠道、也不是 MCP 登记条目的能力(如 iot):必须如实显示。 */
export function otherCapabilityEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
return rows.filter((rec) => !(RENDERED_KINDS as readonly string[]).includes(rec.kind));
}
+13
View File
@@ -8,6 +8,17 @@ export interface MemoryRecord {
text: string; text: string;
} }
/** 一条能力的上游来源(GET /api/v1/admin/plugins 的可选 `origin` 键,契约 v0.83)。
*
* 它回答「这条能力来自哪台上游 server」,来自**注册时的元数据**——绝不是从
* `name` 里解析前缀得来的(MCP 工具名里没有任何前缀)。宿主内置能力的该键
* **整个缺席**(不是 `{}`),前端据此把它们归「宿主内置」。 */
export interface CapabilityOrigin {
kind: string;
server?: string;
transport?: string;
}
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */ /** A capability/plugin as returned by GET /api/v1/admin/plugins. */
export interface AdminPluginRecord { export interface AdminPluginRecord {
name: string; name: string;
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
description: string; description: string;
allowInGroup: boolean; allowInGroup: boolean;
allowInStranger: boolean; allowInStranger: boolean;
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
origin?: CapabilityOrigin;
} }
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28). /** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住 MCP 工具的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// 每把注册进注册表的工具都要带「哪台 server + 什么传输」,供面板按 server 分节。
// TestRegistryRegisterStampsToolOrigin 覆盖**两条接入路径**的共同汇点:
// - 运行时热装:plugin.Manager.start → mcp.NewRegistry → **Register**;
// - `.env` 的 MCP_SERVERS_JSON 启动期装:cmd/cyrene.registerMCPServer →
// mcp.NewRegistry → **同一个 Register**(不经插件管理器)。
//
// 两条路都在这里进注册表,因此这一处的断言就同时钉住了它们;也从反面证明
// 「从插件管理器现场反推归属」会漏掉 env 路径。
func TestRegistryRegisterStampsToolOrigin(t *testing.T) {
reg := registry.New()
cfg := MCPConfig{Name: "calc", TransportType: "stdio"}
r := NewRegistry(reg, cfg, WithTransport(startFakeServer(t, newFakeServer())))
if err := r.Register(context.Background()); err != nil {
t.Fatalf("Register: %v", err)
}
t.Cleanup(func() { _ = r.Unregister(context.Background()) })
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "calc", Transport: "stdio"}
tools := reg.List(registry.KindTool)
if len(tools) != 2 {
t.Fatalf("注册进注册表的工具数 = %d, want 2(echo/add)", len(tools))
}
for _, cap := range tools {
if cap.Origin != want {
t.Fatalf("工具 %q 的来源章 = %+v, want %+v", cap.Name, cap.Origin, want)
}
}
// 来源是 server 名,不是注册键 —— 与 `mcp:calc` 那条 KindMCP 条目无关。
if err := r.Unregister(context.Background()); err != nil {
t.Fatalf("Unregister: %v", err)
}
if left := reg.List(registry.KindTool); len(left) != 0 {
t.Fatalf("停用 server 后还剩 %d 把工具:条目必须连同来源章一起消失,不留孤儿", len(left))
}
}
// TestTransportNameOf 钉住来源章里 transport 的取值:它必须报**实际会用的**传输,
// 而不是原样下发可能为空的 TransportType(配置常见留空,由推断决定)。
// 判据与 newTransport 的推断同源,因此面板显示的传输永远是真在用的那个。
func TestTransportNameOf(t *testing.T) {
cases := []struct {
name string
cfg MCPConfig
want string
}{
{"显式 stdio", MCPConfig{TransportType: "stdio"}, "stdio"},
{"显式 sse", MCPConfig{TransportType: "sse"}, "sse"},
{"显式 http", MCPConfig{TransportType: "http"}, "http"},
{"streamable 归一为 http", MCPConfig{TransportType: "streamable"}, "http"},
{"留空 + 命令 ⇒ stdio", MCPConfig{Command: "npx"}, "stdio"},
{"留空 + 地址 ⇒ sse", MCPConfig{URL: "https://example.test/sse"}, "sse"},
{"命令优先于地址(与 newTransport 同序)", MCPConfig{Command: "npx", URL: "https://example.test"}, "stdio"},
{"都没有 ⇒ 空串(不编一个)", MCPConfig{}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := transportNameOf(tc.cfg); got != tc.want {
t.Fatalf("transportNameOf(%+v) = %q, want %q", tc.cfg, got, tc.want)
}
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package mcp
// 本文件是 MCP 客户端 registry 的**状态读面**(切片 M20:MCP 服务器的管理页)。
//
// 为什么放在 mcp 包而不是插件管理器:工具名与描述的唯一真源是**已注册进能力注册表
// 的那些条目**(`Register`/`refresh` 写进去、`Unregister` 摘掉的正是它们)。管理器只
// 知道「这个 server 活没活」,它没有工具描述;让它自己去猜工具清单就会出现「面板上的
// 工具列表」与「模型真正能调的工具」两份事实。这里只读,不改任何注册/刷新逻辑。
// ToolSummary 是一把工具在状态快照里的形状:名字 + 截断后的描述。
//
// 描述必然要截断:MCP 服务器的描述可以是一整段 Markdown(实测有几百字的),而状态页是
// 给人扫一眼的排障面,把原文全塞进表格只会把有用的列挤没。
type ToolSummary struct {
// Name 是工具名(模型调用时用的标识,不截断:它是排障时要对的那个字符串)。
Name string `json:"name"`
// Description 是截断后的描述(原始描述见 mcp 客户端的 tools/list)。
Description string `json:"description,omitempty"`
}
// ToolSummaries 返回本 registry 当前注册的工具摘要(按注册顺序)。
//
// 「当前注册」= `ToolNames` 里还活着的名字;被 `refresh` 摘掉的工具不会留下影子。
// descriptionLimit ≤ 0 表示不截断。描述取自能力注册表的条目:**注册失败的工具不在
// 其中**,因此清单与模型可见的工具集合同源。
func (r *Registry) ToolSummaries(descriptionLimit int) []ToolSummary {
if r == nil {
return nil
}
names := r.ToolNames()
out := make([]ToolSummary, 0, len(names))
for _, name := range names {
desc := ""
if r.reg != nil {
if cap, err := r.reg.Get(name); err == nil {
desc = cap.Description
}
}
out = append(out, ToolSummary{Name: name, Description: TruncateRunes(desc, descriptionLimit)})
}
return out
}
// TruncateRunes 把 s 截到最多 limit 个字符(按 rune 计,不切坏多字节字符);被截断时
// 末尾补一个省略号,让「这里还有」在界面上可见(静默截断会被读成「原文就这么长」)。
// limit ≤ 0 表示不截断。
func TruncateRunes(s string, limit int) string {
if limit <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "…"
}
+13
View File
@@ -7,6 +7,8 @@ import (
"strings" "strings"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// ToolInfo 是 MCP 服务器暴露的一把工具。 // ToolInfo 是 MCP 服务器暴露的一把工具。
@@ -60,6 +62,17 @@ func (t *mcpTool) Name() string { return t.info.Name }
// Description 返回 MCP 工具描述。 // Description 返回 MCP 工具描述。
func (t *mcpTool) Description() string { return t.info.Description } func (t *mcpTool) Description() string { return t.info.Description }
// Origin 回报这把工具的上游来源(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// server 名与传输形态都在手上的 `client.cfg` 里,因此归属是**注册时的事实**,
// 不靠工具名推断。宿主在 registry.RegisterTool 处读到它并盖在能力条目上。
func (t *mcpTool) Origin() sdk.Origin {
return sdk.Origin{
Kind: sdk.OriginKindMCP,
Server: t.client.cfg.Name,
Transport: transportNameOf(t.client.cfg),
}
}
// Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。 // Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。
func (t *mcpTool) Parameters() (map[string]any, error) { func (t *mcpTool) Parameters() (map[string]any, error) {
if t.info.InputSchema == nil { if t.info.InputSchema == nil {
+24
View File
@@ -33,6 +33,30 @@ func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
} }
} }
// transportNameOf 返回 cfg **实际会用到**的传输名(stdio / sse / http),与
// newTransport 的推断同源:显式 TransportType 优先(http / streamable 归一为 http),
// 为空则「有 Command 走 stdio,有 URL 走 sse」。两者都没有时返回空串 —— 配置不成立,
// 上游来源里该键缺席,而不是编一个 stdio。
//
// 它服务于能力条目的来源章(mcpTool.Origin):面板要能写「来自 fs(stdio)」,
// 而 cfg.TransportType 常常留空(由推断决定),因此不能直接下发原始配置值。
func transportNameOf(cfg MCPConfig) string {
switch cfg.TransportType {
case "http", "streamable":
return "http"
case "sse", "stdio":
return cfg.TransportType
}
switch {
case cfg.Command != "":
return "stdio"
case cfg.URL != "":
return "sse"
default:
return ""
}
}
// httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25 // httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25
// 规范首选),否则走旧式 SSE。 // 规范首选),否则走旧式 SSE。
func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) { func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) {
+195
View File
@@ -0,0 +1,195 @@
package plugin
// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的
// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 =
// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。
//
// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停
// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又
// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即
// 原样返回错误,绝不出现「面板说停用了、重启又连上」。
//
// 刻意不做的事:
// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置);
// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// ErrPluginStatePersist 报告「启用态没写成」:内存/连接态可能已经变了,但 plugins.json
// 还是旧值 —— 因此**重启后会退回旧状态**。
//
// 它与「连不上」是两件相反的事,调用方必须能分开(文案说反就是骗人):连不上 ⇒ server
// 现在不可用;落盘失败 ⇒ server 可能正连着、工具可用,只是这个事实活不过一次重启。
var ErrPluginStatePersist = errors.New("plugin: enabled state not persisted")
// notFound 构造一条带名字的「找不到」。
func notFound(name string) error {
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
// SetEnabled 启用或停用一台已安装的 server。
//
// 语义(design/09 §5.2.1 生命周期一栏):
// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏
// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。
// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。
// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。
//
// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。
// 找不到该 server 时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return notFound("")
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。
//
// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态,
// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」
// 变成一个查不出的谜)。
func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) {
name = strings.TrimSpace(name)
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
cfg, ok = srv.cfg, true
}
}
if !ok {
return Installed{}, notFound(name)
}
if !cfg.IsEnabled() {
return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name)
}
return m.install(ctx, cfg, false, PanelChangeInstalled)
}
// disable 停用一台 server:落盘 → 下线 → 隐藏入口。
//
// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再
// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.liveServer(name)
cfg, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return notFound(name)
}
if !persisted {
// 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条,
// 否则「停用」重启后会变成「不存在」。
cfg = srv.cfg
}
if err := m.persistEnabled(cfg, false); err != nil {
return fmt.Errorf("plugin %q: %w", name, err)
}
if srv != nil {
m.take(srv.cfg.Name)
m.stop(ctx, srv)
}
m.noteDisabled(cfg)
m.setPanelsEnabled(cfg.Name, false)
m.log.Info("plugin disabled", "name", cfg.Name)
return nil
}
// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。
// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
return m.persistEnabled(srv.cfg, true)
}
return notFound(name)
}
if srv := m.liveServer(name); srv != nil {
if cfg.IsEnabled() {
return nil
}
return m.persistEnabled(cfg, true)
}
// 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。
res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled)
if err != nil {
return err
}
return m.persistEnabled(res.Config, true)
}
// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。
//
// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。
// 失败用 ErrPluginStatePersist 包起来:调用方要能把它与「连不上」区分开 —— 这两个失败
// 的事实相反(一个没连上、一个可能已经连上而磁盘没记住)。
func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error {
if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil {
return fmt.Errorf("%w: 插件 %q 的启用态未落盘: %w", ErrPluginStatePersist, cfg.Name, err)
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
+223
View File
@@ -0,0 +1,223 @@
package plugin
// 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20):
// ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来;
// ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且
// **一次都不去连**(不是「连了一下再判定停用」);
// ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。
import (
"context"
"errors"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。
func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) {
t.Helper()
reg := registry.New()
mgr := NewManager(reg, path, nil)
t.Cleanup(func() { mgr.Close(context.Background()) })
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
}); err != nil {
t.Fatalf("Install: %v", err)
}
return mgr, reg
}
// TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。
func TestDisableUnregistersToolsAndPersists(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("安装后工具 = %v,期望 add+mul", names)
}
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 工具必须真的不在注册表里:模型的面就是这样来的。
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用后工具仍可被模型看到 = %v", names)
}
if _, err := reg.Get("mcp:calc"); err == nil {
t.Fatal("停用后 mcp:calc 能力仍在")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs)
}
if cfgs[0].Enabled == nil {
t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)")
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected {
t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st)
}
if st.ToolCount != nil {
t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount)
}
}
// TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。
func TestEnableBringsToolsBack(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if err := mgr.SetEnabled(ctx, "calc", true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || !cfgs[0].IsEnabled() {
t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs)
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 {
t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st)
}
if len(st.Tools) != 2 {
t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools)
}
}
// TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。
func TestRestoreHonoursDisabledFlag(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
mgr.Close(context.Background())
before := srv.Config.Handler.(*fakeMCP).hitCount()
reg2 := registry.New()
mgr2 := NewManager(reg2, path, nil)
defer mgr2.Close(context.Background())
mgr2.Restore(context.Background())
if names := toolNames(reg2); len(names) != 0 {
t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names)
}
if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before {
t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after)
}
st := statusOfOne(t, mgr2, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st)
}
}
// TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。
func TestInstallDisabledDoesNotConnect(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
reg := registry.New()
mgr := NewManager(reg, path, nil)
defer mgr.Close(context.Background())
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false),
})
if err != nil {
t.Fatalf("Install(disabled): %v", err)
}
if got.Live {
t.Fatalf("停用安装不该有活 server:%+v", got)
}
if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 {
t.Fatalf("停用安装发起了 %d 次连接", hits)
}
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用安装注册了工具 = %v", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err)
}
}
// TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。
func TestEnableFailureKeepsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 让 server 连不上:关掉进程内的假 server。
srv.Close()
if err := mgr.SetEnabled(ctx, "calc", true); err == nil {
t.Fatal("启用一台连不上的 server 应当报错")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v)", cfgs, err)
}
if cfgs[0].IsEnabled() {
t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0])
}
// 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 ——
// 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled)
}
if st.Connected == nil || *st.Connected {
t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected)
}
if st.LastError == "" {
t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了")
}
if st.LastErrorAt == "" {
t.Fatal("启用失败后 lastErrorAt 为空")
}
}
// TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。
func TestReconnectRejectsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) {
t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err)
}
if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) {
t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err)
}
}
// enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。
func enabledFlag(v bool) *bool { return &v }
+50 -108
View File
@@ -33,6 +33,19 @@ const (
mcpCapabilityPrefix = "mcp:" mcpCapabilityPrefix = "mcp:"
) )
// MCPServerName 从 `mcp:<server>` 登记条目的注册键里取出 server 名。
// 第二个返回值为 false 表示这不是一条 KindMCP 登记条目的键(或者 server 名为空)。
//
// 它是**注册键格式**的唯一真相(`mcpCapabilityPrefix` 只在这里出现),因此网关在需要
// 「这条登记条目是哪台 server」时应当调它,而不是自己在别处再拼一遍前缀。
func MCPServerName(capName string) (string, bool) {
server, ok := strings.CutPrefix(capName, mcpCapabilityPrefix)
if !ok || server == "" {
return "", false
}
return server, true
}
// Installed is one plugin as reported to the admin API: its persisted config, // Installed is one plugin as reported to the admin API: its persisted config,
// whether a live server currently backs it, and how many tools it registered. // whether a live server currently backs it, and how many tools it registered.
type Installed struct { type Installed struct {
@@ -61,6 +74,10 @@ type Manager struct {
mu sync.Mutex mu sync.Mutex
servers map[string]*server servers map[string]*server
// observed 是每台 server 最近一次被观察到的运行期事实(成功连上的时刻、最近一次失败
// 的文本与时刻)。它与 servers(活没活)是两个不同的事实:一台连不上的 server 不在
// servers 里,但它的失败记录必须留着 —— 否则「为什么挂了」在快照里就是一个空。
observed map[string]observed
// panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。 // panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。
// 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把 // 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把
// 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。 // 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。
@@ -80,6 +97,7 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager
path: path, path: path,
log: log, log: log,
servers: make(map[string]*server), servers: make(map[string]*server),
observed: make(map[string]observed),
panelKeys: make(map[string]string), panelKeys: make(map[string]string),
} }
} }
@@ -116,6 +134,11 @@ func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (
// install is Install with the persist step optional (Restore reuses it without // install is Install with the persist step optional (Restore reuses it without
// rewriting the file it just read). changeReason labels the panel change pushed // rewriting the file it just read). changeReason labels the panel change pushed
// when the plugin becomes live (install vs enable). // when the plugin becomes live (install vs enable).
//
// It respects cfg.Enabled: a config that says enabled=false is **persisted but not
// connected** (no live server, no tools), which is how "installed but switched off"
// survives a restart. Restore goes through this same path, so startup honours the
// flag too.
func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) { func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) {
cfg = cfg.Normalize() cfg = cfg.Normalize()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@@ -129,8 +152,13 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
if prev != nil { if prev != nil {
m.stop(ctx, prev) m.stop(ctx, prev)
} }
if !cfg.IsEnabled() {
return m.installDisabled(cfg, persist, prev)
}
srv, err := m.start(ctx, cfg) srv, err := m.start(ctx, cfg)
if err != nil { if err != nil {
// 失败也要留痕:状态快照要能说出「为什么挂了」,而不是把失败悄悄丢进日志。
m.noteFailure(cfg, err)
m.restore(prev) m.restore(prev)
return Installed{}, err return Installed{}, err
} }
@@ -142,11 +170,30 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
} }
} }
m.put(srv) m.put(srv)
m.noteSeen(cfg)
m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount) m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount)
m.markInstalled(cfg, changeReason) m.markInstalled(cfg, changeReason)
return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil
} }
// installDisabled 走「已安装但停用」这条路:只落配置,不连接、不发现、不注册任何工具,
// 并把面板入口隐藏(`when:{enabled:true}` 的入口不出现)。
//
// 调用前 prev(同名活 server)已经被停掉:配置现在说的是「停用」,让旧 server 继续挂着
// 就成了两份互相矛盾的事实。落盘失败时把 prev 恢复回来(回滚到「启用且活着」那个状态)。
func (m *Manager) installDisabled(cfg runtimeconfig.PluginConfig, persist bool, prev *server) (Installed, error) {
if persist {
if err := m.persistUpsert(cfg); err != nil {
m.restore(prev)
return Installed{}, err
}
}
m.noteDisabled(cfg)
m.markDisabled(cfg)
m.log.Info("plugin installed disabled", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport)
return Installed{Config: cfg}, nil
}
// Uninstall removes the plugin matching name: it unregisters the live server's // Uninstall removes the plugin matching name: it unregisters the live server's
// tools and closes it, then deletes the persisted config. It reports whether // tools and closes it, then deletes the persisted config. It reports whether
// anything matched, so the caller can fall back to a plain capability removal. // anything matched, so the caller can fall back to a plain capability removal.
@@ -166,6 +213,7 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) {
panelName = srv.cfg.Name panelName = srv.cfg.Name
} }
m.forgetPanels(panelName) m.forgetPanels(panelName)
m.forgetObserved(panelName)
m.log.Info("plugin uninstalled", "name", name) m.log.Info("plugin uninstalled", "name", name)
} }
return removed, nil return removed, nil
@@ -231,114 +279,6 @@ func (m *Manager) Close(ctx context.Context) {
} }
} }
// SetEnabled 启用或停用一个已安装插件。
//
// 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的
// 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json**
// —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并
// 让入口回来。两条路都会推一次 `panels_changed`。
//
// ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该
// 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此
// **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给
// plugins.json 加 `enabled` 字段(并让 Restore 读它)。
//
// 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。
//
// 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置
// (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.takeAny(name)
_, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if srv != nil {
m.stop(ctx, srv)
}
m.setPanelsEnabled(name, false)
m.log.Info("plugin disabled", "name", name)
return nil
}
// enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。
// 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
if m.liveServer(name) != nil {
return nil
}
cfg, ok := m.persistedConfig(name)
if !ok {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil {
return err
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
// start connects cfg, registers its tools and surfaces its KindMCP capability. // start connects cfg, registers its tools and surfaces its KindMCP capability.
// On a registration failure it cleans up and returns the error. // On a registration failure it cleans up and returns the error.
func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) { func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) {
@@ -389,6 +329,8 @@ func (m *Manager) restore(prev *server) {
return return
} }
m.put(srv) m.put(srv)
// 回滚成功也是一次成功连接:快照的 lastSeenAt 不该停留在这之前的时刻。
m.noteSeen(prev.cfg)
} }
// take removes and returns the live server named name, or nil. // take removes and returns the live server named name, or nil.
@@ -62,6 +62,15 @@ type fakeMCP struct {
mu sync.Mutex mu sync.Mutex
auth string auth string
hits int
}
// hitCount 返回该 server 收到的 POST 次数(「有没有真的连过」的证据:停用的 server
// 在重启后不该被连一次)。
func (f *fakeMCP) hitCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.hits
} }
func (f *fakeMCP) setTools(tools ...string) { func (f *fakeMCP) setTools(tools ...string) {
@@ -88,6 +97,7 @@ func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
f.mu.Lock() f.mu.Lock()
f.auth = r.Header.Get("Authorization") f.auth = r.Header.Get("Authorization")
f.hits++
tools := append([]string(nil), f.tools...) tools := append([]string(nil), f.tools...)
f.mu.Unlock() f.mu.Unlock()
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth { if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
+203
View File
@@ -0,0 +1,203 @@
package plugin
// 本文件是「MCP 服务器」管理页的**声明面**(design/09 §5.2 的 adminPanels,形状即
// `pkg/plugins/sdk` 的 Panel):清单表(状态列 + 行内动作)+ 安装表单。
//
// 为什么这页由插件管理器自己声明(而不是某个外部插件):它是**管理器自己的页面** ——
// 数据面就是 `*Manager` 的 `Statuses()`,动作面就是 `Install/Reconnect/SetEnabled/
// Uninstall`。把声明放到别处就要再导出一套「代管理器说话」的中转,两处必然分叉。
//
// 声明只说三件事:要什么字段、怎么渲染、动作叫什么。它不注入 JS/CSS/HTML、不给 URL
// (只有动作名)、不开任何路由 —— 分发走既有的通用动作端点
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(写动作要求 owner/admin + 审计)。
//
// 与前端通用渲染器的核对结论(本片任务书要求先验,`frontend/web/src/admin/plugin/`):
// - 表格列只有 `key`/`label`(没有列类型/徽标)⇒ 状态列显示**闭集码**(与 web-search
// 页的 `state` 列同一口径),工具栏退化成一列截断文本;
// - 行内动作没有二次确认,且一律按**写**动作鉴权 ⇒ 卸载只能一键即发;
// - `args`(字符串列表)用 `multiSelect`、`env`(键值对)用 `kv`:这两项**能直接渲染**
// (无候选的 multiSelect 降级成逗号/换行分隔文本框,提交值仍是 `string[]`;
// kv 是 JSON 对象输入),因此本页不需要新增字段类型、也不需要改前端。
import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// MCPPanelPluginID 是「MCP 服务器」页在面板目录里的命名空间。
//
// 它是**宿主内置提供者**的 id,不是某个热装 MCP server 的名字。两者可能撞名(主人可以
// 把 server 也叫 mcp),因此热装插件的生命周期一律不许改动这个名字的目录条目
// (见 isReservedPanelKey)。
const MCPPanelPluginID = "mcp"
// MCPPanelID 是面板 id。前端路由为 `/admin/plugin/mcp/servers`。
const MCPPanelID = "servers"
// 本页声明的动作名。它们只是名字(不是 URL),分发端点是既有的
// `POST /api/v1/admin/plugins/mcp/actions/<name>`。
const (
// ActionListMCPServers 列出全部 server 的状态快照(表格数据源,读面)。
ActionListMCPServers = "plugin.mcpServers"
// ActionInstallMCPServer 安装(或刷新)一台 server(表单提交,写面)。
ActionInstallMCPServer = "plugin.installMcpServer"
// ActionReconnectMCPServer 断开重连并重新发现工具(行内动作,写面)。
ActionReconnectMCPServer = "plugin.reconnectMcpServer"
// ActionEnableMCPServer 启用一台被停用的 server(行内动作,写面)。
ActionEnableMCPServer = "plugin.enableMcpServer"
// ActionDisableMCPServer 停用一台 server(行内动作,写面)。
ActionDisableMCPServer = "plugin.disableMcpServer"
// ActionUninstallMCPServer 卸载一台 server(行内动作,写面)。
ActionUninstallMCPServer = "plugin.uninstallMcpServer"
// ActionMCPTransports 是传输形态的候选来源(读面):`optionsFrom` 为
// OptionsSourceTransports 时前端按此动作名取候选。
ActionMCPTransports = "plugin.mcpTransports"
)
// OptionsSourceTransports 是表单 `transport` 字段的候选来源(声明面写法 `plugin:` 前缀)。
const OptionsSourceTransports = "plugin:mcpTransports"
// 「MCP 服务器」表单与表格的字段 key。它们是**面板契约**的一部分(前端提交的键名),
// 因此集中在这里,处理器引用常量而不另写字面量。
const (
// FieldServerName 是 server 名(唯一标签,也是能力命名空间 `mcp:<name>`)。
FieldServerName = "name"
// FieldTransport 是传输形态(stdio / http / sse)。
FieldTransport = "transport"
// FieldCommand 是 stdio 的启动命令。
FieldCommand = "command"
// FieldArgs 是 stdio 的命令参数(字符串列表)。
FieldArgs = "args"
// FieldURL 是 http/sse 的服务地址(与运行时字段 endpoint 同义,面板用 url 更直白)。
FieldURL = "url"
// FieldAPIKey 是下发给 server 的 Bearer 凭据(secret:读侧永不回显)。
FieldAPIKey = "apiKey"
// FieldEnv 是 stdio 子进程的额外环境变量(键值对)。
FieldEnv = "env"
)
// MCPAdminPanels 返回「MCP 服务器」页的声明。
func MCPAdminPanels() []sdk.Panel {
return []sdk.Panel{{
ID: MCPPanelID,
Title: "MCP 服务器",
Description: "接入外部 MCP server 并管理它们的运行状态:每台可独立启停、重连、" +
"卸载,清单里直接看到连没连上、暴露了哪些工具、最近一次错在哪。" +
"配置落在 $DATA_DIR/plugins.json,装/卸/启停**立即生效**(下一轮对话即可用," +
"无需重启)。",
Group: sdk.GroupPlugins,
Order: 10,
Icon: "server",
// 内置提供者恒为启用;写条件是为了让「启用事实」由面板中枢给出(单一事实来源),
// 而不是由声明自己假设。
When: &sdk.When{Enabled: flagBool(true)},
Sections: []sdk.Section{
{
Type: sdk.SectionTable,
ID: "list",
Title: "已接入的服务器",
Source: &sdk.Source{Action: ActionListMCPServers},
// 列 key 与 ServerRow 的字段同源;`target` / `toolsText` 是给表格用的
// 显示派生量(地址或命令二选一、工具名拼一行),机器字段仍是
// `endpoint`/`command`/`tools[]`。
Columns: []sdk.Column{
{Key: "name", Label: "名称"},
{Key: "transport", Label: "传输"},
{Key: "target", Label: "地址 / 命令"},
{Key: "state", Label: "状态"},
{Key: "toolsText", Label: "工具"},
{Key: "lastError", Label: "最近一次错误"},
{Key: "lastSeenAt", Label: "最近连接"},
},
RowActions: []sdk.RowAction{
{ID: "reconnect", Label: "重连", Action: ActionReconnectMCPServer},
{ID: "enable", Label: "启用", Action: ActionEnableMCPServer},
{ID: "disable", Label: "停用", Action: ActionDisableMCPServer},
{ID: "uninstall", Label: "卸载", Action: ActionUninstallMCPServer},
},
},
{
Type: sdk.SectionForm,
ID: "install",
Title: "安装 / 刷新服务器",
Fields: []sdk.Field{
{Key: FieldServerName, Label: "名称", Type: sdk.FieldText, Required: true,
Placeholder: "如 fs / github;同名即刷新,能力命名空间为 mcp:<名称>"},
{Key: FieldTransport, Label: "传输", Type: sdk.FieldSelect,
OptionsFrom: OptionsSourceTransports,
Placeholder: "留空 = 按 command / url 推断(有命令走 stdio,有地址走 http)"},
{Key: FieldCommand, Label: "启动命令(stdio)", Type: sdk.FieldText,
Placeholder: "npx / uvx / 可执行文件路径"},
{Key: FieldArgs, Label: "命令参数(stdio)", Type: sdk.FieldMultiSelect,
Placeholder: "逗号或换行分隔,如 -y,@modelcontextprotocol/server-filesystem,/tmp"},
{Key: FieldURL, Label: "服务地址(http / sse)", Type: sdk.FieldText,
Placeholder: "https://..."},
{Key: FieldAPIKey, Label: "API Key", Type: sdk.FieldSecret,
Placeholder: "留空 = 不带鉴权(非空则作为 Bearer 下发)"},
{Key: FieldEnv, Label: "环境变量(stdio)", Type: sdk.FieldKV,
Placeholder: `{"TOKEN":"..."}`},
},
Submit: &sdk.Submit{Action: ActionInstallMCPServer},
},
},
}}
}
// DeclareMCPPanel 把「MCP 服务器」页登记到面板中枢(幂等:重复调用等于重新声明)。
//
// 它是组合根在**声明阶段**要调的那一步;动作处理器另由 `(*Manager).RegisterMCPPanelActions`
// 登记(声明与登记是两个事实,见 actions.go 的口径)。
func DeclareMCPPanel(hub *Panels) ([]string, error) {
if hub == nil {
return nil, fmt.Errorf("plugin: nil panel hub")
}
return hub.Declare(MCPPanelPluginID, MCPAdminPanels())
}
// AttachMCPPanel 把「MCP 服务器」页完整接到本管理器的已接线中枢上:先声明、再登记动作
// 处理器。组合根在 `wirePanelHub`(AttachPanels 之后)调它一次即可。
//
// 两步都在这里做完,是因为它们必须成对出现:只声明不登记 ⇒ 每个动作都诚实回 501;
// 只登记不声明 ⇒ 动作不可达(Dispatch 先查声明)。中枢未接线时报错而不是静默成功。
func (m *Manager) AttachMCPPanel() error {
hub := m.Panels()
if hub == nil {
err := fmt.Errorf("%w: %s(面板中枢未接线,MCP 管理页无处挂)",
ErrInvalidActionRegistration, MCPPanelPluginID)
m.log.Error("mcp panel not declared; its actions will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return err
}
if _, err := DeclareMCPPanel(hub); err != nil {
m.log.Error("mcp panel declaration rejected; its actions will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return fmt.Errorf("plugin: declare mcp panel: %w", err)
}
if err := m.RegisterMCPPanelActions(); err != nil {
m.log.Error("mcp panel actions not registered; they will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return err
}
m.log.Info("mcp panel attached", "pluginId", MCPPanelPluginID, "panelId", MCPPanelID)
return nil
}
// isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。
//
// 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很
// 实在 —— 内置提供者的 id 是给人看的短名字(mcp / web-search / browser),而 server 的
// 名字由主人随手起;一台恰好同名的 server 在卸载时若照常 MarkUninstalled,那整页内置页
// 就在**重启前**消失了(`forgetPanels → MarkUninstalled` 连声明与动作索引一起清)。
//
// 名单写死成 switch 而不是包级 map:新增内置页时没补这一行,review 时一眼可见(与
// paneldecl.shapeOf 同一取舍)。当前三个内置提供者:本包的 mcp 页(MCP 服务器)、
// `plugins/tools/io/search` 的 web-search 页、`plugins/tools/browser` 的 browser 页。
func isReservedPanelKey(key string) bool {
switch key {
case MCPPanelPluginID, "web-search", "browser":
return true
default:
return false
}
}
@@ -0,0 +1,405 @@
package plugin
// 本文件是「MCP 服务器」页的**执行面**(切片 M20):动作处理器 + 载荷解析 + 失败分类。
//
// 三条分工(与 design/09 §5.2.1 的动作分发一栏同向):
// - 鉴权与审计在宿主端点(网关 `adminPluginAction`:写动作要求 owner/admin,每次落审计);
// - **能不能调**由声明决定(Dispatch 先查声明),处理器只负责「这个名字要做什么」;
// - 失败要说清病因:ActionError 带稳定错误码 + 一句人话 + 目标状态码,绝不把上游报文
// 原样吐给前端(§5.4.6),也绝不把凭据写进返回值或日志。
//
// 载荷形状由前端通用渲染器决定(`frontend/web/src/admin/plugin/tableState.ts` /
// `formState.ts`):表格数据源发空体;行内动作发 `{"row":{…行数据…}}`;表单提交发
// `{"values":{…}}`(multiSelect → `[]string`、kv → JSON 对象、switch → bool、其余 → string)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本页动作的失败码(机器可读、稳定;前端按码本地化,不解析文案)。
const (
// mcpCodeInvalidConfig 报告提交的配置本身不合法(缺 name / 缺 command / 缺 endpoint)。
mcpCodeInvalidConfig = "invalid_config"
// mcpCodeInstallFailed 报告连接或发现失败(配置**不会**落盘)。
mcpCodeInstallFailed = "install_failed"
// mcpCodeReconnectFailed 报告重连失败(原连接已被摘下)。
mcpCodeReconnectFailed = "reconnect_failed"
// mcpCodeEnableFailed 报告启用失败(server 保持停用,不假装开着)。
mcpCodeEnableFailed = "enable_failed"
// mcpCodeDisableFailed 报告停用失败(落盘失败;连接可能仍在)。
mcpCodeDisableFailed = "disable_failed"
// mcpCodeUninstallFailed 报告卸载失败(落盘失败;工具是否已下线取决于错误发生的时机)。
mcpCodeUninstallFailed = "uninstall_failed"
// mcpCodeServerNotFound 报告点名的 server 既不在 plugins.json 里也不活着。
mcpCodeServerNotFound = "server_not_found"
// mcpCodeServerDisabled 报告对一台已停用的 server 发起了「重连」。
mcpCodeServerDisabled = "server_disabled"
)
// RegisterMCPPanelActions 给「MCP 服务器」页登记动作处理器。
//
// 它必须在**声明之后**调用(`AttachMCPPanel` 就按这个顺序做):登记期会核对「登记的
// 处理器有没有对应声明」,顺序反过来只会得到一串「声明还没到」的 WARN。
func (m *Manager) RegisterMCPPanelActions() error {
return m.RegisterActions(MCPPanelPluginID, map[string]ActionHandler{
ActionListMCPServers: m.actListServers,
ActionInstallMCPServer: m.actInstallServer,
ActionReconnectMCPServer: m.actReconnectServer,
ActionEnableMCPServer: m.actEnableServer,
ActionDisableMCPServer: m.actDisableServer,
ActionUninstallMCPServer: m.actUninstallServer,
ActionMCPTransports: m.actTransports,
})
}
// actListServers 返回全部 server 的状态快照(表格数据源)。
//
// 它是**读面**(viewer 也能调):快照里没有任何凭据(见 status.go 的口径②)。
func (m *Manager) actListServers(_ context.Context, _ ActionRequest) (any, error) {
statuses := m.Statuses()
rows := make([]ServerRow, 0, len(statuses))
for _, st := range statuses {
rows = append(rows, serverRowOf(st))
}
return map[string]any{"rows": rows}, nil
}
// actTransports 返回传输形态候选(`transport` 字段的 `optionsFrom`)。
//
// 候选的 value 与运行时接受的取值逐字一致(`runtimeconfig.Transport*`),label 是给人看
// 的一句说明:写 `http` 而以为是「老式 SSE」是最常见的一次配错。
func (m *Manager) actTransports(_ context.Context, _ ActionRequest) (any, error) {
return map[string]any{"options": []map[string]string{
{"value": runtimeconfig.TransportStdio, "label": "stdio(启动本地子进程)"},
{"value": runtimeconfig.TransportHTTP, "label": "http(Streamable HTTP)"},
{"value": runtimeconfig.TransportSSE, "label": "sse(老式 SSE 长连接)"},
}}, nil
}
// actInstallServer 安装(或刷新)一台 server:连接 + 发现 + 注册后才落盘。
func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, error) {
values, err := actionValues(req.Payload)
if err != nil {
return nil, err
}
cfg, err := installConfigOf(values)
if err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err)
}
res, err := m.Install(ctx, cfg)
if err != nil {
m.log.Warn("mcp install failed", "name", cfg.Name, "transport", cfg.Transport, "error", err)
return nil, NewActionError(mcpCodeInstallFailed,
fmt.Sprintf("连接/发现 MCP server %q 失败,配置未落盘", cfg.Name), 502, err)
}
return map[string]any{
"installed": true,
"name": res.Config.Name,
"transport": res.Config.Transport,
"toolCount": res.ToolCount,
}, nil
}
// installConfigOf 把表单值翻译成插件配置。
//
// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea,
// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示
// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。
// 同理 `args` 里的非字符串元素也不许悄悄消失。
func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) {
args, err := actionStringList(values[FieldArgs])
if err != nil {
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
}
env, err := actionStringMap(values[FieldEnv])
if err != nil {
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
}
return runtimeconfig.PluginConfig{
Name: actionText(values, FieldServerName),
Kind: runtimeconfig.PluginKindMCP,
Transport: actionText(values, FieldTransport),
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
Command: actionText(values, FieldCommand),
Args: args,
APIKey: actionText(values, FieldAPIKey),
Env: env,
}.Normalize(), nil
}
// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。
//
// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。
func invalidConfigError(err error) error {
return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err)
}
// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。
func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
res, err := m.Reconnect(ctx, name)
switch {
case errors.Is(err, ErrPluginNotFound):
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
case errors.Is(err, ErrPluginDisabled):
return nil, NewActionError(mcpCodeServerDisabled,
fmt.Sprintf("server %q 已停用:先「启用」再重连", name), 409, err)
case err != nil:
m.log.Warn("mcp reconnect failed", "name", name, "error", err)
return nil, NewActionError(mcpCodeReconnectFailed,
fmt.Sprintf("重连 %q 失败,详细原因见后端日志", name), 502, err)
}
return map[string]any{"name": res.Config.Name, "reconnected": true, "toolCount": res.ToolCount}, nil
}
// actEnableServer 启用一台 server(连接成功后才把 enabled=true 落盘)。
func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
if err := m.SetEnabled(ctx, name, true); err != nil {
if errors.Is(err, ErrPluginNotFound) {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
}
m.log.Warn("mcp enable failed", "name", name, "error", err)
// 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态
// 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。
if errors.Is(err, ErrPluginStatePersist) {
return nil, NewActionError(mcpCodeEnableFailed,
fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err)
}
return nil, NewActionError(mcpCodeEnableFailed,
fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err)
}
return map[string]any{"name": name, "enabled": true, "connected": true}, nil
}
// actDisableServer 停用一台 server(工具随之下线,配置保留)。
func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
if err := m.SetEnabled(ctx, name, false); err != nil {
if errors.Is(err, ErrPluginNotFound) {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
}
m.log.Warn("mcp disable failed", "name", name, "error", err)
// 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。
return nil, NewActionError(mcpCodeDisableFailed,
fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err)
}
return map[string]any{"name": name, "enabled": false, "connected": false}, nil
}
// actUninstallServer 卸载一台 server(摘工具 + 删配置)。
func (m *Manager) actUninstallServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
removed, err := m.Uninstall(ctx, name)
if err != nil {
m.log.Warn("mcp uninstall failed", "name", name, "error", err)
return nil, NewActionError(mcpCodeUninstallFailed,
fmt.Sprintf("卸载 %q 失败,详细原因见后端日志", name), 500, err)
}
if !removed {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, nil)
}
return map[string]any{"name": name, "uninstalled": true}, nil
}
// ServerRow 是「MCP 服务器」表格的一行:状态快照 + 两列显示派生量。
//
// 快照字段(Status)逐字嵌入,因此一行既是表格数据、也是机器可读的状态契约;两个派生列
// 只为表格排版而存在(一列只能显示一个标量)。
type ServerRow struct {
Status
// Target 是「地址 / 命令」列的显示值(http/sse ⇒ 地址;stdio ⇒ 命令)。
Target string `json:"target,omitempty"`
// ToolsText 是「工具」列的显示值(工具名以「、」拼接并截断)。
ToolsText string `json:"toolsText,omitempty"`
}
// serverRowOf 把一条快照翻成表格行。
func serverRowOf(st Status) ServerRow {
target := st.Endpoint
if target == "" {
target = st.Command
}
return ServerRow{Status: st, Target: target, ToolsText: toolsText(st.Tools)}
}
// --- 载荷解析 -----------------------------------------------------------------
// actionValues 取出表单提交的 `values` 对象(缺省 / 形状不对都如实报错)。
func actionValues(payload any) (map[string]any, error) {
obj, ok := payload.(map[string]any)
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
}
raw, ok := obj["values"]
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
}
values, ok := raw.(map[string]any)
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "values 必须是对象", 400, nil)
}
return values, nil
}
// actionRowName 取出行内动作点名的 server 名(`{"row":{"name":…}}`,也接受 `{"name":…}`)。
func actionRowName(payload any) (string, error) {
obj, ok := payload.(map[string]any)
if !ok {
return "", NewActionError(ActionCodeInvalidPayload, "行内动作缺少 row 对象", 400, nil)
}
row, ok := obj["row"].(map[string]any)
if !ok {
// 容错:某些客户端直接发 `{name, …}`。名字仍然只有这一个真源,不猜别的键。
row = obj
}
name := actionText(row, FieldServerName)
if name == "" {
return "", NewActionError(ActionCodeInvalidPayload, "row 里缺少 name", 400, nil)
}
return name, nil
}
// actionText 取一个字符串值(非字符串 / 缺省都给空串 —— 空串在配置里就是「没填」)。
func actionText(values map[string]any, key string) string {
raw, ok := values[key]
if !ok {
return ""
}
s, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是
// `[]any`)。
//
// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数,
// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一
// 口径去掉(那只来自多余的分隔符,不是用户意图)。
func actionStringList(raw any) ([]string, error) {
switch items := raw.(type) {
case nil:
return nil, nil
case []string:
return compactStrings(items), nil
case []any:
out := make([]string, 0, len(items))
for i, item := range items {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item))
}
out = append(out, s)
}
return compactStrings(out), nil
default:
return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw))
}
}
// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。
//
// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法,
// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」,
// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。
func actionStringMap(raw any) (map[string]string, error) {
var pairs map[string]any
switch obj := raw.(type) {
case nil:
return nil, nil
case map[string]any:
pairs = obj
case map[string]string:
pairs = make(map[string]any, len(obj))
for k, v := range obj {
pairs[k] = v
}
default:
return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw))
}
out := make(map[string]string, len(pairs))
for k, v := range pairs {
key := strings.TrimSpace(k)
if key == "" {
return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空")
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v))
}
out[key] = s
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。
//
// 它刻意不打印值本身:env 的值可能就是凭据。
func jsonKindOf(v any) string {
switch v.(type) {
case nil:
return "null"
case string:
return "字符串"
case bool:
return "布尔"
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return "数字"
case []any:
return "数组"
case map[string]any:
return "对象"
default:
return fmt.Sprintf("%T", v)
}
}
// compactStrings 去掉两端空白与空项;结果为空时返回 nil。
func compactStrings(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if s := strings.TrimSpace(item); s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
// firstNonEmpty 返回首个非空值(安装表单里 url 与 endpoint 是同义键)。
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+430
View File
@@ -0,0 +1,430 @@
package plugin
// 本文件锁定「MCP 服务器」页(切片 M20)的三件事:
// ① 声明本身合法(形状按 paneldecl 校验通过 —— 前端按声明泛化渲染,形状错就是半渲染);
// ② 声明与登记**成对**(每个声明的动作都有处理器:只有声明 ⇒ 每个动作都 501);
// ③ 动作面端到端可用:装 → 看 → 重连 → 停 → 启用 → 卸,每一步都推 panels_changed,
// 失败按结构化错误码如实分类(install_failed / server_not_found / server_disabled)。
import (
"context"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。
func TestMCPPanelDeclarationIsValid(t *testing.T) {
t.Parallel()
if err := paneldecl.Validate(MCPPanelPluginID, MCPAdminPanels()); err != nil {
t.Fatalf("「MCP 服务器」页声明非法:%v", err)
}
}
// TestMCPPanelActionsWired 锁定 ②:声明的动作与登记的处理器必须一一对应。
//
// 这条断言的价值是把「声明里写了、没人实现」从「用户点出一个 501」提前到测试里:少一个
// 处理器,本测试直接红。
func TestMCPPanelActionsWired(t *testing.T) {
t.Parallel()
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
declared := hub.DeclaredActions(MCPPanelPluginID)
handled := hub.ActionHandlerNames(MCPPanelPluginID)
if len(declared) == 0 || len(handled) == 0 {
t.Fatalf("声明 %v / 处理器 %v;两者都不该为空", declared, handled)
}
set := make(map[string]bool, len(handled))
for _, name := range handled {
set[name] = true
}
for _, name := range declared {
if !set[name] {
t.Fatalf("动作 %q 已声明但没有处理器(点了会 501)", name)
}
}
// 候选来源(`optionsFrom: plugin:mcpTransports`)必须能被解析成已登记的动作名,
// 否则前端的传输下拉会静默降级成文本框。
if name := OptionsActionName(OptionsSourceTransports); !set[name] {
t.Fatalf("候选来源 %q 映射到 %q,但没有处理器", OptionsSourceTransports, name)
}
}
// TestMCPPanelInstallAndList 锁定 ③ 的前半:安装(表单载荷)→ 看(表格行)。
func TestMCPPanelInstallAndList(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add", "mul")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
assertInstalledConfig(t, mgr, "calc")
row := mustRows(t, hub, ctx)[0]
if row.Name != "calc" || row.State != ServerStateConnected || row.ToolCount == nil || *row.ToolCount != 2 {
t.Fatalf("行 = %+v,期望 calc/connected/2 工具", row)
}
if row.ToolsText != "add、mul" {
t.Fatalf("toolsText = %q,期望 add、mul", row.ToolsText)
}
if row.Target != srv.URL {
t.Fatalf("target = %q,期望地址 %q", row.Target, srv.URL)
}
}
// TestMCPPanelToggleUninstallAndFrames 锁定 ③ 的后半:重连 / 停用 / 启用 / 卸载,
// 以及每一步都推 `panels_changed`(前端据此热更新,不必刷新页面)。
func TestMCPPanelToggleUninstallAndFrames(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add", "mul")
mgr, hub, rec := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
if _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc"); err != nil {
t.Fatalf("reconnect 动作: %v", err)
}
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
t.Fatalf("disable 动作: %v", err)
}
if names := toolNames(mgr.reg); len(names) != 0 {
t.Fatalf("停用后工具仍在注册表 = %v", names)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateDisabled || row.Enabled {
t.Fatalf("停用后行 = %+v", row)
}
_, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc")
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerDisabled || ae.Status != http.StatusConflict {
t.Fatalf("对停用的 server 重连 = %v,期望 %s/409", err, mcpCodeServerDisabled)
}
if _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc"); err != nil {
t.Fatalf("enable 动作: %v", err)
}
if names := toolNames(mgr.reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul", names)
}
if _, err := dispatchRow(t, hub, ActionUninstallMCPServer, "calc"); err != nil {
t.Fatalf("uninstall 动作: %v", err)
}
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
t.Fatalf("卸载后仍有行:%+v", rows)
}
reasons := changeReasons(rec.all())
for _, want := range []string{
PanelChangeInstalled, PanelChangeDisabled, PanelChangeEnabled, PanelChangeUninstalled,
} {
if !containsString(reasons, want) {
t.Fatalf("变更原因 = %v,缺 %q", reasons, want)
}
}
}
// TestMCPPanelInstallFailureIsClassified 锁定失败分类:装不上 ≠ 找不到 ≠ 已停用。
func TestMCPPanelInstallFailureIsClassified(t *testing.T) {
t.Parallel()
dead := httptest.NewServer(http.NotFoundHandler())
deadURL := dead.URL
dead.Close()
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
_, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{"name": "down", "url": deadURL}},
})
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInstallFailed || ae.Status != http.StatusBadGateway {
t.Fatalf("连接失败 = %v,期望 %s/502", err, mcpCodeInstallFailed)
}
// 配置不落盘,但失败要留在快照里(面板要说得出为什么挂了)。
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
t.Fatalf("失败后落盘 = %+v(err=%v),期望空", cfgs, err)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateError || row.LastError == "" {
t.Fatalf("失败行 = %+v,期望 error 且带 lastError", row)
}
// 缺字段 ⇒ invalid_config / 400(不是 502:请求本身写错了)。
_, err = hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{"transport": "stdio"}},
})
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
t.Fatalf("缺 name/command = %v,期望 %s/400", err, mcpCodeInvalidConfig)
}
// 卸载一个不存在的名字 ⇒ server_not_found / 404。
_, err = dispatchRow(t, hub, ActionUninstallMCPServer, "ghost")
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerNotFound || ae.Status != http.StatusNotFound {
t.Fatalf("卸载不存在的 server = %v,期望 %s/404", err, mcpCodeServerNotFound)
}
}
// TestMCPPanelReservedNamespaceIsGuarded 锁定护栏:一台恰好叫 mcp 的 server 的卸载/停用
// 不许把内置「MCP 服务器」页整页摘掉。
func TestMCPPanelReservedNamespaceIsGuarded(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
installFake(t, mgr, MCPPanelPluginID, srv.URL)
if _, err := mgr.Uninstall(context.Background(), MCPPanelPluginID); err != nil {
t.Fatalf("Uninstall: %v", err)
}
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 1 || entries[0].PluginID != MCPPanelPluginID {
t.Fatalf("内置 MCP 页被热装插件的生命周期改动了:%+v", entries)
}
}
// mustRows 取表格行(`{rows}` 载荷)并断言载荷形状。
func mustRows(t *testing.T, hub *Panels, ctx context.Context) []ServerRow {
t.Helper()
res, err := hub.Dispatch(ctx, ActionRequest{PluginID: MCPPanelPluginID, Action: ActionListMCPServers})
if err != nil {
t.Fatalf("list 动作: %v", err)
}
body, ok := res.(map[string]any)
if !ok {
t.Fatalf("list 结果 = %T,期望对象", res)
}
rows, ok := body["rows"].([]ServerRow)
if !ok {
t.Fatalf("list 结果的 rows 类型 = %T", body["rows"])
}
return rows
}
// dispatchRow 以行内动作的形状(`{row:{name}}`)分发一次动作。
func dispatchRow(t *testing.T, hub *Panels, action, name string) (any, error) {
t.Helper()
return hub.Dispatch(context.Background(), ActionRequest{
PluginID: MCPPanelPluginID, Action: action,
Payload: map[string]any{"row": map[string]any{FieldServerName: name}},
})
}
// installViaAction 以表单载荷(`{values}`)经动作面安装一台 server。
func installViaAction(t *testing.T, hub *Panels, ctx context.Context, name, url string) {
t.Helper()
if _, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{
"name": name, "transport": "http", "url": url,
"args": []any{"--flag", "value"},
"env": map[string]any{"TOKEN": "t0ken"},
}},
}); err != nil {
t.Fatalf("install 动作: %v", err)
}
}
// assertInstalledConfig 断言表单字段确实映射进了 plugins.json(args/env 这类列表/键值
// 最容易在「前端提交了、后端读错键」这一步静默丢失)。
func assertInstalledConfig(t *testing.T, mgr *Manager, name string) {
t.Helper()
cfgs, err := runtimeconfig.LoadPlugins(mgr.path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v),期望一条", cfgs, err)
}
if cfgs[0].Name != name {
t.Fatalf("落盘名字 = %q", cfgs[0].Name)
}
if len(cfgs[0].Args) != 2 || cfgs[0].Args[1] != "value" {
t.Fatalf("args 没映射进配置:%+v", cfgs[0].Args)
}
if cfgs[0].Env["TOKEN"] != "t0ken" {
t.Fatalf("env 没映射进配置:%+v", cfgs[0].Env)
}
}
// containsString 报告 items 里是否有 want。
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// TestMCPPanelInstallRejectsNonStringEnv 锁定 P2-1:`env` / `args` 里的非字符串值必须
// 变成 400,**不许静默丢键**(`{"PORT":8080}` 是前端 kv 控件最自然的写法:装完提示成功、
// server 连上了、env 却没生效 —— 比直接失败更坏)。
func TestMCPPanelInstallRejectsNonStringEnv(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
cases := []struct {
name string
mutate func(values map[string]any)
wantKey string
}{
{"env 值是数字", func(v map[string]any) { v["env"] = map[string]any{"PORT": 9182.0} }, "env.PORT"},
{"env 值是布尔", func(v map[string]any) { v["env"] = map[string]any{"DEBUG": true} }, "env.DEBUG"},
{"args 元素是数字", func(v map[string]any) { v["args"] = []any{"-y", 42.0} }, "args"},
}
for _, tc := range cases {
values := map[string]any{
"name": "calc", "transport": "http", "url": srv.URL,
"env": map[string]any{"TOKEN": "abc"},
"args": []any{"--flag"},
}
tc.mutate(values)
_, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": values},
})
ae, ok := AsActionError(err)
if !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
t.Fatalf("%s:err = %v,期望 %s/400", tc.name, err, mcpCodeInvalidConfig)
}
if !strings.Contains(ae.Message, tc.wantKey) {
t.Fatalf("%s:文案没点名出错的键(%q 不含 %q)", tc.name, ae.Message, tc.wantKey)
}
// 文案里不许回显**收到的值**(env 的值可能就是凭据):9182 是本用例特意挑的
// 独特数字,文案里的 "8080" 只是「要加引号」的示例,不是回显。
if strings.Contains(ae.Message, "9182") {
t.Fatalf("%s:文案回显了值:%q", tc.name, ae.Message)
}
}
// 三次都失败 ⇒ 盘上零变更、注册表零工具、列表零行。
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
t.Fatalf("盘上 = %+v(err=%v),期望空", cfgs, err)
}
if names := toolNames(mgr.reg); len(names) != 0 {
t.Fatalf("注册表里有工具 = %v", names)
}
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
t.Fatalf("快照里有行 = %+v", rows)
}
}
// TestReservedPanelKeysProtectBuiltinProviders 锁定顺手件 1:三台内置提供者的命名空间
// 都不许被一台同名 server 的安装/停用/启用/卸载改写(否则内置页在重启前就消失)。
func TestReservedPanelKeysProtectBuiltinProviders(t *testing.T) {
t.Parallel()
for _, id := range []string{MCPPanelPluginID, "web-search", "browser"} {
id := id
t.Run(id, func(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
ctx := context.Background()
// 模拟该内置提供者在装配期声明的那一页。
if _, err := hub.Declare(id, []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare(%s): %v", id, err)
}
installFake(t, mgr, id, srv.URL)
assertBuiltinPageAlive(t, hub, id, "安装")
if err := mgr.SetEnabled(ctx, id, false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
assertBuiltinPageAlive(t, hub, id, "停用")
if err := mgr.SetEnabled(ctx, id, true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if _, err := mgr.Uninstall(ctx, id); err != nil {
t.Fatalf("Uninstall: %v", err)
}
assertBuiltinPageAlive(t, hub, id, "卸载")
})
}
}
// assertBuiltinPageAlive 断言该内置提供者的页仍在目录里。
func assertBuiltinPageAlive(t *testing.T, hub *Panels, id, step string) {
t.Helper()
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
for _, e := range entries {
if e.PluginID == id {
return
}
}
t.Fatalf("%s一台同名 server 之后,内置页 %q 不见了:%+v", step, id, entries)
}
// TestMCPPanelEnablePersistFailureReportsAccurately 锁定顺手件 2:server **已连上**但
// 启用态没落盘时,文案必须说的是这件事(「仍处于停用状态」是相反的陈述)。
//
// 只在本平台跑:靠「把 plugins.json 置成只读」让 rename 失败(Unix 下 rename 由目录权限
// 决定,同法不成立)。
func TestMCPPanelEnablePersistFailureReportsAccurately(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("只读位只在 windows 的 rename 上生效")
}
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
t.Fatalf("disable: %v", err)
}
if err := os.Chmod(mgr.path, 0o444); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(mgr.path, 0o600) })
_, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc")
ae, ok := AsActionError(err)
if !ok || ae.Code != mcpCodeEnableFailed || ae.Status != http.StatusBadGateway {
t.Fatalf("启用(落盘失败)= %v,期望 %s/502", err, mcpCodeEnableFailed)
}
// 断言的是**文案与事实一致**(下面两条),不是某个内部哨兵:这句话是给主人看的。
if !strings.Contains(ae.Message, "未落盘") || strings.Contains(ae.Message, "仍处于停用状态") {
t.Fatalf("文案与事实不符:%q", ae.Message)
}
// 事实是「已经连上、工具可用」,快照也这么说(state 派生口径不变)。
if names := toolNames(mgr.reg); !names["add"] {
t.Fatalf("启用后工具 = %v,期望已连上并注册", names)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateConnected || row.Enabled {
t.Fatalf("快照 = %+v,期望 state=connected 且 enabled=false(盘上仍是停用)", row)
}
}
+54 -7
View File
@@ -287,15 +287,11 @@ func PanelIDValid(id string) bool {
// markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给 // markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给
// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。 // 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。
func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) { func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) {
key := PanelPluginID(cfg) key := m.rememberPanelKey(cfg)
if key == "" { if key == "" || isReservedPanelKey(key) {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return return
} }
m.mu.Lock() hub := m.panelsHub()
m.panelKeys[cfg.Name] = key
hub := m.panels
m.mu.Unlock()
if hub == nil { if hub == nil {
return return
} }
@@ -308,6 +304,51 @@ func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason str
hub.MarkInstalled(key) hub.MarkInstalled(key)
} }
// markDisabled 记录停用时用过的面板目录键,并让中枢隐藏该插件 `when:{enabled:true}` 的
// 入口(声明保留,重新启用即原样回来)。
//
// 「停用」是**有意**没连,因此入口必须消失(§5.6.1「未启用的插件 ⇒ 入口不出现」);
// 而「连不上」不该摘入口 —— 那时用户正需要点「重连」。
func (m *Manager) markDisabled(cfg runtimeconfig.PluginConfig) {
key := m.rememberPanelKey(cfg)
if key == "" || isReservedPanelKey(key) {
return
}
if hub := m.panelsHub(); hub != nil {
hub.SetEnabled(key, false)
}
}
// rememberPanelKey 记下该插件的面板目录键(memo)并回它;键非法时记日志并回空串。
//
// memo 是**缓存而不是第二份真源**:键由 PanelPluginID 从 cfg 推出,这里只把结果留下,
// 好让「活 server 已不在了」的卸载/停用仍能精确找到该清哪一条。
func (m *Manager) rememberPanelKey(cfg runtimeconfig.PluginConfig) string {
key := PanelPluginID(cfg)
if key == "" {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return ""
}
if isReservedPanelKey(key) {
// 内置提供者的命名空间不归热装插件管:一台恰好叫 mcp 的 server 不许改动
// 「MCP 服务器」页本身(见 isReservedPanelKey)。
m.log.Info("plugin name collides with a built-in panel namespace; panel entry untouched",
"name", cfg.Name, "panelId", key)
return ""
}
m.mu.Lock()
m.panelKeys[cfg.Name] = key
m.mu.Unlock()
return key
}
// panelsHub 返回已接线中枢(未接线时 nil)。
func (m *Manager) panelsHub() *Panels {
m.mu.Lock()
defer m.mu.Unlock()
return m.panels
}
// forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录 // forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录
// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。 // (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。
func (m *Manager) forgetPanels(name string) { func (m *Manager) forgetPanels(name string) {
@@ -325,6 +366,9 @@ func (m *Manager) forgetPanels(name string) {
return return
} }
} }
if isReservedPanelKey(key) {
return
}
hub.MarkUninstalled(key) hub.MarkUninstalled(key)
} }
@@ -343,5 +387,8 @@ func (m *Manager) setPanelsEnabled(name string, on bool) {
return return
} }
} }
if isReservedPanelKey(key) {
return
}
hub.SetEnabled(key, on) hub.SetEnabled(key, on)
} }
+273
View File
@@ -0,0 +1,273 @@
package plugin
// 本文件是 MCP 服务器的**状态读面**(切片 M20):每台 server 的连接态、工具清单、最近一次
// 错误与时间。它是「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」这个洞的补丁。
//
// 三条口径:
//
// 1. **三态,缺席 = 未知**(项目既有纪律:零值不得冒充「没有/未连接」,见
// 「零值 = 不知道」类裁决)。因此 `connected` 是 `*bool`:present true = 连着;
// present false = 确认没连(停用 / 上次连接失败);**absent = 宿主不知道**。同理
// `toolCount` / `tools` / `lastSeenAt` 只在「知道」时才出现 —— 一台停用的 server
// 不是「暴露 0 把工具」,而是「没连,所以不知道它有什么」。
// 2. **凭据绝不出现在快照里**:`apiKey` 不进任何字段(Env 与 Args 也一律不下发 ——
// stdio 的 args 常带 `--token xxx`),自由文本(`lastError`、`endpoint`、`command`)
// 在**记录/构造那一刻**就用 `runtimeconfig.RedactPluginSecrets` 抹过(含凭据的任意
// 片段)。抹在这里而不是在网关:读面不止一条路(面板动作、日志、未来的端点),
// 把安全押在「每个调用方都记得抹」上迟早漏一次。
// 3. **状态是派生的、不是另存一份**:`state` 由「活没活 / 启没启用 / 有没有失败记录」
// 推出来(见 statusOf),只有运行期观察(lastError / lastSeenAt)是记下来的事实。
// 两处各存一份状态必然分叉 —— 那正是「面板说连上了、模型调不到工具」的形状。
import (
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// serverState 的取值闭集(`state` 列 / `Status.State`)。它们是**机器读**的码,不是文案:
// 前端按码分支,中文名由前端映射(与 web-search 页 `state` 列同一口径)。
const (
// ServerStateConnected 表示 server 已连接、工具已注册。
ServerStateConnected = "connected"
// ServerStateError 表示已启用但最近一次连接/发现失败(`lastError` 有值)。
ServerStateError = "error"
// ServerStateDisabled 表示被显式停用(`enabled=false`):没连,是**有意**没连。
ServerStateDisabled = "disabled"
// ServerStateUnknown 表示宿主说不清:已启用、没连着、也没有失败记录
// (例如 plugins.json 被手工改过、启动期还没轮到它)。
ServerStateUnknown = "unknown"
)
// 快照里的截断上限(都是「表格能扫一眼」的排版倒推出来的,不是随手取的数)。
const (
// toolDescriptionLimit 是单把工具描述的字符上限。
toolDescriptionLimit = 200
// lastErrorLimit 是最近一次错误的字符上限。错误链可能很长,排障要的是病因那一句。
lastErrorLimit = 400
// toolsTextLimit 是表格「工具」列(工具名拼成的文本)的字符上限。
toolsTextLimit = 240
)
// Status 是一台 MCP server 的状态快照(可序列化,供面板行与未来端点复用)。
type Status struct {
// Name 是 server 名(plugins.json 的唯一标签,也是能力的命名空间)。
Name string `json:"name"`
// ID 是 server 的稳定标识(缺省省略)。
ID string `json:"id,omitempty"`
// Kind 是插件种类(本期恒为 mcp)。
Kind string `json:"kind,omitempty"`
// Transport 是连接形态(stdio / http / sse)。
Transport string `json:"transport,omitempty"`
// Endpoint 是 http/sse 的地址(已抹凭据)。
Endpoint string `json:"endpoint,omitempty"`
// Command 是 stdio 的可执行文件(参数**不下发**:args 常带凭据)。
Command string `json:"command,omitempty"`
// Enabled 是启用态(来自 plugins.json;缺省视为 true)。
Enabled bool `json:"enabled"`
// State 是状态闭集码(见 ServerState*)。
State string `json:"state"`
// Connected 是三态连接态:true/false 是事实,**缺席是未知**(见文件头口径①)。
Connected *bool `json:"connected,omitempty"`
// ToolCount 是已注册的工具数。只在**连着**时给出:不连的 server 不是「0 把工具」,
// 而是「不知道它有哪些工具」。
ToolCount *int `json:"toolCount,omitempty"`
// Tools 是工具摘要(名字 + 截断描述)。同样只在连着时给出;连着但零工具时
// `toolCount` 是 0 而本字段缺席(空数组同样意味着「没有工具」,两种表达不必并存)。
Tools []mcp.ToolSummary `json:"tools,omitempty"`
// LastError 是最近一次连接/发现失败的文本(已抹凭据、已截断)。
LastError string `json:"lastError,omitempty"`
// LastErrorAt 是那次失败的时刻(RFC3339)。
LastErrorAt string `json:"lastErrorAt,omitempty"`
// LastSeenAt 是最近一次**成功**连上的时刻(RFC3339)。缺席 = 还没成功连过。
LastSeenAt string `json:"lastSeenAt,omitempty"`
}
// observed 是一台 server 最近一次被观察到的运行期事实(内存态,不落盘)。
//
// 它只记「事件」:成功连上过(lastSeenAt)、失败过(lastError/lastErrorAt)。启用态与
// 连接态都不在这里 —— 前者的真源是 plugins.json,后者的真源是 servers 表。
type observed struct {
cfg runtimeconfig.PluginConfig
lastError string
lastErrorAt time.Time
lastSeenAt time.Time
}
// Statuses 返回全部 MCP server 的状态快照:plugins.json 的顺序在前,其后是「只活在内存里」
// 的(装了但还没落盘、或文件丢了但 server 活着)与「只留下失败记录」的,各自按名字排序
// (map 迭代顺序不稳定,快照顺序必须稳定 —— 否则前端每次刷新都在跳行)。
func (m *Manager) Statuses() []Status {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load skipped", "path", m.path, "error", err)
cfgs = nil
}
live, obs := m.snapshotState()
out := make([]Status, 0, len(cfgs)+len(obs))
seen := make(map[string]bool, len(cfgs)+len(obs))
for _, cfg := range cfgs {
out = append(out, statusOf(cfg, live[cfg.Name], obs[cfg.Name]))
seen[cfg.Name] = true
}
for _, name := range sortedKeys(obs) {
if seen[name] {
continue
}
out = append(out, statusOf(obs[name].cfg, live[name], obs[name]))
seen[name] = true
}
for _, name := range sortedKeys(live) {
if seen[name] {
continue
}
out = append(out, statusOf(live[name].cfg, live[name], obs[name]))
seen[name] = true
}
return out
}
// snapshotState 复制一份活 server 表与观察表(锁内复制、锁外计算:statusOf 会问能力注册表,
// 那是外部代码,持本管理器的锁去碰它等于把两把锁的顺序交给外部)。
func (m *Manager) snapshotState() (map[string]*server, map[string]observed) {
m.mu.Lock()
defer m.mu.Unlock()
live := make(map[string]*server, len(m.servers))
for name, srv := range m.servers {
live[name] = srv
}
obs := make(map[string]observed, len(m.observed))
for name, rec := range m.observed {
obs[name] = rec
}
return live, obs
}
// statusOf 把「配置 + 活 server + 观察记录」推成一条快照。
func statusOf(cfg runtimeconfig.PluginConfig, srv *server, rec observed) Status {
st := Status{
Name: cfg.Name,
ID: cfg.ID,
Kind: cfg.Kind,
Transport: cfg.Transport,
Endpoint: runtimeconfig.RedactPluginSecrets(cfg, cfg.Endpoint),
Command: runtimeconfig.RedactPluginSecrets(cfg, cfg.Command),
Enabled: cfg.IsEnabled(),
LastError: rec.lastError,
LastErrorAt: formatTime(rec.lastErrorAt),
}
switch {
case srv != nil:
st.State = ServerStateConnected
st.Connected = flagBool(true)
count := srv.toolCount
st.ToolCount = &count
st.Tools = srv.reg.ToolSummaries(toolDescriptionLimit)
st.LastSeenAt = formatTime(rec.lastSeenAt)
case !cfg.IsEnabled():
// 停用是**有意**没连:连接态是确认的 false(不是未知),工具则是未知。
//
// 「想启用但失败了」也落在这里(落盘的 enabled 仍是 false,见 enabled.go):此时
// `state` 说「它现在是停用的」,`lastError` 说「刚才那次启用为什么没成」—— 两个
// 事实各归各的列,谁也不冒充谁。若让 error 抢在 disabled 前面,一台**故意**停用
// 过又失败过一次的 server 会永远显示「错误」,用户就没法把它当成「关着的」。
st.State = ServerStateDisabled
st.Connected = flagBool(false)
st.LastSeenAt = formatTime(rec.lastSeenAt)
case rec.lastError != "":
st.State = ServerStateError
st.Connected = flagBool(false)
st.LastSeenAt = formatTime(rec.lastSeenAt)
default:
// 已启用、没连着、也没有失败记录 ⇒ 宿主不知道它到底行不行(不猜)。
st.State = ServerStateUnknown
}
return st
}
// noteSeen 记录一次成功连接(tools 已注册)。
func (m *Manager) noteSeen(cfg runtimeconfig.PluginConfig) {
m.observe(cfg, func(rec *observed) { rec.lastSeenAt = time.Now().UTC() })
}
// noteFailure 记录一次连接/发现失败。错误文本在**这一刻**就抹掉凭据并截断:内存里也不留
// 明文,之后的任何出口(面板、日志、未来端点)都不必再各自记得抹。
func (m *Manager) noteFailure(cfg runtimeconfig.PluginConfig, err error) {
if err == nil {
return
}
text := mcp.TruncateRunes(runtimeconfig.RedactPluginSecrets(cfg, err.Error()), lastErrorLimit)
m.observe(cfg, func(rec *observed) {
rec.lastError = text
rec.lastErrorAt = time.Now().UTC()
})
}
// noteDisabled 记录「这台 server 现在是停用的」(保留历史观察:排障时要看它停用前发生过什么)。
func (m *Manager) noteDisabled(cfg runtimeconfig.PluginConfig) {
m.observe(cfg, func(*observed) {})
}
// observe 在锁内更新一台 server 的观察记录(不存在则新建)。
func (m *Manager) observe(cfg runtimeconfig.PluginConfig, apply func(*observed)) {
name := cfg.Name
if name == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.observed == nil {
m.observed = make(map[string]observed, 1)
}
rec := m.observed[name]
rec.cfg = cfg
apply(&rec)
m.observed[name] = rec
}
// forgetObserved 丢掉一台 server 的观察记录(卸载即不再有这台 server 的任何事实)。
func (m *Manager) forgetObserved(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.observed, name)
}
// formatTime 把时刻格式化成 RFC3339;零值返回空串(缺席 = 还没发生,不写 1970)。
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// flagBool 是 `&true` / `&false` 的简写(Connected 是三态指针)。
func flagBool(v bool) *bool { return &v }
// sortedKeys 返回 map 键的有序副本(快照顺序必须稳定)。
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// toolsText 把工具摘要拼成表格里「工具」列的一行文本(名字在前、描述省略)。
//
// 表格单元格只认标量(前端 `cellText` 会把数组用「、」拼起来,但长度不可控),因此这里
// 自己拼并截断:排障要看的是**有哪些工具**,不是每把工具的完整描述(描述在 `tools[]` 里)。
func toolsText(tools []mcp.ToolSummary) string {
if len(tools) == 0 {
return ""
}
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Name)
}
return mcp.TruncateRunes(strings.Join(names, "、"), toolsTextLimit)
}
+185
View File
@@ -0,0 +1,185 @@
package plugin
// 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20):
// ① 三态:缺席 = 未知,不用零值冒充「未连接」;
// ② 凭据绝不出现(apiKey 及其任意片段),带变异自证;
// ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里
// (否则「为什么挂了」在面板上根本无处可看)。
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。
func statusOfOne(t *testing.T, mgr *Manager, name string) Status {
t.Helper()
for _, st := range mgr.Statuses() {
if st.Name == name {
return st
}
}
t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses())
return Status{}
}
// TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。
func TestStatusesTriState(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
// 连着:connected=true、工具数/明细/最近连接都在。
live := statusOfOne(t, mgr, "calc")
if live.State != ServerStateConnected {
t.Fatalf("state = %q,期望 connected", live.State)
}
if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 {
t.Fatalf("连接态快照不对 = %+v", live)
}
if live.LastSeenAt == "" || live.LastError != "" {
t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live)
}
if live.Endpoint != srv.URL {
t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL)
}
// 停用:connected 是**确认的 false**(有意没连),工具则是未知。
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
off := statusOfOne(t, mgr, "calc")
if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected {
t.Fatalf("停用快照 = %+v", off)
}
if off.ToolCount != nil || off.Tools != nil {
t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools)
}
// 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。
handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP,
Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"}
if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
unknown := statusOfOne(t, mgr, "manual")
if unknown.State != ServerStateUnknown {
t.Fatalf("state = %q,期望 unknown", unknown.State)
}
if unknown.Connected != nil {
t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected)
}
if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" {
t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown)
}
}
// TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。
func TestStatusesFailureIsVisible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
dead := httptest.NewServer(http.NotFoundHandler())
deadURL := dead.URL
dead.Close()
mgr := NewManager(registry.New(), path, nil)
defer mgr.Close(context.Background())
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL,
}); err == nil {
t.Fatal("对关掉的 server 安装应当失败")
}
st := statusOfOne(t, mgr, "down")
if st.State != ServerStateError || st.Connected == nil || *st.Connected {
t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st)
}
if st.LastError == "" {
t.Fatal("失败快照没有 lastError:面板说不出病因")
}
if st.Enabled != true {
t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st)
}
}
// TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。
//
// 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把
// Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。
func TestStatusesNeverLeakAPIKey(t *testing.T) {
t.Parallel()
const key = "sk-live-SECRET-9f3c2b1d"
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, key, "add")
mgr := NewManager(registry.New(), path, nil)
defer mgr.Close(context.Background())
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key,
}); err != nil {
t.Fatalf("Install: %v", err)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("LoadPlugins = %+v, %v", cfgs, err)
}
cfgWithKey := cfgs[0]
// 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。
mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3"))
mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed"))
raw, err := json.Marshal(mgr.Statuses())
if err != nil {
t.Fatalf("marshal statuses: %v", err)
}
body := string(raw)
assertNoSecret(t, body, key)
if !strings.Contains(body, "failed") {
t.Fatalf("抹凭据不该把病因整条抹掉:%s", body)
}
}
// TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。
func TestRedactPluginSecrets(t *testing.T) {
t.Parallel()
const key = "tok_ABCDEF123456"
cfg := runtimeconfig.PluginConfig{APIKey: key,
Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}}
cases := []string{
"authorization: Bearer " + key,
"prefix " + key[:8] + " suffix",
"middle ABCDEF123456 and more",
"env dump GITHUB_TOKEN=ghp_ZZZZZZ9999",
}
for _, in := range cases {
out := runtimeconfig.RedactPluginSecrets(cfg, in)
assertNoSecret(t, out, key)
assertNoSecret(t, out, "ghp_ZZZZZZ9999")
}
// 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。
if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") {
t.Fatalf("非敏感值被误抹:%q", out)
}
}
// assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。
func assertNoSecret(t *testing.T, text, secret string) {
t.Helper()
if strings.Contains(text, secret) {
t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text)
}
const frag = 8
for i := 0; i+frag <= len(secret); i++ {
if part := secret[i : i+frag]; strings.Contains(text, part) {
t.Fatalf("文本里出现了凭据片段 %q:%s", part, text)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package registry
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住「来源章」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
// 归属只能来自注册时的元数据,因此盖章必须发生在**注册汇点**上,且热替换不能把它抹掉。
//
// 三条判据各自**能变红**:
// - 去掉 RegisterTool 里的盖章 → 第一条红;
// - 去掉 ReplaceTool 里的盖章(它逐字段重建条目)→ 第二条红;
// - 给所有工具都编一个章(比如按名字猜)→ 第三条红。
// originTool 是自报来源的最小工具(宿主内置工具不实现 sdk.OriginAware)。
type originTool struct {
name string
origin sdk.Origin
}
func (t originTool) Name() string { return t.name }
func (t originTool) Description() string { return "来自 MCP" }
func (t originTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t originTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
func (t originTool) Origin() sdk.Origin { return t.origin }
var _ sdk.OriginAware = originTool{}
// TestRegisterToolStampsOrigin:注册汇点把工具自报的来源盖在能力条目上。
func TestRegisterToolStampsOrigin(t *testing.T) {
r := New()
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: want}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Origin != want {
t.Fatalf("条目上的来源章 = %+v, want %+v(RegisterTool 是唯一汇点,必须在这里盖章)", cap.Origin, want)
}
}
// TestReplaceToolKeepsOrigin:热替换必须重新盖章 —— 它逐字段重建 Capability,
// 漏一位就等于「面板保存一次,这把工具从它的 server 分节里掉进内置」。
func TestReplaceToolKeepsOrigin(t *testing.T) {
r := New()
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "http"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
// 热替换成同名的新实现,来源相同(换实现不换 server)。
if err := r.ReplaceTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("ReplaceTool: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Origin != origin {
t.Fatalf("热替换后来源章 = %+v, want %+v(ReplaceTool 重建条目,必须重新盖章)", cap.Origin, origin)
}
}
// TestRegisterToolLeavesBuiltinOriginZero:宿主内置工具(没实现 OriginAware)
// 的条目**没有**来源章。它是「键缺席」的根据 —— 读面据此省略 origin,而不是编一个。
func TestRegisterToolLeavesBuiltinOriginZero(t *testing.T) {
r := New()
if err := r.RegisterTool(&stubTool{name: "web_search", desc: "内置"}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
cap, err := r.Get("web_search")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Origin.IsZero() {
t.Fatalf("内置工具却带来源章 %+v:没有 OriginAware 就不得编一个来源", cap.Origin)
}
}
// TestSetPolicyKeepsOrigin:策略写路径不受来源章影响,也不许把它覆盖掉。
// 这是「tool_policies / 权限闸零影响」的断言 —— 策略仍按**名字**存,
// 来源章只在条目上,两者互不干涉。
func TestSetPolicyKeepsOrigin(t *testing.T) {
r := New()
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
if err := r.SetAllowInGroup("read_file_mcp", true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Fatalf("策略 = %+v, want 只有 AllowInGroup=true(按名字写的策略行)", cap.Policy)
}
if cap.Origin != origin {
t.Fatalf("改策略把来源章冲掉了:%+v, want %+v", cap.Origin, origin)
}
}
+28
View File
@@ -58,6 +58,12 @@ type Capability struct {
// Policy is the editable reachability markers for this capability. It // Policy is the editable reachability markers for this capability. It
// defaults to zero, which the policy engine reads as owner-only. // defaults to zero, which the policy engine reads as owner-only.
Policy ToolPolicy Policy ToolPolicy
// Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
// 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点
// RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 ——
// 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面
// (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。
Origin sdk.Origin
// Value is the underlying capability object. For KindTool/KindSkill/ // Value is the underlying capability object. For KindTool/KindSkill/
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and // KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
// KindIoT it is owned by the relevant subsystem. // KindIoT it is owned by the relevant subsystem.
@@ -225,6 +231,13 @@ func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) e
} }
// RegisterTool registers t as a KindTool capability. // RegisterTool registers t as a KindTool capability.
//
// 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
// MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装
// (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的
// MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章
// 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。
// 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。
func (r *Registry) RegisterTool(t sdk.Tool) error { func (r *Registry) RegisterTool(t sdk.Tool) error {
if t == nil { if t == nil {
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument) return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
@@ -233,10 +246,21 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
Name: t.Name(), Name: t.Name(),
Kind: KindTool, Kind: KindTool,
Description: t.Description(), Description: t.Description(),
Origin: originOf(t),
Value: t, Value: t,
}) })
} }
// originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时
// 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。
func originOf(t sdk.Tool) sdk.Origin {
origin, ok := sdk.OriginOf(t)
if !ok {
return sdk.Origin{}
}
return origin
}
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5: // ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
// 面板保存后下一轮即用新接线,不必重启)。 // 面板保存后下一轮即用新接线,不必重启)。
// //
@@ -270,6 +294,10 @@ func (r *Registry) ReplaceTool(t sdk.Tool) error {
Description: t.Description(), Description: t.Description(),
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。 // Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
Policy: cur.Policy, Policy: cur.Policy,
// Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源
// 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的
// 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。
Origin: originOf(t),
Value: t, Value: t,
} }
return nil return nil
+110 -95
View File
@@ -13,21 +13,32 @@ import (
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must // It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
// never decide identity: who the sender is was already resolved structurally by the // never decide identity: who the sender is was already resolved structurally by the
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap. // channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
//
// The gate calls it **off the admission path** (see FlowGate.Admit), so an
// implementation may take as long as the model takes; it is bounded by
// reviewTimeout and by the per-window budget, never by the caller's patience.
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error) type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
// GateVerdict reports how the gate classified one inbound message. // GateVerdict reports how the gate classified one inbound message.
type GateVerdict struct { type GateVerdict struct {
// Proceed reports whether the message should be delivered downstream. // Proceed reports whether the message should be delivered downstream.
Proceed bool Proceed bool
// Reason is a short token describing a drop: "rate_limited", "review_pruned" // Reason is a short token describing a drop. After M24 it is always
// or "capped". It is empty when Proceed is true. // "rate_limited" (over the sliding window — dropped at the door, the message
// never enters the pending set), because nothing else on the admission path can
// refuse a message: the pending cap evicts the **oldest** entries and the review
// model runs in the background, so neither can reject the message that just
// arrived. The audit hook still reports their drops with "capped" /
// "review_pruned"; those are retained-message bookkeeping, not admissions.
// Reason is empty when Proceed is true.
Reason string Reason string
// DroppedMsg is the message that failed admission (nil when Proceed is true). // DroppedMsg is the message that failed admission (nil when Proceed is true).
DroppedMsg *UnifiedMessage DroppedMsg *UnifiedMessage
// Pruned lists every message the gate dropped during this admission. On the // Pruned lists every message the gate dropped during this admission: on the cap
// review path it is the noise the reviewer rejected; on the cap path it is the // path it is the oldest messages evicted to honor the total cap. Each is recorded
// oldest messages evicted to honor the total cap. Each is recorded to the // to the OnPruned audit hook before being dropped, so nothing is lost silently.
// OnPruned audit hook before being dropped, so nothing is lost silently. // Backlog drops made by a background review reach the audit hook with
// "review_pruned" when that review completes, not through this field.
Pruned []UnifiedMessage Pruned []UnifiedMessage
} }
@@ -56,15 +67,23 @@ type FlowGateOptions struct {
MsgPerMin int MsgPerMin int
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6 // MaxMessages is the **per-conversation** pending-message cap (design/12 §6
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer // 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
// is invoked to prune noise; with no Reviewer the oldest messages are evicted // is invoked — in the background — to prune noise; with no Reviewer the oldest
// FIFO. Zero disables the cap. // messages are evicted FIFO. Zero disables the cap.
MaxMessages int MaxMessages int
// Window is the sliding window over which the per-minute limits are counted. // Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow. // Zero uses defaultRateWindow.
Window time.Duration Window time.Duration
// Review is the optional review model. When nil the gate enforces the cap with // Review is the optional review model. When nil the gate enforces the cap with
// FIFO eviction instead of model judgment. // FIFO eviction instead of model judgment. It is always called from a background
// goroutine, at most once per conversation per Window, with at most one call in
// flight per gate.
Review Reviewer Review Reviewer
// ReviewPerMin is the hard per-account ceiling on Reviewer calls started per
// Window: the budget that bounds review cost no matter how many conversations
// cross their own threshold in the same window. Zero (or negative) uses
// defaultReviewPerWindow. It bounds calls started, not calls finished, so the
// bound holds independently of model latency.
ReviewPerMin int
// OnPruned is called once per pruned/dropped message with a short reason token // OnPruned is called once per pruned/dropped message with a short reason token
// ("review_pruned" or "capped") so the caller can record an audit trail. // ("review_pruned" or "capped") so the caller can record an audit trail.
// It may be nil (then the caller only sees Pruned on the verdict). // It may be nil (then the caller only sees Pruned on the verdict).
@@ -80,6 +99,13 @@ const (
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard // runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
// that keeps review from firing on every message. // that keeps review from firing on every message.
defaultReviewPct = 80 defaultReviewPct = 80
// defaultReviewPerWindow is the hard per-adapter ceiling on reviewer calls per
// window (6/minute, the same order of magnitude as FlowLimitDefault). Before it
// existed the only bound was "one per conversation per window", which an account
// with many conversations could multiply without limit. At p50 5.3 s per call
// this still allows ~half a minute of review work per minute per account, so it
// bounds the tail without starving normal traffic.
defaultReviewPerWindow = 6
// FlowLimitDefault is the protective default of 6 group messages per minute // FlowLimitDefault is the protective default of 6 group messages per minute
// (design/12 §6 策略三). It is applied when an account pins no group rate limit. // (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6 FlowLimitDefault = 6
@@ -128,6 +154,10 @@ type flowSession struct {
groupHits []time.Time groupHits []time.Time
directHits []time.Time directHits []time.Time
buffer []queuedMsg buffer []queuedMsg
// lastReview 是**最近一次审查被派发**的时刻(不是完成时刻,也无论成败):
// 写在这里而不是「成功之后」,失败路径才同样被窗口节流(修复前它只在
// err == nil 分支置位,而 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满之后
// 每条入站帧都付一次 ~6 s 的模型调用)。
lastReview time.Time lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道 // inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。 // 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
@@ -148,6 +178,14 @@ type flowSession struct {
// //
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。 // 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
// //
// **审查不在准入路径上**:Admit 只读待处理集合里已经存在的结论(限流计数、
// 上一批已完成审查留下的队列),触发条件满足时只把一次审查**交给后台**,因此
// 适配器的读 goroutine 永远不会等模型 —— 修复前它会同步等一次 `noise_review`
// (实测 p50 5.3 s / p95 6.7 s / max 9.2 s),把消息、心跳与探活回执全排在同一条
// 队上(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。后台审查的三重上界:
// 同一适配器至多一次在飞(reviewInFlight)、同一会话每 Window 至多一次
// (lastReview)、整个适配器每 Window 至多 defaultReviewPerWindow 次(reviewHits)。
//
// Owner-private messages are not routed through the gate (the caller bypasses it), // Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here. // so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn / // The gate also carries the per-conversation running-turn slot (AcquireTurn /
@@ -160,11 +198,18 @@ type FlowGate struct {
reviewAt int reviewAt int
window time.Duration window time.Duration
review Reviewer review Reviewer
reviewBudget int
onPruned func(UnifiedMessage, string) onPruned func(UnifiedMessage, string)
now func() time.Time now func() time.Time
mu sync.Mutex mu sync.Mutex
totalHits []time.Time totalHits []time.Time
// reviewHits 是本适配器在窗口内**派发**审查的时刻,用于账号级预算
// (defaultReviewPerWindow)。
reviewHits []time.Time
// reviewInFlight 表示本适配器已有一次审查在后台跑:同一适配器至多一次在飞,
// 使后台审查有界(不会因为多个群同时越阈而并发起 N 个模型调用)。
reviewInFlight bool
sessions map[string]*flowSession sessions map[string]*flowSession
admitted uint64 admitted uint64
seq uint64 seq uint64
@@ -173,11 +218,28 @@ type FlowGate struct {
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can // queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
// be audited and the fate of the just-admitted message be tracked across index // be audited and the fate of the just-admitted message be tracked across index
// shifts (value equality on UnifiedMessage is unsafe because it holds slices). // shifts (value equality on UnifiedMessage is unsafe because it holds slices).
// The sequence also lets a review verdict land on the entries the reviewer actually
// saw, even when the buffer has moved on while the call was in flight.
type queuedMsg struct { type queuedMsg struct {
seq uint64 seq uint64
msg UnifiedMessage msg UnifiedMessage
} }
// ReviewAtFor 返回给定待处理上限对应的审查触发水位(design/12 §6 策略四:
// maxMessages 的 80%,至少 1)。它是 NewFlowGate 内部同一处的口径,导出只为让调用方
// **如实报出生效值**(例如 cmd/cyrene 的逐账号入口流控启动日志),而不是在别处另写
// 一遍 80% 的算法。非正上限表示上限关闭,返回 0(永不触发审查)。
func ReviewAtFor(maxMessages int) int {
if maxMessages <= 0 {
return 0
}
at := maxMessages * defaultReviewPct / 100
if at < 1 {
return 1
}
return at
}
// NewFlowGate builds a gate from opts. Options that are zero disable their limit, // NewFlowGate builds a gate from opts. Options that are zero disable their limit,
// except Window which defaults to one minute when left zero. // except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate { func NewFlowGate(opts FlowGateOptions) *FlowGate {
@@ -188,6 +250,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
maxMessages: opts.MaxMessages, maxMessages: opts.MaxMessages,
window: opts.Window, window: opts.Window,
review: opts.Review, review: opts.Review,
reviewBudget: opts.ReviewPerMin,
onPruned: opts.OnPruned, onPruned: opts.OnPruned,
now: opts.Now, now: opts.Now,
sessions: make(map[string]*flowSession), sessions: make(map[string]*flowSession),
@@ -195,22 +258,24 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
if g.window <= 0 { if g.window <= 0 {
g.window = defaultRateWindow g.window = defaultRateWindow
} }
if g.maxMessages > 0 { if g.reviewBudget <= 0 {
g.reviewAt = g.maxMessages * defaultReviewPct / 100 g.reviewBudget = defaultReviewPerWindow
if g.reviewAt < 1 {
g.reviewAt = 1
}
} }
g.reviewAt = ReviewAtFor(g.maxMessages)
if g.now == nil { if g.now == nil {
g.now = time.Now g.now = time.Now
} }
return g return g
} }
// Admit applies the rate limits then the per-conversation cap/review gate to one // Admit applies the rate limits then the per-conversation cap gate to one inbound
// inbound message. It returns a verdict telling the caller whether to proceed; // message. It returns a verdict telling the caller whether to proceed; messages
// messages that the gate drops are returned (and audited) so nothing is lost // that the gate drops are returned (and audited) so nothing is lost silently.
// silently. //
// **它从不同步调用审查模型**:限流与上限是 O(1) 的判定,审查只是一次「后台修剪」
// 的派发。修复前这里同步等 `noise_review`(p50 5.3 s),而五路入站里有三路
// (qq-ws、kook-ws、weixin 长轮询)是「单读 goroutine + 同步派发」,于是消息、
// 心跳、探活回执共享同一条 6 s 队列 —— 主人口中「消息一直排队」的机制。
// //
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的 // 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。 // flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
@@ -218,48 +283,55 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now() now := g.now()
key := ConversationKey(msg) key := ConversationKey(msg)
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock()
g.maybeGCLocked(now) g.maybeGCLocked(now)
verdict, job := g.admitLocked(msg, key, now)
g.mu.Unlock()
if job != nil {
// 派发在解锁之后:后台审查自己会加锁把结论落回待处理集合,绝不能让它
// 与这次准入抢同一把锁(也绝不能让准入等它)。
go g.runReview(ctx, job)
}
return verdict
}
// admitLocked runs the whole admission decision under g.mu and returns the verdict
// plus, when due, the background review job the caller must launch after unlocking.
// It never calls the Reviewer itself.
func (g *FlowGate) admitLocked(msg UnifiedMessage, key string, now time.Time) (GateVerdict, *reviewJob) {
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin // 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door // 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
// (downgraded) instead of blocking. // (downgraded) instead of blocking. 这一段的语义与修复前**一字不改**:
// 被限流的消息根本不进待处理集合,也永远不会因为审查被「捞回来」。
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup { if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
st := g.sessionLocked(key) st := g.sessionLocked(key)
st.groupHits = g.trimHits(st.groupHits, now) st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) >= g.groupPerMin { if len(st.groupHits) >= g.groupPerMin {
return g.drop("rate_limited", msg) return g.drop("rate_limited", msg), nil
} }
st.groupHits = append(st.groupHits, now) st.groupHits = append(st.groupHits, now)
} }
if g.msgPerMin > 0 { if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now) g.totalHits = g.trimHits(g.totalHits, now)
if len(g.totalHits) >= g.msgPerMin { if len(g.totalHits) >= g.msgPerMin {
return g.drop("rate_limited", msg) return g.drop("rate_limited", msg), nil
} }
g.totalHits = append(g.totalHits, now) g.totalHits = append(g.totalHits, now)
} }
if g.maxMessages <= 0 { if g.maxMessages <= 0 {
return GateVerdict{Proceed: true} return GateVerdict{Proceed: true}, nil
} }
// 策略四(会话级): add to this conversation's pending set, then prune when it // 策略四(会话级): add to this conversation's pending set, then enforce the cap
// nears the cap. // synchronously. 审查只被**派发**(下面的 scheduleReviewLocked),结论稍后由
// 后台落回 —— 准入读的是「当前已有结论」。
st := g.sessionLocked(key) st := g.sessionLocked(key)
g.seq++ g.seq++
seq := g.seq seq := g.seq
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg}) st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
var dropped []queuedMsg var dropped []queuedMsg
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
keep, err := g.review(ctx, queueMessages(st.buffer))
if err == nil {
st.buffer, dropped = g.applyReview(st.buffer, keep)
st.lastReview = now
g.auditDropped(dropped, "review_pruned")
}
}
if len(st.buffer) > g.maxMessages { if len(st.buffer) > g.maxMessages {
n := len(st.buffer) - g.maxMessages n := len(st.buffer) - g.maxMessages
var evicted []queuedMsg var evicted []queuedMsg
@@ -267,18 +339,12 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
dropped = append(dropped, evicted...) dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped") g.auditDropped(evicted, "capped")
} }
job := g.scheduleReviewLocked(st, key, now)
if !bufferHasSeq(st.buffer, seq) { // 本条一定还在缓冲里:上限按新旧顺序丢最旧,而它是最新的(maxMessages ≥ 1)。
reason := "capped" // 于是修复后准入路径上唯一能让消息 Proceed=false 的判据就是上面的限流 —— 审查
if containsSeq(dropped, seq) { // (已移到后台)与上限(丢最旧)都只影响**已经放行过的**历史条目,只留审计痕迹。
reason = "review_pruned" return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}, job
}
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
c := msg
v.DroppedMsg = &c
return v
}
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
} }
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct // AllowDirect enforces the per-sender sliding-window rate limit for a **direct
@@ -385,15 +451,6 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
} }
} }
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// trimHits drops event timestamps older than the sliding window. // trimHits drops event timestamps older than the sliding window.
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time { func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-g.window) cutoff := now.Add(-g.window)
@@ -407,27 +464,6 @@ func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
return hits[:keep] return hits[:keep]
} }
// applyReview keeps only the entries at the returned indices (validated, de-duped,
// ascending) and returns the kept slice plus the dropped entries in original order.
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
seen := make(map[int]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(buffer) {
seen[i] = true
}
}
out := make([]queuedMsg, 0, len(buffer))
drop := make([]queuedMsg, 0)
for i, entry := range buffer {
if seen[i] {
out = append(out, entry)
} else {
drop = append(drop, entry)
}
}
return out, drop
}
// dropFront removes n entries from the front (oldest) and returns the rest plus the // dropFront removes n entries from the front (oldest) and returns the rest plus the
// dropped ones. // dropped ones.
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) { func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
@@ -437,27 +473,6 @@ func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedM
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...) return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
} }
// bufferHasSeq reports whether an entry with the given sequence is still buffered
// in the conversation's pending set.
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
for _, e := range buffer {
if e.seq == seq {
return true
}
}
return false
}
// containsSeq reports whether seq appears among the dropped entries.
func containsSeq(dropped []queuedMsg, seq uint64) bool {
for _, e := range dropped {
if e.seq == seq {
return true
}
}
return false
}
// auditDropped records every dropped entry to the OnPruned audit hook. // auditDropped records every dropped entry to the OnPruned audit hook.
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) { func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
for _, e := range dropped { for _, e := range dropped {
@@ -0,0 +1,411 @@
package adapter
// 本文件覆盖 M24:把策略四的审查**移出准入路径**,并让节流在失败路径同样生效。
// 每条用例都对应一条可变异点(见 M24 回报里的「预期会红的用例名」)。
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
// auditRecorder 收集 OnPruned 的审计回调。审查结论来自后台 goroutine,因此这里
// 必须加锁(-race 会盯)。
type auditRecorder struct {
mu sync.Mutex
events []auditEvent
}
type auditEvent struct {
content string
reason string
}
func (r *auditRecorder) hook() func(UnifiedMessage, string) {
return func(m UnifiedMessage, reason string) {
r.mu.Lock()
r.events = append(r.events, auditEvent{content: m.Content, reason: reason})
r.mu.Unlock()
}
}
func (r *auditRecorder) snapshot() []auditEvent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]auditEvent(nil), r.events...)
}
func (r *auditRecorder) reasons() []string {
out := make([]string, 0)
for _, e := range r.snapshot() {
out = append(out, e.reason)
}
return out
}
// waitReviewIdle 等到闸门没有在飞的审查调用,使后台结论已经落回待处理集合。
// reviewInFlight 在**派发时**(Admit 持锁期间)就置位,因此 Admit 返回后立刻
// 调用它不会漏掉刚派发的调用。
func waitReviewIdle(t *testing.T, g *FlowGate) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
g.mu.Lock()
busy := g.reviewInFlight
g.mu.Unlock()
if !busy {
return
}
if time.Now().After(deadline) {
t.Fatal("reviewer call did not finish within 5s")
}
time.Sleep(time.Millisecond)
}
}
// pendingLen 返回某会话待处理集合的条目数。
func pendingLen(g *FlowGate, key string) int {
g.mu.Lock()
defer g.mu.Unlock()
if st := g.sessions[key]; st != nil {
return len(st.buffer)
}
return 0
}
// keepAll 是「审查者什么都不想丢」的替身:返回全部索引,使待处理集合恒在阈值上,
// 从而让节流/预算成为唯一的抑制因素(否则队列一被修剪就跌破阈值,测不出节流)。
func keepAll(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
keep := make([]int, len(msgs))
for i := range msgs {
keep[i] = i
}
return keep, nil
}
// TestFlowGate_AdmitNeverWaitsForSlowReview 是 M24 的核心证据:一个 3–6 s 量级的
// 慢审查替身压住闸门时,入站帧仍然按节奏被放行 —— 读循环不再被模型调用阻塞。
//
// 变异判据:把审查改回同步调用(在 Admit 里直接 g.review(...))⇒ 帧间隔出现一个
// ≈4 s 的空窗 ⇒ 本用例红。
func TestFlowGate_AdmitNeverWaitsForSlowReview(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan struct{}, 1)
reviewer := func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
select {
case started <- struct{}{}:
default:
}
// 4 s 的慢审查替身:实测 noise_review 的 p50 5.3 s / max 9.2 s 同量级。
select {
case <-release:
case <-time.After(4 * time.Second):
}
return nil, fmt.Errorf("slow stand-in returned")
}
g := NewFlowGate(FlowGateOptions{MaxMessages: 10, Window: time.Minute, Review: reviewer})
defer close(release)
const frames = 100
const cadence = 20 * time.Millisecond
var maxGap time.Duration
var slowestAdmit time.Duration
prev := time.Now()
for i := 0; i < frames; i++ {
admitStart := time.Now()
// 消息与心跳/探活回执在适配器侧走的是同一条 Admit 路,这里逐帧模拟。
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("frame %d dropped by the gate: %+v", i, v)
}
if d := time.Since(admitStart); d > slowestAdmit {
slowestAdmit = d
}
if gap := time.Since(prev); gap > maxGap {
maxGap = gap
}
prev = time.Now()
time.Sleep(cadence)
}
// 先断言「帧没有被拖住」——同步实现会在这里给出可读的红。
if maxGap > 500*time.Millisecond {
t.Fatalf("inbound frames stalled behind the reviewer: max gap %v (cadence %v)", maxGap, cadence)
}
if slowestAdmit > 100*time.Millisecond {
t.Fatalf("Admit waited on the reviewer: slowest admission %v", slowestAdmit)
}
// 审查确实被派发并且**仍在飞**:证明替身是慢的,上面测到的不是「审查根本没跑」。
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("reviewer was never called: the gate did not schedule the review")
}
g.mu.Lock()
busy := g.reviewInFlight
g.mu.Unlock()
if !busy {
t.Fatal("slow reviewer already returned; the stand-in was not slow enough to prove anything")
}
}
// TestFlowGate_ReviewThrottleHoldsOnFailure 固定「节流在失败路径也必须生效」:
// 审查者连续失败时,同一窗口内同一会话只允许一次调用 —— 这正是修复前失守的地方
// (lastReview 只在 err == nil 分支置位,而实测 67–85% 的审查因 BOM 解析失败)。
//
// 变异判据:把 scheduleReviewLocked 里的 `st.lastReview = now` 挪回「成功之后」
// (或删掉)⇒ 11 条消息各派发一次 ⇒ 本用例红。
func TestFlowGate_ReviewThrottleHoldsOnFailure(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
calls := 0
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 4, // reviewAt = 3
Window: time.Minute,
Now: now,
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
mu.Lock()
calls++
mu.Unlock()
return nil, fmt.Errorf("invalid character 'ï' looking for beginning of value")
},
OnPruned: audit.hook(),
})
for i := 0; i < 11; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d dropped although the reviewer only failed: %+v", i, v)
}
waitReviewIdle(t, g)
}
mu.Lock()
got := calls
mu.Unlock()
if got != 1 {
t.Fatalf("reviewer calls within one window = %d, want 1 (throttle must cover the failure path)", got)
}
// 失败方向:既不额外丢弃,也不给队列任何条目「保留」特权 —— 只有上限该有的
// "capped" 审计,绝不出现 "review_pruned"。
for _, r := range audit.reasons() {
if r == "review_pruned" {
t.Fatalf("a failed review must not prune anything, got audits %v", audit.reasons())
}
}
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 4 {
t.Fatalf("pending set = %d, want 4 (cap only, no review pruning)", n)
}
}
// TestFlowGate_ReviewBudgetBoundsCallsPerWindow 把「每适配器每分钟 LLM 审查次数」的
// 硬上界写成可测断言:40 个群在同一窗口内全部越阈,实际调用不超过
// defaultReviewPerWindow(6)。
//
// 变异判据:删掉 reviewBudgetLocked 的调用(或把 reviewBudget 置 0/极大)⇒ 40 次
// ⇒ 本用例红。
func TestFlowGate_ReviewBudgetBoundsCallsPerWindow(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
calls := 0
g := NewFlowGate(FlowGateOptions{
MaxMessages: 4, // reviewAt = 3
Window: time.Minute,
Now: now,
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
calls++
mu.Unlock()
return keepAll(ctx, msgs)
},
})
const groups = 40
for gi := 0; gi < groups; gi++ {
group := fmt.Sprintf("-20%d", gi)
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn(group, i))
}
waitReviewIdle(t, g)
}
mu.Lock()
got := calls
mu.Unlock()
if got > defaultReviewPerWindow {
t.Fatalf("reviewer calls in one window = %d, want <= %d (per-adapter budget)", got, defaultReviewPerWindow)
}
if got != defaultReviewPerWindow {
t.Fatalf("reviewer calls = %d, want exactly %d (every conversation crossed, budget is the only cap)", got, defaultReviewPerWindow)
}
}
// TestFlowGate_ReviewSingleInFlightPerAdapter 固定「同一适配器至多一次在飞」:
// 两个群同时越阈,第二个必须让位,不并发起第二次模型调用。
//
// 变异判据:删掉 scheduleReviewLocked 里的 reviewInFlight 判断 ⇒ 第二个群也起一次
// 调用 ⇒ 本用例红。
func TestFlowGate_ReviewSingleInFlightPerAdapter(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan string, 4)
reviewer := func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
started <- msgs[0].Content
<-release
return keepAll(context.Background(), msgs)
}
g := NewFlowGate(FlowGateOptions{MaxMessages: 4, Window: time.Minute, Review: reviewer})
defer close(release)
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn("-100", i))
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("first review never started")
}
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn("-200", i))
}
select {
case extra := <-started:
t.Fatalf("second review started while one was in flight (%q)", extra)
case <-time.After(100 * time.Millisecond):
}
}
// TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall 固定后台审查的落点:结论只作用于
// 审查者**当时看到的那批**(用序号对齐)。审查在飞期间新到的消息没被审查过,绝不能被
// 这次结论连带修剪 —— 否则审计会记出「review_pruned」却都是已经放行的消息。
//
// 变异判据:把 applyReviewSeqs 写成「不在 keep 集合里就丢」(不做快照过滤)⇒ 在飞期间
// 到达的两条也被丢 ⇒ 本用例红。
func TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan struct{}, 1)
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 10, // reviewAt = 8
Window: time.Minute,
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
started <- struct{}{}
<-release
return []int{0}, nil // 只保留快照里的第 0 条,其余视为噪声
},
OnPruned: audit.hook(),
})
for i := 0; i < 8; i++ {
g.Admit(context.Background(), groupMsg(i))
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("review never started")
}
// 审查在飞期间又到两条(seq 9、10):它们不在这次审查的快照里。
g.Admit(context.Background(), groupMsg(8))
g.Admit(context.Background(), groupMsg(9))
close(release)
waitReviewIdle(t, g)
// 快照第 0 条(msg 0)保留 + 在飞期间到达的两条 = 3。
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 3 {
t.Fatalf("pending set = %d, want 3 (msg0 + the two messages the reviewer never saw)", n)
}
for _, e := range audit.snapshot() {
if e.reason != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
}
if e.content == "msg 8" || e.content == "msg 9" {
t.Fatalf("a message that arrived during the call was pruned: %q", e.content)
}
}
if got := len(audit.snapshot()); got != 7 {
t.Fatalf("review_pruned count = %d, want 7 (snapshot entries 1..7)", got)
}
}
// TestFlowGate_CapStillEvictsOnAdmission 固定上限那一半仍是**同步**的:队列超出
// maxMessages 时最旧的条目在准入当次就被挤掉并审计 "capped",不依赖审查。
//
// 变异判据:把上限修剪也挪进后台 ⇒ v.Pruned 为空 ⇒ 本用例红。
func TestFlowGate_CapStillEvictsOnAdmission(t *testing.T) {
t.Parallel()
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 3,
Review: keepAll,
OnPruned: audit.hook(),
})
for i := 0; i < 3; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should fit under the cap: %+v", i, v)
}
}
v := g.Admit(context.Background(), groupMsg(3))
if !v.Proceed {
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
}
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
}
found := false
for _, r := range audit.reasons() {
if r == "capped" {
found = true
}
}
if !found {
t.Fatalf("cap eviction not audited as capped: %v", audit.reasons())
}
}
// TestFlowGate_RateLimitDropsAtTheDoor 固定 rate_limited 的「门口丢」语义一字不改:
// 被滑窗挡下的消息根本不进待处理集合,审查也绝不会把它「捞回来」。
func TestFlowGate_RateLimitDropsAtTheDoor(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
g := NewFlowGate(FlowGateOptions{
GroupMsgPerMin: 2,
MaxMessages: 3,
Window: time.Minute,
Now: now,
Review: keepAll,
})
for i := 0; i < 2; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should pass the group window: %+v", i, v)
}
}
v := g.Admit(context.Background(), groupMsg(2))
if v.Proceed {
t.Fatal("third message in the window must be rate limited")
}
if v.Reason != "rate_limited" {
t.Fatalf("reason = %q, want rate_limited", v.Reason)
}
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 2" {
t.Fatalf("dropped msg = %+v, want msg 2", v.DroppedMsg)
}
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
t.Fatalf("pending set = %d, want 2: a rate-limited message never enters it", n)
}
waitReviewIdle(t, g)
}
@@ -0,0 +1,94 @@
package adapter
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
// encoding/json 见到它报 `invalid character 'ï' looking for beginning of value` ——
// 实测 9 天 1,909 次审查里 1,288 次(67%)因此解析失败,每一次都白烧一次模型调用
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
const utf8BOM = "\ufeff"
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(字节)。它只含模型
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
// 消息内容带进日志。
const reviewExcerptLen = 40
// ParseReviewIndices decodes the keep-index array a review model returned, rising
// above the shapes real gateways produce instead of requiring a bare JSON array:
//
// - a UTF-8 BOM prefix (`\ufeff`) — the actual cause of the 67% failure rate;
// - leading/trailing whitespace and newlines;
// - a Markdown code fence (` ```json … ``` ` or ` ``` … ``` `);
// - surrounding prose ("好的,保留以下编号:[0, 2]") — the outermost `[ … ]`
// span is used.
//
// The decoded indices are validated the same way the previous strict parser did:
// out-of-range and duplicate values are dropped and the rest sorted ascending, so a
// hallucinated number can never evict a real message. An empty or absent array is
// NOT an error here — the caller decides; a missing/non-array answer is.
func ParseReviewIndices(s string, bound int) ([]int, error) {
body, ok := reviewArraySpan(s)
if !ok {
return nil, fmt.Errorf("no JSON array in reviewer output %q", reviewExcerpt(s))
}
var idx []int
if err := json.Unmarshal([]byte(body), &idx); err != nil {
return nil, fmt.Errorf("decode reviewer indices %q: %w", reviewExcerpt(body), err)
}
seen := make(map[int]bool, len(idx))
out := make([]int, 0, len(idx))
for _, i := range idx {
if i >= 0 && i < bound && !seen[i] {
seen[i] = true
out = append(out, i)
}
}
sort.Ints(out)
return out, nil
}
// reviewArraySpan strips the decorations a model puts around its answer (BOM,
// whitespace, code fence, prose) and returns the outermost JSON array span.
func reviewArraySpan(s string) (string, bool) {
s = strings.TrimSpace(strings.TrimPrefix(s, utf8BOM))
s = strings.TrimSpace(unwrapCodeFence(s))
start := strings.IndexByte(s, '[')
end := strings.LastIndexByte(s, ']')
if start < 0 || end < start {
return "", false
}
return s[start : end+1], true
}
// unwrapCodeFence drops a leading ``` fence line and the closing ``` so the fenced
// body is left for the array scan. Anything without a leading fence is returned
// unchanged; a fence with no body is returned as-is and fails the array scan.
func unwrapCodeFence(s string) string {
if !strings.HasPrefix(s, "```") {
return s
}
nl := strings.IndexByte(s, '\n')
if nl < 0 {
return s
}
s = s[nl+1:]
if close := strings.LastIndex(s, "```"); close >= 0 {
s = s[:close]
}
return s
}
// reviewExcerpt truncates text for an error message so the next parse failure is
// diagnosable without logging the whole completion.
func reviewExcerpt(s string) string {
s = strings.TrimSpace(s)
if len(s) <= reviewExcerptLen {
return s
}
return s[:reviewExcerptLen] + "…"
}
@@ -0,0 +1,128 @@
package adapter
// 本文件用**对端原样输出**(含 UTF-8 BOM 字节)做夹具,固定审查输出的解析韧性。
// 生产症状(`~/cy.log`,9 天):`flow gate review output unparsable` 1,301 次,其中
// 1,288 次的错误串逐字是 `invalid character 'ï' looking for beginning of value` ——
// 对端在 JSON 数组前带了 BOM,而闸门用的 json.Unmarshal 不接受它。
import (
"encoding/json"
"strings"
"testing"
)
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
// JSON 数组。写死字节而不是用 \ufeff,是为了让「夹具被谁悄悄去掉 BOM」本身就会红。
const bomReviewFixture = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]"
// TestReviewBOMFixtureStillReproducesTheFailure 先钉住夹具的真实性:它必须以 BOM
// 开头,且严格的 json.Unmarshal 必须在它上面失败,错误串必须就是生产日志里那条。
//
// 变异判据:把夹具里的 BOM 去掉 ⇒ 本用例红(同时也说明 BOM 相关用例已失去意义)。
func TestReviewBOMFixtureStillReproducesTheFailure(t *testing.T) {
t.Parallel()
if !strings.HasPrefix(bomReviewFixture, "\xef\xbb\xbf") {
t.Fatal("BOM fixture lost its BOM bytes: it no longer reproduces the production failure")
}
var idx []int
err := json.Unmarshal([]byte(bomReviewFixture), &idx)
if err == nil {
t.Fatal("strict json.Unmarshal accepted the BOM fixture: the regression it models is gone")
}
if !strings.Contains(err.Error(), "invalid character 'ï'") {
t.Fatalf("strict parse error = %v, want the production symptom \"invalid character 'ï'\"", err)
}
}
// TestParseReviewIndices_ToleratesBOM 是修复的正面证据:同一份带 BOM 的原样回包
// 现在能解析出编号。
//
// 变异判据:去掉 ParseReviewIndices 里的 reviewArraySpan(回到裸 json.Unmarshal)
// ⇒ 本用例红。
func TestParseReviewIndices_ToleratesBOM(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices(bomReviewFixture, 6)
if err != nil {
t.Fatalf("ParseReviewIndices(BOM fixture) error = %v, want nil", err)
}
want := []int{0, 1, 2, 3, 4, 5}
if len(keep) != len(want) {
t.Fatalf("keep = %v, want %v", keep, want)
}
for i := range want {
if keep[i] != want[i] {
t.Fatalf("keep = %v, want %v", keep, want)
}
}
}
// TestParseReviewIndices_ToleratesDecorations 覆盖模型常见的几种外壳:首尾空白与换行、
// Markdown 代码围栏(带或不带语言标签)、以及围在数组外面的解说文字。
func TestParseReviewIndices_ToleratesDecorations(t *testing.T) {
t.Parallel()
cases := map[string]string{
"newlines": "\n\n[0, 2]\n\n",
"fence": "```\n[0, 2]\n```",
"fence with lang": "```json\n[0, 2]\n```",
"prose prefix": "好的,保留以下编号:[0, 2]",
"prose suffix": "[0, 2] 以上是保留的编号。",
"bom plus fence": "\xef\xbb\xbf```json\n[0, 2]\n```",
"spaced values": "[ 0 , 2 ]",
}
for name, raw := range cases {
keep, err := ParseReviewIndices(raw, 3)
if err != nil {
t.Fatalf("%s: error = %v, want nil", name, err)
}
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
t.Fatalf("%s: keep = %v, want [0 2]", name, keep)
}
}
}
// TestParseReviewIndices_FiltersHallucinations 固定过滤口径与修复前一致:越界、负数、
// 重复的编号一律丢弃(模型幻觉出的数字绝不能淘汰真实消息),结果升序。
func TestParseReviewIndices_FiltersHallucinations(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices("\xef\xbb\xbf[2, 2, 99, -1, 0]", 3)
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
t.Fatalf("keep = %v, want [0 2]", keep)
}
}
// TestParseReviewIndices_EmptyArrayIsNotAnError:模型明确回空数组 = 「一条都不保留」,
// 这是有效结论(全部当噪声),不是解析失败 —— 失败方向必须留给真正的畸形输出。
func TestParseReviewIndices_EmptyArrayIsNotAnError(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices("\xef\xbb\xbf[]", 5)
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
if len(keep) != 0 {
t.Fatalf("keep = %v, want empty", keep)
}
}
// TestParseReviewIndices_RejectsGarbage:真正畸形的输出仍然报错(调用方据此走
// 「不额外丢弃、也不提权」的安全方向),错误里带一小段原文便于定位下一次变种。
func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
t.Parallel()
for name, raw := range map[string]string{
"no array": "抱歉,我无法判断。",
"empty": "",
"array of str": `["a", "b"]`,
} {
if _, err := ParseReviewIndices(raw, 5); err == nil {
t.Fatalf("%s: error = nil, want a decode error", name)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package adapter
// 本文件是策略四的「审查」半边:把它从准入路径上摘下来的派发器(reviewJob /
// scheduleReviewLocked / runReview)与三重上界(同适配器在飞、每会话窗口、每适配器
// 窗口预算)。解析韧性在 flowgate_parse.go,准入判定仍在 flowgate.go。
//
// 为什么必须是后台:`noise_review` 实测 p50 5,294 ms / p95 6,651 ms / max 9,204 ms
// (`~/cy.log`,9 天 1,900 次),而五路入站里 qq-ws、kook-ws、weixin 长轮询都是
// 「单读 goroutine + 同步派发」—— 同步跑在读循环上等于把消息、心跳、探活回执全排在
// 一次 6 秒的模型调用后面(DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
//
// # 与修复前的语义差异(哪一条变了、哪一条没变)
//
// - `rate_limited`(策略三,门口丢):**一字不改**。超窗消息在准入当次就被拒绝、
// 根本不进待处理集合,也不经过审查。
// - 上限修剪(策略四的硬部分):**一字不改**。待处理集合超过 maxMessages 时,最旧的
// 条目仍在**准入当次同步**被挤掉并审计为 "capped";触发水位仍是上限的 80%。
// - 审查修剪(策略四的模型部分):**判据不变,时机变了**。触发条件(≥80% 上限、
// 同一会话每窗口至多一次、返回保留编号、越界/重复编号丢弃)逐条沿用;变的是结论
// 落地时点 —— 它不再决定「触发它的那条消息」是否放行,而是修剪**它当时看到的**
// 那批历史条目(审计为 "review_pruned")。
// 直接后果:`review_pruned` 不再从门口丢消息(实测原本 41–74 条/日),那些消息
// 现在会进入流水线 —— 完整性↑、噪声↑,是否开口仍由 engage_judge / 冷避 / owner
// 闸判(这三处判据一行未动)。
//
// 为什么这个差异可以接受:design/12 §6 策略四给审查的职责是「保护 loop 不被不重要的
// 消息塞满」的历史积压去噪,不是逐条消息的准入判决;把一次 6 秒的模型调用钉在准入
// 路径上,代价(读循环阻塞 → 消息/心跳/探活全排队)远大于收益(少进 41–74 条噪声/日)。
// 真正决定「进不进 loop」的两道判据 —— 限流与上限 —— 都留在准入路径上,且未改口径。
import (
"context"
"time"
)
const (
// reviewTimeout bounds one background reviewer call. The in-flight slot is held
// for the whole call, so an unbounded call would also freeze every other
// conversation's review for that account. 上一版没有上界,最长一次实测 9.2 s;
// 30 s 留足余量而不至于让一次挂死的调用长期占位。
reviewTimeout = 30 * time.Second
)
// reviewJob is one scheduled reviewer call: the snapshot of the pending set the
// reviewer is asked about, plus the sequence numbers that identify those entries
// when the answer comes back (the buffer keeps moving while the call is in flight).
type reviewJob struct {
key string
msgs []UnifiedMessage
seqs []uint64
}
// scheduleReviewLocked reports whether this admission should start a background
// review and, when it should, marks that review as started and returns its job.
//
// 三重上界都在这里(调用方须持有 g.mu):
// 1. 同一适配器至多一次在飞(reviewInFlight)—— 后台审查有界,不会因为多个群
// 同时越阈而并发起 N 个模型调用;
// 2. 同一会话每 window 至多一次(lastReview)—— 修复前它只在成功分支置位,
// 于是 68% 解析失败时「每窗口一次」退化成「每条消息一次」;
// 3. 整个适配器每 window 至多 reviewBudget 次(reviewHits)—— 与模型耗时无关的
// 硬上界,会话再多也乘不上去。
//
// lastReview 在这里(**派发时**)写入,而不是等调用返回:成功与失败两条路径因此
// 同样被节流,这是「每条入站帧都付一次 6 秒」的正面修复。
func (g *FlowGate) scheduleReviewLocked(st *flowSession, key string, now time.Time) *reviewJob {
if g.review == nil || g.maxMessages <= 0 || len(st.buffer) < g.reviewAt {
return nil
}
if g.reviewInFlight || g.reviewRecently(st, now) || !g.reviewBudgetLocked(now) {
return nil
}
st.lastReview = now
g.reviewInFlight = true
g.reviewHits = append(g.reviewHits, now)
return &reviewJob{key: key, msgs: queueMessages(st.buffer), seqs: bufferSeqs(st.buffer)}
}
// runReview executes one scheduled review off the admission path and folds its
// verdict back into the conversation's pending set. It is the only place the
// Reviewer is called.
//
// 失败方向(安全侧,与修复前 `err != nil` 分支同口径):审查失败**既不额外丢弃**
// (失败绝不被解释成「低价值 ⇒ 丢掉」),**也不给任何条目保留特权**(失败绝不被
// 解释成「高价值 ⇒ 提权」);待处理集合原样保留,只由既有的 FIFO 上限兜底。
// 唯一的代价是这一批噪声没被修剪,而窗口节流保证下一次尝试要等到下个窗口。
func (g *FlowGate) runReview(ctx context.Context, job *reviewJob) {
rctx, cancel := reviewContext(ctx)
defer cancel()
keep, err := g.review(rctx, job.msgs)
g.mu.Lock()
g.reviewInFlight = false
var dropped []queuedMsg
if err == nil {
// 会话可能在审查期间被 GC 掉(空闲清理),那时结论无处可落,直接丢弃。
if st := g.sessions[job.key]; st != nil {
st.buffer, dropped = applyReviewSeqs(st.buffer, job.seqs, keep)
}
}
g.mu.Unlock()
// 审计在锁外:钩子由 channelbridge 接(落库/面板),不该占着闸门的锁跑 I/O。
g.auditDropped(dropped, "review_pruned")
}
// reviewContext detaches the reviewer call from the admission caller's cancellation
// — the inbound handler returns the moment the message is admitted, and on several
// adapters its ctx dies with the frame — while still bounding the call so a hung
// model cannot pin the single in-flight slot forever.
func reviewContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(context.WithoutCancel(ctx), reviewTimeout)
}
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// reviewBudgetLocked enforces the per-adapter ceiling on reviewer calls started in
// the current window. 调用方须持有 g.mu。
func (g *FlowGate) reviewBudgetLocked(now time.Time) bool {
g.reviewHits = g.trimHits(g.reviewHits, now)
return len(g.reviewHits) < g.reviewBudget
}
// applyReviewSeqs keeps only the buffered entries the reviewer chose. Entries are
// matched by sequence number, so:
// - entries evicted by the cap while the call was in flight simply do not appear
// in the buffer any more (they were audited as "capped" at eviction time);
// - entries that arrived **after** the snapshot are never judged by this review
// and always survive (the reviewer never saw them).
func applyReviewSeqs(buffer []queuedMsg, seqs []uint64, keep []int) (kept, dropped []queuedMsg) {
reviewed := make(map[uint64]bool, len(seqs))
for _, s := range seqs {
reviewed[s] = true
}
keepSet := make(map[uint64]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(seqs) {
keepSet[seqs[i]] = true
}
}
kept = make([]queuedMsg, 0, len(buffer))
dropped = make([]queuedMsg, 0)
for _, e := range buffer {
if !reviewed[e.seq] || keepSet[e.seq] {
kept = append(kept, e)
continue
}
dropped = append(dropped, e)
}
return kept, dropped
}
// bufferSeqs extracts the sequence numbers of buffered entries in order, so a
// review verdict can be applied to exactly the entries it was asked about.
func bufferSeqs(buffer []queuedMsg) []uint64 {
out := make([]uint64, len(buffer))
for i, e := range buffer {
out[i] = e.seq
}
return out
}
+53 -30
View File
@@ -2,6 +2,7 @@ package adapter
import ( import (
"context" "context"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -118,10 +119,16 @@ func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
} }
} }
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) { // TestFlowGate_ReviewPrunesBacklogAfterAdmission 固定策略四的新口径:审查**不在准入
// 路径上**。越阈那一条消息照常放行(它的到达只负责派发审查),审查完成后只修剪
// **它当时看到的那批**待处理条目,并以 "review_pruned" 入审计。
//
// 变异判据:把审查改回在 Admit 里同步执行并按结论丢「刚到的那条」⇒ 本用例的
// `v.Proceed` 断言红。
func TestFlowGate_ReviewPrunesBacklogAfterAdmission(t *testing.T) {
t.Parallel() t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0)) now, _ := fixedClock(time.Unix(1000, 0))
var audited []string audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{ g := NewFlowGate(FlowGateOptions{
MaxMessages: 10, MaxMessages: 10,
Window: time.Minute, Window: time.Minute,
@@ -130,9 +137,7 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
// Keep only the first two messages; drop the rest as noise. // Keep only the first two messages; drop the rest as noise.
return []int{0, 1}, nil return []int{0, 1}, nil
}, },
OnPruned: func(_ UnifiedMessage, reason string) { OnPruned: audit.hook(),
audited = append(audited, reason)
},
}) })
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires. // Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
@@ -141,69 +146,87 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
t.Fatalf("message %d should be admitted before threshold: %+v", i, v) t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
} }
} }
if len(audited) != 0 { waitReviewIdle(t, g)
t.Fatalf("review fired below threshold: %v", audited) if n := len(audit.snapshot()); n != 0 {
t.Fatalf("review fired below threshold: %d audits", n)
} }
// The 8th message crosses the 80% threshold → review runs and drops msg2..7, // The 8th message crosses the 80% threshold → the review is dispatched in the
// including the just-admitted message 7. // background; the message itself is still admitted (admission never waits).
v := g.Admit(context.Background(), groupMsg(7)) v := g.Admit(context.Background(), groupMsg(7))
if v.Proceed { if !v.Proceed {
t.Fatal("just-admitted message 7 must be pruned by the reviewer") t.Fatalf("the message that crosses the threshold must still be admitted, got %+v", v)
} }
if v.Reason != "review_pruned" { if v.Reason != "" {
t.Fatalf("reason = %q, want review_pruned", v.Reason) t.Fatalf("reason = %q, want empty", v.Reason)
} }
if len(v.Pruned) != 6 { waitReviewIdle(t, g)
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
// The review keeps 0 and 1, so msg2..7 are pruned from the pending set and audited.
if n := len(audit.snapshot()); n != 6 {
t.Fatalf("audit count = %d, want 6 (msg2..msg7)", n)
} }
if len(audited) != 6 { for _, e := range audit.snapshot() {
t.Fatalf("audit count = %d, want 6", len(audited)) if e.reason != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
} }
for _, r := range audited {
if r != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", r)
} }
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
t.Fatalf("pending set = %d, want 2 (only the kept entries)", n)
} }
} }
// TestFlowGate_ReviewThrottledWithinWindow 固定「每会话每窗口至多一次审查」。
// 审查者保留全部条目,使待处理集合恒在阈值上 —— 于是节流(而不是队列长度)是唯一的
// 抑制因素;这正是修复前失守的那条判据(lastReview 只在成功分支置位)。
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) { func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
t.Parallel() t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0)) now, clock := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
reviews := 0 reviews := 0
g := NewFlowGate(FlowGateOptions{ g := NewFlowGate(FlowGateOptions{
MaxMessages: 3, MaxMessages: 3,
Window: time.Minute, Window: time.Minute,
Now: now, Now: now,
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) { Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
reviews++ reviews++
return []int{0}, nil mu.Unlock()
return keepAll(ctx, msgs)
}, },
}) })
count := func() int {
mu.Lock()
defer mu.Unlock()
return reviews
}
// First threshold crossing triggers a review. // First threshold crossing triggers a review.
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 1 { waitReviewIdle(t, g)
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews) if got := count(); got != 1 {
t.Fatalf("reviews after crossing threshold = %d, want 1", got)
} }
// Within the same window the gate must not re-review even when it refills. // Within the same window the gate must not re-review even when it refills.
for i := 3; i < 6; i++ { for i := 3; i < 9; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 1 { waitReviewIdle(t, g)
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews) if got := count(); got != 1 {
t.Fatalf("reviews within window = %d, want 1 (throttled)", got)
} }
// A fresh window allows a review again. // A fresh window allows a review again.
*clock = clock.Add(2 * time.Minute) *clock = clock.Add(2 * time.Minute)
for i := 6; i < 9; i++ { for i := 9; i < 12; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 2 { waitReviewIdle(t, g)
t.Fatalf("reviews after window roll = %d, want 2", reviews) if got := count(); got != 2 {
t.Fatalf("reviews after window roll = %d, want 2", got)
} }
} }
+54
View File
@@ -0,0 +1,54 @@
package session
// 本文件把「消息 → 会话」的键派生**导出**给别的层复用(M22 复审 P1-1)。
//
// 为什么必须导出:会话的键一直是会话层自己算的(`channelID` + `platform \x00 channelID`),
// 而渠道桥接器的**每会话回合车道**需要「什么算同一个会话」这个判据与它**同源**。
// 各算各的会漂移,而且漂移的后果不是排序不好看,是**同一个 session 两条车道、两个回合
// 并发**——两条 run 共享 sessionID,checkpoint 行 id `{sessionID}:{step}` 互相覆盖、
// `loadCheckpoint` 可能续到另一条在途 run 的快照而重放工具调用,flow 桶
// `p.graphs[sessionID]` 被先收尾者删掉(loop 没有会话级互斥,IM 侧也没有网关那层
// `session_turn_fence` 兜底)。
//
// 两处真实反例(都来自复审实测):
// - kook:同一 guild 的两个 text channel —— `ChannelID` 不同(c1/c2)但 `GroupID` 相同,
// 会话层按 `group:<GroupID>` 判成**同一个** session;任何把 `ChannelID` 当会话判据的
// 派生都会把它们拆成两条车道。
// - 多账号:同一平台两个账号对同一对端 —— 账号名不同(`telegram:conf0`/`:conf1`)、
// `platform` 常量相同、`ChannelID` 相同 ⇒ 也是**同一个** session;任何把「账号」当
// 会话判据的派生都会把它们拆开。
//
// 因此本文件只做一件事:把既有的派生**原样**导出,语义一个字不改(`channelID` 仍是唯一
// 的实现,见 manager.go)。
import "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
// sessionKeySep 分隔 (platform, channel_id) 两个维度。用 NUL 是因为平台名与渠道 id 都
// 不可能包含它,拼接因此不会产生歧义(`a` + "b" 与 `ab` + "" 不会撞键)。
const sessionKeySep = "\x00"
// ChannelKey 返回一条入站消息所代表的**渠道**键(会话派生的第二个维度):
// 群消息按 `group:<GroupID>`(加前缀使群不会与同 id 的私聊渠道撞键),私聊按
// `ChannelID`,没有显式渠道 id 时退回 `SenderID`。
//
// 它就是 `SessionManager.GetOrCreate` 内部用的那一个函数(manager.go 的 `channelID`),
// 导出只为让别的层(渠道桥接器的回合车道)能与会话键同源,**不得**在别处另写一份。
// 返回空串表示这条消息没有可用的渠道(`GetOrCreate` 会以 `ErrInvalidChannel` 拒掉它)。
func ChannelKey(msg adapter.UnifiedMessage) string { return channelID(msg) }
// Key 返回一条入站消息的**会话键** —— 与 `SessionManager` 的缓存键和
// `channel_sessions` 表的唯一键**逐字相同**(`platform \x00 channelID`)。
//
// 它是本包对外唯一的「什么算同一个会话」判据:任何需要按会话串行/分桶的调用方都用它,
// 不要自己拼 `platform|channel` 之类的近似键(那正是 M22 复审里「车道键与会话键不同源」
// 的成因)。返回的键在渠道不可用时仍然非空(`platform\x00`),调用方若需要区分,
// 用 ChannelKey 判空。
func Key(msg adapter.UnifiedMessage) string {
return sessionKey(msg.Platform, channelID(msg))
}
// sessionKey 拼接 (platform, channel_id) 两维度。本包内所有「会话键」的**唯一**出处
// (GetOrCreate / List / 导出的 Key 都经这里),因此不可能各自漂移。
func sessionKey(platform, cid string) string {
return platform + sessionKeySep + cid
}
+89
View File
@@ -0,0 +1,89 @@
package session
// 本文件钉住 keys.go 导出的两个派生:`ChannelKey` 与会话层内部 `channelID` 是**同一个**
// 实现,`Key` 与会话缓存键**逐字相同**。它们是「什么算同一个会话」的唯一定义 —— 车道
// (internal/services/channelbridge/lane.go)按会话串行就靠它,两处一旦分叉,同一个
// session 就会出现两条车道、两个回合并发(M22 复审 P1-1)。
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestChannelKeyDerivations(t *testing.T) {
t.Parallel()
cases := []struct {
name string
msg adapter.UnifiedMessage
want string
}{
{
name: "群:按 group:<GroupID>,与频道无关",
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"},
want: "group:g1",
},
{
name: "群:缺 GroupID 时退回 ChannelID",
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1"},
want: "c1",
},
{
name: "私聊:按 ChannelID",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, ChannelID: "200", SenderID: "200"},
want: "200",
},
{
name: "私聊:缺 ChannelID 时退回 SenderID",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "200"},
want: "200",
},
{
name: "私聊:两者都缺 ⇒ 空(调用方据此以 ErrInvalidChannel 拒掉)",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect},
want: "",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := ChannelKey(c.msg); got != c.want {
t.Fatalf("ChannelKey = %q, want %q", got, c.want)
}
})
}
}
// TestKeyIsTheSessionManagerCacheKey 钉住「导出的 Key 就是会话缓存键」:
// 形状 `platform \x00 channelID`(NUL 分隔,杜绝拼接歧义),且两维度都参与 ——
// 同平台不同渠道、不同平台同渠道都必须是**不同**的键。
func TestKeyIsTheSessionManagerCacheKey(t *testing.T) {
t.Parallel()
group1 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
group1OtherChannel := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c2", GroupID: "g1"}
group2 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g2"}
otherPlatform := adapter.UnifiedMessage{Platform: "qq-obv11", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
if got, want := Key(group1), "kook\x00group:g1"; got != want {
t.Fatalf("Key = %q, want %q", got, want)
}
// 同一 guild 的两个 text channel 在会话层是**同一个**会话。
if Key(group1) != Key(group1OtherChannel) {
t.Fatalf("same guild channels must share one session key: %q vs %q", Key(group1), Key(group1OtherChannel))
}
// 不同群、不同平台必须是不同的会话。
if Key(group1) == Key(group2) {
t.Fatalf("different groups must not share a session key: %q", Key(group1))
}
if Key(group1) == Key(otherPlatform) {
t.Fatalf("different platforms must not share a session key: %q", Key(group1))
}
// 渠道缺失时键仍非空,但 ChannelKey 能为空(调用方据此区分不可用渠道)。
noChannel := adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect}
if Key(noChannel) == "" || ChannelKey(noChannel) != "" {
t.Fatalf("missing-channel case: Key=%q ChannelKey=%q", Key(noChannel), ChannelKey(noChannel))
}
}
+2 -2
View File
@@ -110,7 +110,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
if cid == "" { if cid == "" {
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel) return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
} }
key := msg.Platform + "\x00" + cid key := sessionKey(msg.Platform, cid)
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。 // 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role { if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role {
@@ -155,7 +155,7 @@ func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil { if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
return nil, fmt.Errorf("session: scan list: %w", err) return nil, fmt.Errorf("session: scan list: %w", err)
} }
key := platform + "\x00" + cid key := sessionKey(platform, cid)
if cached, ok := m.lookupCached(key); ok { if cached, ok := m.lookupCached(key); ok {
sessions = append(sessions, cached) sessions = append(sessions, cached)
continue continue
@@ -0,0 +1,475 @@
// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。
//
// 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里
// 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按
// design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在
// $DATA_DIR/config/coalesce.json,**零新 env 键**。
//
// 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** ——
// 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在
// internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定,
// **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的
// config 子目录口径。
coalesceConfigSubdir = "config"
// coalesceFileName 是静默窗口合并旋钮的落盘文件名。
coalesceFileName = "coalesce.json"
// coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。
coalesceLogSource = "config/runtimeconfig/coalesce"
// clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、
// 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**,
// 绝不静默采信一个不可能的值,也绝不因此拦住入站。
CoalesceWindowMinMs = 300
CoalesceWindowMaxMs = 15000
CoalesceMaxWaitMinMs = CoalesceWindowMinMs
CoalesceMaxWaitMaxMs = 30000
CoalesceBatchMin = 1
CoalesceBatchMax = 50
coalesceDefaultWindow = 2000
coalesceDefaultWait = 6000
coalesceDefaultBatch = 24
)
// DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。
func DefaultCoalescePath(dataDir string) string {
return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName)
}
// CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源)
// 渲染成落盘/面板用的可读键:NUL 换成 `|`。
//
// 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定),
// 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本
// 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是
// 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。
func CoalesceConversationKey(laneKey string) string {
return strings.ReplaceAll(laneKey, "\x00", "|")
}
// CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。
//
// 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走
// DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。
type CoalescePolicy struct {
// Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。
Enabled bool `json:"enabled"`
// WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。
WindowMs int `json:"windowMs"`
// MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回)。
MaxWaitMs int `json:"maxWaitMs"`
// MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首
// 成为下一批(见 channelbridge/coalesce.go 的 split)。
MaxBatchMessages int `json:"maxBatchMessages"`
// ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。
ApplyToStrangers bool `json:"applyToStrangers"`
// StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留:
// 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。
StrangerWindowMs int `json:"strangerWindowMs"`
}
// DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。
func DefaultCoalescePolicy() CoalescePolicy {
return CoalescePolicy{
Enabled: true,
WindowMs: coalesceDefaultWindow,
MaxWaitMs: coalesceDefaultWait,
MaxBatchMessages: coalesceDefaultBatch,
ApplyToStrangers: true,
StrangerWindowMs: coalesceDefaultWindow,
}
}
// CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。
// `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。
type CoalescePolicyPatch struct {
Enabled *bool `json:"enabled,omitempty"`
WindowMs *int `json:"windowMs,omitempty"`
MaxWaitMs *int `json:"maxWaitMs,omitempty"`
MaxBatchMessages *int `json:"maxBatchMessages,omitempty"`
ApplyToStrangers *bool `json:"applyToStrangers,omitempty"`
StrangerWindowMs *int `json:"strangerWindowMs,omitempty"`
}
// apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。
func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy {
if patch.Enabled != nil {
p.Enabled = *patch.Enabled
}
if patch.WindowMs != nil {
p.WindowMs = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
p.MaxWaitMs = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
p.MaxBatchMessages = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
p.ApplyToStrangers = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
p.StrangerWindowMs = *patch.StrangerWindowMs
}
return p
}
// merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留
// 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。
func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch {
out := patch
if next.Enabled != nil {
out.Enabled = next.Enabled
}
if next.WindowMs != nil {
out.WindowMs = next.WindowMs
}
if next.MaxWaitMs != nil {
out.MaxWaitMs = next.MaxWaitMs
}
if next.MaxBatchMessages != nil {
out.MaxBatchMessages = next.MaxBatchMessages
}
if next.ApplyToStrangers != nil {
out.ApplyToStrangers = next.ApplyToStrangers
}
if next.StrangerWindowMs != nil {
out.StrangerWindowMs = next.StrangerWindowMs
}
return out
}
// Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。
func (patch CoalescePolicyPatch) Empty() bool {
return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil &&
patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil &&
patch.StrangerWindowMs == nil
}
// CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表
// (键 = CoalesceConversationKey(车道键))。
type CoalesceFile struct {
Default CoalescePolicyPatch `json:"default"`
Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"`
}
// EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。
// 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开:
// 一个是运行期事实,一个是盘上原文。
func (f CoalesceFile) EffectiveDefault() CoalescePolicy {
p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy()))
return p
}
// EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。
func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) {
conv, ok := f.Conversations[key]
if !ok {
return CoalescePolicy{}, false
}
p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault()))
return p, true
}
// Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」
// 如实回显。值是该键在盘上的原值(未经 clamp)。
func (patch CoalescePolicyPatch) Values() map[string]any {
out := map[string]any{}
if patch.Enabled != nil {
out["enabled"] = *patch.Enabled
}
if patch.WindowMs != nil {
out["windowMs"] = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
out["maxWaitMs"] = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
out["maxBatchMessages"] = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
out["applyToStrangers"] = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
out["strangerWindowMs"] = *patch.StrangerWindowMs
}
return out
}
// ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明
// (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成
// 一个没人知道的 300ms)。
func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) {
var notes []string
if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs))
p.WindowMs = CoalesceWindowMinMs
}
if p.WindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs))
p.WindowMs = CoalesceWindowMaxMs
}
if p.WindowMs == 0 {
// 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。
notes = append(notes, "windowMs=0 视为关闭静默窗口")
p.Enabled = false
p.WindowMs = coalesceDefaultWindow
}
if p.StrangerWindowMs <= 0 {
p.StrangerWindowMs = p.WindowMs
}
if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs))
p.StrangerWindowMs = p.WindowMs
}
if p.MaxWaitMs < p.WindowMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs))
p.MaxWaitMs = p.WindowMs
}
if p.MaxWaitMs > CoalesceMaxWaitMaxMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs))
p.MaxWaitMs = CoalesceMaxWaitMaxMs
}
if p.MaxBatchMessages < CoalesceBatchMin {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin))
p.MaxBatchMessages = CoalesceBatchMin
}
if p.MaxBatchMessages > CoalesceBatchMax {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax))
p.MaxBatchMessages = CoalesceBatchMax
}
return p, notes
}
// ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由
// JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。
func ValidateCoalescePatch(p CoalescePolicyPatch) error {
fields := []struct {
name string
v *int
}{
{"windowMs", p.WindowMs},
{"maxWaitMs", p.MaxWaitMs},
{"maxBatchMessages", p.MaxBatchMessages},
{"strangerWindowMs", p.StrangerWindowMs},
}
for _, f := range fields {
if f.v != nil && *f.v < 0 {
return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v)
}
}
return nil
}
// CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、
// 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒
// **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。
type CoalescePatch struct {
Default *CoalescePolicyPatch `json:"default,omitempty"`
Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"`
}
// Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。
func (p CoalescePatch) Empty() bool {
if p.Default != nil && !p.Default.Empty() {
return false
}
return len(p.Conversations) == 0
}
// CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。
type CoalesceStore struct {
path string
log *slog.Logger
mu sync.RWMutex
cfg CoalesceFile
}
// NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
// 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。
func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore {
if log == nil {
log = slog.Default()
}
s := &CoalesceStore{
path: path,
log: logger.WithSource(log, coalesceLogSource),
cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}},
}
s.load()
return s
}
// Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。
func (s *CoalesceStore) Path() string { return s.path }
// Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
func (s *CoalesceStore) Present() bool {
if s.path == "" {
return false
}
_, err := os.Stat(s.path)
return err == nil
}
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
// 其它读/解析错误留 WARN 后仍走默认值。
func (s *CoalesceStore) load() {
if s.path == "" {
return
}
raw, err := os.ReadFile(s.path)
if err != nil {
if !os.IsNotExist(err) {
s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err)
}
return
}
var f CoalesceFile
if err := json.Unmarshal(raw, &f); err != nil {
s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err)
return
}
if f.Conversations == nil {
f.Conversations = map[string]CoalescePolicyPatch{}
}
s.mu.Lock()
s.cfg = f
s.mu.Unlock()
s.logClamp(f)
}
// logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。
func (s *CoalesceStore) logClamp(f CoalesceFile) {
if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; "))
}
for key, patch := range f.Conversations {
if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce conversation override clamped",
"path", s.path, "conversation", key, "notes", strings.Join(notes, "; "))
}
}
}
// PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖,
// 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。
//
// 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效),
// 也与入站路径一样不会被一次落盘抖动卡住。
func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy {
s.mu.RLock()
def := s.cfg.Default
conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)]
s.mu.RUnlock()
p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy()))
if ok {
p, _ = ClampCoalescePolicy(conv.apply(p))
}
return p
}
// Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。
// 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」,
// 而不是「运行期把它夹成了什么」。
func (s *CoalesceStore) Snapshot() CoalesceFile {
s.mu.RLock()
defer s.mu.RUnlock()
out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))}
for k, v := range s.cfg.Conversations {
out.Conversations[k] = v
}
return out
}
// Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里
// 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内,
// 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。
//
// 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着
// 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。
func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
before, _ := json.Marshal(s.cfg)
if patch.Default != nil {
s.cfg.Default = s.cfg.Default.merge(*patch.Default)
}
if s.cfg.Conversations == nil {
s.cfg.Conversations = map[string]CoalescePolicyPatch{}
}
for key, conv := range patch.Conversations {
if conv == nil {
delete(s.cfg.Conversations, key)
continue
}
s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv)
}
after, _ := json.Marshal(s.cfg)
if string(before) == string(after) {
return false, nil
}
if err := s.saveLocked(); err != nil {
return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err)
}
return true, nil
}
// saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。
// 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。
func (s *CoalesceStore) saveLocked() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(s.cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("chmod %s: %w", tmpName, err)
}
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("write %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("close %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
+79
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
) )
@@ -61,10 +62,30 @@ type PluginConfig struct {
// Env is extra environment for a stdio subprocess. Values under a sensitive // Env is extra environment for a stdio subprocess. Values under a sensitive
// key (token/secret/password/key...) are redacted by MaskedPlugin. // key (token/secret/password/key...) are redacted by MaskedPlugin.
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
// Enabled is the per-server on/off switch. It is a pointer so that "the file
// has no enabled key" (every plugins.json written before this field existed)
// stays distinguishable from an explicit false: absent means enabled
// (IsEnabled), and only an explicit disable is persisted.
//
// Disabled is NOT uninstalled: the entry stays in plugins.json (so it survives
// a restart as disabled) and only its live connection + tools go away.
Enabled *bool `json:"enabled,omitempty"`
// CreatedAt is the RFC3339 install time. // CreatedAt is the RFC3339 install time.
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
} }
// IsEnabled reports whether the server should be connected. A missing enabled key
// (older plugins.json / an install that never mentioned it) counts as enabled.
func (p PluginConfig) IsEnabled() bool {
return p.Enabled == nil || *p.Enabled
}
// WithEnabled returns a copy of p with an explicit enabled flag.
func (p PluginConfig) WithEnabled(on bool) PluginConfig {
p.Enabled = &on
return p
}
// DefaultPluginsPath returns the plugins.json path under dataDir. // DefaultPluginsPath returns the plugins.json path under dataDir.
func DefaultPluginsPath(dataDir string) string { func DefaultPluginsPath(dataDir string) string {
return filepath.Join(dataDir, pluginsFileName) return filepath.Join(dataDir, pluginsFileName)
@@ -245,6 +266,64 @@ func maskEnv(env map[string]string) map[string]string {
return out return out
} }
// secretMask is what a redacted secret (or a fragment of one) collapses to inside
// free text. It is deliberately not maskToken: free text needs a marker that
// cannot be mistaken for a real tail of the credential.
const secretMask = "***"
// minRedactFragment is the shortest run of a secret that is still scrubbed from
// free text. Anything shorter is not evidence of that credential (a 2-3 char run
// of a hex key shows up in ordinary prose by chance), and keeping the floor low
// enough that a leaked *middle* of a key (an error printing a substring) is still
// caught is the point of scanning fragments at all.
const minRedactFragment = 6
// RedactPluginSecrets scrubs every configured credential out of text, returning a
// string that is safe to show in a status snapshot or a log line.
//
// Why this exists (and why MaskedPlugin is not enough): a connect failure's error
// text is written by the MCP client, the HTTP stack or the server itself, so it
// can carry the bearer token (a 401 echoing the Authorization header, a URL with
// `?token=...`, a stdio subprocess dumping its env). The snapshot exposes that
// text as `lastError`, therefore the text — not just the config — has to be
// redacted. Replacement is by whole value first, then by every fragment of at
// least minRedactFragment characters, so a partially printed key is caught too.
func RedactPluginSecrets(p PluginConfig, text string) string {
if text == "" {
return ""
}
out := redactSecret(text, p.APIKey)
keys := make([]string, 0, len(p.Env))
for k := range p.Env {
if isSensitiveKey(k) {
keys = append(keys, k)
}
}
// 排序只为结果稳定:Env 是 map,不排序会让同一份输入在不同运行里产出不同文本。
sort.Strings(keys)
for _, k := range keys {
out = redactSecret(out, p.Env[k])
}
return out
}
// redactSecret replaces secret and every fragment of it (longest first, down to
// minRedactFragment runes-worth of bytes) with secretMask.
func redactSecret(text, secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" || text == "" {
return text
}
out := strings.ReplaceAll(text, secret, secretMask)
// 长片段优先:先抹掉最长的,剩下的短片段才不会被抹成一片 *** 后漏掉边界。
for n := len(secret) - 1; n >= minRedactFragment; n-- {
for i := 0; i+n <= len(secret); i++ {
out = strings.ReplaceAll(out, secret[i:i+n], secretMask)
}
}
return out
}
// samePlugin reports whether cur is the entry p replaces: same name when p has // samePlugin reports whether cur is the entry p replaces: same name when p has
// one, otherwise same id. // one, otherwise same id.
func samePlugin(cur, p PluginConfig) bool { func samePlugin(cur, p PluginConfig) bool {
@@ -186,3 +186,64 @@ func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) {
t.Fatalf("masked JSON leaks a secret: %s", raw) t.Fatalf("masked JSON leaks a secret: %s", raw)
} }
} }
// TestPluginEnabled_BackwardCompatible 锁定 enabled 的向后兼容:既有 plugins.json
// (没有 enabled 键)必须被当成**启用**,而显式 false 必须原样存活(含重启)。
func TestPluginEnabled_BackwardCompatible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
legacy := `[{"name":"calc","kind":"mcp","transport":"http","endpoint":"https://x"}]`
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatalf("write legacy file: %v", err)
}
got, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 1 || !got[0].IsEnabled() {
t.Fatalf("老文件(无 enabled 键)应视为启用:%+v", got)
}
if got[0].Enabled != nil {
t.Fatalf("缺失的 enabled 必须保持 nil(= 缺席),得到 %v", *got[0].Enabled)
}
off := got[0].WithEnabled(false)
if err := SavePlugins(path, []PluginConfig{off}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("显式停用必须落盘:%s", raw)
}
back, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if back[0].IsEnabled() {
t.Fatalf("重启后停用态丢失:%+v", back[0])
}
}
// TestRedactPluginSecrets_ScrubsFragments 锁定「凭据任意片段也不许泄漏」。
func TestRedactPluginSecrets_ScrubsFragments(t *testing.T) {
t.Parallel()
const key = "sk-live-0123456789abcdef"
p := PluginConfig{APIKey: key, Env: map[string]string{"GH_TOKEN": "ghp_secret_value"}}
for _, text := range []string{
"bearer " + key,
"echo " + key[8:20],
"tail " + key[len(key)-6:],
"env GH_TOKEN=ghp_secret_value",
} {
out := RedactPluginSecrets(p, text)
if strings.Contains(out, key) || strings.Contains(out, "ghp_secret_value") {
t.Fatalf("整把凭据仍在:%q", out)
}
if strings.Contains(out, key[8:20]) || strings.Contains(out, key[len(key)-6:]) {
t.Fatalf("凭据片段仍在:%q", out)
}
}
}
@@ -81,6 +81,18 @@ func candidateIDs(cands []ingestCandidate) []string {
return out return out
} }
// measureGateBudget 是墙钟实测用例显式给闸的**总墙钟预算**。
//
// 这个用例要量的是「闸真扫真判」时写入路径多花多少墙钟,不是「闸会不会超预算」——
// 后者由 preinsert_gate_test.go 的预算/fail-open 用例守。旧形状沿用默认
// defaultReviewGateBudget(150ms):负载一高,一次候选召回(SQLite 查询 +
// context.WithTimeout)就会超时 ⇒ 闸 fail-open ⇒ GateFailed++/GateChecked 少一条,
// 用例的「每轮都真扫真判且不合并」前置断言因此炸(本轮空载也 2/3 复现)。
//
// 本机实测该场景单次闸耗时是**毫秒级**(p95 约 30ms);5s 是该量级的 ~150 倍,
// 只放开预算这条闸门,被测量的工作量一字不改,因此 p50/p95/批均值仍然可比。
const measureGateBudget = 5 * time.Second
// TestIngestGate_Add墙钟实测_闸开vs关 给出**报告里要那个数字**:闸开与闸关时 // TestIngestGate_Add墙钟实测_闸开vs关 给出**报告里要那个数字**:闸开与闸关时
// `Add` 的墙钟 p50/p95(多轮取样,最坏可复现场景:每轮都扫满候选且都不合并)。 // `Add` 的墙钟 p50/p95(多轮取样,最坏可复现场景:每轮都扫满候选且都不合并)。
// //
@@ -131,7 +143,7 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
base := tidyTestTime(t) base := tidyTestTime(t)
var reviewer *PreInsertReviewer var reviewer *PreInsertReviewer
if gateOn { if gateOn {
reviewer = newGateReviewer(t, store, ReviewConfig{}, nil) reviewer = newGateReviewer(t, store, ReviewConfig{GateBudget: measureGateBudget}, nil)
store.AttachIngestReviewer(reviewer) store.AttachIngestReviewer(reviewer)
} }
for i := 0; i < seeded; i++ { for i := 0; i < seeded; i++ {
@@ -149,8 +161,10 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
} }
total := time.Since(batchStart) total := time.Since(batchStart)
if gateOn { if gateOn {
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateChecked != seeded+rounds { // 「实测场景成立」= 每一轮都真扫真判、不合并、也**没有 fail-open**。预算本身由
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并):%+v", s) // measureGateBudget 放开,因此 GateFailed 非零只可能是候选源不可用等真异常。
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateFailed != 0 || s.GateChecked != seeded+rounds {
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并、且不 fail-open):%+v", s)
} }
} }
return lat, total return lat, total
+19 -3
View File
@@ -205,25 +205,41 @@ func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
} }
// await 阻塞到批次结算,并把合并结果作为 design/05 §2「结束后写执行总结 → 返回主会话」 // await 阻塞到批次结算,并把合并结果作为 design/05 §2「结束后写执行总结 → 返回主会话」
// 的交接广播出去。它运行在脱离的 goroutine 上;总线为 nil 或 id 未知时安静结束。 // 的交接广播出去。它运行在脱离的 goroutine 上。
//
// 🔴 **每一条出口都要留下痕迹**(M29 勘测 §7 的可观测性缺口):修复前「发布成功零
// 日志、无总线静默 return」,于是「到底发没发」只能靠负证据推断(这次缺陷最费时的
// 地方)。现在成功发布记一条带来源的 INFO,缺总线与发布失败各记一条 WARN ——
// 「压根没发 / 发了但失败 / 发了」三态都能从日志正面判定。
//
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果 // parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
// 归属到父会话的实时流,而不是发一张无归属的帧。 // 归属到父会话的实时流,而不是发一张无归属的帧;gateway 的回填订阅者也按它写
// 父会话的系统注记(M32)。
func (t *SpawnTool) await(ids []string, parentSession string) { func (t *SpawnTool) await(ids []string, parentSession string) {
res, err := t.sched.Wait(context.Background(), ids) res, err := t.sched.Wait(context.Background(), ids)
if err != nil { if err != nil {
t.log.Warn("sub-agent batch wait failed", "count", len(ids), "error", err) t.log.Warn("sub-agent batch wait failed", "count", len(ids), "error", err)
if res == nil { if res == nil {
t.log.Warn("sub-agent results not published: batch produced no result",
"count", len(ids), "session", parentSession)
return return
} }
} }
b := t.sched.Bus() b := t.sched.Bus()
if b == nil { if b == nil {
t.log.Warn("sub-agent results not published: no bus",
"count", len(ids), "session", parentSession)
return return
} }
payload := &SubagentResultEvent{SessionID: parentSession, SpawnResult: doneSpawnResult(res)} payload := &SubagentResultEvent{SessionID: parentSession, SpawnResult: doneSpawnResult(res)}
if perr := b.Publish(context.Background(), SubagentResultSubject, payload); perr != nil { if perr := b.Publish(context.Background(), SubagentResultSubject, payload); perr != nil {
t.log.Warn("publish sub-agent results", "error", perr) t.log.Warn("publish sub-agent results", "error", perr,
"count", len(ids), "session", parentSession)
return
} }
t.log.Info("sub-agent results published",
"subject", SubagentResultSubject, "session", parentSession,
"count", len(ids), "status", payload.Status, "agents", len(payload.Agents))
} }
// SpawnResult 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果, // SpawnResult 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果,
@@ -0,0 +1,187 @@
package orchestration
import (
"context"
"errors"
"log/slog"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
// 本文件锁住 M29 勘测 §7 的可观测性缺口(M32 顺手补掉的那一半):`await` 的
// **每一条出口都必须留痕**。修复前「发布成功零日志、无总线静默 return」,于是
// 「后台结果到底发没发」只能靠负证据推断 —— 那正是这次缺陷最费时间的地方。
//
// 三条被测出口,与日志一一对应:
// - 发布成功 ⇒ INFO `sub-agent results published`(带 source、父会话、条数、状态);
// - 没有总线 ⇒ WARN `... no bus`(以前是静默 return);
// - 发布失败 ⇒ WARN `publish sub-agent results`(带 err)。
// failingPublishBus 是一个 Publish 必定失败的 Bus:用于证明「发布失败不静默」。
type failingPublishBus struct {
err error
}
func (b failingPublishBus) Publish(context.Context, string, any) error { return b.err }
func (b failingPublishBus) Subscribe(context.Context, string, bus.Handler) (func(), error) {
return func() {}, nil
}
func (b failingPublishBus) Close() error { return nil }
// awaitLogSink 收集结构化日志记录(spawntool 的 logger 已带 source)。
type awaitLogSink struct {
mu sync.Mutex
recs []slog.Record
}
// awaitLogCapture 是一个会把 WithAttrs 累积进记录里的处理器:source 等标签是经
// `logger.With` 挂上的,只读 Record.Attrs 会看不到它们。
type awaitLogCapture struct {
sink *awaitLogSink
attrs []slog.Attr
}
func newAwaitLogCapture() *awaitLogCapture { return &awaitLogCapture{sink: &awaitLogSink{}} }
func (c *awaitLogCapture) Enabled(context.Context, slog.Level) bool { return true }
func (c *awaitLogCapture) Handle(_ context.Context, rec slog.Record) error {
rec.AddAttrs(c.attrs...)
c.sink.mu.Lock()
defer c.sink.mu.Unlock()
c.sink.recs = append(c.sink.recs, rec.Clone())
return nil
}
func (c *awaitLogCapture) WithAttrs(as []slog.Attr) slog.Handler {
merged := make([]slog.Attr, 0, len(c.attrs)+len(as))
merged = append(merged, c.attrs...)
merged = append(merged, as...)
return &awaitLogCapture{sink: c.sink, attrs: merged}
}
func (c *awaitLogCapture) WithGroup(string) slog.Handler { return c }
// find 返回第一条级别匹配且 msg 等值的记录。
func (c *awaitLogCapture) find(level slog.Level, want string) (slog.Record, bool) {
c.sink.mu.Lock()
defer c.sink.mu.Unlock()
for _, rec := range c.sink.recs {
if rec.Level == level && rec.Message == want {
return rec, true
}
}
return slog.Record{}, false
}
// attr 读取一条记录上的字符串属性(含经 WithAttrs 挂上的标签)。
func (c *awaitLogCapture) attr(rec slog.Record, key string) (string, bool) {
var out string
var ok bool
rec.Attrs(func(a slog.Attr) bool {
if a.Key != key {
return true
}
out, ok = a.Value.String(), true
return false
})
return out, ok
}
// settledTool 构建一个派发并结算一个子代理的工具,返回工具、该子代理 id 与日志捕获。
func settledTool(t *testing.T, opts Options) (*SpawnTool, string, *awaitLogCapture) {
t.Helper()
s := New(opts)
s.runner = &mockRunner{}
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "查一下 X", ParentSession: "web"})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
capture := newAwaitLogCapture()
return NewSpawnTool(s, WithSpawnLogger(slog.New(capture)), WithSpawnWait(time.Second)), sub.ID, capture
}
// TestAwaitLogsSuccessfulPublish 锁住「发成功要能从日志正面看出来」。
func TestAwaitLogsSuccessfulPublish(t *testing.T) {
t.Parallel()
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
sp, id, capture := settledTool(t, Options{MaxParallel: 1, Bus: b})
published := make(chan *SubagentResultEvent, 1)
unsub, err := b.Subscribe(context.Background(), SubagentResultSubject,
func(_ context.Context, e bus.Event) error {
if ev, ok := e.Payload.(*SubagentResultEvent); ok {
select {
case published <- ev:
default:
}
}
return nil
})
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer unsub()
sp.await([]string{id}, "web")
select {
case ev := <-published:
if ev.SessionID != "web" || ev.Status != "done" {
t.Fatalf("payload = %+v, want the settled batch for session web", ev)
}
case <-time.After(waitTimeout):
t.Fatal("the settled batch was never published")
}
rec, ok := capture.find(slog.LevelInfo, "sub-agent results published")
if !ok {
t.Fatal("a successful publish left no INFO: 'did it publish?' must be answerable from the log")
}
if src, _ := capture.attr(rec, "source"); src != "orchestration/spawn" {
t.Fatalf("log source = %q, want %q (AGENTS §10: no bare logs)", src, "orchestration/spawn")
}
if session, _ := capture.attr(rec, "session"); session != "web" {
t.Fatalf("log session = %q, want the parent session", session)
}
if status, _ := capture.attr(rec, "status"); status != "done" {
t.Fatalf("log status = %q, want done", status)
}
}
// TestAwaitWarnsWithoutBus 锁住「没有总线」不再是静默 return。
func TestAwaitWarnsWithoutBus(t *testing.T) {
t.Parallel()
sp, id, capture := settledTool(t, Options{MaxParallel: 1})
if sp.sched.Bus() != nil {
t.Skip("this test needs a scheduler without a bus")
}
sp.await([]string{id}, "web")
rec, ok := capture.find(slog.LevelWarn, "sub-agent results not published: no bus")
if !ok {
t.Fatal("a scheduler with no bus dropped the results silently")
}
if session, _ := capture.attr(rec, "session"); session != "web" {
t.Fatalf("log session = %q, want the parent session (traceability)", session)
}
}
// TestAwaitWarnsOnPublishFailure 锁住「发布失败必须留下 err」。
func TestAwaitWarnsOnPublishFailure(t *testing.T) {
t.Parallel()
sp, id, capture := settledTool(t, Options{MaxParallel: 1, Bus: failingPublishBus{err: errors.New("bus down")}})
sp.await([]string{id}, "web")
rec, ok := capture.find(slog.LevelWarn, "publish sub-agent results")
if !ok {
t.Fatal("a failed publish left no WARN: the result would silently never arrive")
}
if msg, _ := capture.attr(rec, "error"); msg == "" {
t.Fatal("the publish-failure WARN carries no error detail")
}
}
+67
View File
@@ -0,0 +1,67 @@
package tool
import (
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住「模型可见的工具面**零影响**」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
// §2 方案①的影响表):来源章只是管理面的呈现归属,绝不进发给模型的函数签名。
//
// 判据是**泄漏**:工具面上既不能出现 origin 这个键,也不能出现 server 名 ——
// 一旦有人把归属顺手塞进 ToolSpec("让模型知道工具从哪来"),这条就红。
// originMockTool 是带来源章的工具(包一层既有 mockTool,只为提供 Origin)。
type originMockTool struct {
mockTool
origin sdk.Origin
}
func (m originMockTool) Origin() sdk.Origin { return m.origin }
var _ sdk.OriginAware = originMockTool{}
// TestToolSchemasDoNotLeakOrigin:注册一把来自 "fs-origin-probe" 的 MCP 工具,
// 发给模型的 schema 里既没有来源键、也没有 server 名。
func TestToolSchemasDoNotLeakOrigin(t *testing.T) {
d, reg := newDispatcher(t)
probe := originMockTool{
mockTool: mockTool{
name: "read_file",
desc: "读文件",
params: map[string]any{"type": "object"},
},
origin: sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs-origin-probe", Transport: "stdio"},
}
if err := reg.RegisterTool(probe); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
specs := d.ToolSchemas()
if len(specs) != 1 || specs[0].Name != "read_file" {
t.Fatalf("ToolSchemas = %+v, want 仅 read_file", specs)
}
raw, err := json.Marshal(specs)
if err != nil {
t.Fatalf("marshal schemas: %v", err)
}
wire := string(raw)
if strings.Contains(wire, "origin") {
t.Fatalf("模型可见的工具面带上了来源键:%s(来源只用于管理面呈现)", wire)
}
if strings.Contains(wire, "fs-origin-probe") {
t.Fatalf("模型可见的工具面泄漏了 server 名:%s", wire)
}
// 来源章仍在管理面上(注册表条目),两者不是同一份事实。
cap, gerr := reg.Get("read_file")
if gerr != nil {
t.Fatalf("Get: %v", gerr)
}
if cap.Origin.Server != "fs-origin-probe" {
t.Fatalf("管理面上的来源章丢了:%+v", cap.Origin)
}
}
@@ -0,0 +1,143 @@
package turnreply
import (
"math"
"strings"
)
// 本文件承载助手文本里**字面转义**的渲染:模型偶尔把换行写成**两个字符**
// `\n`(反斜杠 + n),而不是一个真换行。
//
// 缺陷形状(主人 2026-10-24 报障,真机原文):她列了七个中古调式,用字面 `\n`
// 当分隔,全挤在**同一个 `<p>`** 里 ——
//
// <p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:…\n弗里吉亚:…</p>
//
// 清洗链不认这两个字符(全仓没有任何一处把字面 `\n` 当换行),于是原样发给 QQ,
// 用户看到的是「F\n多利亚」,Web 气泡里也一样(`whitespace-pre-wrap` 只对**真**
// 换行生效)。⇒ 这一层是**写入侧规整**:把字面 `\n` 换成真换行,落库文本、WS
// `message_persisted`、REST reply、IM 出站气泡就是**同一份**(与
// NormalizeParagraphs 同一条口径,见 paragraph.go 的调用点)。
//
// 判据与边界:
// - 只认**恰好一个**反斜杠紧跟 `n`。`\\n`(双反斜杠 + n,模型在讲转义规则或
// 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠
// 加字母 n」,把它当换行等于篡改内容。
// - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论
// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。围栏的闭合串
// 按 CommonMark 取「不短于开启串」(```` 开的围栏不能被内层 ``` 关闭),行内
// 代码取「等长」,因此嵌套围栏示例(```go 套在 ````markdown 里)同样受保护。
// - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层
// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。
// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `<br>` / `<p>` ——
// `<br>` 在 chat 口径里是**气泡/消息分隔符**(把一条列表升级成七条消息反而更吵),
// 字面 `\n` 只应是**气泡内部的换行**。
// - 幂等:渲染后的文本里不再有可被本函数改写的字面 `\n`(代码里的那些本来就不动),
// 因此二次调用逐字相同。
const (
// literalBreakEscape 是模型实际写出的「字面换行」:反斜杠 + n 两个字符。
literalBreakEscape = `\n`
// backtickCh 是代码围栏与行内代码的定界符。
backtickCh = '`'
// backslashCh 是转义引导符。
backslashCh = '\\'
// fenceRunLen 是围栏代码块的定界符长度(paragraph.go 的 codeFenceMarker 是
// 三个反引号;它在 var 块里,len() 不是常量,故这里写死同一个 3)。
fenceRunLen = 3
// unboundedRun 表示「反引号串长度不设上限」(围栏的闭合串可以更长,
// 如 ````> 也能闭合 ``` 开的围栏)。
unboundedRun = math.MaxInt
)
// RenderLiteralLineBreaks 返回把字面 `\n` 渲染成真换行后的文本:代码围栏与行内
// 代码内原样保留,`\\n` 与其余转义不动,不含字面 `\n` 的文本逐字返回(零分配路径)。
func RenderLiteralLineBreaks(text string) string {
if !strings.Contains(text, literalBreakEscape) {
return text
}
var b strings.Builder
b.Grow(len(text) + 8)
for i := 0; i < len(text); {
switch text[i] {
case backtickCh:
// 围栏/行内代码:整段逐字复制,内部的 `\n` 一个字不动。
end := codeProtectedEnd(text, i)
b.WriteString(text[i:end])
i = end
case backslashCh:
switch {
case i+1 >= len(text):
// 行尾孤立的 `\`:原文保留。
b.WriteByte(text[i])
i++
case text[i+1] == 'n':
b.WriteByte('\n')
i += 2
case text[i+1] == backslashCh:
// `\\` 成对吞掉 ⇒ 紧随其后的 `n` 是普通字母,`\\n` 因此原样保留。
b.WriteString(text[i : i+2])
i += 2
default:
// `\t` / `\r` / `\u0041` 等:本层不处理,原样保留。
b.WriteByte(text[i])
i++
}
default:
b.WriteByte(text[i])
i++
}
}
return b.String()
}
// codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开):
// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于**不短于开启串**的反引号串
// (CommonMark 口径:```` 开的围栏不能被内层的 ``` 关闭 —— 否则嵌套围栏示例里的
// `\n` 会被当成正文渲染,而这段文本紧接着就落库)。没有闭合围栏时**保护到文本
// 末尾** —— 宁可少渲染一处换行,也不把可能仍是代码的正文改坏。
// - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串
// (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符,
// 区间就是它们自身(不吞后面的正文)。
func codeProtectedEnd(text string, start int) int {
n := backtickRunLen(text, start)
body := start + n
if n >= fenceRunLen {
if close := findBacktickRun(text, body, n, unboundedRun); close >= 0 {
return close + backtickRunLen(text, close)
}
return len(text)
}
if close := findBacktickRun(text, body, n, n); close >= 0 {
return close + n
}
return body
}
// findBacktickRun 在 text[from:] 里找**下一个**长度落在
// [atLeast, atMost] 区间内的连续反引号串的起始下标,找不到返回 -1。
// 围栏用 atLeast=fenceRunLen / atMost=unboundedRun(更长的围栏也能闭合),
// 行内代码用 atLeast==atMost==定界符长度(等长才算闭合)。
func findBacktickRun(text string, from, atLeast, atMost int) int {
for i := from; i < len(text); {
if text[i] != backtickCh {
i++
continue
}
r := backtickRunLen(text, i)
if r >= atLeast && r <= atMost {
return i
}
i += r
}
return -1
}
// backtickRunLen 返回 text[at:] 起连续反引号的个数(at 越界时为 0)。
func backtickRunLen(text string, at int) int {
n := 0
for at+n < len(text) && text[at+n] == backtickCh {
n++
}
return n
}
@@ -0,0 +1,233 @@
package turnreply
import (
"strings"
"testing"
)
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」(literal_breaks.go)的判据。
//
// 重点三条:
// - **代码内容不许被改**:围栏(```)与行内代码(`…`)里的 `\n` 逐字不动 ——
// 模型讨论代码时写 `print("a\nb")`,全局替换会把代码改坏;
// - **`\\n` 不动**:它表示「一个反斜杠 + 字母 n」,当换行等于篡改内容;
// - **幂等**:它跑在每一条助手回复的写入路径上,二次调用必须逐字相同。
// literalEscapeSample 是模型实际写出的那两个字符:反斜杠 + n(不是换行)。
const literalEscapeSample = "\\n"
// realLiteralBreakReply 是真机原文(主人 2026-10-24 报障那条乐理回复,6 处字面
// `\n` 全挤在同一个 <p> 里)。它与 channelbridge 侧
// sanitize_literal_breaks_test.go 的同名字面量**逐字相同** —— 那正是「网关/落库
// 与 IM 出站吃同一份文本」的断言方式(同一份样本,两侧各自断言)。
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭</p>
<p>那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。</p>
<p>不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪</p>`
// TestRenderLiteralLineBreaks_RealMachineMessage 是必测①的**修前红/修后绿**判据:
// 真机原文的 6 处字面 `\n` 全部渲染成真换行,段落结构(5 个 <p>)一字不变,且
// 绝不引入 `<br>`(它不是气泡分隔符,只是气泡内的换行)。
func TestRenderLiteralLineBreaks_RealMachineMessage(t *testing.T) {
t.Parallel()
got := RenderLiteralLineBreaks(realLiteralBreakReply)
if strings.Contains(got, literalEscapeSample) {
t.Fatalf("仍剩字面 `\\n` —— QQ 上还会看到「F\\n多利亚」:\n%q", got)
}
if wantGain := strings.Count(realLiteralBreakReply, literalEscapeSample); strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n") != wantGain {
t.Fatalf("新增真换行 = %d, want %d", strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n"), wantGain)
}
if n := strings.Count(got, "<p>"); n != 5 {
t.Fatalf("<p> 块 = %d, want 5(段落口径不得被改动)", n)
}
if strings.Contains(got, "<br") {
t.Fatalf("渲染出了 <br> —— 字面 `\\n` 只应是气泡内换行,不是消息分隔符:\n%q", got)
}
// 逐字校验第三段:七个调式各占一行。
const scaleHead = "<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:"
if !strings.Contains(got, scaleHead) {
t.Fatalf("调式段没有被换行渲染:\n%q", got)
}
}
// TestRenderLiteralLineBreaks_CodeFenceProtected 是必测②:围栏代码块里的 `\n`
// 逐字保留(她解释 `print("a\nb")` 时那两下是**代码内容**),围栏前后的正文照常渲染。
func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) {
t.Parallel()
in := "先说明一句" + literalEscapeSample + "这里要换行。\n" +
"```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```\n" +
"围栏之后这句" + literalEscapeSample + "也换行。"
got := RenderLiteralLineBreaks(in)
if !strings.Contains(got, `fmt.Print("a\nb")`) {
t.Fatalf("围栏内的 `\\n` 被改坏了(代码内容必须逐字保留):\n%q", got)
}
if got := strings.Count(RenderLiteralLineBreaks(in), literalEscapeSample); got != 1 {
t.Fatalf("剩余字面 `\\n` = %d, want 1(只该剩围栏里那一处)", got)
}
for _, want := range []string{"先说明一句\n这里要换行。", "围栏之后这句\n也换行。"} {
if !strings.Contains(got, want) {
t.Fatalf("围栏外正文没有被渲染,want 含 %q:\n%q", want, got)
}
}
}
// TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun 钉住 CommonMark 的围栏闭合
// 口径:**闭合串不得短于开启串**。四个反引号开启的围栏里嵌着三反引号的示例时,内层
// ``` 只是**代码内容**,不能把外层围栏闭合 —— 否则示例里的 `print("a\nb")` 会被渲染成
// 真换行,而这段文本紧接着就落库(改坏的代码会永久进历史)。
func TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun(t *testing.T) {
t.Parallel()
const nested = "````markdown\n```go\nfmt.Println(\"a" + literalEscapeSample + "b\")\n```\n````"
in := "教你怎么写围栏代码块:\n\n" + nested + "\n\n围栏之后这句" + literalEscapeSample + "也换行。"
got := RenderLiteralLineBreaks(in)
if !strings.Contains(got, nested) {
t.Fatalf("嵌套围栏没有被逐字保留(内层 ``` 提前闭合了外层 ````):\n got %q\nwant 含 %q", got, nested)
}
if strings.Contains(got, "fmt.Println(\"a\nb\")") {
t.Fatalf("嵌套围栏里的字面 `\\n` 被渲染成真换行 —— 代码内容被改坏:\n%q", got)
}
if !strings.HasSuffix(got, "围栏之后这句\n也换行。") {
t.Fatalf("围栏之外的正文没被渲染(保护不得溢出):\n%q", got)
}
}
// TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码
// (单个反引号与两个反引号两种定界符)里的 `\n` 也不动。
func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) {
t.Parallel()
in := "行内代码 `a" + literalEscapeSample + "b` 里那两下不动;真的换行这样写:" + literalEscapeSample + "下一句。"
got := RenderLiteralLineBreaks(in)
if !strings.Contains(got, "行内代码 `a"+literalEscapeSample+"b` 里那两下不动") {
t.Fatalf("行内代码里的 `\\n` 被改坏了:\n%q", got)
}
if !strings.HasSuffix(got, "真的换行这样写:\n下一句。") {
t.Fatalf("行内代码之外的 `\\n` 没被渲染:\n%q", got)
}
// 双反引号定界符同样受保护。
double := "``a" + literalEscapeSample + "b`` 不动,外面" + literalEscapeSample + "要换行"
if got := RenderLiteralLineBreaks(double); !strings.Contains(got, "``a"+literalEscapeSample+"b``") ||
!strings.HasSuffix(got, "外面\n要换行") {
t.Fatalf("双反引号行内代码处理错:\n%q", got)
}
// 落单反引号(没有等长闭合串)只是普通字符,不吞掉后面的正文。
stray := "一个孤零零的反引号 `,然后" + literalEscapeSample + "这里照常换行"
if got := RenderLiteralLineBreaks(stray); !strings.HasSuffix(got, "然后\n这里照常换行") {
t.Fatalf("落单反引号把后面的正文一并保护了(应为普通字符):\n%q", got)
}
}
// TestRenderLiteralLineBreaks_DoubleBackslashUntouched 是必测③:`\\n`(双反斜杠 +
// n,模型讲转义规则或写路径时会写)**原样保留** —— 它的语义是一个反斜杠加字母 n。
// 顺带钉住「`\\` 之后紧跟的字面 `\n` 仍要渲染」这条配对口径。
func TestRenderLiteralLineBreaks_DoubleBackslashUntouched(t *testing.T) {
t.Parallel()
const doubleBackslash = "\\\\n" // \\n:两个反斜杠 + n
unchanged := "字面反斜杠加 n 要写成 " + doubleBackslash + " 这样"
if got := RenderLiteralLineBreaks(unchanged); got != unchanged {
t.Fatalf("`\\\\n` 被改动了:\n got %q\nwant %q", got, unchanged)
}
paired := "先一个转义的反斜杠 \\\\ 然后" + literalEscapeSample + "这里换行"
if got := RenderLiteralLineBreaks(paired); !strings.HasSuffix(got, "然后\n这里换行") {
t.Fatalf("`\\\\` 之后的字面 `\\n` 没有被渲染:\n%q", got)
}
}
// TestRenderLiteralLineBreaks_OtherEscapesUntouched 记录本层的**范围边界**:只认
// `\n` 这一个形状,`\t` / `\r` / 行尾孤立的 `\` 一律不动(其余字面转义见切片报告,
// 本片不扩大语义)。
func TestRenderLiteralLineBreaks_OtherEscapesUntouched(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, in string }{
{"tab", "缩进" + "\\t" + "不处理"},
{"carriage", "回车" + "\\r" + "不处理"},
{"trailing-backslash", "行尾孤立的反斜杠\\"},
{"unicode", "转义" + "\\u00e9" + "不处理"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := RenderLiteralLineBreaks(tc.in); got != tc.in {
t.Fatalf("%s:被本层改动了:\n got %q\nwant %q", tc.name, got, tc.in)
}
})
}
}
// TestRenderLiteralLineBreaks_UnclosedFenceProtected 是保守兜底:围栏没闭合时,
// 后面的文本**一律原样保留**(宁可少渲染一处换行,也不把可能仍是代码的正文改坏)。
func TestRenderLiteralLineBreaks_UnclosedFenceProtected(t *testing.T) {
t.Parallel()
in := "```go\n代码里的" + literalEscapeSample + "不动"
if got := RenderLiteralLineBreaks(in); got != in {
t.Fatalf("未闭合围栏之后的正文被改了:\n got %q\nwant %q", got, in)
}
}
// TestRenderLiteralLineBreaks_IdempotentAndFastPath 是守门断言:没有字面 `\n` 的
// 文本逐字返回;有的话二次调用逐字相同(它跑在每条回复的写入路径上,破掉幂等就会
// 每读改写一次叠一层)。
func TestRenderLiteralLineBreaks_IdempotentAndFastPath(t *testing.T) {
t.Parallel()
for _, plain := range []string{"", "普通一句话,没有字面转义。", "<p>已经规整好的段落。</p>"} {
if got := RenderLiteralLineBreaks(plain); got != plain {
t.Fatalf("无字面 `\\n` 的文本被改动:\n got %q\nwant %q", got, plain)
}
}
samples := []string{
realLiteralBreakReply,
"甲" + literalEscapeSample + "乙",
"围栏外" + literalEscapeSample + "换行\n```\n代码" + literalEscapeSample + "\n```",
}
for _, s := range samples {
once := RenderLiteralLineBreaks(s)
if twice := RenderLiteralLineBreaks(once); twice != once {
t.Fatalf("不幂等:\n once %q\ntwice %q", once, twice)
}
}
}
// TestNormalizeParagraphs_CarriesLiteralBreaks 是必测①的**落点**断言:字面 `\n`
// 的渲染挂在 NormalizeParagraphs 上 —— 网关(Web)与渠道桥(IM)都调这一个函数,
// 因此落库、WS/REST 与 IM 出站拿到的是同一份真换行文本。
//
// 含围栏时段落规整整体 skip,但**围栏外的**字面 `\n` 仍必须被渲染(渲染刻意放在
// 围栏 skip 之前),围栏内的不动。
func TestNormalizeParagraphs_CarriesLiteralBreaks(t *testing.T) {
t.Parallel()
got := NormalizeParagraphs(realLiteralBreakReply)
if strings.Contains(got, literalEscapeSample) {
t.Fatalf("NormalizeParagraphs 没有渲染字面 `\\n`:\n%q", got)
}
if n := strings.Count(got, "<p>"); n != 5 {
t.Fatalf("<p> 块 = %d, want 5", n)
}
mixed := "<p>甲" + literalEscapeSample + "乙</p>\n\n" +
"```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```"
got = NormalizeParagraphs(mixed)
if !strings.Contains(got, "<p>甲\n乙</p>") {
t.Fatalf("含围栏的消息里,围栏外那段没有被渲染:\n%q", got)
}
if !strings.Contains(got, `fmt.Print("a\nb")`) {
t.Fatalf("围栏内的 `\\n` 被改坏了:\n%q", got)
}
}
+7 -1
View File
@@ -56,8 +56,14 @@ var (
// paragraph_repair.go)。 // paragraph_repair.go)。
// //
// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) == // 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。 // NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本按段落口径原样返回。
//
// 开头先跑 `RenderLiteralLineBreaks`(literal_breaks.go):模型把换行写成字面 `\n`
// 时,落库/WS/REST/IM 出站要拿到**同一份**真换行文本。这一步刻意放在**围栏 skip
// 之前** —— 否则含代码围栏的消息里,围栏外那些字面 `\n` 就一律不渲染了;围栏与
// 行内代码内部由那个函数自己保护,段落口径的 skip 不受影响。
func NormalizeParagraphs(text string) string { func NormalizeParagraphs(text string) string {
text = RenderLiteralLineBreaks(text)
if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) { if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) {
return text return text
} }
@@ -18,10 +18,19 @@ import (
// 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。 // 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。
func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) { func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
t.Parallel() t.Parallel()
// worker 卡住 ⇒ 排进去的那一轮不会把槽取走,`pending` 因此可确定地断言。 // 让自排轮卡在 Think 里:worker 因此**忙**着,后面排进去的那一轮不会被取走,
// `pending` 才能确定地断言。
//
// 🔴 卡住 Think 只能让 Running 恒真,**不足以**占住待跑槽:worker 是先 take(把槽取空)
// 再进 Think 的。旧写法只卡 Think 就断言「排一轮后 Pending=true」,实际是在赌
// 「WakeNow 返回得比 worker 取槽快」—— -race 下 worker 常常先跑赢,Pending 已经是
// false(本轮实测 3/3 必红)。因此这里改成:第一轮先让 worker 忙起来,第二轮才断言
// 待跑槽被占用 —— 断言口径(快照如实报告待跑槽)一字未改,只是把它放到可确定的相位上。
w := newWakeThinker() w := newWakeThinker()
w.holdSelfWake = make(chan struct{}) w.holdSelfWake = make(chan struct{})
b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w) b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w)
// 放行卡住的那一轮,好让 Stop 的 t.Cleanup 能排空 worker。
defer close(w.holdSelfWake)
// 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。 // 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。
before := mustStatus(t, b) before := mustStatus(t, b)
@@ -54,21 +63,38 @@ func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
if !receipt.Accepted { if !receipt.Accepted {
t.Fatalf("收据 = %+v, want Accepted", receipt) t.Fatalf("收据 = %+v, want Accepted", receipt)
} }
// 等这一轮真的被 worker 取走并卡进 Think(= 槽已空、worker 已忙):这是下一段的**前置件**,
// 等不到说明 worker 没被调度(那是另一个失败,由此处明确报出)。
w.waitFor(t, 2, 5*time.Second)
after := mustStatus(t, b) after := mustStatus(t, b)
if after.Used != receipt.Used || after.Used != 1 { if after.Used != receipt.Used || after.Used != 1 {
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 1(同一口径)", after.Used, receipt.Used) t.Fatalf("快照 Used = %d, 收据 Used = %d, want 1(同一口径)", after.Used, receipt.Used)
} }
if !after.Pending {
t.Fatalf("排入后待跑槽应报告占用:%+v", after) // worker 正忙 ⇒ 这一轮留在待跑槽里:快照必须如实报告 Pending。
pending, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "占住待跑槽"})
if err != nil {
t.Fatalf("WakeNow 占槽: %v", err)
}
if !pending.Accepted {
t.Fatalf("收据 = %+v, want Accepted(worker 正忙,槽是空的)", pending)
}
occupied := mustStatus(t, b)
if !occupied.Pending {
t.Fatalf("排入后待跑槽应报告占用:%+v", occupied)
}
if occupied.Used != pending.Used || occupied.Used != 2 {
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 2(同一口径,槽满=额度用尽)",
occupied.Used, pending.Used)
} }
// 第二次撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。 // 撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
if _, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "再排一轮"}); err != nil { if _, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "再排一轮"}); err != nil {
t.Fatalf("WakeNow 合并: %v", err) t.Fatalf("WakeNow 合并: %v", err)
} }
merged := mustStatus(t, b) merged := mustStatus(t, b)
if merged.Used != 1 { if merged.Used != 2 {
t.Fatalf("合并后 Used = %d, want 1(合并不额外占额度)", merged.Used) t.Fatalf("合并后 Used = %d, want 2(合并不额外占额度,停在 Limit)", merged.Used)
} }
if merged.Limit != 2 { if merged.Limit != 2 {
t.Fatalf("合并后 Limit = %d, want 2", merged.Limit) t.Fatalf("合并后 Limit = %d, want 2", merged.Limit)
+25 -5
View File
@@ -480,23 +480,43 @@ func TestWakeNow_ZeroLimitFallsBackToDefault(t *testing.T) {
} }
// TestSelfWakeGate_WindowExpiry 覆盖「窗口过期后额度回来」。session.RateLimiter 内部 // TestSelfWakeGate_WindowExpiry 覆盖「窗口过期后额度回来」。session.RateLimiter 内部
// 用 time.Now()(design/12 §N C.5 已登记的取舍:不为自排再自造第二套滑窗),因此这里 // 用 time.Now()(design/12 §N C.5 已登记的取舍:不为自排再自造第二套滑窗),因此窗口
// 用极小窗口 + 真实短等,只取下界判断(等过窗口必放行),不依赖上界精度。 // **无法注入假时钟** —— 这里只能把余量放大到与真实机器负载相称,断言口径不变:
// 窗口内第二次必被拒、等过窗口必放行且 Used 回到 1。
//
// 为什么是 6s(旧值 120ms):
// - 「窗口内第二次」这条断言要求「第一次受理 → worker 真的进入两轮(waitFor)→ 第二次
// 调用」整段落在窗口内;waitFor 那一段是纯调度等待,73 个测试包并行抢核时会被拉长,
// 120ms 一被拉长就必红(本轮其他证人实测红;本机 12 核上 30~60 次带载复跑未复现,
// 所以这里按**余量**加固而不是拿复现驱动)。窗口取 6s ⇒ 给 waitFor 的 4s 上界留下
// 2s 余量:哪怕 waitFor 用满上界,也仍在窗口内,失败点因此只会是「worker 没被调度」
// 而不是一条含义被负载改写的窗口断言。
// - 到期等待不再是盲睡 3×窗口(放大后那是 18s 白等):以**第一次受理时刻**为基准,
// 等到「窗口 + 250ms 结算余量」,既保留「等过窗口必放行」的下界语义,又不浪费墙钟。
func TestSelfWakeGate_WindowExpiry(t *testing.T) { func TestSelfWakeGate_WindowExpiry(t *testing.T) {
t.Parallel() t.Parallel()
const window = 120 * time.Millisecond const (
window = 6 * time.Second
// scheduleWait 是「worker 已被调度」这个前置件的上界,必须小于 window。
scheduleWait = 4 * time.Second
// expirySettle 是窗口到期后的结算余量(RateLimiter 用 t.After(cutoff) 严格比较)。
expirySettle = 250 * time.Millisecond
)
w := newWakeThinker() w := newWakeThinker()
b, _ := startSelfWake(t, Config{SelfWakeLimit: 1, SelfWakeWindow: window}, w) b, _ := startSelfWake(t, Config{SelfWakeLimit: 1, SelfWakeWindow: window}, w)
firstAt := time.Now()
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "第一次"}); err != nil || !got.Accepted { if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "第一次"}); err != nil || !got.Accepted {
t.Fatalf("第一次收据 = %+v err = %v, want 排上", got, err) t.Fatalf("第一次收据 = %+v err = %v, want 排上", got, err)
} }
w.waitFor(t, 2, 3*time.Second) w.waitFor(t, 2, scheduleWait)
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口内第二次"}); err != nil || got.Accepted { if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口内第二次"}); err != nil || got.Accepted {
t.Fatalf("窗口内第二次收据 = %+v err = %v, want 被上限拒绝", got, err) t.Fatalf("窗口内第二次收据 = %+v err = %v, want 被上限拒绝", got, err)
} }
time.Sleep(3 * window) if wait := window + expirySettle - time.Since(firstAt); wait > 0 {
time.Sleep(wait)
}
got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口过了"}) got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口过了"})
if err != nil { if err != nil {
t.Fatalf("窗口过后 WakeNow: %v", err) t.Fatalf("窗口过后 WakeNow: %v", err)
@@ -23,6 +23,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
@@ -139,6 +140,22 @@ type Options struct {
// Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now, // Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now,
// 使依赖时间的行为在测试中可注入。 // 使依赖时间的行为在测试中可注入。
Now func() time.Time Now func() time.Time
// LaneDepth 是**每会话**回合车道的排队深度(M22 切片 LANEQUEUE,见 lane.go):
// 群聊/陌生人车道满时**在门前拒收这一条**(WARN + 计数 + 审计,队列内容不动),
// 主人私聊永不拒收(溢到 spill 只排队)。零或负数取
// defaultLaneDepth(那里写了取值依据)。
LaneDepth int
// LaneStaleAfter 是**陈旧度闸**的阈值:轮到某条消息时它已等了超过这个时长,
// 就丢弃不补答(WARN + 计数 + 审计,绝不静默)。零或负数取
// defaultLaneStaleAfter(那里写了取值依据)。主人私聊与非聊天事件不受该闸约束。
LaneStaleAfter time.Duration
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数 +
// 可注入的定时器工厂。零值 = 走代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24 条),
// 也就是「装配方忘了接线也有合理行为」,而不是把特性静默关掉。
//
// 🔴 它**不能**改变车道类型:主人私聊永远是 direct 车道(无窗口、无定时器),
// 由 actor 元数据决定,配置无权翻转(见 lane.go 的 newDirectLane/newBatchLane)。
Coalesce CoalesceOptions
// OwnerLaneFor 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)的**读取**接缝: // OwnerLaneFor 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)的**读取**接缝:
// 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖 // 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖
// (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。 // (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。
@@ -325,6 +342,17 @@ type Bridge struct {
wg sync.WaitGroup wg sync.WaitGroup
sem chan struct{} sem chan struct{}
// lanes 是**每会话回合车道**的注册表(M22 切片 LANEQUEUE,见 lane.go):
// 读循环只投递不跑回合;同一会话内串行 FIFO、不同会话并行;超龄与背压丢弃都留痕。
// 只在 enqueueInbound 里与 mu 同时出现,锁顺序恒为 mu → lanes.mu。
lanes *laneSet
// coalesceFor 是静默窗口参数(M23)的读取接缝(面板配置文件 → 生效值);
// coalesceLog 是窗口引擎的子功能日志器(source=services/channelbridge/coalesce);
// coalesceStats 是窗口/发车/续跑的计数面(CoalesceStats() 的只读出口)。
// 三者的类型与语义见 coalesce.go;它们只被 batch 车道的头部用到。
coalesceFor func(laneKey string) *runtimeconfig.CoalescePolicy
coalesceLog *slog.Logger
coalesceStats *coalesceCounters
} }
// New 依据 opts 构建 Bridge。当必需依赖缺失时返回错误, // New 依据 opts 构建 Bridge。当必需依赖缺失时返回错误,
@@ -400,5 +428,12 @@ func New(opts Options) (*Bridge, error) {
voiceAudioStore: opts.VoiceAudioStore, voiceAudioStore: opts.VoiceAudioStore,
cancel: func() {}, cancel: func() {},
sem: make(chan struct{}, maxConcurrent), sem: make(chan struct{}, maxConcurrent),
// 每会话车道:深度与陈旧度阈值可经 Options 覆盖(零值取 lane.go 里的默认值)。
// 定时器工厂来自 Options.Coalesce.After(M23 测试缝);direct 车道拿不到它。
lanes: newLaneSet(opts.LaneDepth, opts.LaneStaleAfter, opts.Coalesce.After, log),
// 静默窗口合并(M23):参数读取接缝 + 独立日志来源 + 计数面。
coalesceFor: opts.Coalesce.For,
coalesceLog: logger.WithSource(log, coalesceLogSource),
coalesceStats: &coalesceCounters{},
}, nil }, nil
} }
@@ -59,6 +59,13 @@ func (f *fakeAdapter) Capabilities() adapter.Capabilities
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil } func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
// stubChatter 记录传给 agent 循环的会话 id 与消息。 // stubChatter 记录传给 agent 循环的会话 id 与消息。
//
// 不加锁是**有意的**:用到它的 21 个用例全部走同步便捷路径
// (`b.process → processFrom → runBatch → Loop.Run`,见 inbound.go 的 processFrom 注释),
// 写与读发生在同一个 goroutine 上,没有跨 goroutine 的 happens-before 缺口
// (M33 实测:`-race` 数十次跑、含把锁删掉的变异,DATA RACE 恒为 0)。
// 🔴 若将来有用例把它放上车道(laneWorker)再读这些字段,**必须**加锁(sync.Mutex)
// 并补一条 -race 用例 —— 届时的竞态是真的,不能只靠运气。
type stubChatter struct { type stubChatter struct {
sessionID string sessionID string
messages []model.Message messages []model.Message
@@ -75,6 +82,9 @@ func (s *stubChatter) Run(ctx context.Context, sessionID string, msgs []model.Me
} }
// stubSessions 记录入站消息并返回固定会话。 // stubSessions 记录入站消息并返回固定会话。
//
// 与 stubChatter 同理:只在同步路径上被调用,因此不加锁;若将来从车道 worker 触碰,
// 必须补锁 + 补一条 -race 用例。
type stubSessions struct { type stubSessions struct {
msg adapter.UnifiedMessage msg adapter.UnifiedMessage
owner *user.OwnerProfile owner *user.OwnerProfile
+301
View File
@@ -0,0 +1,301 @@
package channelbridge
// 本文件承载**静默窗口合并**(M23)的窗口引擎:生效参数的解析、等待(滑动窗口 +
// 绝对上限)、发车原因与计数。它回答主人 2026-10-23/24 的原话「要让群聊聊天很自然,
// 很流畅的做法」——群里「一鼓作气打三句」不该让她回三次,也不该让她等很久才开口。
//
// 落点(M23 蓝图 §2 候选 C):**车道工作者头部**,也就是 `enqueueInbound` 之后、
// 回合之前。理由:车道本身就是那条会话唯一的缓冲,窗口只是「何时把队列里的东西发车」;
// 不新造第二套缓冲,也不会把「本来就会被流控丢掉的消息」拉进窗口。
//
// 三条结构保证(不是靠自觉):
//
// ① **窗口内零丢失**:消息只存在于车道队列(lane.ch / lane.spill / lane.front),
// 入队后除了车道的单消费者 takeAll 没有任何移除路径;本文件与 coalesce_*.go
// 的合并阶段没有「跳过某条」的分支(超量的尾部回插队首,见 requeueFront)。
// ② **一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复**,但批内 N 条仍是
// **各自独立的 user 行**(design/15 §2.1 明文禁止把多条拼成一条)。
// ③ **主人私聊没有窗口**:那条车道是 direct 车道(`lane.batcher == nil`),
// 连定时器工厂都拿不到(见 lane.go 的 newDirectLane / newBatchLane),
// 类型由 actor 元数据决定,**配置无权翻转**。
//
// 时间基准:窗口用**到达时刻**(Bridge.now,可注入),**不**用 msg.Timestamp ——
// qq/telegram/kook 是平台秒级时间、weixin 是本机时钟,精度与漂移都不可靠。
// 陈旧度闸仍按 M22 口径用到达时刻判,两者互不掺杂。
import (
"context"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
const (
// coalesceLogSource 是窗口引擎的子功能日志来源(AGENTS §10:日志必须标来源)。
// 车道自身(背压/陈旧度)的来源归 M22(services/channelbridge/lane)。
coalesceLogSource = "services/channelbridge/coalesce"
// 发车原因(`flush_reason` 的闭集,进日志与计数):
// quiet —— 静默窗口到点(「她把话说完了」);
// max_wait —— 绝对上限到点(「说得没完没了也得先回一句」);
// stop —— 桥停止,未发车的批如实登记丢弃;
// all_stale —— 批内逐条都超龄(M22 口径:太旧不补答),整批不发车;
// off —— 该车道/该会话没有窗口(direct 车道,或开关关闭),到达即发车。
coalesceFlushQuiet = "quiet"
coalesceFlushMaxWait = "max_wait"
coalesceFlushStop = "stop"
coalesceFlushAllStale = "all_stale"
coalesceFlushOff = "off"
)
// batchWindow 是**只有 batch 车道**才持有的窗口状态:它持有定时器工厂。
//
// 这是 M23 最硬的一条结构约束(红线级):direct 车道(主人私聊)的类型里**没有**
// 这个字段,也没有任何能拿到它的路径 ⇒「主人私聊不会建窗口」不是 `if enabled`
// 判出来的结果,而是**类型上不可达**。配置因此无权把它翻成 batch。
type batchWindow struct {
// after 是定时器工厂(默认 time.After)。测试注入手动触发的实现,使
// 「2.0s 静默」与「6s 绝对上限」都能被确定性地推进,且能数出调用次数 ——
// TestOwnerPrivateNeverCreatesWindow / TestOwnerPrivateIgnoresEnabledConfig
// 断言的正是「这个工厂一次都没被调用」。
after func(time.Duration) <-chan time.Time
}
// coalesceWindow 是一条车道**本次发车**实际使用的窗口参数(已归一为 Duration)。
type coalesceWindow struct {
window time.Duration
maxWait time.Duration
maxBatch int
}
// CoalesceOptions 是静默窗口合并的注入面(M23)。
//
// 时钟走 Options.Now(与陈旧度闸同一条注入时钟:一片里有两个时间源必然漂移)。
type CoalesceOptions struct {
// For 按**车道键**返回该会话生效的窗口参数;nil 表示未接线(走代码默认)。
// 每次发车前重读它 ⇒ 面板保存后热生效,不需要重启后端。
//
// 这里刻意用 runtimeconfig 的策略**类型**(而不是桥内再定义一份):默认值与 clamp
// 只有一处实现(`runtimeconfig.DefaultCoalescePolicy` / `ClampCoalescePolicy`),
// 面板读到的规则与运行期真正生效的区间因此不可能分叉。桥只依赖这个**值类型**,
// 不持有 store、不做 I/O(store 由组合根注入)。
For func(laneKey string) *runtimeconfig.CoalescePolicy
// After 是定时器工厂(测试缝)。nil ⇒ time.After。
After func(time.Duration) <-chan time.Time
}
// coalesceCounters 是窗口引擎的进程内计数(原子,读侧无需锁)。
type coalesceCounters struct {
windowsCreated atomic.Int64
batchesTotal atomic.Int64
batchMessagesTotal atomic.Int64
flushQuiet atomic.Int64
flushMaxWait atomic.Int64
batchesSplit atomic.Int64
continuations atomic.Int64
coalescedDropped atomic.Int64
abandonedAtStop atomic.Int64
batchesAllStale atomic.Int64
}
// CoalesceStats 是窗口引擎的计数快照(M23 蓝图 §8)。它给进程内调用方(测试、
// 将来的运维探针)读同一组数字的窄口,**不新开 HTTP 端点**(那是契约变更,归塔)。
//
// CoalescedDroppedTotal **必须恒为 0**:合并阶段没有任何丢弃点 —— 它存在的意义
// 就是让「有人不小心加了一条 continue」有一个能被断言的出口。
type CoalesceStats struct {
// WindowsCreated 是「真正等过窗口」的批数(direct 车道与开关关闭时恒不增加)。
WindowsCreated int64
// BatchesTotal 是发过车的批数(含单条批)。
BatchesTotal int64
// BatchMessagesTotal 是这些批里的入站条数合计。
BatchMessagesTotal int64
// FlushQuietTotal / FlushMaxWaitTotal 按发车原因分开计数。
FlushQuietTotal int64
FlushMaxWaitTotal int64
// BatchesSplitTotal 是因单批上限被拆开、尾部回插队首的次数。
BatchesSplitTotal int64
// ContinuationsTotal 是「批内首条在该会话上一轮结束之前就已到达」的批数(⑤)。
ContinuationsTotal int64
// CoalescedDroppedTotal 必须恒为 0。
CoalescedDroppedTotal int64
// AbandonedAtStopTotal 是桥停止时未发车、随进程一起作废的入站条数(诚实登记)。
AbandonedAtStopTotal int64
// BatchesAllStaleTotal 是整批都超龄、因此一条都没跑的批数。
BatchesAllStaleTotal int64
}
// CoalesceStats 返回窗口引擎计数的快照。
func (b *Bridge) CoalesceStats() CoalesceStats {
c := b.coalesceStats
if c == nil {
return CoalesceStats{}
}
return CoalesceStats{
WindowsCreated: c.windowsCreated.Load(),
BatchesTotal: c.batchesTotal.Load(),
BatchMessagesTotal: c.batchMessagesTotal.Load(),
FlushQuietTotal: c.flushQuiet.Load(),
FlushMaxWaitTotal: c.flushMaxWait.Load(),
BatchesSplitTotal: c.batchesSplit.Load(),
ContinuationsTotal: c.continuations.Load(),
CoalescedDroppedTotal: c.coalescedDropped.Load(),
AbandonedAtStopTotal: c.abandonedAtStop.Load(),
BatchesAllStaleTotal: c.batchesAllStale.Load(),
}
}
// coalesceWindowFor 解析某条车道**此刻**生效的窗口:代码默认 → 注入的 store
// (面板配置文件)→ 归一/夹紧 → 按会话类型决定「这条车道要不要窗口」。
//
// 返回 ok=false 的三种情形都表示「到达即发车」(行为回到 M22):
// - 开关关闭(`enabled:false`,也是「回退到步 1 行为」的唯一旋钮);
// - 陌生人私聊且 `applyToStrangers:false`;
// - 配置算出窗口 0(归一后不会出现,防御性兜底)。
//
// 🔴 它**不**决定车道类型:主人私聊根本不进这里(direct 车道没有 batcher)。
func (b *Bridge) coalesceWindowFor(laneKey string, strangerPrivate bool) (coalesceWindow, bool) {
p := runtimeconfig.DefaultCoalescePolicy()
if b.coalesceFor != nil {
if got := b.coalesceFor(laneKey); got != nil {
p = *got
}
}
p, _ = runtimeconfig.ClampCoalescePolicy(p)
if !p.Enabled {
return coalesceWindow{}, false
}
if strangerPrivate && !p.ApplyToStrangers {
return coalesceWindow{}, false
}
windowMs := p.WindowMs
if strangerPrivate && p.StrangerWindowMs > 0 {
windowMs = p.StrangerWindowMs
}
w := coalesceWindow{
window: time.Duration(windowMs) * time.Millisecond,
maxWait: time.Duration(p.MaxWaitMs) * time.Millisecond,
maxBatch: p.MaxBatchMessages,
}
if w.window <= 0 || w.maxWait < w.window || w.maxBatch <= 0 {
return coalesceWindow{}, false
}
return w, true
}
// laneWindowAdmits 是**窗口准入的唯一判据**(M23 任务书:「窗口只收 chat/image/voice/file;
// **不收**系统通知 / action 帧 / 我们自己出站回执」)。
//
// 它只读消息自身的**结构化事实** —— ContentType 与「是不是平台帧」(isSystemNoticeMessage
// 同样只按 ContentType 判定),**绝不解析正文**、绝不猜语义。拒绝的两类理由:
//
// - **平台帧 / 系统通知 / 我们自己的 `message_sent` 回执**(ContentType=action):延迟
// 2s 只为合并它们毫无收益,更糟的是**一条回执就能把窗口一直续住**(它们和群聊共用
// 同一条车道键);
// - 其余非聊天类型:本来就没有「等她把话说完」的语义。
//
// 不进窗口的条目**不是被丢弃**:它们走「到达即发车」的 solo 路径(见 coalesce_lane.go
// 的 runBatchLaneItem / coalesceWait 的 solos)。
func laneWindowAdmits(msg adapter.UnifiedMessage) bool {
if isSystemNoticeMessage(msg) {
return false
}
switch msg.ContentType {
case adapter.ContentText, adapter.ContentImage, adapter.ContentVoice, adapter.ContentFile:
return true
default:
return false
}
}
// splitWindowItems 把一片条目按**窗口准入**分成两半:进窗口的(保持原序)与不进窗口的
// (同样保持原序)。两半都会被处理 —— 这是分类,不是剔除。
func splitWindowItems(items []laneItem) (admitted, others []laneItem) {
for _, item := range items {
if laneWindowAdmits(item.msg) {
admitted = append(admitted, item)
continue
}
others = append(others, item)
}
return admitted, others
}
// coalesceWait 在车道头部等一段静默期,把等待期间到达的**可进窗口**的消息并进这一批,
// 并返回(这一批、不进窗口的条目、发车原因)。
//
// 语义(M23 蓝图 §4):
// - **滑动**:新消息把计时重置到 w.window(「等她把话说完」);
// - **绝对上限**:w.maxWait 锚定在批内**第一条**的到达时刻 —— 一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回);
// - **准入**:只有 `laneWindowAdmits` 为真的条目参与攒批与重置计时;通知 / action 帧 /
// 出站回执只被**收进 solos**(原序),既不改计时也不进批 —— 否则「一条我们自己的
// 回执把窗口续住」就是生产上的真实现场。
//
// 它只做「等」与「收」:不判内容、不丢弃、不改顺序(收进来的顺序就是到达顺序:
// 车道 FIFO ⇒ takeAll 的切片本身有序)。
func (b *Bridge) coalesceWait(ctx context.Context, l *lane, held []laneItem, w coalesceWindow) (items, solos []laneItem, reason string) {
items = held
anchor := items[0].arrivedAt
lastArrival := items[len(items)-1].arrivedAt
b.coalesceStats.windowsCreated.Add(1)
for {
now := b.now()
// 绝对上限先判:到点必发车(哪怕静默窗口因为不停有新消息而一直没归零)。
maxLeft := w.maxWait - now.Sub(anchor)
if maxLeft <= 0 {
return items, solos, coalesceFlushMaxWait
}
quietLeft := w.window - now.Sub(lastArrival)
if quietLeft <= 0 {
return items, solos, coalesceFlushQuiet
}
wait := quietLeft
if maxLeft < wait {
wait = maxLeft
}
select {
case <-l.batcher.after(wait):
seen, others := splitWindowItems(b.takeAll(l))
solos = append(solos, others...)
if len(seen) == 0 {
// 没有新的**聊天**消息:到点即发车,原因由「哪条上限先到」决定
// (不进窗口的帧不推迟这个判断)。
if w.maxWait-b.now().Sub(anchor) <= w.window-b.now().Sub(lastArrival) {
return items, solos, coalesceFlushMaxWait
}
return items, solos, coalesceFlushQuiet
}
items = append(items, seen...)
lastArrival = items[len(items)-1].arrivedAt
case item := <-l.ch:
if !laneWindowAdmits(item.msg) {
// 不进窗口:只登记到 solos,**绝不重置计时**。
solos = append(solos, item)
continue
}
items = append(items, item)
seen, others := splitWindowItems(b.takeAll(l))
items = append(items, seen...)
solos = append(solos, others...)
lastArrival = items[len(items)-1].arrivedAt
case <-ctx.Done():
return items, solos, coalesceFlushStop
}
}
}
// noteFlushReason 把发车原因记进对应的计数(未知原因不计数:宁可少一个数,
// 也不要把它归到一个「看起来像」的桶里)。
func (b *Bridge) noteFlushReason(reason string) {
switch reason {
case coalesceFlushQuiet:
b.coalesceStats.flushQuiet.Add(1)
case coalesceFlushMaxWait:
b.coalesceStats.flushMaxWait.Add(1)
case coalesceFlushAllStale:
b.coalesceStats.batchesAllStale.Add(1)
}
}
@@ -0,0 +1,257 @@
package channelbridge
// coalesce_admission_test.go 钉住 M23 的**窗口准入条件**(复审 r1 的 P2-1):
//
// 窗口只收 chat/image/voice/file;**不收**系统通知 / action 帧 / 我们自己出站回执。
//
// 为什么这条要用例钉死(而不是靠注释):准入判断必须发生在**窗口等待阶段**(车道头),
// 而不是等窗口到了、prepareInbound 跑完才发现「这条本来不该进批」。否则一条我们自己的
// `message_sent` 回执(它与群聊共用同一条车道键)就能把这一批的静默计时**一直续住**:
// 群友说了三句话,她却因为「撤回事件」「入群事件」「我们自己的回执」而迟迟不开口。
//
// 四条断言(每条都能被「准入失效」这一变异打红):
// - 窗口中途的 action 帧**不重置计时**(剩余等待仍是 500ms,发车时刻不变);
// - 系统通知**不进这一批**(批内 user 行只有聊天消息);
// - 单条出站回执**不建窗口**(定时器一次都没被请求);
// - 对照组:普通聊天消息**仍然**重置计时(否则上面三条可能只是「窗口根本不工作」)。
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// coalesceActionFrame 是一条**平台帧**(action):撤回 / 入群 / typing / 未知 post_type。
// 它的生产形状与本群聊天共用车道的键(ChannelType=group + GroupID),ContentType=action。
func coalesceActionFrame(platform, groupID, postType string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: platform,
ChannelID: groupID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: "9",
Sender: adapter.Actor{Role: adapter.RoleMember},
ContentType: adapter.ContentAction,
RawData: []byte(`{"post_type":"` + postType + `"}`),
}
}
// coalesceOutboundReceipt 是**我们自己那条消息的回执**(OneBot 的 message_sent):
// 它是一条 ContentType=action、没有正文的群帧 —— 也正是「能把窗口续住」的那条。
func coalesceOutboundReceipt(platform, groupID string) adapter.UnifiedMessage {
msg := coalesceActionFrame(platform, groupID, "message_sent")
msg.SenderID = "100" // 她自己
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
return msg
}
// latestDuration 取请求时长序列里**最新**的一个。
//
// 用例关心的是「刚发的那条消息之后 worker 续的臂」,而不是历史第几条:臂的次数取决于
// worker 什么时候把消息收进本批(调度决定),按下标取会引入时序假设。
func latestDuration(d []time.Duration) time.Duration {
if len(d) == 0 {
return 0
}
return d[len(d)-1]
}
// waitTimerCalls 等到定时器工厂至少被请求 n 次,返回到目前为止的**请求时长序列**。
func waitTimerCalls(t *testing.T, f *coalesceBridge, n int) []time.Duration {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if d := f.timers.durations(); len(d) >= n {
return d
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timer factory called %d times, want at least %d", f.timers.callCount(), n)
return nil
}
// fireLatest 把注入时钟推进到**最近一次**请求的截止时刻并触发它。
//
// 为什么是最近一次:窗口被重置/续臂时,旧的定时器通道留在那里(再没人监听),
// 真正决定发车时刻的是**最近一次**请求的那个。
//
// 为什么按**截止时刻**推进而不是「再推一个请求时长」:worker 可能已经在这段时间里
// 重新续过臂(例如一条系统通知落在窗口中途),把时长再累加一次会让时钟越过绝对上限,
// 循环头于是走「到点直接发车、不 takeAll」的分支;按截止时刻推进只表示「时间走到了
// 它约定的那个点」。
//
// 为什么要重试:登记与触发之间隔着 worker 的一次循环 —— 万一它刚续了新臂,被触发的
// 那个通道就没人接(时钟已经推了,却没人醒)。这里在 done() 未成立、仍有待触发定时器时
// 继续触发,直到 worker 真的发车;断言口径由调用方守。
func (t *coalesceTimers) fireLatest(tb *testing.T, clock *laneClock, done func() bool) {
tb.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
t.mu.Lock()
if len(t.pending) == 0 {
t.mu.Unlock()
if done != nil && done() {
return
}
time.Sleep(time.Millisecond)
continue
}
timer := t.pending[len(t.pending)-1]
t.pending = t.pending[:len(t.pending)-1]
t.mu.Unlock()
clock.advanceTo(timer.at)
timer.ch <- timer.at
if done != nil && done() {
return
}
time.Sleep(time.Millisecond)
}
tb.Fatalf("fireLatest: 3s 内没有把窗口推到发车(calls=%d)", t.callCount())
}
// TestCoalesceActionFrameDoesNotResetWindow 断言**准入在窗口等待阶段就生效**:
// 一条平台帧(action)落在窗口中途,既不进批、也不重置静默计时 ——
// 剩余等待仍是 500ms,发车时刻仍是「m1 到达 + 2.0s」。
//
// 变异:让 laneWindowAdmits 恒真(= 修前行为)⇒ 第二次请求的时长变成 2000ms,本用例红。
func TestCoalesceActionFrameDoesNotResetWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
// 投递用 sendSettled:它等到 worker 把这条收进本批、并**为它**续好臂才返回。
// 这样「哪一次续臂属于哪条消息」是确定的 —— 只按「至少 N 次请求 + 取最新」仍有
// 缝:worker 可能在帧之后才补上 m1 的续臂,最新那一次就是 2s(假红)。
if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")); d != 2000*time.Millisecond {
// 第一条聊天消息 ⇒ 一个完整窗口(2.0s)。
t.Fatalf("first window = %s, want 2s", d)
}
// 窗口走到 1.5s 时来了一条平台帧。
f.clock.advance(1500 * time.Millisecond)
if d := f.sendSettledAndDuration(t, coalesceActionFrame("telegram", "-20", "notice.group_recall")); d != 500*time.Millisecond {
// 🔴 关键断言(状态,不是结果):这条帧续出的窗口**剩余**仍是 500ms。
// 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。
t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d)
}
f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 })
f.waitRuns(t, 1)
f.waitIdle(t)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (the frame must not add a turn)", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 1 || got[0] != "m1" {
t.Fatalf("batch user messages = %v, want [m1] (the frame must not join the batch)", got)
}
stats := f.b.CoalesceStats()
if stats.WindowsCreated != 1 || stats.BatchesTotal != 1 {
t.Fatalf("stats = %+v, want one window and one batch", stats)
}
}
// TestCoalesceSystemNoticeDoesNotJoinBatch 断言系统通知**不进这一批**:
// 两条聊天消息 + 中途一条通知 ⇒ 一轮、批内只有那两条聊天消息。
func TestCoalesceSystemNoticeDoesNotJoinBatch(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
// 每条都等「worker 收进本批 + 为它续好臂」再继续(见 sendSettledAndDuration):
// 只按「至少 N 次请求 + 取最新」有缝 —— worker 可能在通知之后才补上 m1/m2 的
// 续臂,最新那一次就是 2s,用例假红。
f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.clock.advance(1500 * time.Millisecond)
// 与 action 帧同理:通知也不许重置计时(剩余仍是 500ms)。
// 变异(准入失效)⇒ 这里拿到 2s,本用例红。
if d := f.sendSettledAndDuration(t, coalesceActionFrame("telegram", "-20", "notice.group_increase")); d != 500*time.Millisecond {
t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d)
}
f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 })
f.waitRuns(t, 1)
f.waitIdle(t)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (the notice must not drive its own turn here)", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
t.Fatalf("batch user messages = %v, want [m1 m2] only", got)
}
if stats := f.b.CoalesceStats(); stats.BatchesTotal != 1 {
t.Fatalf("batches = %d, want 1 (the notice is not a batch)", stats.BatchesTotal)
}
}
// TestCoalesceOutboundReceiptCreatesNoWindow 断言**我们自己的出站回执**不建窗口:
// 单条回执到达 ⇒ 定时器工厂一次都没被请求、没有窗口、没有批、也没有回合。
//
// 变异:让 laneWindowAdmits 恒真 ⇒ 回执会建一个窗口(定时器被请求、windows_created=1),
// 本用例红 —— 这条正是「一条回执就能把窗口续住」的最小复现。
func TestCoalesceOutboundReceiptCreatesNoWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceOutboundReceipt("telegram", "-20"))
// 先看「有没有窗口」再等空闲:修前会建一个永不触发的窗口(calls=1),
// 这里因此留下的是「窗口被建出来了」这条最直接的证据,而不是一句「车道没退休」。
// 轮询到车道退休(或 300ms)是必要的:投递返回时 worker 可能还没起势,
// 一次性读 callCount() 会让这条断言在修前也偶尔通过。
deadline := time.Now().Add(300 * time.Millisecond)
for time.Now().Before(deadline) {
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for an outbound receipt, want 0", calls)
}
if f.b.LaneStats().ActiveLanes == 0 {
break
}
time.Sleep(time.Millisecond)
}
stats := f.b.CoalesceStats()
if stats.WindowsCreated != 0 || stats.BatchesTotal != 0 {
t.Fatalf("stats = %+v, want no window and no batch for a lone receipt", stats)
}
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d for a lone receipt, want 0", n)
}
f.waitIdle(t)
}
// TestCoalesceChatStillResetsWindow 是对照组:普通聊天消息**仍然**重置计时
// (否则上面三条可能只是「窗口本来就不工作」这种伪结论)。
func TestCoalesceChatStillResetsWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")); d != 2000*time.Millisecond {
t.Fatalf("first window = %s, want 2s", d)
}
f.clock.advance(1500 * time.Millisecond)
// 同 action-frame 用例:读的是**这条消息**续出的臂(sendSettledAndDuration 保证归属)。
if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")); d != 2000*time.Millisecond {
t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d)
}
f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 })
f.waitRuns(t, 1)
if got := userTextsIn(chat.runAt(0).messages); len(got) != 2 {
t.Fatalf("batch user messages = %v, want both chat messages", got)
}
}
@@ -0,0 +1,162 @@
package channelbridge
// coalesce_batch.go 承载「**一条批 = 一次回合**」的落地:一个 turnID、一次 loop.Run、
// 一条助手回复,但批内 N 条仍是**各自独立的 user 行**(design/15 §2.1 明文禁止把多条
// 合并成一条:open-webui 曾把多条拼成一条,被当 bug 修过)。
//
// 形状(对 M22 的 runInboundTurn 逐字保持,只是把「一条」换成「一对多」):
//
// [来源/边界系统行(首条)] …转录… [user₁, user₂, …, userₙ] ← 模型可见
// chat_messages: user×N(同一 turnID、到达顺序)+ assistant×1(同 turnID)
//
// 三条口径:
// 1. **转录播种在落库之前**(与 M22 同序):因此本批自己的行绝不会同时出现在转录与
// 当次消息里;批内每条都只出现一次(不重)。
// 2. **回复路由取批内最后一条**(`sendReply` 只认 Platform/ChannelID/GroupID/SenderID):
// 同一车道的消息属于同一个对话,用最新那条的路由字段回话是唯一不会回错群的口径。
// 3. **附属内容各自挂各自的消息**:每条消息的附件/图片/语音只挂它自己那条 user 行,
// 绝不合并成一条附件清单(用户内容只进用户角色消息,项目红线)。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// batchMessage 是一条入站消息在本批里**已装配好、尚未落库**的产物。
type batchMessage struct {
prepared *preparedInbound
senderName string
attachments inboundContent
images inboundContent
voiceRef voiceref.Ref
document string
image string
msgs []model.Message
}
// runBatch 跑一批:**一个 turnID、一次 Run、一条助手回复**。批内 N 条消息各自落一条
// user 行(共享 turnID、顺序 = 到达顺序),助手回复与它们同 turnID。
//
// 它同时是「一条消息」的路径(batch 只有一个成员时逐字等于 M22 的 runInboundTurn):
// 主人私聊、以及 `process()` 的同步便捷路径都走这里。
func (b *Bridge) runBatch(ctx context.Context, batch []preparedInbound) {
if len(batch) == 0 {
return
}
last := &batch[len(batch)-1]
// 陌生人私聊的「同一发送者同时最多 1 个在跑回合」槽:一批 = 一个回合 = 一个槽。
if release := b.acquireStrangerTurn(last.item.msg, last.sess, last.item.a); release != nil {
defer release()
}
target := inboundTargetFor(last.sess, last.item.msg.Platform)
turnID := newTurnID()
runEnv := turnEnv(batch[0].sess, target,
b.resolvePersona(ctx, batch[0].sess, target, adapterName(batch[0].item.a)))
parts, msgs := b.buildBatchContext(ctx, batch, runEnv)
// 转录在落库**之前**读(M22 的既有保证):本批自己的行因此不会在上下文里出现两次。
msgs = b.seedTurnTranscript(ctx, target, msgs)
rows := b.persistBatchUsers(ctx, parts, target, turnID)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, msgs)
if err != nil {
b.log.Warn("agent run failed", "platform", last.item.msg.Platform,
"session", target.sessionID, "messages", len(batch), "error", err)
}
// 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按
// 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式)。
for _, row := range rows {
b.persistTurnDocumentStatus(row, res)
}
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外)。
b.deliverReply(ctx, last.item.msg, last.item.a, last.sess, target, res, err, turnID)
}
// runBatchSegments 按 `solo` 把一批切成若干段:solo(系统通知升级成的真会话)**单独
// 一轮**,其余相邻消息合并成一轮。切分保持到达顺序,绝不为了多合并而把通知并进聊天。
//
// 罕见路径:solo 只在「传入前审查判为高价值事件并升级」时出现,通常一批里没有。
func (b *Bridge) runBatchSegments(ctx context.Context, kept []preparedInbound) {
start := 0
for i := range kept {
if !kept[i].solo {
continue
}
if i > start {
b.runBatch(ctx, kept[start:i])
}
b.runBatch(ctx, kept[i:i+1])
start = i + 1
}
if start < len(kept) {
b.runBatch(ctx, kept[start:])
}
}
// buildBatchContext 装配本批的模型可见消息:逐条摄取附件/语音/图片、构建用户消息、
// 只保留首条的系统框架行,并把每条消息的附属内容挂到**它自己**那条 user 行上。
func (b *Bridge) buildBatchContext(ctx context.Context, batch []preparedInbound, runEnv loop.SessionEnv) ([]batchMessage, []model.Message) {
parts := make([]batchMessage, 0, len(batch))
var msgs []model.Message
for i := range batch {
p := &batch[i]
msg := p.item.msg
// 入站附件摄取留在**闸门之后**(走到这里意味着这条已被放行):被黑白名单或
// 最小打扰拦下的消息绝不在盘上留下文件。
part := batchMessage{
prepared: p,
senderName: thirdPartySenderName(msg, p.item.actor),
attachments: b.ingestInboundDocuments(ctx, msg),
images: b.ingestInboundImages(msg),
voiceRef: b.ingestInboundVoiceCopy(ctx, msg, p.voiceIn),
}
part.msgs = b.buildLoopMessages(ctx, p.text, runEnv, p.item.actor, part.senderName, msg)
if i > 0 {
// 系统框架(来源行 + 第三方身份边界)每轮只出现一次:后续消息只贡献自己的
// user 行,否则上下文里会重复 N 遍同一段边界。
part.msgs = dropLeadingSystem(part.msgs)
}
attachInboundContent(part.msgs, part.attachments, part.images)
part.document = b.inboundDocumentColumn(msg, part.attachments)
part.image = inboundImageColumn(part.images)
parts = append(parts, part)
msgs = append(msgs, part.msgs...)
}
return parts, msgs
}
// dropLeadingSystem 去掉消息列表最前面的系统行(批内第 2..N 条用)。
func dropLeadingSystem(msgs []model.Message) []model.Message {
i := 0
for i < len(msgs) && msgs[i].Role == model.RoleSystem {
i++
}
return msgs[i:]
}
// persistBatchUsers 按**到达顺序**逐条落 user 行(共享 turnID),并把语音事实与用户
// 消息同源落库(design/24 §10.2 第 5 步)。返回刚落库的行,供回合结束后补抽取结论。
func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, target inboundTarget, turnID string) []history.Message {
rows := make([]history.Message, 0, len(parts))
for i := range parts {
p := &parts[i]
msg := p.prepared.item.msg
a := p.prepared.item.a
var row history.Message
if target.private {
row = b.persistToMain(ctx, history.RoleUser, p.prepared.text, "", p.document, p.image,
target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
row = b.persistIsolated(ctx, history.RoleUser, p.prepared.text, p.document, p.image,
deviceIDFor(msg), p.prepared.sess, msg.Platform, p.senderName, adapterName(a), turnID)
}
b.persistMessageVoice(row, p.voiceRef)
rows = append(rows, row)
}
return rows
}
@@ -0,0 +1,266 @@
package channelbridge
// coalesce_continuation_test.go 是 M23 的**回合中续跑**(⑤)与两个边界(陈旧度叠加、
// 停机诚实)的断言集,对应蓝图 §12 的 #11/#12/#13/#14/#17。
//
// ⑤ 的实现要点(蓝图 §5):**不存在 "turn in flight" 标志** —— 车道的单消费者性质
// 就是那个容器。回合在跑时 worker 不在循环头,新消息落进同一个队列;回合结束回到
// 循环头,takeAll 把这段期间攒下的整片取出 ⇒ **一轮续跑**(不是 N 轮、也不是 0 条)。
import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
"time"
)
// TestCoalesceMultiGroupIsolation(#11)断言作用域:两个群各 3 条交错到达 ⇒ 两批、
// 各自 3 条、互不串窗;同一平台的**两个账号**对同一个群则共用一条车道(与会话键同源,
// 是一个会话而不是两个 —— 见 lane.go 的 laneKey 取舍说明)。
func TestCoalesceMultiGroupIsolation(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-1", "9", "张三", fmt.Sprintf("a%d", i)))
f.send(coalesceGroupMsg("telegram", "-2", "9", "张三", fmt.Sprintf("b%d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (one per group)", got)
}
seen := map[string]int{}
for i := 0; i < chat.runCount(); i++ {
texts := userTextsIn(chat.runAt(i).messages)
if len(texts) != 3 {
t.Fatalf("batch %d has %d messages, want 3 (%v)", i, len(texts), texts)
}
prefix := texts[0][:1]
for _, text := range texts {
if text[:1] != prefix {
t.Fatalf("batch %d mixes groups: %v", i, texts)
}
seen[prefix]++
}
}
if seen["a"] != 3 || seen["b"] != 3 {
t.Fatalf("messages per group = %v, want a:3 b:3", seen)
}
// 多账号:同一个群的两个账号(同 platform / 同 channel)是**同一个会话** ⇒ 一条车道、
// 一批;回复走最后一条消息自己的适配器(车道本身不持有账号级状态)。
other := &laneAdapter{name: "telegram:conf1"}
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "9", "张三", "c0"), f.ad)
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "10", "李四", "c1"), other)
before := chat.runCount()
f.pump(t, func() bool { return chat.runCount() > before })
f.waitIdle(t)
if got := chat.runCount(); got != before+1 {
t.Fatalf("turns started = %d, want %d (two accounts of one group = one lane, one batch)", got, before+1)
}
if got := userTextsIn(chat.runAt(before).messages); len(got) != 2 {
t.Fatalf("multi-account batch = %v, want both messages in one turn", got)
}
}
// TestContinuationAfterTurnEnds(#12)断言⑤:回合进行中到达 3 条 ⇒ 回合结束后**一轮**
// 续跑吃掉 3 条(不是 3 轮、不是 0 条)。
func TestContinuationAfterTurnEnds(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate
}
}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.advanceAndFireUntil(t) // 第一批到点发车
chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住)
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
}
// 模拟「第一轮花了 100ms」:因此这 3 条的到达时刻**早于**上一轮结束时刻,
// 它们才是「回合中到达」的续跑(真实时钟下这一步自然成立)。
f.clock.advance(100 * time.Millisecond)
close(gate)
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (the first one + exactly one continuation)", got)
}
if got := userTextsIn(chat.runAt(1).messages); strings.Join(got, ",") != "c0,c1,c2" {
t.Fatalf("continuation batch = %v, want [c0 c1 c2]", got)
}
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 {
t.Fatalf("continuations_total = %d, want 1", stats.ContinuationsTotal)
}
}
// TestContinuationNoDuplicateAndNoLoss(#13)断言⑤的「不重不丢」:每条消息的落库行恰好
// 1 条、每个 turn_id 恰好 1 条助手回复、`continuations_total==1`。
func TestContinuationNoDuplicateAndNoLoss(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate
}
}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.advanceAndFireUntil(t) // 第一批到点发车
chat.waitRuns(t, 1, 3*time.Second)
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
}
f.clock.advance(100 * time.Millisecond)
close(gate)
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
userRows := map[string]int{}
assistantPerTurn := map[string]int{}
for _, row := range hist.snapshot("sess--20") {
switch row.Role {
case "user":
userRows[row.Content]++
case "assistant":
assistantPerTurn[row.TurnID]++
}
}
for _, text := range []string{"first", "c0", "c1", "c2"} {
if userRows[text] != 1 {
t.Fatalf("user row %q persisted %d times, want exactly 1", text, userRows[text])
}
}
if len(userRows) != 4 {
t.Fatalf("distinct user rows = %d, want 4: %v", len(userRows), userRows)
}
for turnID, n := range assistantPerTurn {
if n != 1 {
t.Fatalf("turn %s has %d assistant rows, want exactly 1", turnID, n)
}
}
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 || stats.CoalescedDroppedTotal != 0 {
t.Fatalf("stats = %+v, want one continuation and zero coalesced drops", stats)
}
}
// TestCoalesceWithStaleGate(#14)断言与陈旧度闸的叠加:批内 2 条超龄 + 1 条新鲜 ⇒
// 只跑新鲜的 1 条,超龄的逐条留痕;整批都超龄 ⇒ 不发车 + all_stale 计数。
//
// 顺序要求(蓝图 §5):**先**陈旧度**后**组上下文,且绝不让「批里最新一条新鲜」把超龄
// 的旧条也塞进上下文(M22「太旧不补答」优先)。
func TestCoalesceWithStaleGate(t *testing.T) {
t.Parallel()
rec := &laneRecorder{}
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
LaneDepth: 16,
OnRejected: rec.record,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old2"))
// 两条排着的时候时间越过陈旧度阈值,然后来了第三条新鲜的。
f.clock.advance(defaultLaneStaleAfter + time.Second)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "fresh"))
// worker 可能已经把前面两条按「绝对上限已过」自行发车了(全超龄 ⇒ 不发车),
// 也可能把三条并成一批 —— 两种时序下都要把这一批推到发车,因此按窗口触发(而不是
// 假定它「自己会跑」,那是调度时序,不是被测语义)。
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want 1 (only the fresh message may run)", got)
}
if got := userTextsIn(chat.runAt(0).messages); strings.Join(got, ",") != "fresh" {
t.Fatalf("batch user messages = %v, want [fresh] only", got)
}
if stats := f.b.LaneStats(); stats.DroppedStale != 2 {
t.Fatalf("dropped_stale = %d, want 2 (each stale message traced individually)", stats.DroppedStale)
}
if reasons := rec.reasons(); len(reasons) != 2 {
t.Fatalf("audit reasons = %v, want two lane_stale entries", reasons)
}
if stats := f.b.CoalesceStats(); stats.CoalescedDroppedTotal != 0 {
t.Fatalf("coalesced_dropped_total = %d, want 0 (stale drops are M22's gate, not coalescing)", stats.CoalescedDroppedTotal)
}
// 整批都超龄:一条都不发车,但留一条可查的 all_stale 痕迹。
allStale := newCoalesceBridge(t, Options{
Loop: &laneChatter{},
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
allStale.send(coalesceGroupMsg("telegram", "-30", "9", "张三", "gone"))
// 时钟越过陈旧度阈值:循环头看见「绝对上限已过」⇒ 直接按 max_wait 发车,
// 逐条判完发现整批都超龄 ⇒ 一条都不跑(这一步根本不建定时器)。
allStale.clock.advance(defaultLaneStaleAfter + time.Second)
allStale.waitIdle(t)
if got := allStale.chat.runCount(); got != 0 {
t.Fatalf("turns started = %d for an all-stale batch, want 0", got)
}
if stats := allStale.b.CoalesceStats(); stats.BatchesAllStaleTotal != 1 {
t.Fatalf("batches_all_stale = %d, want 1 (stats=%+v)", stats.BatchesAllStaleTotal, stats)
}
}
// TestCoalesceStopsHonestly(#17)断言停机诚实:桥在窗口内停止时,那一批**不会静默
// 消失** —— 有带 source 的 WARN(count=N)、`abandoned_at_stop_total` 增加。
func TestCoalesceStopsHonestly(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
// 走真实的生命周期:Start 建 b.ctx,车道 worker 与投递都用它,Stop 才取消得到它。
f.b.Start()
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.timers.waitRegistered(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册)
f.b.Stop(context.Background())
if got := chat.runCount(); got != 0 {
t.Fatalf("turns started = %d after stop, want 0", got)
}
if stats := f.b.CoalesceStats(); stats.AbandonedAtStopTotal != 2 {
t.Fatalf("abandoned_at_stop_total = %d, want 2", stats.AbandonedAtStopTotal)
}
out := logs.String()
if !strings.Contains(out, "coalesce batch abandoned at bridge stop") ||
!strings.Contains(out, "source=services/channelbridge/coalesce") ||
!strings.Contains(out, "count=2") {
t.Fatalf("stop left no honest trace in the log:\n%s", out)
}
}
@@ -0,0 +1,512 @@
package channelbridge
// coalesce_fixture_test.go 是 M23(静默窗口合并 + 回合中续跑)全部用例共用的夹具:
//
// - **注入时钟**(laneClock):窗口与陈旧度共用同一个时间源(Options.Now),
// 因此「2.0s 静默」「6s 绝对上限」「30s 超龄」都能被确定性推进;
// - **注入定时器工厂**(coalesceTimers):它既让窗口等待可以被手动触发,
// **又是「主人私聊从未建窗口」的证据面** —— calls 必须为 0;
// - **群消息夹具**:非主人、带提及(⇒ 必然进回合)、同一群共用一个隔离会话。
//
// 夹具全部落在 *_test.go 里,不进生产二进制。
import (
"context"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// coalesceOffFor 是「把静默窗口关掉」的注入面:lane_test.go 的 M22 语义用例用它,
// 使那些用例仍以毫秒级钉住车道本身(顺序/串行/背压/陈旧度)。
func coalesceOffFor(string) *runtimeconfig.CoalescePolicy {
p := runtimeconfig.DefaultCoalescePolicy()
p.Enabled = false
return &p
}
// coalesceOn 返回一组「群聊与陌生人私聊都开窗口」的生效参数。
func coalesceOn(windowMs, maxWaitMs, maxBatch int) runtimeconfig.CoalescePolicy {
p := runtimeconfig.DefaultCoalescePolicy()
p.Enabled = true
p.WindowMs = windowMs
p.MaxWaitMs = maxWaitMs
p.MaxBatchMessages = maxBatch
p.ApplyToStrangers = true
p.StrangerWindowMs = windowMs
return p
}
// coalesceFor 把一组固定参数包成注入面(同一实例可被多条车道共用)。
func coalesceFor(p runtimeconfig.CoalescePolicy) func(string) *runtimeconfig.CoalescePolicy {
return func(string) *runtimeconfig.CoalescePolicy {
c := p
return &c
}
}
// newCoalesceClock 返回一个**本地正午**的注入时钟。
//
// 起点刻意避开免打扰时段(23-07):本文件测的是窗口合并,而凌晨的注入时钟会让
// 非主人群消息先在「免打扰」那一档被静默掉 —— 那样每条用例都变成了空断言
// (测试要观察的只是「什么时候发车」,不是「说不说」)。
func newCoalesceClock() *laneClock {
c := &laneClock{}
c.t = time.Date(2026, 5, 1, 12, 0, 0, 0, time.Local)
return c
}
// coalesceTimers 是注入的定时器工厂:记录每次请求的时长,交回一个由测试手动触发的
// 通道(带缓冲,测试的发送永远不会阻塞)。
//
// 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它
// (TestOwnerPrivateNeverCreatesWindow 断言 calls==0)。
//
// **触发语义按「截止时刻」而不是「先进先出队列」**:每次请求记下「注册时的注入时钟 + 时长」,
// 测试推钟时**只把时钟推进到当前最早的截止时刻**,然后触发到期的那一个。
// 为什么不能按队列逐个推(旧写法):`coalesceWait` 每个循环迭代都会重新 `after(wait)`,
// 于是每条新到的消息都会留下一个**再没人监听**的旧定时器;旧写法把它们也逐个触发,
// 每次照样推一个「请求时长」——实测 12 条消息内攒到 5 个,时钟被推 +10s(窗口 2s、
// 绝对上限 6s)⇒ 时钟越过绝对上限 ⇒ 循环头在**没有 takeAll** 的分支上直接发车,
// 队列尾部留在下一批(「12 条 → 2 轮」)。按截止时刻推进让「推了多少时间」= 「真的过了
// 多少时间」,旧定时器过期后被触发也不会把时钟推过头。
type coalesceTimers struct {
mu sync.Mutex
calls []time.Duration
pending []coalesceTimer
now func() time.Time
// depth 返回「车道上还没被 worker 取走的条目数」(含 ch/spill/front)。
// 推钟前必须等它归零,见 pump 的说明。
depth func() int
// seq 是单调请求序号:worker 每次重新挂定时器都会 +1,因此「比某个序号更大的请求」
// 才可能是它此刻**真正在等**的那一个(旧的都已被它放弃)。
seq int64
}
// coalesceTimer 是一次定时器请求:通道 + 请求时长 + **截止时刻**(注册时的注入时钟 + d)。
type coalesceTimer struct {
ch chan time.Time
d time.Duration
at time.Time
seq int64
}
func (t *coalesceTimers) after(d time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
at := time.Time{}
if t.now != nil {
at = t.now().Add(d)
}
t.mu.Lock()
t.calls = append(t.calls, d)
t.seq++
t.pending = append(t.pending, coalesceTimer{ch: ch, d: d, at: at, seq: t.seq})
t.mu.Unlock()
return ch
}
// callCount 返回定时器被请求的总次数。
func (t *coalesceTimers) callCount() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.calls)
}
// durations 返回每次请求的时长(按请求顺序)。
func (t *coalesceTimers) durations() []time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
return append([]time.Duration(nil), t.calls...)
}
// nextDue 取走**截止最早**的那一个未触发定时器(没有就返回 false)。
func (t *coalesceTimers) nextDue() (coalesceTimer, bool) {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.pending) == 0 {
return coalesceTimer{}, false
}
idx := 0
for i := 1; i < len(t.pending); i++ {
if t.pending[i].at.Before(t.pending[idx].at) {
idx = i
}
}
timer := t.pending[idx]
t.pending = append(t.pending[:idx], t.pending[idx+1:]...)
return timer, true
}
// waitRegistered 等到「至少有一次定时器请求挂着」(= worker 确实在等窗口)。
//
// 它只做**存在性检查**,不触发、也不消耗定时器:旧夹具里有调用方用 take 来证明
// 「它在等窗口」,却把那个定时器丢掉了 —— 按截止时刻推进之后那会丢掉唯一活着的那个。
// seqNow 返回当前请求序号(投递前记下它,之后用 waitFresh 等「这次投递之后新挂的」定时器)。
func (t *coalesceTimers) seqNow() int64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.seq
}
// waitFresh 等到「序号大于 after 的定时器已挂上」= worker 已经把这次投递收进本批并重新
// 开始等窗口。旧请求(已被 worker 放弃、再没人监听)不算。
func (t *coalesceTimers) waitFresh(tb *testing.T, after int64, timeout time.Duration) {
tb.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
t.mu.Lock()
fresh := false
for _, tm := range t.pending {
if tm.seq > after {
fresh = true
break
}
}
t.mu.Unlock()
if fresh {
return
}
time.Sleep(time.Millisecond)
}
tb.Fatalf("等不到投递之后新挂的定时器(seq>%d, calls=%d)", after, t.callCount())
}
func (t *coalesceTimers) waitRegistered(tb *testing.T, timeout time.Duration) {
tb.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
t.mu.Lock()
n := len(t.pending)
t.mu.Unlock()
if n > 0 {
return
}
time.Sleep(time.Millisecond)
}
tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount())
}
// advanceAndFire 把注入时钟推进到**当前所有未触发请求里最晚的那个截止时刻**,
// 然后触发它们(= 「这些窗口全部到点」)。
//
// 推进量因此恒等于「真的过了多久」,不会再出现旧写法里「按每个请求时长累加」的推过头。
func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock, done func() bool) {
tb.Helper()
deadline := time.Now().Add(3 * time.Second)
// 与 pump 同一条不变量:先把队列排空再推钟(否则循环头会按窗口到点发车,
// 而那条分支不 takeAll,尾部消息会落到下一批)。
for t.depth != nil && t.depth() > 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
for time.Now().Before(deadline) {
due, ok := t.allDue()
if ok {
clock.advanceTo(due)
// 为什么要重试:worker 可能在「登记」与「触发」之间续了新臂,
// 被触发的旧通道于是没人接(时钟推了、没人醒)。done 成立即收手。
if done == nil || done() {
return
}
time.Sleep(time.Millisecond)
continue
}
if done != nil && done() {
return
}
time.Sleep(time.Millisecond)
}
if done != nil && done() {
return
}
tb.Fatalf("advanceAndFire: 3s 内没有把窗口推到发车(calls=%d)", t.callCount())
}
// advanceAndFireUntil 推进并触发窗口,直到**回合数比调用时多一个**(或超时)。
func (f *coalesceBridge) advanceAndFireUntil(t *testing.T) {
t.Helper()
before := f.chat.runCount()
f.timers.advanceAndFire(t, f.clock, func() bool { return f.chat.runCount() > before })
}
// allDue 取走全部未触发请求,把时钟推到其中最晚的截止时刻,并逐个触发。
// 返回被推到的时间点(没有任何请求时 ok=false)。
func (t *coalesceTimers) allDue() (time.Time, bool) {
t.mu.Lock()
pending := t.pending
t.pending = nil
t.mu.Unlock()
if len(pending) == 0 {
return time.Time{}, false
}
latest := pending[0]
for _, tm := range pending[1:] {
if tm.at.After(latest.at) {
latest = tm
}
}
for _, tm := range pending {
tm.ch <- latest.at
}
return latest.at, true
}
// pump 反复「把时钟推进到最早截止时刻并触发它」,直到 done() 成立(或超时)。
//
// 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限
// 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」,
// 就会被调度时序(worker 什么时候启动)绊倒;pump 因此只看**结果条件**。
func (f *coalesceBridge) pump(t *testing.T, done func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if done() {
return
}
if timer, ok := f.timers.nextDue(); ok {
// 🔴 **推钟前必须等队列排空**:时钟一旦前进,`coalesceWait` 的循环头就可能
// 直接按「窗口到点」发车(那条分支不 takeAll),还没被 worker 收进本批的
// 尾部消息就会落到下一批 —— 「12 条 → 2 轮」正是这么来的。
// 这里等的是**状态**(车道上没有待取条目),不是拿 sleep 掩盖时序:
// worker 一旦看到队列有货就会收(select 的 l.ch 分支),所以排空必然发生。
if !f.awaitQueueDrained(deadline) {
break
}
f.clock.advanceTo(timer.at)
timer.ch <- timer.at
continue
}
time.Sleep(time.Millisecond)
}
t.Fatalf("condition never met within 3s (runs=%d, queued=%d, stats=%+v)",
f.chat.runCount(), f.queueDepth(), f.b.CoalesceStats())
}
// coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话
// (真实 SessionManager 就是这么派生的),因此「合成一轮」的落库行落在同一处。
type coalesceGroupSessions struct{}
func (coalesceGroupSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) {
return session.Session{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SessionID: "sess-" + msg.GroupID,
IsGroup: true,
MemWritable: false,
}, nil
}
// coalesceGroupMsg 是一条**非主人、指向她本人**的群消息:它因此必然进回合
// (不受最小打扰分级影响),是静默窗口最干净的夹具。
func coalesceGroupMsg(platform, groupID, sender, senderName, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: platform,
ChannelID: groupID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: sender,
SenderName: senderName,
Sender: adapter.Actor{Role: adapter.RoleMember},
Mentions: []adapter.Mention{{UserID: sender}},
Content: content,
ContentType: adapter.ContentText,
}
}
// coalesceBridge 是一个装好注入时钟与定时器工厂的桥接器(外加它的日志/历史/审计面)。
type coalesceBridge struct {
b *Bridge
chat *laneChatter
ad *laneAdapter
clock *laneClock
timers *coalesceTimers
}
// newCoalesceBridge 构造夹具:opts.Loop 必须是 *laneChatter(用例要读回合记录),
// 时钟与定时器工厂由夹具注入(opts.Now / opts.Coalesce.After 若已给出则不覆盖)。
func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge {
t.Helper()
chat, ok := opts.Loop.(*laneChatter)
if !ok {
t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop)
}
clock := newCoalesceClock()
timers := &coalesceTimers{now: clock.Now}
_ = timers
if opts.Now == nil {
opts.Now = clock.Now
}
opts.Coalesce.After = timers.after
if opts.Sessions == nil {
opts.Sessions = coalesceGroupSessions{}
}
if opts.Owner == nil {
opts.Owner = &user.OwnerProfile{}
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &laneAdapter{name: "telegram"}
b.Add(ad)
timers.depth = func() int { return queueDepthOf(b) }
return &coalesceBridge{b: b, chat: chat, ad: ad, clock: clock, timers: timers}
}
// snapshot 在锁内返回某个会话已落库的行(测试直接读 h.rows 会与车道 worker 的
// 写入构成数据竞争 —— -race 下必红,因此统一走这里)。
func (h *laneHistory) snapshot(sessionID string) []history.Message {
h.mu.Lock()
defer h.mu.Unlock()
return append([]history.Message(nil), h.rows[sessionID]...)
}
// send 投递一条消息(走真实的 onMessage 入口:Validate → 流控闸 → 车道)。
//
// ctx 取 b.ctx(与生产的读循环一致:`readLoop` 传的就是桥自己的生命周期 context),
// 这样 Stop 取消的车道 worker 与用例投递的消息用的是同一棵树 —— 否则「停机诚实」
// 这条用例根本验证不到真实路径(b.ctx 为 nil 时退回 Background)。
func (f *coalesceBridge) send(msg adapter.UnifiedMessage) {
ctx := f.b.ctx
if ctx == nil {
ctx = context.Background()
}
f.b.onMessage(ctx, msg, f.ad)
}
// userTextsIn 返回一次回合的模型可见消息里的 user 文本(按顺序)。
func userTextsIn(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role != model.RoleUser {
continue
}
out = append(out, lastText(m))
}
return out
}
// waitRuns 等至少 n 个回合开跑(封装 chatter 的同名方法,省得到处传超时)。
func (f *coalesceBridge) waitRuns(t *testing.T, n int) {
t.Helper()
f.chat.waitRuns(t, n, 3*time.Second)
}
// runTexts 返回每个已开跑回合的 user 文本(按开跑顺序)。只用于失败信息:
// 「12 条 → 2 轮」这类断言失守时,得先看清每条落到了哪一批,才能判断是夹具相位问题
// 还是合并真的拆了批。
func runTexts(c *laneChatter) [][]string {
n := c.runCount()
out := make([][]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, userTextsIn(c.runAt(i).messages))
}
return out
}
// waitIdle 等到车道全部退休。
func (f *coalesceBridge) waitIdle(t *testing.T) {
t.Helper()
// 「等车道全部退休」必须在**推动窗口**的前提下等:worker 若正停在一个还没到点的
// 窗口里,就永远走不到「队列空 ⇒ 退休」那一行。这里因此一边等一边把挂着的定时器
// 按截止时刻触发(与 pump 同一条语义),判据仍是 waitLaneIdle 自己那套断言。
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if f.b.LaneStats().ActiveLanes == 0 {
return
}
if timer, ok := f.timers.nextDue(); ok {
f.clock.advanceTo(timer.at)
timer.ch <- timer.at
continue
}
time.Sleep(time.Millisecond)
}
waitLaneIdle(t, f.b, time.Millisecond)
}
// awaitQueueDrained 等到车道上没有待取条目(= worker 已把该收的都收进本批)。
// 返回 false 表示到 deadline 都没排空(由调用方的断言/超时报出来)。
func (f *coalesceBridge) awaitQueueDrained(deadline time.Time) bool {
for time.Now().Before(deadline) {
if f.queueDepth() == 0 {
return true
}
time.Sleep(time.Millisecond)
}
return f.queueDepth() == 0
}
// queueDepth 返回所有车道里「还没被 worker 取走」的条目数(ch + spill + front)。
func (f *coalesceBridge) queueDepth() int { return queueDepthOf(f.b) }
func queueDepthOf(b *Bridge) int {
if b == nil || b.lanes == nil {
return 0
}
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
n := 0
for _, l := range b.lanes.lanes {
n += len(l.ch) + len(l.spill) + len(l.front)
}
return n
}
// sendSettled 投递一条,并等 worker 把它收进本批(车道排空)后才返回。
//
// 给「投递与注入时钟交替推进」的用例用(如每隔 500ms 来一条的绝对上限用例):
// 时钟只能表示「时间过去了」,所以必须保证每条都已经到了 worker 手里再走时间,
// 否则「到点发车」会漏掉还没被取走的尾部(实测批次 8/12)。
// 注意:**不要**在需要验证「队列能装下整批、不溢出」的用例里用它 —— 那里要的是
// 真的把消息压进队列。
func (f *coalesceBridge) sendSettled(t *testing.T, msg adapter.UnifiedMessage) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
before := f.timers.seqNow()
f.send(msg)
if !f.awaitQueueDrained(deadline) {
t.Fatalf("投递后车道没有排空(queued=%d)", f.queueDepth())
}
// 还要等 worker **为这次投递重新挂上定时器**(= 它已回到 select 里等窗口)。
// 必须按序号判新鲜度:pending 里可能还躺着此前那些「再没人监听」的旧请求,
// 拿它们当「已就绪」会让下一步推钟落在两轮循环之间(定时器换了新的、旧的被触发没人接)。
f.timers.waitFresh(t, before, time.Until(deadline))
}
// waitRows 等到会话落了至少 want 行指定角色的记录(回合是异步跑完再落库的,
// 读历史前必须等它真的写完 —— 否则断言会读到「12 user + 0 assistant」的半成品)。
func (h *laneHistory) waitRows(tb *testing.T, sessionID, role string, want int, timeout time.Duration) {
tb.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
n := 0
for _, row := range h.snapshot(sessionID) {
if row.Role == role {
n++
}
}
if n >= want {
return
}
time.Sleep(time.Millisecond)
}
tb.Fatalf("等了 %s 仍只有 %d 行 %s(want %d)", timeout, len(h.snapshot(sessionID)), role, want)
}
// sendSettledAndDuration 投递一条并返回**这条消息之后 worker 续出的那次窗口时长**。
//
// 它把「投递 → worker 收进本批 → 为它续臂」做成一个确定的相位:续臂用请求序号认领
// (`seq > 投递前`),因此不会把此前那条消息的续臂误当成这一次的 —— 只按「至少 N 次
// 请求 + 取最新」在负载下仍会拿到上一条的时长(实测 -race -count=200 有 2/200 假红)。
func (f *coalesceBridge) sendSettledAndDuration(t *testing.T, msg adapter.UnifiedMessage) time.Duration {
t.Helper()
f.sendSettled(t, msg)
return latestDuration(f.timers.durations())
}
@@ -0,0 +1,173 @@
package channelbridge
// coalesce_lane.go 把静默窗口接到车道上(M23 的「车道头部」):攒批 → 逐条陈旧度
// → 逐条闸门 → 单批上限拆分 → 一个 turnID 发车。它是 coalesce.go(窗口引擎)与
// coalesce_batch.go(一回合一 Reply)之间的那层「调度」。
//
// 本文件里**没有**任何「跳过某条消息」的分支,唯一的剔除来自 M22 的**既有**闸门:
// - 陈旧度闸(staleInbound:轮到时已超龄 ⇒ 不补答,逐条 WARN + 计数 + 审计);
// - 逐条闸门(prepareInbound:黑白名单 / 静默 / 空内容 / 仅登记的通知,全是
// 「这条本来就不该驱动回合」,不是「批装不下所以扔掉」)。
//
// 装不下的**不是丢弃**:超过单批上限的尾部原样回插队首,成为下一批(requeueFront),
// 并且 CoalesceStats().CoalescedDroppedTotal 恒为 0 —— 它存在的意义就是让「有人不小心
// 加了一条 continue」有一个能被断言的出口。
import (
"context"
)
// runBatchLaneItem 是 batch 车道的发车入口:把刚取到的 first 与此刻队列里的其余条目
// 合成一批,按窗口等待,然后交给 flushLaneBatch。
//
// 🔴 **回合中续跑(⑤)不需要额外标志**:worker 在跑回合时根本不在这里,新消息落进
// 同一个队列;回合结束回到 laneWorker 循环头,下一次 nextLaneItem + takeAll 就把
// 这段期间攒下的整片取出 ⇒ 一轮续跑(见 flushLaneBatch 的 continued 计数)。
func (b *Bridge) runBatchLaneItem(ctx context.Context, l *lane, first laneItem) {
w, windowed := b.coalesceWindowFor(l.key, laneStrangerPrivate(first))
if !windowed || !laneWindowAdmits(first.msg) {
// 三种「到达即发车」:关窗(逐字回到 M22)、该会话不适用窗口、以及**不进窗口的帧**
// (系统通知 / action 帧 / 我们自己的出站回执)。最后一种刻意**不**建窗口:
// 一条回执不该让这一批的静默计时重新开始(M23 任务书的窗口准入条件)。
// 刻意**不** takeAll:多取的条目会让「不合并」变成「悄悄合并」。
b.flushLaneBatch(ctx, l, []laneItem{first}, w, false, coalesceFlushOff)
return
}
items, solos := splitWindowItems(b.takeAll(l))
items = append([]laneItem{first}, items...)
var flushReason string
var moreSolos []laneItem
items, moreSolos, flushReason = b.coalesceWait(ctx, l, items, w)
solos = append(solos, moreSolos...)
if flushReason == coalesceFlushStop {
b.abandonAtStop(l, append(append([]laneItem{}, items...), solos...))
return
}
if len(solos) > 0 {
// 不进窗口的帧原样**插回队首**:本批先发车,随后它们各自单跑(一条一轮、
// 不建窗口)。回插保序:它们比队列里其余条目都早,front 因此在 ch/spill 之前。
b.requeueFront(l, solos)
}
b.flushLaneBatch(ctx, l, items, w, true, flushReason)
}
// flushLaneBatch 把一批**已经攒好**的条目发车(或按上限拆开)。
//
// 顺序是刻意的:**拆分**(结构,不影响任何一条的去向)→ **取全局回合槽** →
// 逐条陈旧度 → 逐条闸门 → 组上下文 → 一次 Run。拆分放在最前面,因为回插的尾部
// 还没到「发车那一刻」,它的陈旧度要在它自己那一批发车时判。
func (b *Bridge) flushLaneBatch(ctx context.Context, l *lane, items []laneItem, w coalesceWindow, windowed bool, reason string) {
if len(items) == 0 {
return
}
items = b.splitBatch(l, items, w, windowed)
if !b.acquireTurn(ctx) {
// 桥停了:这一批一条都没跑,必须如实登记(绝不静默消失)。
b.abandonAtStop(l, items)
return
}
defer b.releaseTurn()
kept, stale := b.gateBatch(ctx, items)
if len(kept) == 0 {
if stale == len(items) {
// 整批都超龄:不发车、留一条可查的痕迹(M22 的「太旧不补答」优先)。
b.noteFlushReason(coalesceFlushAllStale)
b.coalesceLog.Info("coalesce batch all stale, nothing to say",
"lane", l.key, "count", len(items), "flush_reason", coalesceFlushAllStale)
l.lastTurnEnd = b.now()
return
}
// 一条都没进回合(全被逐条闸门静默/拒收):这是「回什么」的既有裁决,
// 不是合并丢的 —— 照样记一行,便于和上面的分支区分。
b.coalesceLog.Debug("coalesce batch produced no turn",
"lane", l.key, "count", len(items), "flush_reason", reason)
l.lastTurnEnd = b.now()
return
}
continued := !l.lastTurnEnd.IsZero() && items[0].arrivedAt.Before(l.lastTurnEnd)
b.countDeparture(kept, reason, continued)
b.logDeparture(l, items[0], kept, reason, continued)
b.runBatchSegments(ctx, kept)
l.lastTurnEnd = b.now()
}
// splitBatch 实施单批上限:超过上限时把**尾部**原样回插队首(保序、锁内、发车前),
// 本批只带走前 maxBatch 条。返回本批真正要发车的切片。
func (b *Bridge) splitBatch(l *lane, items []laneItem, w coalesceWindow, windowed bool) []laneItem {
if !windowed || w.maxBatch <= 0 || len(items) <= w.maxBatch {
return items
}
tail := items[w.maxBatch:]
b.requeueFront(l, tail)
b.coalesceStats.batchesSplit.Add(1)
b.coalesceLog.Info("coalesce batch split, tail requeued (not dropped)",
"lane", l.key, "kept", w.maxBatch, "requeued", len(tail), "max_batch", w.maxBatch)
return items[:w.maxBatch]
}
// gateBatch 是**逐条闸门**的批量形式,不是合并期的筛选:它调用的两个判断(陈旧度闸 /
// 逐条闸门)在 M22 的单条流水线里本来就对每一条各跑一次,合并**没有新增任何丢弃点** ——
// 因此 CoalescedDroppedTotal 恒为 0(见 coalesce.go 的说明)。
//
// 它返回要进本轮的消息与「因超龄被丢」的条数。陈旧度必须在**组上下文之前**判:不能让
// 「批里最新一条是新鲜的」把超龄的旧条也塞进上下文(M22「太旧不补答」优先);
// 被丢的每一条都由 dropInbound 留 WARN + 计数 + 审计(与单条路径逐字同一条流水)。
func (b *Bridge) gateBatch(ctx context.Context, items []laneItem) ([]preparedInbound, int) {
kept := make([]preparedInbound, 0, len(items))
stale := 0
for _, item := range items {
if b.staleInbound(item, b.now().Sub(item.arrivedAt)) {
stale++
continue
}
p, ok := b.prepareInbound(ctx, item)
if !ok {
continue
}
kept = append(kept, p)
}
return kept, stale
}
// countDeparture 记发车计数(批数/条数/发车原因/续跑)。计数与日志分开,
// 使「发车的形状」既能在日志里读、也能在测试里断言。
func (b *Bridge) countDeparture(kept []preparedInbound, reason string, continued bool) {
b.coalesceStats.batchesTotal.Add(1)
b.coalesceStats.batchMessagesTotal.Add(int64(len(kept)))
if continued {
b.coalesceStats.continuations.Add(1)
}
b.noteFlushReason(reason)
}
// logDeparture 记「这一批是怎么发车的」:条数、等了多久、发车原因、是否续跑。
// quiet/off 走 DEBUG,max_wait 走 INFO(它是「差点迟滞」的信号,值得在日常日志里看见)。
func (b *Bridge) logDeparture(l *lane, first laneItem, kept []preparedInbound, reason string, continued bool) {
attrs := []any{
"lane", l.key, "messages", len(kept),
"waited_ms", b.now().Sub(first.arrivedAt).Milliseconds(),
"flush_reason", reason, "continued", continued,
"platform", first.msg.Platform, "channel", first.msg.ChannelID, "group", first.msg.GroupID,
}
if reason == coalesceFlushMaxWait {
b.coalesceLog.Info("coalesce batch assembled (max wait reached)", attrs...)
return
}
b.coalesceLog.Debug("coalesce batch assembled", attrs...)
}
// abandonAtStop 诚实登记「进程停机时这批一条都没发车」。主人 2026-10-24 的口径是
// 「宁可晚回,不许不回」,但进程已经要停了 —— 唯一能做对的事是**说出来**:
// 带 source 的 WARN + 累计计数(abandoned_at_stop_total),绝不静默消失。
func (b *Bridge) abandonAtStop(l *lane, items []laneItem) {
if len(items) == 0 {
return
}
total := b.coalesceStats.abandonedAtStop.Add(int64(len(items)))
b.coalesceLog.Warn("coalesce batch abandoned at bridge stop",
"lane", l.key, "count", len(items),
"flush_reason", coalesceFlushStop, "abandoned_at_stop_total", total,
"platform", items[0].msg.Platform, "channel", items[0].msg.ChannelID)
}
@@ -0,0 +1,136 @@
package channelbridge
// coalesce_owner_test.go 是 M23 的**红线**断言集:主人私聊必须**结构性**地没有静默窗口。
//
// 断言的层次(塔 2026-10-24 要求「断言状态,不是断言结果」):
//
// ① `timers.callCount() == 0` —— 定时器工厂一次都没被调用(窗口**从未被创建**);
// ② `lane.batcher == nil` —— 那条车道的类型里根本没有窗口状态;
// ③ `CoalesceStats().WindowsCreated == 0` —— 计数面也证明没有窗口;
// ④ 消息立刻被回答(没有 2s 迟滞)。
//
// 变异检验(塔说会亲自做):把车道类型判定(ownerPrivateLane)改成「主人私聊也走
// batch 车道」⇒ ①②③ 立刻红。
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// laneForTest 在锁内取一条车道(测试用;车道可能已退休 ⇒ nil)。
func laneForTest(b *Bridge, key string) *lane {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
return b.lanes.lanes[key]
}
// sent 返回该适配器已经送出去的回复(并发安全)。
func (a *laneAdapter) sent() []adapter.UnifiedMessage {
a.mu.Lock()
defer a.mu.Unlock()
return append([]adapter.UnifiedMessage(nil), a.sends...)
}
// ownerPrivateBridge 构造一个「主人私聊」夹具:主人白名单 telegram/100,会话层报
// Owner=true(与生产 SessionManager 的派生一致)。
func ownerPrivateBridge(t *testing.T, chat *laneChatter, p func(string) *runtimeconfig.CoalescePolicy) *coalesceBridge {
t.Helper()
return newCoalesceBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
Coalesce: CoalesceOptions{For: p},
})
}
// assertNoTimer 在「等第一轮开跑」的同时盯住定时器工厂:一旦它被调用就立刻失败
// (变异体的**第一种红**正是这里:主人私聊被塞进 batch 车道 ⇒ 窗口被建出来)。
func assertNoTimer(t *testing.T, f *coalesceBridge, chat *laneChatter) {
t.Helper()
deadline := time.Now().Add(300 * time.Millisecond)
for time.Now().Before(deadline) {
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
}
if chat.runCount() > 0 {
return
}
time.Sleep(time.Millisecond)
}
}
// TestOwnerPrivateNeverCreatesWindow 断言红线:主人私聊**不建窗口、不建定时器**,
// 且消息立刻被回答。
func TestOwnerPrivateNeverCreatesWindow(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate }
f := ownerPrivateBridge(t, chat, coalesceFor(coalesceOn(2000, 6000, 24)))
f.send(ownerMsg("主人说话"))
assertNoTimer(t, f, chat)
chat.waitRuns(t, 1, 3*time.Second)
// 车道还在(回合卡住)⇒ 这一刻的类型判定是可观察的。
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil {
t.Fatalf("owner private lane %q is missing while its turn is running", ownerPrivateLaneKey)
} else if l.batcher != nil {
t.Fatalf("owner private lane has a batch window (%+v): the type must be direct", l.batcher)
}
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
}
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 0 {
t.Fatalf("windows_created = %d for owner private traffic, want 0", stats.WindowsCreated)
}
close(gate)
f.waitIdle(t)
if got := f.ad.sent(); len(got) != 1 {
t.Fatalf("replies = %d, want 1 (owner private must be answered immediately)", len(got))
}
}
// TestOwnerPrivateIgnoresEnabledConfig 断言「配置无权翻转车道类型」:把配置开到最激进
// (enabled / applyToStrangers 全开、窗口 10s),主人私聊仍然没有定时器、没有窗口;
// 而同一条配置对**群聊**是生效的(同一个定时器工厂会被调用)—— 后者是这条用例的
// **对照组**,用来证明 calls==0 不是「工厂没接上」这种伪结论。
func TestOwnerPrivateIgnoresEnabledConfig(t *testing.T) {
t.Parallel()
aggressive := coalesceOn(10000, 30000, 24)
aggressive.Enabled = true
aggressive.ApplyToStrangers = true
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate }
f := ownerPrivateBridge(t, chat, coalesceFor(aggressive))
f.send(ownerMsg("主人私聊"))
assertNoTimer(t, f, chat)
chat.waitRuns(t, 1, 3*time.Second)
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("timer factory called %d times with an aggressive config, want 0", calls)
}
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil || l.batcher != nil {
t.Fatalf("owner private lane = %+v, want a direct lane (batcher == nil)", l)
}
// 对照组:同一条配置对群聊**生效**(定时器被请求并按点到发车)。
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "群里的消息"))
f.advanceAndFireUntil(t)
close(gate)
chat.waitRuns(t, 2, 3*time.Second)
f.waitIdle(t)
if calls := f.timers.callCount(); calls == 0 {
t.Fatal("control group: group traffic created no timer, so the fixture proves nothing")
}
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 1 {
t.Fatalf("windows_created = %d, want 1 (only the group batch waited)", stats.WindowsCreated)
}
}
@@ -0,0 +1,151 @@
package channelbridge
// coalesce_prepare.go 把入站流水线拆成两段(M23 蓝图 §11 步 1):
//
// prepareInbound —— **逐条**闸门(语音归一 → 传入前审查/事件留存 → 空内容 →
// 黑白名单 → 会话解析 → 最小打扰/owner 车道 → 陌生人回合槽),
// 行为与 M22 的 processFrom 逐字一致,**一个字都没改**;
// runBatch —— **一批**一轮回合(见 coalesce_batch.go)。
//
// 为什么要拆:静默窗口只决定「什么时候把一批已经决定要说的东西说出来」,它不该
// 参与「说不说」的判断。拆开之后,闸门仍然逐条跑(每条消息照旧独立判静默/限流),
// 窗口只作用在闸门**之后**的调度上 —— 因此「批不得把一条本应静默的消息捞回来」
// 在结构上成立:被静默的消息在 prepareInbound 就 return false 了,根本进不了批。
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// preparedInbound 是一条**已过全部逐条闸门**、确定要驱动回合的入站消息。
type preparedInbound struct {
// item 是车道条目(msg 已被语音归一/通知剥离就地改写过,actor 是本次解析结果)。
item laneItem
// sess 是解析出的会话绑定(主人私聊 → 主会话,群聊/陌生人 → 隔离会话)。
sess session.Session
// text 是本回合的正文(语音已归一成文本、通知已剥离)。
text string
// voiceIn 是那条语音的归一结论(只在放行之后才会落副本)。
voiceIn voiceTurn
// solo 为 true ⇒ 这条不能与别人并进同一批(传入前审查升级的真会话):
// 它单独一轮,以免把「平台事件升级」与「群友聊天」混成一次回答。
solo bool
}
// prepareInbound 逐条过闸门,返回「这条要不要驱动回合」以及驱动所需的一切。
//
// 它逐字保留了 M22 `processFrom` 的闸门顺序与日志,唯一的变化是**副作用分家**:
// 落库、装配上下文、跑回合、发回复都搬到了 runBatch;本函数只做判断与一次性 I/O
// (语音转写、事件留存)。
func (b *Bridge) prepareInbound(ctx context.Context, item laneItem) (preparedInbound, bool) {
msg := item.msg
// 语音 → 文本归一是入站流水线的**第一件事**(design/06 §1.1):它必须在「丢空判据」
// 之前发生,否则 telegram 的纯语音(无 caption ⇒ Content 为空)会被整条丢掉。
voiceIn := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」。
notice := b.preEntryReview(ctx, &msg, item.a)
// design/26 §4/§5:渠道事件留存的唯一插入点(无条件、早于所有早退分支、绝不决定回合走不走)。
b.recordEvent(ctx, msg, item.a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, item.a, notice)
return preparedInbound{}, false
}
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容(带附件的纯文件/纯语音/纯图片消息
// 必须继续往下走:附件就是它的内容,design/24 §4.4「不许静默丢」)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return preparedInbound{}, false
}
// design/16 §4.6 §6:黑白名单在流水线的最前端执行(早于会话解析与循环驱动)。
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() && b.blacklistDrop(msg, item.a) {
return preparedInbound{}, false
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return preparedInbound{}, false
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二 + design/18 §11「乙 = C」:owner 车道不再无条件短路这道闸。
// 被拦下的消息只登记到其隔离会话日志并静默 —— 它**进不了批**(这就是「批不得把
// 本应静默的消息捞回来」的结构保证)。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一终局判定。
if !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, item.a); !ok {
b.registerSilenced(ctx, msg, actor, sess, item.a)
b.log.Debug("inbound message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
return preparedInbound{}, false
}
}
return preparedInbound{
item: laneItem{msg: msg, a: item.a, actor: actor, arrivedAt: item.arrivedAt},
sess: sess,
text: text,
voiceIn: voiceIn,
solo: !coalesceAdmitsMessage(msg, notice),
}, true
}
// coalesceAdmitsMessage 是窗口准入的**第二道保险**(第一道、也是真正生效的那道在车道头:
// coalesce.go 的 laneWindowAdmits —— 窗口等待阶段就按它分类,非准入条目根本进不了批)。
//
// 它比车道头多知道一件事:`notice.Applicable`(那要 preEntryReview 跑完才有结论)。
// 但 isSystemNoticeMessage 只按 ContentType=action 判定,而 action 帧在车道头已经被挡下,
// 所以这里实际上永远为真 —— 保留它是为了让「未来某个适配器把平台事件标成 text」这类
// 变更仍然在组批前被 solo 切出去(runBatchSegments),而不是混进群友的同一轮回答。
func coalesceAdmitsMessage(msg adapter.UnifiedMessage, notice noticeOutcome) bool {
if notice.Applicable {
return false
}
switch msg.ContentType {
case adapter.ContentText, adapter.ContentImage, adapter.ContentVoice, adapter.ContentFile:
return true
default:
return false
}
}
// acquireStrangerTurn 施加「陌生人私聊同一发送者同时最多一个回合」的既有闸(裁决①)。
//
// 它由 runBatch 在**开跑前**取、**回合结束后**还 —— 也就是「一批 = 一个回合」占一个槽,
// 与 M22 的 `defer gate.ReleaseTurn(msg)` 同一语义(那一次 defer 从 processFrom 搬到了
// 回合本身)。放在这里而不是逐条闸门里是必须的:一批里同一发送者的第 2..N 条不该
// 被自己这一批占的槽挡下。
//
// M23 的车道已经给出**更强**的保证(同一会话串行),因此这道闸在车道里几乎不可能
// 拒绝;万一拒绝(闸门键与车道键分叉的缺陷),这里**绝不**再退回「这条不回」——
// 主人 2026-10-24 的口径是「宁可晚回,不许不回」,而本函数返回 nil 时消息仍会被本轮
// 回答,只是少了一个可以同时占用的槽。拒绝会留一条带 source 的 WARN,不会被吞掉。
func (b *Bridge) acquireStrangerTurn(msg adapter.UnifiedMessage, sess session.Session, a adapter.PlatformAdapter) func() {
if !isStrangerPrivate(sess) {
return nil
}
gate := b.gateFor(a)
if gate == nil {
return nil
}
if !gate.AcquireTurn(msg) {
b.log.Warn("stranger private turn slot busy inside a serialized lane, answering anyway",
"platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID)
return nil
}
return func() { gate.ReleaseTurn(msg) }
}
@@ -0,0 +1,414 @@
package channelbridge
// coalesce_test.go 钉住 M23 的**静默窗口合并**(④)本身:滑动等待、到期发车、
// 绝对上限、零丢失/顺序/快照一致、开关回退、单批上限拆分。
//
// 对应 M23 蓝图 §12 的 #1/#2/#3/#4/#5/#6/#7/#8/#15/#16。
import (
"fmt"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestCoalesceWaitsForQuietThenFlushes(#1)断言**滑动**语义:第一条到达后 1.5s 又来一条,
// 计时重置;静默满 2.0s 才发车,且这一批同时带走两条。
//
// 若实现是 fixed 窗口(不重置),那么在 m1 到达 2.0s 时就会先发一次车(只剩 m1)——
// 下面「advance(1500) 之后一轮都不许跑」与「重置后再满 2.0s 才发一次、批内两条」
// 两点同时钉住它。
func TestCoalesceWaitsForQuietThenFlushes(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
// 时钟走到 m1 之后 1.5s:窗口还没到点,一轮都不该跑(不是 fixed 窗口的 2.0s 边界)。
f.clock.advance(1500 * time.Millisecond)
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d after 1.5s, want 0 (window is 2.0s)", n)
}
// 窗口内的第二条:计时必须重置(新定时器 = 从 m2 起再等满一个窗口)。
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.advanceAndFireUntil(t)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want exactly 1 (two messages, one turn)", n)
}
got := userTextsIn(chat.runAt(0).messages)
if len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
t.Fatalf("batch user messages = %v, want [m1 m2]", got)
}
if stats := f.b.CoalesceStats(); stats.FlushQuietTotal != 1 || stats.WindowsCreated != 1 {
t.Fatalf("stats = %+v, want one quiet flush on one window", stats)
}
}
// TestCoalesceFlushesOnWindowExpiry(#2)断言「只来一条」时窗口到期即发车:
// 到点**之前**一轮都不许跑(不提前),到点之后恰好一轮(不延后)。
func TestCoalesceFlushesOnWindowExpiry(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "only"))
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d before the window expired, want 0", n)
}
f.advanceAndFireUntil(t)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 1 || got[0] != "only" {
t.Fatalf("batch user messages = %v, want [only]", got)
}
if stats := f.b.CoalesceStats(); stats.FlushMaxWaitTotal != 0 {
t.Fatalf("flush reason = max_wait, want quiet (stats=%+v)", stats)
}
}
// TestCoalesceTwelveBurstOneTurn(#3)是塔要的确定性判据:12 条 2s 内到达 ⇒
// **一次 Run**、模型可见 user 行 12 条、落库 12 user + 1 assistant、turn_id 全同。
func TestCoalesceTwelveBurstOneTurn(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
const n = 12
for i := 0; i < n; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst(各批=%v stats=%+v)",
got, runTexts(chat), f.b.CoalesceStats())
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != n {
t.Fatalf("batch user messages = %d, want %d: %v", len(got), n, got)
}
// 回合是异步跑完再落库的:先等 assistant 行出现,否则读到的是半成品
// (实测「12 user + 0 assistant」)。
hist.waitRows(t, "sess--20", "assistant", 1, 5*time.Second)
rows := hist.snapshot("sess--20")
userRows, assistantRows := 0, 0
turnIDs := map[string]struct{}{}
for _, row := range rows {
switch row.Role {
case "user":
userRows++
turnIDs[row.TurnID] = struct{}{}
case "assistant":
assistantRows++
}
}
if userRows != n || assistantRows != 1 {
t.Fatalf("persisted rows = %d user + %d assistant, want %d + 1", userRows, assistantRows, n)
}
if len(turnIDs) != 1 {
t.Fatalf("distinct user turnIDs = %d, want 1 (one batch = one turn)", len(turnIDs))
}
if stats := f.b.CoalesceStats(); stats.CoalescedDroppedTotal != 0 || stats.BatchMessagesTotal != n {
t.Fatalf("stats = %+v, want %d messages and zero coalesced drops", stats, n)
}
}
// TestCoalesceNeverDropsInWindow(#4)断言窗口期一条都不丢:20 条连发(超过默认车道深度
// 8,因此也顺带钉住「batch 车道的容量必须够装一整批」),最终 20 条**全部**按到达顺序
// 出现在某一批里,且没有任何合并期丢弃。
func TestCoalesceNeverDropsInWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
const n = 20
want := make([]string, 0, n)
for i := 0; i < n; i++ {
text := fmt.Sprintf("m%02d", i)
want = append(want, text)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", text))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
var got []string
for i := 0; i < chat.runCount(); i++ {
got = append(got, userTextsIn(chat.runAt(i).messages)...)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("batch messages = %v, want %v (nothing dropped, order kept)", got, want)
}
stats := f.b.CoalesceStats()
if stats.CoalescedDroppedTotal != 0 {
t.Fatalf("coalesced_dropped_total = %d, want 0", stats.CoalescedDroppedTotal)
}
if lane := f.b.LaneStats(); lane.DroppedOverflow != 0 || lane.DroppedStale != 0 {
t.Fatalf("lane stats = %+v, want no drops (batch lane must fit a whole batch)", lane)
}
}
// TestCoalescePreservesArrivalOrder(#5)断言跨发送者的顺序:模型可见消息与落库行的顺序
// 都逐字等于到达顺序(两个群友交替说话),且每条仍是**独立的 user 消息**(不拼成一条)。
func TestCoalescePreservesArrivalOrder(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
steps := []struct{ sender, name, text string }{
{"9", "张三", "m1"}, {"10", "李四", "m2"}, {"9", "张三", "m3"},
{"10", "李四", "m4"}, {"9", "张三", "m5"}, {"10", "李四", "m6"},
}
for _, s := range steps {
f.send(coalesceGroupMsg("telegram", "-20", s.sender, s.name, s.text))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want 1", got)
}
msgs := chat.runAt(0).messages
var users []model.Message
for _, m := range msgs {
if m.Role == model.RoleUser {
users = append(users, m)
}
}
if len(users) != len(steps) {
t.Fatalf("user messages = %d, want %d (one row per message, never concatenated)", len(users), len(steps))
}
for i, s := range steps {
if lastText(users[i]) != s.text {
t.Fatalf("user[%d] = %q, want %q (arrival order)", i, lastText(users[i]), s.text)
}
if users[i].SenderName != s.name {
t.Fatalf("user[%d] SenderName = %q, want %q", i, users[i].SenderName, s.name)
}
}
var persisted []string
for _, row := range hist.snapshot("sess--20") {
if row.Role == "user" {
persisted = append(persisted, row.Content)
}
}
if strings.Join(persisted, ",") != "m1,m2,m3,m4,m5,m6" {
t.Fatalf("persisted user rows = %v, want arrival order", persisted)
}
}
// TestCoalesceBatchesAreConsistentSnapshots(#6)断言批与批之间互不污染:
// 同一批里的消息**不会**出现在这一批自己的转录里(各自恰好一次),而前一批的行会作为
// 历史进入下一批 —— 这正是「播种在读之前、落库在后」的既有顺序。
func TestCoalesceBatchesAreConsistentSnapshots(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "a1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "a2"))
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "b1"))
f.pump(t, func() bool { return chat.runCount() >= 2 })
first := userTextsIn(chat.runAt(0).messages)
if strings.Join(first, ",") != "a1,a2" {
t.Fatalf("first batch user messages = %v, want [a1 a2] exactly once each", first)
}
second := userTextsIn(chat.runAt(1).messages)
if strings.Join(second, ",") != "a1,a2,b1" {
t.Fatalf("second batch user messages = %v, want [a1 a2 b1] (previous batch as transcript, own rows once)", second)
}
if joined := joinTexts(chat.runAt(1).messages); !strings.Contains(joined, "reply:a2") {
t.Fatalf("second batch is missing the previous assistant reply:\n%s", joined)
}
}
// TestCoalesceBatchUsesLatestContext(#7)断言最新性:续跑那一批的上下文里含上一轮刚落库的
// 助手回复(她接得上自己刚才说的话)。
func TestCoalesceBatchUsesLatestContext(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "second"))
f.pump(t, func() bool { return chat.runCount() >= 2 })
if joined := joinTexts(chat.runAt(1).messages); !strings.Contains(joined, "reply:first") {
t.Fatalf("continuation context does not contain the previous reply:\n%s", joined)
}
}
// TestCoalesceMaxWaitForcesFlush(#8)断言绝对上限:每 500ms 来一条、连续 6s,
// 到 6s 必发车(滑动计时被一条条重置也压不住),绝不无限等下去。
func TestCoalesceMaxWaitForcesFlush(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
// 每条投递后都等 worker 收走再推进时钟:时钟只表示「时间过去了」,它一转,
// 循环头就可能直接按到点发车(那条分支不 takeAll),没被收走的尾部会漏到下一批。
f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
for i := 1; i < 12; i++ {
f.clock.advance(500 * time.Millisecond)
f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1)))
}
// 最后那个定时器请求的正是「距离 6s 绝对上限还差多少」——按它推完最后 500ms,
// 循环头因此看见 maxLeft<=0 并按 max_wait 发车(滑动窗口被一条条重置也压不住它)。
f.advanceAndFireUntil(t)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (max wait forces one flush)", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 12 {
t.Fatalf("batch user messages = %d, want 12", len(got))
}
if stats := f.b.CoalesceStats(); stats.FlushMaxWaitTotal != 1 {
t.Fatalf("stats = %+v, want flush_reason=max_wait", stats)
}
}
// TestCoalesceDisabledMatchesLaneBehavior(#15)断言开关回退:`enabled:false` ⇒ 12 条
// 就是 12 轮(到达即投递、一条一轮),**一个定时器都不建**,落库 12 user + 12 assistant。
//
// LaneDepth 取 16 是刻意的:关窗之后车道深度回到 M22 口径(默认 8),而本用例要断言
// 「逐条跑」而不是「背压丢了几条」,因此给它一条足够深的车道。
func TestCoalesceDisabledMatchesLaneBehavior(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
LaneDepth: 16,
Coalesce: CoalesceOptions{For: coalesceOffFor},
})
const n = 12
for i := 0; i < n; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i)))
}
f.waitRuns(t, n)
f.waitIdle(t)
if got := chat.runCount(); got != n {
t.Fatalf("turns started = %d, want %d (feature off = one turn per message)", got, n)
}
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timers created = %d, want 0 when the feature is off", calls)
}
stats := f.b.CoalesceStats()
if stats.WindowsCreated != 0 || stats.BatchesTotal != n {
t.Fatalf("stats = %+v, want 0 windows and %d batches", stats, n)
}
userRows, assistantRows := 0, 0
for _, row := range hist.snapshot("sess--20") {
switch row.Role {
case "user":
userRows++
case "assistant":
assistantRows++
}
}
if userRows != n || assistantRows != n {
t.Fatalf("persisted rows = %d user + %d assistant, want %d + %d", userRows, assistantRows, n, n)
}
}
// TestCoalesceBatchOverflowSplitsNotDrops(#16)断言单批上限:30 条(> 24)入队 ⇒
// 两批 24 + 6,顺序不变、零丢弃、被拆出去的尾部**排在最前**(成为下一批)。
func TestCoalesceBatchOverflowSplitsNotDrops(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
LaneDepth: 40, // 让 30 条都排得下,从而让「拆分」这条分支可达
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
const n = 30
for i := 0; i < n; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%02d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
// 被回插队尾的那 6 条**已经等过了**窗口(时钟在第一批到点时已走到窗口末尾),
// 因此它们下一批立刻发车,不需要再推进一个窗口。
f.waitRuns(t, 2)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (24 + 6)", got)
}
first := userTextsIn(chat.runAt(0).messages)
second := userTextsIn(chat.runAt(1).messages)
if len(first) != 24 || len(second) != 6 {
t.Fatalf("batch sizes = %d + %d, want 24 + 6", len(first), len(second))
}
var all []string
all = append(all, first...)
all = append(all, second...)
for i, text := range all {
if text != fmt.Sprintf("m%02d", i) {
t.Fatalf("message[%d] = %q, want m%02d (order must survive the split)", i, text, i)
}
}
if stats := f.b.CoalesceStats(); stats.BatchesSplitTotal != 1 || stats.CoalescedDroppedTotal != 0 {
t.Fatalf("stats = %+v, want exactly one split and zero coalesced drops", stats)
}
}
// joinTexts 把一批模型可见消息的文本拼起来(断言「上下文里含有什么」用)。
func joinTexts(msgs []model.Message) string {
var out []string
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out = append(out, c.Text)
}
}
}
return strings.Join(out, "\n")
}
+14 -41
View File
@@ -19,6 +19,12 @@ import (
// 态度时的冷避。它按进程存于内存(重启即重置), // 态度时的冷避。它按进程存于内存(重启即重置),
// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要 // 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要
// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。 // 打扰某人」就把聊天消息发给 LLM(design/18 红线)。
//
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
// 口径见 coldShoulderLocked。
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它 // EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供 // 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
@@ -168,21 +174,14 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage,
defer st.mu.Unlock() defer st.mu.Unlock()
now := b.now() now := b.now()
// design/18 §5 冷避优先:群成员表达负面态度是 // design/18 §5 冷避优先:群成员表达负面态度是明确的「安静」信号 →
// 明确的「安静」信号 → COOLDOWN,即使正处在参与中。直接 @ / 高价值 // COOLDOWN,即使正处在参与中。判据本体与 shouldRespond 的冷避闸
// 关键词已在上方返回,因此只有真正「内容值得回复」 // (coldShoulderGate)**只有这一份**;那里在廉价规则之前先跑它,因此
// 的消息(或主人消息,它根本不会走到这里)才能覆盖它。 // @她 / 高价值关键词都拦不住冷避(M18,主人 2026-10-14 裁决「给」)。
if b.negativeAttitude(msg.Content) { // 这里保留调用是**纵深防御**:engageDecision 单看仍然自带冷避优先语义,
b.enterCooldown(st, cfg, now) // 将来若多出别的调用点也不会漏掉它。
b.log.Debug("engage cooldown (negative attitude)", if quiet, reason := b.coldShoulderLocked(msg, actor, key, st, cfg, now); quiet {
"group", key, "until", st.cooldownUntil) return false, reason
return false, "engage: cooldown (negative attitude)"
}
if st.inCooldown(now) {
b.log.Debug("engage cooldown active, not responding",
"group", key, "until", st.cooldownUntil)
return false, "engage: cooldown active"
} }
// ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断 // ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断
@@ -331,32 +330,6 @@ func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
return b.engageRand() < cfg.Probability return b.engageRand() < cfg.Probability
} }
// negativeAttitude 报告群是否在让她安静(design/18 §5
// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号
// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。
func (b *Bridge) negativeAttitude(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, k := range negativeAttitudeKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是
// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。
// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②),
// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。
var negativeAttitudeKeywords = []string{
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
"安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
"打住", "住嘴", "shut up", "too loud", "stop spamming",
}
// contentSnippet 把消息截短为状态机所用的有界话题标记。 // contentSnippet 把消息截短为状态机所用的有界话题标记。
func contentSnippet(text string) string { func contentSnippet(text string) string {
runes := []rune(strings.TrimSpace(text)) runes := []rune(strings.TrimSpace(text))
@@ -0,0 +1,168 @@
package channelbridge
import (
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 本文件承载**冷避规则**(design/18 §5)——与它的状态机骨架(engage.go)分开,
// 使两边都留在项目「文件 ≤500 行」的上限内:负面态度词表 + 例外②的真故障/求救
// 严格子集 + 冷避闸本体(coldShoulderGate / coldShoulderLocked)。
//
// 顺序口径(塔 2026-10-15 定稿,文档面 design/18 §5/§9 由塔同步):
// 同句负面态度优先 → 冷却期内真故障/求救信号穿透(不解除冷却)→ 其余一律静默;
// @她 / @全体 不穿透;主人不需要例外(owner 车道在 shouldRespond 第一步已分岔)。
// negativeAttitude 报告群是否在让她安静(design/18 §5
// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号
// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。
func (b *Bridge) negativeAttitude(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, k := range negativeAttitudeKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是
// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。
// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②),
// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。
//
// 匹配是**子串**(strings.Contains),因此词条的选取标准是「够长、够专指」:短而
// 泛的词(「减少」「输出」「最少」「小声」)会在与她无关的日常对话里大量误伤,
// 把整群按进 30 分钟静默。末尾两条是 M18 依**真机原话**补的(2026-10-14 群
// 531082168「企鹅神明」两次报障的原文),刻意只取带「文本输出」这个专指锚点的
// 长短语 —— 见 engage_coldshoulder_test.go 的词表护栏用例。
var negativeAttitudeKeywords = []string{
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
"安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
"打住", "住嘴", "shut up", "too loud", "stop spamming",
// 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式,
// 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 ——
// 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。
"减少文本输出",
// 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。
// 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出
// (音箱/视频/日志)调低的话题,故不取。
"文本输出调到最少",
}
// coldShoulderPierceKeywords 是冷避**例外②专用**的严格子集(塔 2026-10-15 裁定):
// 冷却期内只有命中它的消息能穿透冷避。它**只服务这一个用途** —— 与 light.go 的
// highValueKeywords(基线规则,决定「平时值不值得回」)是**两张表**:那张表一个字
// 不许改,也**不要**把它并进来(见下面的理由)。
//
// **为什么它比全表严**:例外②给命中词「穿透 30 分钟冷避」的权力,于是噪声不再只是
// 多回一句话,而是**直接削弱冷避本身**。实测(598 条真实 user 消息)全表 14 条只
// 命中 1 次,且那次是**假阳性** —— QQ 机器人卡片文案「唯一的 bug 是你至今没搞懂
// 为什么猪喜欢拱泥」(群 531082168,2026-10-14),而该群本来就在放这类卡片。
// 因此按塔裁定剔掉三类噪声源:
//
// - ASCII `bug` / `error` / `help`:子串会命中结构化载荷(`%7B%22version%22`)、
// URL、markdown 卡片与英文闲聊里的同形片段(上面那例即实证);
// - 情绪 / 口语歧义的中文 `崩溃`(「我崩溃了」是情绪,不是故障)与
// `坏了` / `坏掉`(「坏了,我忘了」是「糟糕」义)。
//
// `urgent` 同样是 ASCII 却在保留之列:它的语义只有「紧急」一种,不会像 `error`/`bug`
// 那样以名词形态出现在卡片/日志载荷里(实测 0 命中)—— 按塔的裁定保留。
//
// 代价明写在案:用英文写的真求救("…error…help…")**不再穿透**。塔裁定以「宁可少
// 穿透、不可多穿透」为准;全表本身的收窄是**独立切片**,不在本片做。
var coldShoulderPierceKeywords = []string{
"报错", "出错", "故障", "紧急", "救命", "求助", "帮帮我", "urgent",
}
// hasColdShoulderPierceKeyword 报告文本是否命中例外②的严格子集。与全表
// (hasHighValueKeyword)同一手法:廉价子串匹配,只读正文,绝不推断身份。
func hasColdShoulderPierceKeyword(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, k := range coldShoulderPierceKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸
// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环:
// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内且本条
// 没有命中**真故障/求救信号**严格子集(coldShoulderPierceKeywords)。
//
// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住
// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化
// (2026-10-14 报障的病灶)。适用面严格限定如下:
//
// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按
// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟;
// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套
// 结构化旋钮(ownerlane.go),冷避绝不碰主人的必回语义;
// - **与参与度引擎是否装配无关**:未装配引擎(旧轻量判定车道)的部署顺序与
// 装配了的**完全一致** —— 闸本身不查 engageConfigured()。
//
// 例外口径(塔 2026-10-15 定稿,design/18 §5/§9 由塔同步)见 coldShoulderLocked。
func (b *Bridge) coldShoulderGate(msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) {
cfg := b.engageConfigFor(msg, actor)
key := engageKey(msg, actor)
st := b.engageState(key)
st.mu.Lock()
defer st.mu.Unlock()
return b.coldShoulderLocked(msg, actor, key, st, cfg, b.now())
}
// coldShoulderLocked 是冷避判定的本体(调用方须持有 st.mu)。它是该规则
// **唯一**的实现:shouldRespond 的冷避闸与 engageDecision 都调它。
// 命中负面态度时进 COOLDOWN 并留一条带来源的 INFO —— 判据只读正文关键词,
// 日志只带群键、发送者与截止时刻,**绝不打印正文**(design/18 §9.7)。
//
// 判定顺序(塔 2026-10-15 裁定;该口径的文档面 design/18 §5/§9 由塔同步,本片只写代码注释):
//
// ① 同句**负面态度优先**:一句里既有「安静点」又有「报错」也按负面态度处理
// —— 加个高价值词绝不能把「安静点」作废,仍进冷却、仍静默。
// ② 冷却期内 + **真故障/求救信号** ⇒ **放行这一条**,且**不解除冷却**
// (不动 st.state / st.cooldownUntil):「让她少**闲聊**」≠「让她漏掉**真故障**」。
// 判据是**严格子集** coldShoulderPierceKeywords,**不是** hasHighValueKeyword 全表
// —— 例外给的是「穿透冷却」的权力,噪声会直接削弱冷避(依据见该表的注释);
// 但这不等于撤诉:紧接着的 @她 / 闲聊消息仍在冷却里静默。
// ③ 冷却期内其余一切(含 @她 / @全体)⇒ 静默。
//
// **主人为什么不需要单独写一条例外**:owner 车道在 shouldRespond 的**第一步**就按
// `actor.IsOwner()` 分岔(见 ownerlane.go 的 ownerGroupShouldRespond),主人的消息
// 根本走不到这个闸;在这里再写一条「主人例外」是死代码,还会让人误以为主人
// 也可能被冷避压住。
func (b *Bridge) coldShoulderLocked(msg adapter.UnifiedMessage, actor adapter.Actor, key string, st *engageGroupState, cfg EngageConfig, now time.Time) (bool, string) {
if b.negativeAttitude(msg.Content) {
b.enterCooldown(st, cfg, now)
// INFO:主人要能看出「她为什么突然不说话了」(谁在哪个群、冷却到什么时候)。
b.log.Info("engage cooldown: group asked her to stay quiet",
"group", key, "sender", msg.SenderID, "sender_name", senderDisplayName(msg, actor),
"until", st.cooldownUntil)
return true, "engage: cooldown (negative attitude)"
}
if st.inCooldown(now) {
// 例外②:真故障/求救信号穿透冷避 —— 只放行这一条,冷却本身照旧。
// 判据必须是**严格子集**(coldShoulderPierceKeywords),不是
// hasHighValueKeyword 全表:穿透权一旦挂在全表上,卡片/载荷里的
// `bug` 这类噪声就能把冷避按掉(2026-10-15 实测假阳性)。
if hasColdShoulderPierceKeyword(msg.Content) {
b.log.Debug("engage cooldown: fault signal allowed through, cooldown kept",
"group", key, "until", st.cooldownUntil)
return false, ""
}
b.log.Debug("engage cooldown active, not responding",
"group", key, "until", st.cooldownUntil)
return true, "engage: cooldown active"
}
return false, ""
}
@@ -0,0 +1,198 @@
package channelbridge
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件覆盖冷避**冷却期内的例外口径**(塔 2026-10-15 定稿;文档面 design/18 §5/§9
// 由塔同步,这里只钉行为):
//
// ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默;
// ② **真故障/求救信号**(严格子集 coldShoulderPierceKeywords)穿透冷却 ——
// 只放行这一条,且**不解除**冷却;**不是**高价值关键词全表(`bug`/`error`/`help`
// 与情绪歧义的 `崩溃`/`坏了`/`坏掉` 被剔出去,矩阵里有对应行);
// ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质);
// ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。
//
// 冷避闸本体的顺序修正(M18 主体)与登记/词表/可见性等用例见
// engage_coldshoulder_test.go(同包,共享 coldShoulderMention / coldShoulderBridge 夹具)。
// TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**:
// 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。
//
// 每行的 bite 标出它对**上一提交**(例外放宽为全表的那一版)的性质:红前红 / 护栏 ——
// 不得把护栏吹成证据。
func TestColdShoulderCooldownMatrix(t *testing.T) {
t.Parallel()
cases := []struct {
name string
msg func() adapter.UnifiedMessage
wantReply bool
bite string
}{
{
name: "fault keyword pierces the cooldown",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("服务报错了,谁知道怎么回事") },
wantReply: true,
bite: "本轮红前红(fe53e02 的冷避闸会把它静默掉,即「让她漏掉真故障」)",
},
{
name: "fault keyword addressed to her pierces too",
msg: func() adapter.UnifiedMessage { return coldShoulderMention("系统报错了,快看看") },
wantReply: true,
bite: "本轮红前红;例外②按**正文**判定,@她 不在排除项里(与裁定 ①的差别在于本条含真故障)",
},
{
// 真机实证(群 531082168,2026-10-14):机器人卡片文案里出现 `bug`。
// 例外②收窄前(用 hasHighValueKeyword 全表)这条会**穿透冷却**;
// 收窄成严格子集后不穿透 —— 这正是塔 2026-10-15 改裁定的依据。
name: "bot card containing bug does NOT pierce",
msg: func() adapter.UnifiedMessage {
return nonOwnerGroupMsg("【猪工智能】Lv.2 正在学习 —— 唯一的bug是你至今没搞懂为什么猪喜欢拱泥")
},
wantReply: false,
bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行这张卡片)",
},
{
// ASCII `error` / `help` 同属被剔的三类。如实记录代价:用英文写的
// 真求救不再穿透(塔裁定以「宁可少穿透」为准)。
name: "english error/help does NOT pierce",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("there is an error in my code, help me") },
wantReply: false,
bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行);同时如实记录严格子集的已知代价",
},
{
// 情绪/口语歧义的 `崩溃` 同属被剔的三类(「我崩溃了」= 情绪,不是故障)。
name: "emotional 崩溃 does NOT pierce",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("这周稿子又没写完,我崩溃了") },
wantReply: false,
bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行)",
},
{
name: "cooldown is NOT lifted by the exception",
msg: func() adapter.UnifiedMessage { return coldShoulderMention("晚上有空吗") },
wantReply: false,
bite: "护栏(fe53e02 亦绿):钉住穿透只放行那一条,不撤诉 ---- 本片的命根子",
},
{
name: "plain chatter stays silenced",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("今晚吃点什么") },
wantReply: false,
bite: "护栏(fe53e02 亦绿)",
},
{
name: "negative attitude wins inside the same message",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("别刷屏了,线上报错了") },
wantReply: false,
bite: "护栏(fe53e02 亦绿):钉住例外②不得被插到负面态度判定之前(对 base 是红)",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// 每行一张干净的桥接器、各自由真机原话①触发冷却,使各行的前状态完全一致。
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
hist := &captureHistory{}
b := coldShoulderBridge(t, chat, hist, func() time.Time { return now })
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("未能进入冷却:循环被驱动 = %q", chat.sessionID)
}
before := len(hist.records())
b.process(context.Background(), tc.msg())
if got := chat.sessionID != ""; got != tc.wantReply {
t.Fatalf("冷却期内循环被驱动 = %v, want %v(%s)", got, tc.wantReply, tc.bite)
}
// 静默 ≠ 丢弃:本条必须已登记(被放行的那条在回合里另有一份落库)。
if len(hist.records()) <= before {
t.Fatalf("本条没有被登记:records 停在 %d", before)
}
})
}
}
// TestColdShoulderHighValueKeywordBaselineOutsideCooldown 是反例护栏(塔要求 2 的第 4 条):
// 冷却期**已过**后,高价值关键词与普通消息的行为与基线完全一致 —— 前者照旧必回
// (ruleLightJudge 的高价值关键词路径),后者照旧只登记(未装配引擎时失败关闭)。
//
// 诚实标注:本条对 base 与本轮修前**都是绿的**,它是**护栏而非红前红** ——
// 它守的是「例外只改冷却期内,不碰冷却之外的既有语义」。
func TestColdShoulderHighValueKeywordBaselineOutsideCooldown(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
b := coldShoulderBridge(t, chat, &captureHistory{}, func() time.Time { return now })
b.process(context.Background(), coldShoulderMention("减少文本输出量")) // 进冷却
now = now.Add(defaultCooldownDuration + time.Minute) // 推过冷却
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("服务报错了"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过后的高价值关键词未回:session = %q, want sess-g", chat.sessionID)
}
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("今晚吃点什么"))
if chat.sessionID != "" {
t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.sessionID)
}
}
// TestColdShoulderPierceKeywordSubset 直接钉住例外②的**严格子集**本身:保留的八条命中,
// 被剔的三类(ASCII `bug`/`error`/`help`、情绪歧义的 `崩溃`、口语「糟糕」义的 `坏了`/`坏掉`)
// 不命中。这是防「后人顺手把全表并回来」的第一道闸(第二道是矩阵里的卡片/英文/情绪三行)。
//
// 同时钉住**分工边界**:`hasHighValueKeyword` 全表一个字未改 —— `bug` 在 ruleLightJudge
// 眼里仍是高价值词(基线行为),只是不再拥有「穿透冷避」的权力。
func TestColdShoulderPierceKeywordSubset(t *testing.T) {
t.Parallel()
cases := []struct {
name string
text string
want bool
}{
{"report error", "服务报错了,谁知道怎么回事", true},
{"error occurred", "这里出错了", true},
{"fault", "群文件传输故障", true},
{"urgent", "this is urgent", true},
{"rescued", "救命啊", true},
{"asking for help", "帮帮我看看这个", true},
{"help me", "谁能帮我一下", false}, // 「帮我」不在表里,只有「帮帮我」在
{"bug in a bot card", "唯一的bug是你至今没搞懂为什么猪喜欢拱泥", false},
{"english error/help", "there is an error in my code, help me", false},
{"emotional collapse", "这周稿子又没写完,我崩溃了", false},
{"colloquial oops", "坏了,我忘了", false},
{"plain chatter", "今晚吃点什么", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := hasColdShoulderPierceKeyword(tc.text); got != tc.want {
t.Fatalf("hasColdShoulderPierceKeyword(%q) = %v, want %v", tc.text, got, tc.want)
}
})
}
// 分工边界:全表(基线)不许被这次收窄波及。
if !hasHighValueKeyword("唯一的bug是你至今没搞懂为什么猪喜欢拱泥") {
t.Fatal("hasHighValueKeyword 被改动了:它是基线行为(ruleLightJudge 用),本片不许动")
}
}
@@ -0,0 +1,409 @@
package channelbridge
import (
"bytes"
"context"
"io"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件覆盖 M18(主人 2026-10-14 裁决「给」:群成员也能让她少说话)在
// channelbridge 里的落点 —— **冷避优先于「@她必回」**(design/18 §5):
//
// - 非主人**群**消息先过冷避闸(命中负面态度 → COOLDOWN;冷却期内一律静默),
// 顺序排在廉价规则判定(ruleLightJudge)**之前**,因此 @她 / @全体 /
// 高价值关键词都拦不住它;
// - 边界:私聊(含陌生人私聊)与 owner 车道**不受影响**;闸与是否装配
// 参与度引擎**无关**;
// - 被冷避静默的消息**照样进上下文**(registerSilenced / persistIsolated),
// 只是不进 loop —— M13 红线,端到端钉住。
// coldShoulderSelfID 是 2026-10-14 报障现场那个号(她自己的 self_id),用于把
// 「@她」判据按**结构化字段**接上线(与 ownerlane.go 的 ownerAddressedToMe 同一口径)。
const coldShoulderSelfID = "3443115524"
// coldShoulderMention 构造一条**指向她本人**的成员群消息:SelfID 已接线 ⇒
// AddressedToMe 由结构化字段算出(不是靠正文里的 "@<数字>" 猜)。修前的实现会把它
// 当成「必回」,这正是本片要改的顺序偏离。
func coldShoulderMention(content string) adapter.UnifiedMessage {
m := nonOwnerGroupMsg(content, adapter.Mention{UserID: coldShoulderSelfID})
m.SelfID = coldShoulderSelfID
m.AddressedToMe = m.AddressedToSelf()
return m
}
// coldShoulderBridge 构建一条成员群消息用的桥接器:**未装配**参与度引擎与 LightJudge
// (规则得不出结论时失败关闭为静默),时钟可注入 —— 因此「必不必回」只由规则判据与
// 冷避闸决定,用例完全确定。
func coldShoulderBridge(t *testing.T, chat *stubChatter, hist *captureHistory, now func() time.Time) *Bridge {
t.Helper()
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist, Now: now,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b
}
// TestColdShoulderPrecedesAddressedToMe 是本片的主断言(任务⑤①,**修前红 / 修后绿**):
// 群成员 @她 并说「减少文本输出量」时 ——
//
// - 修前:@她 命中 ruleLightJudge 的 verdictRespond ⇒ shouldRespond 直接 return true,
// 冷避根本走不到(红:循环被驱动,她只会口头答应、行为零变化,即报障现场);
// - 修后:冷避闸排在「@她必回」之前 ⇒ 静默,但**照样登记**(静默 ≠ 丢弃)。
//
// 两种部署都断言:装配了参与度引擎的(原本走 engageDecision)与未装配的
// (原本走旧轻量判定车道)—— 顺序修复**与引擎装配无关**(任务①的边界要求)。
func TestColdShoulderPrecedesAddressedToMe(t *testing.T) {
t.Parallel()
cases := []struct {
name string
engage bool
}{
{name: "engage engine wired", engage: true},
{name: "no engage engine (legacy light lane)", engage: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{}
chat := &stubChatter{res: &loop.Result{Text: "好嘛,人家少说点♪"}}
hist := &captureHistory{}
opts := Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(),
}
if tc.engage {
opts.Light = judge
opts.TriggerWords = []string{"昔涟"}
opts.Engage = DefaultEngageConfig()
opts.EngageRand = func() float64 { return 0.5 }
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.sessionID)
}
recs := hist.records()
if len(recs) != 1 || recs[0].Content != "减少文本输出量" {
t.Fatalf("records = %+v, want 恰好 1 条登记(静默 ≠ 丢弃)", recs)
}
// 只有装配了引擎的那一路才够得着判定器;旧车道根本没有它,
// 因此这一条只在装配时断言(不给旧车道记一笔假功劳)。
if tc.engage && judge.calls != 0 {
t.Fatalf("judge calls = %d, want 0(冷避命中不得咨询轻量 LLM:成本红线)", judge.calls)
}
})
}
}
// TestColdShoulderSecondReportedPhraseAndCooldownWindow 覆盖报障的**第二句原话**,并钉住
// 冷却的**时限性**:进冷却后连普通 @她 也不回(M18 口径 —— 旧口径「@ 打断冷却」正是本片
// 修掉的偏离);时钟推过冷却时长后她重新回到正常判定,**不是**永久闭嘴。
func TestColdShoulderSecondReportedPhraseAndCooldownWindow(t *testing.T) {
t.Parallel()
// 真机时刻:2026-10-14 00:27(群 531082168,成员「企鹅神明」)。
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
chat := &stubChatter{res: &loop.Result{Text: "好♪"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: &captureHistory{},
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("在本群中文本输出调到最少"))
if chat.sessionID != "" {
t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.sessionID)
}
// 冷却期内:普通 @她 也不回(冷避优先于「@她必回」)。
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "" {
t.Fatalf("冷却期内 @她 被驱动 = %q, want \"\"(@ 不得作废冷避)", chat.sessionID)
}
// 推过默认冷却时长(30 分钟):她恢复到「@她必回」的常态。
now = now.Add(defaultCooldownDuration + time.Minute)
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过期后 @她 仍未回:session = %q, want sess-g(冷避是**有时限**的)", chat.sessionID)
}
}
// TestColdShoulderNormalMentionNotCooled 是反例护栏(任务⑤②):不含负面态度的普通
// @她 消息**不得**被误冷却 —— 回得照常,且不把该群留在冷却态里(第二条普通 @她 仍回)。
// 诚实声明:这条在修前也是绿的,它是**护栏而非红前红**;红前红的证据是上面两条。
func TestColdShoulderNormalMentionNotCooled(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.sessionID)
}
// 第二条紧接着的普通 @她:若第一条被误判成负面态度,这条会落进冷却而静默。
chat.sessionID = ""
b.process(context.Background(), coldShoulderMention("在不在"))
if chat.sessionID != "sess-g" {
t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.sessionID)
}
}
// TestColdShoulderGroupOnlyBoundaries 钉住冷避的**适用面**(任务①的三条边界):
//
// - 陌生人**私聊**说「闭嘴」照样正常聊天(冷避只存在于群聊车道,design/18 §11);
// - 主人**私聊**说「闭嘴」照样恒回(owner 车道在更早处分岔);
// - 主人**群聊**里 @她 说「安静点」仍必回(owner 车道的必回不受冷避影响,主人
// 有自己那套旋钮)。
//
// 三条在修前也是绿的(护栏),但它们钉住的是「不许把闸开大」的边界 —— 少了它们,
// 一次把闸挪到 shouldRespond 最前端就能悄悄改掉私聊与主人的行为而无人察觉。
func TestColdShoulderGroupOnlyBoundaries(t *testing.T) {
t.Parallel()
t.Run("stranger private still chats", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "好呀"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-p", IsGroup: false, Owner: false}},
Owner: &user.OwnerProfile{}, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "9", ChannelType: adapter.ChannelDirect,
SenderID: "9", Sender: adapter.Actor{Role: adapter.RoleMember},
Content: "闭嘴", ContentType: adapter.ContentText,
})
if chat.sessionID != "sess-p" {
t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.sessionID)
}
})
t.Run("owner private still replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "好的主人"}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{}, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
m := directMessage()
m.Content = "闭嘴"
b.process(context.Background(), m)
if chat.sessionID != history.MainSessionID {
t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.sessionID, history.MainSessionID)
}
})
t.Run("owner group addressed still replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{},
dayNow(), slog.New(slog.NewTextHandler(io.Discard, nil)))
b.process(context.Background(), ownerLaneGroupMsg("安静点", adapter.Mention{UserID: "10001"}))
if chat.sessionID != "sess-og" {
t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.sessionID)
}
})
}
// TestColdShoulderSilencedMessageStillEntersContext 是 M13 红线的**端到端**守卫(任务②):
// 被冷避静默的消息照样进上下文,只是不进 loop。断言不走返回值,而走真实链路:
//
// ① 静默那条经 registerSilenced → persistIsolated 落进该群隔离会话(历史可读);
// ② 冷却结束后下一条 @她 真正驱动回合时,**该回合的模型可见消息里含那条被静默的话**
// —— 她看得见「刚才有人让她少说话」,不是被蒙在鼓里(transcript.go 的播种)。
func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
chat := &stubChatter{res: &loop.Result{Text: "知道啦♪"}}
hist := &recentHistory{}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("循环被驱动 = %q, want \"\"", chat.sessionID)
}
// ① 静默那条已被登记(不是被丢掉)。
registered := false
for _, r := range hist.records() {
if r.SessionID == "sess-g" && r.Role == history.RoleUser && r.Content == "减少文本输出量" {
registered = true
}
}
if !registered {
t.Fatalf("被冷避的消息没有登记:records = %+v", hist.records())
}
// ② 冷却过期后 @她 驱动回合 → 该回合上下文里读得到那条被静默的话。
now = now.Add(defaultCooldownDuration + time.Minute)
b.process(context.Background(), coldShoulderMention("在吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.sessionID)
}
if !loopMessagesContain(chat.messages, "减少文本输出量") {
t.Fatalf("被静默登记的消息没进上下文:%+v", chat.messages)
}
}
// TestNegativeAttitudeKeywordList 逐条钉住 M18 的词表增补(任务③):认识**真机原话**,
// 且**不**认识只在字面上擦边的日常说法(子串匹配的误伤护栏 —— 词条选取见
// engage.go 的 negativeAttitudeKeywords 注释)。
func TestNegativeAttitudeKeywordList(t *testing.T) {
t.Parallel()
b := &Bridge{}
cases := []struct {
name string
text string
want bool
}{
{"real report 1", "@3443115524 减少文本输出量", true},
{"real report 2", "@3443115524 在本群中文本输出调到最少", true},
{"same wording, no quantifier", "减少文本输出频率", true},
{"pre-existing owner wording", "少说点", true},
{"dev talk about logs", "我在写脚本,减少输出日志", false},
{"praising output quality", "这个模型的输出质量不错", false},
{"another kind of output", "把音箱输出调到最少", false},
{"plain chatter", "今天天气不错", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := b.negativeAttitude(tc.text); got != tc.want {
t.Fatalf("negativeAttitude(%q) = %v, want %v", tc.text, got, tc.want)
}
})
}
}
// TestColdShoulderLogsVisibleInfo 钉住可见性(任务④):进冷却时必须有**带来源的 INFO**
// (哪个群 / 被谁的话 / 冷却到什么时候),使主人能看出「她为什么突然不说话了」。
// 日志绝不打印正文(design/18 §9.7),因此这里只断言键值。
func TestColdShoulderLogsVisibleInfo(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, Log: log, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
out := buf.String()
for _, want := range []string{
"level=INFO",
"source=services/channelbridge",
"group asked her to stay quiet",
"group=telegram",
"sender=9",
"sender_name=张三",
"until=2026-01-01T12:30:00.000Z",
} {
if !strings.Contains(out, want) {
t.Fatalf("INFO 日志缺少 %q;got:\n%s", want, out)
}
}
if strings.Contains(out, "减少文本输出量") {
t.Fatalf("日志打印了正文(design/18 §9.7 禁止):\n%s", out)
}
}
// loopMessagesContain 报告本回合的模型可见消息里是否出现了 want(用于断言
// 「被静默登记的消息确实进了上下文」,而不是只看落库记录)。
func loopMessagesContain(msgs []model.Message, want string) bool {
for _, m := range msgs {
for _, c := range m.Content {
if strings.Contains(c.Text, want) {
return true
}
}
}
return false
}
// recentHistory 是 captureHistory 的读取侧补齐:Recent 返回本会话已登记的消息,
// 使 seedTurnTranscript 真能把「被静默登记」的那条话播进下一个回合的上下文。
type recentHistory struct {
captureHistory
}
func (h *recentHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
var out []history.Message
for _, m := range h.records() {
if m.SessionID == sessionID {
out = append(out, m)
}
}
if n > 0 && len(out) > n {
out = out[len(out)-n:]
}
return out, nil
}
@@ -269,11 +269,15 @@ func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) {
t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID) t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID)
} }
// 直接 @ 仍会打断冷却(design/18 §5:除非 @ 她)。 // M18(主人 2026-10-14 裁决「给」):冷却期内**连 @ 也不回** ——
// 冷避优先于「@她必回」,群成员的一句「少说点」不被随后的 @ 作废。
// (旧口径「@ 打断冷却」正是本片修掉的偏离;见 design/18 §5 与
// engage_coldshoulder_test.go 的端到端用例。)
chat.sessionID = "" chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"})) b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" { if chat.sessionID != "" || judge.calls != 0 {
t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID) t.Fatalf("mention during cooldown: session=%q calls=%d, want \"\"/0 (@ no longer breaks the cold shoulder)",
chat.sessionID, judge.calls)
} }
// 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。 // 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。
+9 -19
View File
@@ -2,10 +2,8 @@ package channelbridge
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"strings" "strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
@@ -201,22 +199,14 @@ func truncateText(s string, n int) string {
return string(runes[:n]) + "…" return string(runes[:n]) + "…"
} }
// parseKeepIndices 解码审查模型返回的 JSON 索引数组,并按升序 // parseKeepIndices 解码审查模型返回的编号数组,并按升序只保留范围内、去重后的值。
// 只保留范围内、去重后的值。越界或重复的 // 越界或重复的索引被丢弃,因此模型幻觉出的数字绝不会淘汰真实消息。
// 索引被丢弃,因此模型幻觉出的数字绝不会淘汰真实消息。 //
// 解析本身在 adapter.ParseReviewIndices(解析属于闸门的契约,和 Reviewer 类型同一个包),
// 它容忍对端回带的东西:**UTF-8 BOM**、首尾空白/换行、Markdown 代码围栏、数组外的解说
// 文字。修复前这里是裸 json.Unmarshal,实测 9 天 1,909 次审查里 1,301 次解析失败
// (其中 1,288 次错误串逐字是 `invalid character 'ï' looking for beginning of value`),
// 每次失败都白烧一次模型调用 —— 见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md。
func parseKeepIndices(s string, bound int) ([]int, error) { func parseKeepIndices(s string, bound int) ([]int, error) {
var idx []int return adapter.ParseReviewIndices(s, bound)
if err := json.Unmarshal([]byte(s), &idx); err != nil {
return nil, err
}
seen := make(map[int]bool, len(idx))
out := make([]int, 0, len(idx))
for _, i := range idx {
if i >= 0 && i < bound && !seen[i] {
seen[i] = true
out = append(out, i)
}
}
sort.Ints(out)
return out, nil
} }
@@ -3,6 +3,7 @@ package channelbridge
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"log/slog" "log/slog"
"strings" "strings"
"testing" "testing"
@@ -329,3 +330,88 @@ func TestParseKeepIndices(t *testing.T) {
t.Fatal("expected error for non-JSON") t.Fatal("expected error for non-JSON")
} }
} }
// ── M24:审查解析的**接线**(BOM 韧性只有在这里才算真的生效)────────────────
//
// parseKeepIndices 现在是 adapter.ParseReviewIndices 的一行委派;adapter 侧有自己的
// 用例,但「委派还在不在」只有本文件的用例能锁住 —— 谁把它改回裸 json.Unmarshal,
// 下面这几条就会红。生产症状(`~/cy.log`,9 天):1,909 次审查里 1,301 次解析失败,
// 其中 1,288 次错误串逐字是 `invalid character 'ï' looking for beginning of value`
// (对端在数组前回带了 UTF-8 BOM),每一次都白烧一次模型调用。
//
// 另一条已确认接受的语义代价(M24,塔裁决):审查移出准入路径后 review_pruned 不再
// 从门口丢消息(实测 41–74 条/日 现在会进流水线)—— 这是「不阻塞读循环」的必然代价。
// rate_limited 的门口丢与 FIFO 上限修剪**一字不改**:前者由本文件既有的
// TestFlowGate_RateLimitDropsNonOwnerAndAudits 钉住,后者由 adapter 侧的
// TestFlowGate_CapStillEvictsOnAdmission 钉住。
// bomReviewOutput 是审查模型网关的**原样**回包:UTF-8 BOM 紧接 JSON 数组。写死字节
// (而不是 \ufeff 转义或运行时拼装),使「夹具被谁悄悄去掉 BOM」本身就会红。
const bomReviewOutput = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]"
// TestParseKeepIndices_ToleratesBOMWiring 锁住接线:生产路径的 parseKeepIndices 必须
// 吃下带 BOM 的对端回包。
func TestParseKeepIndices_ToleratesBOMWiring(t *testing.T) {
t.Parallel()
// 夹具真实性:它必须以 BOM 字节开头,且裸 json.Unmarshal 在它上面必须失败 ——
// 否则这条用例就不再模拟任何生产故障。
if !strings.HasPrefix(bomReviewOutput, "\xef\xbb\xbf") {
t.Fatal("fixture lost its BOM bytes: this case no longer models the production failure")
}
var raw []int
if err := json.Unmarshal([]byte(bomReviewOutput), &raw); err == nil {
t.Fatal("strict json.Unmarshal accepted the BOM fixture; the case is meaningless now")
}
got, err := parseKeepIndices(bomReviewOutput, 6)
if err != nil {
t.Fatalf("parseKeepIndices(BOM) error = %v, want nil (delegation to adapter.ParseReviewIndices lost?)", err)
}
if len(got) != 6 {
t.Fatalf("keep = %v, want [0 1 2 3 4 5]", got)
}
for i := range got {
if got[i] != i {
t.Fatalf("keep = %v, want ascending [0 1 2 3 4 5]", got)
}
}
}
// TestParseKeepIndices_StillFiltersAfterDelegation 委派之后过滤口径不变:越界、负数、
// 重复的编号仍被丢弃,结果按升序(模型幻觉出的数字绝不能淘汰真实消息)。
func TestParseKeepIndices_StillFiltersAfterDelegation(t *testing.T) {
t.Parallel()
got, err := parseKeepIndices("\xef\xbb\xbf[3, -1, 3, 1, 99]", 4)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got) != 2 || got[0] != 1 || got[1] != 3 {
t.Fatalf("keep = %v, want [1 3] (out-of-range/negative/dup dropped, ascending)", got)
}
}
// TestNewLLMReviewer_ToleratesBOMOutput 走**整条生产路径**:审查模型回带 BOM ⇒
// NewLLMReviewer 产出的 Reviewer 必须给出保留编号,而不是报 unparsable 退回 FIFO。
// (它证明的是密封在函数里的 wiring,而不只是 parseKeepIndices 这一个符号。)
func TestNewLLMReviewer_ToleratesBOMOutput(t *testing.T) {
t.Parallel()
review := NewLLMReviewer(&mockLLM{content: bomReviewOutput}, nil)
msgs := make([]adapter.UnifiedMessage, 0, 6)
for i := 0; i < 6; i++ {
msgs = append(msgs, adapter.UnifiedMessage{
SenderID: "u",
Content: "m",
ContentType: adapter.ContentText,
})
}
keep, err := review(context.Background(), msgs)
if err != nil {
t.Fatalf("review with BOM output: %v, want nil error", err)
}
if len(keep) != 6 {
t.Fatalf("keep = %v, want all six indices", keep)
}
}
+27 -221
View File
@@ -3,7 +3,6 @@ package channelbridge
import ( import (
"context" "context"
"errors" "errors"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
@@ -59,9 +58,10 @@ func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) {
b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name) b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name)
} }
// onMessage 是面向适配器的入站回调。它在有界 goroutine 中运行 // onMessage 是面向适配器的入站回调。它**只投递、不跑回合**:过完流控闸就把消息投进
// 流水线,使适配器的读取循环不会因缓慢的 agent 轮次而阻塞。 // 该会话自己的回合车道(lane.go)并立即返回,因此适配器的读循环(WS 单线程读 socket /
// 适配器被显式携带,使流水线知道是哪个账号收到了 // iLink 长轮询 / webhook 处理器)永远不会被一轮缓慢的 agent 调用阻塞 —— 心跳与其它帧
// 照常被读、被解析。适配器被显式携带,使流水线知道是哪个账号收到了
// 消息(多账号回复路由 + 按账号允许/拒绝,design/16 // 消息(多账号回复路由 + 按账号允许/拒绝,design/16
// §4.6 §5)。 // §4.6 §5)。
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
@@ -94,29 +94,17 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad
} }
} }
select { // M22(切片 LANEQUEUE)——**读与回水解耦**:流控闸在读循环里仍然是同步的
case b.sem <- struct{}{}: // (一次滑动窗口判断,微秒级;洪泛正是在这里被挡掉,不该让它进车道占内存),
case <-ctx.Done(): // 闸后立刻把消息投进**该会话自己的回合车道**并返回,读循环因此马上可以继续读
return // socket —— 心跳与其它帧不再被一轮模型调用阻塞(此前这里同步抢 16 个全局回合槽,
} // 槽满即停止读 socket,这正是「消息一直排队、后面才回复前面的话」的现场)。
//
// 在 Stop 设置 closed 所用的同一把锁下 Add 工作项,使 // 入队非阻塞、有界(背压见 lane.go 的 offer):群聊/陌生人车道满时**在门前拒收
// 并发的 Stop 要么在等待前看到我们的 Add,要么我们 // 这一条**并留痕(WARN + 计数 + 审计),队列里已有的条目一条不动;主人私聊永不
// 观察到 closed 后退出,不泄露 goroutine。 // 拒收,溢到 spill 只排队。回合本身在 laneWorker 里跑,全局并发上限
b.mu.Lock() // (maxConcurrent)也搬到那里取。
if b.closed { b.enqueueInbound(ctx, msg, a, actor)
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
} }
// process 用平台与消息匹配的适配器运行入站流水线 // process 用平台与消息匹配的适配器运行入站流水线
@@ -126,109 +114,20 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform)) b.processFrom(ctx, msg, b.adapter(msg.Platform))
} }
// processFrom 针对指定的账号适配器运行完整入站流水线: // processFrom 针对指定的账号适配器运行完整入站流水线:逐条过闸门(prepareInbound)
// 解析会话/actor、应用允许/拒绝黑白名单、以聊天文本 // 然后跑**一条**回合(runBatch)。身份由会话层结构化解析;桥接器绝不读取消息内容来
// 驱动 agent 循环,并把循环的回复经接收该消息的同一 // 判定发送者是谁(项目红线)。
// 适配器送回去。身份由会话层结构化解析; //
// 桥接器绝不读取消息内容来判定发送者是谁 // 它是**同步**便捷路径(`process` 与测试用)。真实的渠道入站走车道
// (项目红线)。 // (enqueueInbound → laneWorker → prepareInbound + runBatch),因为只有车道能提供
// 「同一会话串行」与「静默窗口合并」这两件事。
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
// 语音 → 文本归一是入站流水线的**第一件事**(切片 IM-ASR;design/06 §1.1): item := laneItem{msg: msg, a: a, arrivedAt: b.now()}
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content p, ok := b.prepareInbound(ctx, item)
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与 if !ok {
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
//
// 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步),
// 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。
inboundVoice := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
// 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**,
// 继续走常规流水线(限定注入 / 升级真会话)。
notice := b.preEntryReview(ctx, &msg, a)
// design/26 §4/§5(切片 E3):**渠道事件留存**的唯一插入点。
//
// 位置说明(与 §5.1 示意图的一行之差):preEntryReview **就是**那一步归一化
// (它调用适配器的 SystemNoticeNormalizer 并净化 SafeText),把 recordEvent 放在
// 它之后一行可以**只归一化一次**,并且 §9.2 第 2 条要求把 NoticeClass 的裁决结果
// 记进日志 —— 那只有这一行之后才有结论。关键性质不变:留存**无条件**,在任何
// 早退分支(仅登记 / 静默 / 升级)之前,且**绝不**决定 loop 走不走。
b.recordEvent(ctx, msg, a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, a, notice)
return return
} }
b.runBatch(ctx, []preparedInbound{p})
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的
// 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走:
// 附件/语音/图片就是它的内容(design/24 §4.4 的「不许静默丢」;语音那一路的归一已
// 保证它要么有文本、要么有诚实提示,图片那一路的失败也留一行中文诚实提示)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6:黑白名单在流水线的最前端执行,
// 早于会话解析与循环驱动,因此被拉黑或不在白名单的
// 群/用户消息绝不会到达 LLM。
// 主人始终放行(由解析出的 actor 结构化决定)。
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() && b.blacklistDrop(msg, a) {
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二 + design/18 §11「乙 = C」(主人 2026-10-13 裁决):
// owner 车道不再**无条件短路**这道闸 —— 主人群消息也按结构化判据决定说不说。
// - 主人**私聊/非群**:恒回,不受影响;
// - 主人**群聊**:只有指向她本人的(@她 / @全体 / 未接线渠道旧判据)才必回,
// 其余**仍进上下文但不触发回复**(落进该群隔离会话,见 ownerlane.go);
// - 非主人:走既有的最小打扰分级(shouldRespond)。
// 被拦下的消息只登记到其隔离会话日志并静默——绝不驱动完整 agent 循环。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
if !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("inbound message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
return
}
}
// 裁决①的第二个流控(主人 2026-10-05):陌生人私聊虽然一律回答,但
// **同一发送者同时最多 1 个在跑的回合**。槽位复用同一个 flowgate 的
// 会话计数(AcquireTurn/ReleaseTurn,键口径与限流一致);已有回合在跑时
// 这一条只登记、不回,并留可见告警 —— 一个陌生人无法用并发消息占满循环。
if isStrangerPrivate(sess) {
if gate := b.gateFor(a); gate != nil {
if !gate.AcquireTurn(msg) {
b.registerThrottled(ctx, msg, actor, sess, a,
"stranger private: a turn is already running for this sender")
return
}
defer gate.ReleaseTurn(msg)
}
}
b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice)
} }
// blacklistDrop 对非主人消息应用黑白名单,返回 // blacklistDrop 对非主人消息应用黑白名单,返回
@@ -271,99 +170,6 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
return inboundTarget{sessionID: sess.SessionID} return inboundTarget{sessionID: sess.SessionID}
} }
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
// (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并
// 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的
// 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取
// 该隔离会话,见 transcript.go),在轮次之前持久化入站
// 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道
// 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论
// 补回同一 document 列,运行循环并把结果交给 deliverReply。
//
// voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本:
// 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) {
target := inboundTargetFor(sess, msg.Platform)
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
attachments := b.ingestInboundDocuments(ctx, msg)
// 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用
// internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。
// 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。
voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn)
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
images := b.ingestInboundImages(msg)
// 本回合的**权威 turn id**:IM 车道没有编排器,桥接器自己就是这条车道的回合
// 权威(turnid.go)。它在落库**之前**生成,因此同一次入站回合的用户消息、助手
// 回复带上的是同一个值,前端按 (sessionId, turnId) 两级分桶时才分得进同一个回合
// —— 这正是此前缺口所在:IM 的落库行没有 turn id,任何消费方都无法判定「这条
// 回复属于哪个回合」。它同时挂到 loop ctx 上,使循环发出的流帧与历史行同源。
turnID := newTurnID()
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
// 记忆只读(MemWritable=false),并携带发送者名,
// 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。
// 谁算主人、谁算第三方由解析出的 actor 结构化决定——绝不
// 依据提示词文本。主人本人的发言**不带**发送者名(主人从不被标注):
// 受限会话里「带昵称的 user 消息 = 第三方」「不带昵称的 = 主人本人」
// 就是模型侧的归属判定车道(loop/attribution.go),落库也走同一个值 ——
// 否则转录会把主人的历史发言读成第三方人员说的。
//
// 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊
// 镜像进主会话时取主会话的绑定,否则取账号的
// 渠道级绑定,再否则取全局默认;群聊或
// 陌生人会话强制使用默认角色。
runEnv := turnEnv(sess, target, b.resolvePersona(ctx, sess, target, adapterName(a)))
senderName := thirdPartySenderName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// 附件/图片先挂到**本回合的**用户消息上(attachInboundContent 认列表里第一条
// RoleUser),再前置历史转录 —— 顺序反过来会把附件挂到转录里的旧用户消息上。
attachInboundContent(loopMsgs, attachments, images)
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认
// `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。
document := b.inboundDocumentColumn(msg, attachments)
image := inboundImageColumn(images)
// 主人私聊消息落在 Web 主会话并广播给 Web 客户端;
// 受限消息落在其隔离会话(适配器日志页),
// 以 `channel_persisted` 事件广播(绝不进入
// 主/`web` 会话或 Web 聊天)。
// 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(design/24 §4.4)。
var userMsg history.Message
if target.private {
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
}
// 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行,
// 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。
b.persistMessageVoice(userMsg, voiceRef)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
}
// 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按
// 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式,与 Web 侧
// gateway/session_document_status.go 同一口径)。
b.persistTurnDocumentStatus(userMsg, res)
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外,
// 见 turnreply.go)。
b.deliverReply(ctx, msg, a, sess, target, res, err, turnID)
}
// deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器 // deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器
// 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字, // 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字,
// 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 —— // 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 ——
@@ -371,7 +177,7 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复 // (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。 // 已在历史存储中落地。
// //
// turnID 是这次入站回合的权威标识(见 runInboundTurn):助手回复与刚落的用户消息 // turnID 是这次入站回合的权威标识(见 coalesce_batch.go 的 runBatch):助手回复与刚落的用户消息
// 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。 // 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
sess session.Session, target inboundTarget, res *loop.Result, runErr error, turnID string) { sess session.Session, target inboundTarget, res *loop.Result, runErr error, turnID string) {
+189
View File
@@ -0,0 +1,189 @@
package channelbridge
// 本文件承载入站的**回合车道**(M22 切片 LANEQUEUE)。
//
// 报障现场(主人 2026-10-24):「群聊消息速率限制似乎会让消息一直排队,导致后面才回复
// 前面的话。」病灶不在模型侧,而在**入站入口把回合跑在了适配器的读循环里**:
// `onMessage` 曾在读循环里**同步**抢全局回合槽(`b.sem`,16 个),槽满的那一刻读循环
// 就停止读 socket —— 心跳不被回应、后续帧不被解析,等一轮跑完再按条重放,于是先回旧话、
// 而且回得晚。本文件把「投递」与「跑回合」拆开,并给每个会话一条自己的车道。
//
// - **读与回水解耦**:适配器的读循环只投递、不跑回合(`enqueueInbound` 立刻返回),
// 心跳与其它帧因此永远不会被一轮模型调用堵住。全局回合槽(`b.sem`)搬到车道 worker
// 里取,仍然限制「最多 16 个回合同时跑」,但等待发生在线程池里而不是读循环里。
// - **每会话一条回合车道**:同一会话(群 / 私聊各自键,见 `laneKey`)的回合**串行**且
// **先来先跑**(车道是一条有界 FIFO + 一个 worker),不同会话可并行。
// - **陈旧度闸**:轮到某条时它已超龄 ⇒ 丢弃不补答,并留下 WARN + 计数 + 审计。
// - **背压有界且诚实**:每会话车道深度有界。群聊 / 陌生人满时**在门前拒收这一条**
// (队列里已有的条目一条都不动);**主人私聊永不拒收**,只排队(见 offer)。
//
// 本切片**不改「回什么」的语义**:该回的回、该静的仍然静,也没有新增任何「系统繁忙」
// 式回复 —— 丢弃只走审计轨(与 flowgate 的 rate_limited / review_pruned 同一条流水)。
// 静默窗口合并、回合中来了新消息就续跑一轮,属于下一片(M23),不在这里做。
//
// 四条不变量(每条都在 lane_test.go 里有一条断言):
//
// ① 顺序:同一会话内的回复顺序与到达顺序一致(FIFO 车道 + 单 worker)。
// ② 一致性:一轮内上下文是快照,同一会话绝不会有两个回合同时跑(车道串行;
// 主人私聊跨平台共用一条 `main` 车道,因为它们都路由进同一个 Web 主会话)。
// ③ 完整性:被丢的只有「超龄」与「背压」两类,两者都留 WARN + 计数 + 审计。
// ④ 最新性:回合上下文在**出队之后**才装配(processFrom 原样保留,见 runLaneItem),
// 所以一条排队中的消息看到的是它开始那一刻的最新历史,而不是入队时的快照。
import (
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
const (
// laneLogSource 是车道的子功能日志来源(AGENTS §10:日志必须标来源)。
laneLogSource = "services/channelbridge/lane"
// defaultLaneDepth 是**每会话**车道能排的条目数。取值依据:
// - 够吸收一次「连着发几条」的群聊突发(微信/QQ 里 5-8 条连发是常态,
// 尤其是几个人同时在说话而她正在答上一个问题);
// - 又小到车道永远不会变成「按条补答」的长队伍:一轮 IM 回合(含工具循环)
// 通常 3-20s,深 8 的车道在最坏情况下意味着约 2-3 分钟的滞后,那时排在后面的
// 条目大多已过陈旧度闸,多留只会多丢;
// - 内存上界随之确定:条目数 ≤ 活跃会话数 × 8,绝不无界增长。
defaultLaneDepth = 8
// defaultLaneStaleAfter 是**陈旧度闸**的默认阈值(可经 Options.LaneStaleAfter 覆盖,
// 见 channelbridge.go)。取值依据:
// - 一轮 IM 回合(含工具循环)实测多在 3-20s 这一档,30s ≈ 两倍上限;
// - 群聊里超过半分钟的「回答」在用户看来已经是「答上一句」,补它比不补更糟
// (正是报障里的「后面才回复前面的话」);
// - 比它更短会误伤正常的「她正在打字」等待(一个人等她回答 10-20s 是正常的)。
defaultLaneStaleAfter = 30 * time.Second
// laneDropReasonStale / laneDropReasonOverflow 是两类**唯一**允许的丢弃原因,
// 进 onRejected 审计轨时用的原因标记(与 flowgate 的 rate_limited /
// review_pruned / capped 同一口径,面板与排障据此聚合)。
laneDropReasonStale = "lane_stale"
laneDropReasonOverflow = "lane_overflow"
// ownerLaneBacklogWarn 是主人私聊溢出区的告警水位:越过它(并按倍率 16/32/64…)
// 记一条 WARN,表明「她落后了这么多条主人私聊」—— 这是主人私聊车道的**唯一**可观测
// 上界信号,它不丢任何一条(主人 2026-10-24 裁定:宁可晚回,不许不回)。
ownerLaneBacklogWarn = 16
// ownerPrivateLaneKey 是主人私聊车道的键:那条 lane 对应的是「Web 主会话」这个
// **目的地**(inboundTargetFor 把各平台的主人私聊都路由进去),因此与会话键故意不同。
ownerPrivateLaneKey = "main|" + history.MainSessionID
// laneKeySep 只用于「没有可用渠道」的兜底键(见 laneKey);真实车道的键逐字来自
// session.Key,本来就是 `platform \x00 channelID`。
laneKeySep = "\x00"
)
// laneItem 是车道上的一条待办:入站消息 + 它的账号适配器 + 已解析出的 actor。
//
// actor 在入口(onMessage)已经解析过一次(结构化判据,不读正文),这里带着走,
// 使陈旧度闸不必重解析,也不必为了判「主人私聊」而多一次会话查询。
type laneItem struct {
msg adapter.UnifiedMessage
a adapter.PlatformAdapter
actor adapter.Actor
arrivedAt time.Time
}
// lane 是一个会话的回合通道:有界 FIFO + 一个 worker(串行 ⇒ 不变量 ①②)。
//
// neverDrop 只对**主人私聊**车道为真:它满了不拒收,条目改落 spill(溢出区,只排队)。
// spill 恒为空的车道因此不占额外内存。
//
// 车道分**两种类型**(M23 的红线级结构约束,见下面两个构造函数):
// - **direct 车道**(主人私聊):到达即投递、一条一条跑。它**没有** batcher 字段值,
// 因此拿不到定时器工厂 —— 「主人私聊没有静默窗口」是**类型上不可达**,
// 不是配置判出来的(配置无权翻转,见 newDirectLane)。
// - **batch 车道**(群聊 / 陌生人私聊):持有一个 batchWindow,由窗口引擎决定何时发车。
type lane struct {
key string
ch chan laneItem
neverDrop bool
spill []laneItem
// front 是「回插队首」区:单批超过上限时,尾部**原样保序**地插回这里,
// 成为下一批(M23 蓝图 §4-3:超量不是丢弃)。front 里的条目一定早于
// ch 与 spill 里的,因此读取顺序恒为 front → ch → spill。
front []laneItem
// batcher 非 nil ⇒ 这是一条 batch 车道(持有静默窗口的定时器工厂)。
// nil ⇒ direct 车道:窗口/定时器对它是**不可达类型**。
batcher *batchWindow
// lastTurnEnd 是该车道**上一轮回合结束**的时刻(只有 worker 读写 ⇒ 无需加锁)。
// 它只服务 continuations_total:批内首条在上一轮结束前就已到达 ⇒ 这批是⑤续跑。
lastTurnEnd time.Time
}
// newDirectLane 构造**主人私聊**的 direct 车道:它没有任何窗口状态,也不持有定时器
// 工厂(不传 batchWindow ⇒ 拿不到)。
//
// 🔴 车道类型由 **actor 元数据**决定(判据 `ownerPrivateLane`:`!msg.IsGroup() &&
// actor.IsOwner()`,与 M22 的车道键/丢弃策略同源),配置无权翻转:唯一能选 direct
// 的地方是 enqueueInbound 的 ownerPrivateLane 判定。主人私聊是
// 「她和我说话」,2s 迟滞在私聊里没有任何收益(只有被拖慢的观感),
// 且 M22 已裁定主人私聊永不丢弃、不受陈旧度闸约束 —— 本函数是同一取向的延续。
func newDirectLane(key string, ch chan laneItem, neverDrop bool) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop}
}
// newBatchLane 构造**群聊 / 陌生人私聊**的 batch 车道:窗口引擎只在这里可达。
func newBatchLane(key string, ch chan laneItem, neverDrop bool, batcher *batchWindow) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop, batcher: batcher}
}
// laneSet 是全部活跃车道的注册表。车道在**空闲时退休**(worker 见队列空即把自己摘掉),
// 因此表的大小只与「此刻有排队/在跑的会话数」相关。
type laneSet struct {
depth int
staleAfter time.Duration
log *slog.Logger
// after 是静默窗口的定时器工厂(M23):**只**在构造 batch 车道时被塞进
// batchWindow,direct 车道拿不到它(见 newDirectLane)。
after func(time.Duration) <-chan time.Time
mu sync.Mutex
lanes map[string]*lane
droppedStale int64
droppedOverflow int64
}
// laneSpec 描述「这条车道该长什么样」。类型(direct / batch)由**actor 元数据**决定,
// 调用方(enqueueInbound)算好传进来;配置只能影响窗口参数,改不了类型。
type laneSpec struct {
// neverDrop ⇒ 主人私聊:队列满时不拒收,落 spill 只排队。
neverDrop bool
// batch ⇒ 群聊/陌生人私聊:持有静默窗口(direct 车道不持有)。
batch bool
// capacity 是队列深度:batch 车道至少能装下一整批(否则「12 条成 1 轮」在
// 结构上不可达 —— 第 9 条就会在门前被拒收)。
capacity int
}
// newLaneSet 构造车道注册表;depth / staleAfter 为 0 时取上面的默认值,
// after 为 nil 时取 time.After。
func newLaneSet(depth int, staleAfter time.Duration, after func(time.Duration) <-chan time.Time, log *slog.Logger) *laneSet {
if depth <= 0 {
depth = defaultLaneDepth
}
if staleAfter <= 0 {
staleAfter = defaultLaneStaleAfter
}
if after == nil {
after = time.After
}
if log == nil {
log = logger.New(logger.Options{})
}
return &laneSet{
depth: depth,
staleAfter: staleAfter,
log: logger.WithSource(log, laneLogSource),
after: after,
lanes: make(map[string]*lane),
}
}
@@ -0,0 +1,131 @@
package channelbridge
// 本文件承载车道的**丢弃与停机诚实**:陈旧度闸、门前拒收、以及「桥停了,这些东西一条
// 都没跑」的登记。它是「丢弃是诚实且有界」的落点 —— 每一类丢弃都留
// WARN + 计数 + onRejected 审计,绝不静默消失(与 flowgate 的 rate_limited /
// review_pruned 同一条流水)。回什么属于回合语义,本文件一个字都不改。
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// LaneStats 是车道的计数快照:**被丢弃的入站有多少、为什么**(不变量 ③ 的可审计面),
// 加上主人私聊溢出区的当前积压。
type LaneStats struct {
// ActiveLanes 是此刻有排队或在跑的会话车道数。
ActiveLanes int
// DroppedStale 是因陈旧度闸被丢弃的条目累计数(超龄不补答)。
DroppedStale int64
// DroppedOverflow 是车道满时**在门前被拒收**的条目累计数。它只可能落在群聊 /
// 陌生人私聊上:主人私聊车道永不拒收(见 offer 的 neverDrop)。
DroppedOverflow int64
// Backlog 是主人私聊溢出区里此刻还排着的条目数(其余车道恒为 0)。
Backlog int
}
// LaneStats 返回车道计数的快照。它是「丢弃是诚实且有界」的进程内数字答案。
//
// 生产可见性的口径(复审 P2-3):**不新开 HTTP 端点**(那是契约变更,归塔)。真正
// 生产可见的是每一条丢弃 WARN 里带的 `dropped_stale_total` / `dropped_overflow_total`
// (累计计数随日志落地)以及 `onRejected` 审计轨;本方法只是给进程内调用方(管理面板
// 将来接线、运维探针、测试)读同一组数字的窄口,不构成对外契约。
func (b *Bridge) LaneStats() LaneStats {
if b.lanes == nil {
return LaneStats{}
}
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
backlog := 0
for _, l := range b.lanes.lanes {
backlog += len(l.spill)
}
return LaneStats{
ActiveLanes: len(b.lanes.lanes),
DroppedStale: b.lanes.droppedStale,
DroppedOverflow: b.lanes.droppedOverflow,
Backlog: backlog,
}
}
// staleInbound 实现陈旧度闸:轮到时已超龄 ⇒ 丢弃不补答,并留 WARN + 计数 + 审计。
// 返回 true 表示这条已被丢弃。waited 由调用方算好(含等全局回合槽的时间)。
func (b *Bridge) staleInbound(item laneItem, waited time.Duration) bool {
if !laneStaleEligible(item) {
return false
}
if waited <= b.lanes.staleAfter {
return false
}
b.dropInbound(item, laneDropReasonStale, "waited past the staleness threshold")
return true
}
// laneStaleEligible 报告某条入站是否受陈旧度闸约束。两类**豁免**各有硬理由:
//
// - **非聊天事件**(notice / request / meta_event / 解析失败兜底,ContentType=action):
// 它们不产生「回答旧话」这种失真,却是渠道事件留存(design/26 §5)与审计的唯一来源。
// 按年龄丢掉它们只会让留存出现空洞。
// - **主人私聊**:「回主人私聊」不是对群聊话题的应答,迟到的回答仍然是回答;
// 丢它等于对主人静默,直接违反「该回的回」这条红线。
func laneStaleEligible(item laneItem) bool {
if item.msg.ContentType == adapter.ContentAction {
return false
}
if !item.msg.IsGroup() && item.actor.IsOwner() {
return false
}
return true
}
// dropInbound 给「被丢弃的入站」留下**三样痕迹**,缺一不可:
// - 一条带 source 的 WARN(谁、哪个平台/会话/发送者、等了多久、为什么),**并且带上
// 两类丢弃的累计计数** —— 计数因此不需要新端点就能在生产日志里被读到(复审 P2-3:
// 计数只活在进程内存里等于没有出口);
// - 按原因的累计计数(LaneStats,供进程内调用方/后续面板接线读,不新开端点);
// - onRejected 审计轨(与 flowgate 的 rate_limited / review_pruned 同一条)。
//
// 它刻意**不**回一条「系统繁忙」:回什么属于回合语义,本切片不动它。
func (b *Bridge) dropInbound(item laneItem, reason, detail string) {
stale, overflow := b.lanes.noteDrop(reason)
b.lanes.log.Warn("inbound dropped by session lane",
"reason", reason, "detail", detail,
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "message_id", item.msg.MessageID,
"waited", b.now().Sub(item.arrivedAt).String(),
"dropped_stale_total", stale, "dropped_overflow_total", overflow)
if b.onRejected != nil {
b.onRejected(item.msg, reason)
}
}
// noteDrop 累加某一类丢弃的计数,并返回累加后的两类计数快照(写进 WARN 用)。
func (s *laneSet) noteDrop(reason string) (stale, overflow int64) {
s.mu.Lock()
defer s.mu.Unlock()
switch reason {
case laneDropReasonStale:
s.droppedStale++
case laneDropReasonOverflow:
s.droppedOverflow++
}
return s.droppedStale, s.droppedOverflow
}
// abandonLaneAtStop 登记「桥停止时车道里还排着、因此永远不会被回答的条目」。
//
// 它与 coalesce_lane.go 的 abandonAtStop 分工明确:那边管**已经取在手上、正在等窗口**
// 的那一批,这里管**还躺在队列里**的(front/ch/spill)。两处都带 source 落 WARN 并计入
// abandoned_at_stop_total —— 进程要停了没法「晚回」,唯一能做对的是**说出来**。
func (b *Bridge) abandonLaneAtStop(l *lane) {
b.lanes.mu.Lock()
remaining := len(l.front) + len(l.ch) + len(l.spill)
b.lanes.mu.Unlock()
if remaining == 0 {
return
}
total := b.coalesceStats.abandonedAtStop.Add(int64(remaining))
b.lanes.log.Warn("lane queue abandoned at bridge stop",
"lane", l.key, "count", remaining, "abandoned_at_stop_total", total)
}
@@ -0,0 +1,68 @@
package channelbridge
// 本文件承载「**主人私聊**」这条车道的判据与车道键派生(M22 切片 LANEQUEUE;
// M23 的红线「主人私聊不要应用静默窗口」建立在这里的判据上)。
//
// 判据只用 actor 元数据 + 白名单:**绝不读正文**、绝不看提示词(项目红线)。
//
// 注意判据是 M22 既有的 `!msg.IsGroup() && actor.IsOwner()`,**不是**任务书里写的
// `actor.IsGroupChat()`:两者在「适配器把群事件标成 Sender.IsGroup 但 ChannelType 仍是
// Direct」这类帧上可能分叉(那条帧会走 direct,更保守,红线方向不受影响)。
// 本片不改这个判据(改它会动 M22 的车道键与丢弃策略,属另一片)。
// 它是 `neverDrop`(永不拒收)与 `direct`(无窗口、拿不到定时器工厂)两件事的
// **同一个**来源,因此不存在「键说是主人、丢弃策略说不是」的两套口径。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// ownerPrivateLane 报告这条消息是否属于「主人私聊」车道 —— 也就是**永不丢弃**的那条。
// 判据与 laneKey 完全同源(私聊 + actor 元数据说他是主人),因此不会出现「键说是主人、
// 丢弃策略说不是」的两套口径。这里绝不读正文:身份只来自 actor 元数据 + 白名单。
func ownerPrivateLane(msg adapter.UnifiedMessage, actor adapter.Actor) bool {
return !msg.IsGroup() && actor.IsOwner()
}
// laneStrangerPrivate 报告一条入站是否属于**陌生人私聊**(非群 + 非主人)。
//
// 它与 `isStrangerPrivate(sess)` 同源(同一判据的两面):会话层从 actor 派生
// `sess.Owner`,这里直接用 actor 元数据,因此不需要先查会话就能判定「这条车道
// 要不要受 applyToStrangers 管」。主人私聊走不到这里(那是 direct 车道)。
func laneStrangerPrivate(item laneItem) bool {
return !item.msg.IsGroup() && !item.actor.IsOwner()
}
// laneKey 决定「哪些消息共用一条车道」,也就是「什么算同一个会话」。它必须与
// **会话键同源**(M22 复审 P1-1):两套各算各的判据会让同一个 session 出现两条车道、
// 两个回合并发,而两条 run 共享 sessionID ⇒ checkpoint 行 id `{sessionID}:{step}` 互相
// 覆盖、`loadCheckpoint` 可能续到另一条在途 run 的快照而重放工具调用,flow 桶
// `p.graphs[sessionID]` 被先收尾者删掉(loop 没有会话级互斥,IM 侧也没有网关那层
// `session_turn_fence`)。
//
// - **主人私聊**:无论来自哪个平台,都被 inboundTargetFor 路由进同一个 Web 主会话
// (history.MainSessionID),因此共用一条 `main` 车道。这里的键与会话键**故意不同**
// ——`main` 是那条路由的目的地,比渠道键更强(把各平台的主人私聊并成一条)。
// - 其余:**逐字就是** `session.Key(msg)`(`platform \x00 channelID`,与会话层缓存键
// 同一个出处),所以「会话层认为是一个会话」与「车道认为是一个会话」不可能分叉。
//
// 🔴 **取舍(比旧实现少了一个维度,是有意的)**:键里**没有账号名**。同一平台的两个账号
// 收到同一对端/同一个群的消息时,它们是**同一个 session**(会话键只含 platform 与
// channel),因此必须共用一条车道 —— 分开就会让同一会话两个回合并发。副作用是同一对端
// 的多个账号的入站会串行排队;回复出口不受影响:出站路由取的是**每条消息自己的**
// `msg.Platform`/`ChannelID`/`GroupID` 与它自己的适配器(`laneItem.a`),车道本身从不
// 持有任何账号级状态。
//
// 同理也**不要**在这里重新引入「频道」维度:kook 同一 guild 的两个 text channel 在会话层
// 是同一个群会话(键按 `group:<GroupID>`),按 `ChannelID` 分车道同样会造成双车道。
func laneKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if ownerPrivateLane(msg, actor) {
return ownerPrivateLaneKey
}
if session.ChannelKey(msg) == "" {
// 没有任何渠道可用(私聊既无 ChannelID 也无 SenderID):会话层会以
// ErrInvalidChannel 拒掉这条消息,这里只需要一个不会与真实会话撞键的稳定键。
return "unbound" + laneKeySep + adapter.ConversationKey(msg)
}
return session.Key(msg)
}
@@ -0,0 +1,246 @@
package channelbridge
// 本文件承载车道的**入队与取队列**(M22 切片 LANEQUEUE;M23 加了 batch 车道的容量与
// 回插队首)。它是「读与回水解耦」的那一侧:读循环只投递,worker 只按 FIFO 取。
//
// 三条不变量落在这里:
// - **有界且诚实**:群聊/陌生人满时在门前拒收**这一条**(队列里的条目一条都不动),
// 主人私聊永不拒收(溢到 spill 只排队,按倍率告警);
// - **FIFO 不可超车**:spill 一旦非空就一直追加 spill;front(回插区)里的一定早于
// ch 与 spill 里的,因此读取顺序恒为 front → ch → spill;
// - **整片取出**:`takeAll` 是静默窗口攒批的唯一取队列入口(只搬不筛、不改序)。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// enqueueInbound 把一条**已过流控闸**的入站消息投进它所属会话的车道,并立刻返回 ——
// 这是「读与回水解耦」的全部:适配器的读循环调用它之后马上可以继续读 socket。
//
// 🔴 这就是 M23 静默窗口合并的**入队 seam**:入参是一条**已过闸门的 prepared item**
// (Validate/ResolveActor/Admit 都已在本函数的调用方 onMessage 里做完,连同解析结果
// actor 一起带进来)。车道头部只做「等(窗口)+ 发车」,准入判断不在这里。
//
// 三件事在这里一次性做完:
// - 认领车道(没有就建一条并起 worker)—— **类型由 actor 元数据决定**:
// 主人私聊 ⇒ direct(无窗口),其余 ⇒ batch(有窗口);
// - 入队(群聊/陌生人满则**在门前拒收这一条**并留痕;主人私聊**永不拒收**);
// - 与 Stop 的 `wg.Wait` 对齐:`b.wg.Add` 与 `closed` 的判定在同一把 `b.mu` 下,
// 因此 Stop 要么看见这个工作项、要么我们看见 closed 而不入队,二者必居其一。
//
// 锁顺序:全树只有这一处同时取两把锁,顺序恒为 `b.mu` → `lanes.mu`。
func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, actor adapter.Actor) {
if b.lanes == nil {
return
}
item := laneItem{msg: msg, a: a, actor: actor, arrivedAt: b.now()}
key := laneKey(msg, actor)
// 主人私聊(私聊 + 主人在场,由 actor 元数据 + 白名单结构化判定)**永不丢弃**:
// 队列满时落到溢出区只排队(主人 2026-10-24 裁定:「宁可晚回,不许不回」)。
neverDrop := ownerPrivateLane(msg, actor)
spec := b.laneSpecFor(key, item, neverDrop)
b.mu.Lock()
if b.closed {
b.mu.Unlock()
b.lanes.log.Debug("bridge closed, inbound not queued",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
l, start, rejected := b.lanes.offer(key, item, spec)
if start {
b.wg.Add(1)
}
b.mu.Unlock()
if start {
go b.laneWorker(ctx, l)
}
if rejected {
b.dropInbound(item, laneDropReasonOverflow, "lane queue full")
}
}
// laneSpecFor 决定一条车道的形状:**类型只由 actor 元数据决定**
// (`IsOwner() && !IsGroupChat()` ⇒ direct),窗口参数只影响容量。
//
// 容量:batch 车道至少要能装下一整批,否则「一批 N 条」在结构上不可达
// (队列深度 8 时第 9 条就会在门前被拒收,合并就成了摆设)。因此
// capacity = max(默认深度, 生效的单批上限);direct 车道用默认深度。
// 内存口径因此是「活跃会话数 × 批上限」,而批上限本身被 clamp 在 [1,50]。
func (b *Bridge) laneSpecFor(key string, item laneItem, neverDrop bool) laneSpec {
spec := laneSpec{neverDrop: neverDrop, batch: !neverDrop, capacity: b.lanes.depth}
if neverDrop {
return spec
}
if w, ok := b.coalesceWindowFor(key, laneStrangerPrivate(item)); ok && w.maxBatch > spec.capacity {
spec.capacity = w.maxBatch
}
return spec
}
// offer 在**一个临界区**内完成「查/建车道 + 入队」,使 `retireLaneIfIdle` 不可能夹在
// 中间把刚查到、还没写入的车道摘掉(那样条目会落进一条没有 worker 的死车道)。
//
// 队列满时的策略分两档(都不是静默的):
// - **主人私聊**(neverDrop):绝不拒收,落到溢出区 `spill` 里**只排队**,并按倍率
// (ownerLaneBacklogWarn 及其翻倍)留 WARN —— 一个白名单账号的积压只反映「她落后了」,
// 不构成第三方洪泛面;
// - **群聊 / 陌生人**:**在门前拒收这一条**(而不是挤掉队列里已有的某条)。队列里
// 的条目因此一条都不会凭空消失 —— 窗口的 takeAll 依赖这一点;被拒收的那条
// 由调用方留痕(WARN + 计数 + 审计)。
//
// 🔴 **FIFO 的关键**:`spill` 一旦非空,后面的条目**一律继续追加到 spill**,不再回写
// `ch`。否则「ch 腾出一个位置 + 此时正好有新条目到达」会让后到者超车(复审实测顺序
// `[m1 m2 m3 m5 m4]`)。`nextLaneItem` 因此可以安全地「先取完 ch、再按序取 spill」:
// spill 里的一定晚于 ch 里的,而 spill 一旦开始就用到底(直到它被取空)。
//
// 同理,`front`(回插队首区)里的一定**早于** ch 与 spill 里的(只有 worker 自己
// 在发车前把超量的尾部放进去),所以读取顺序恒为 front → ch → spill。
func (s *laneSet) offer(key string, item laneItem, spec laneSpec) (l *lane, start bool, rejected bool) {
s.mu.Lock()
defer s.mu.Unlock()
l = s.lanes[key]
if l == nil {
capacity := spec.capacity
if capacity <= 0 {
capacity = s.depth
}
ch := make(chan laneItem, capacity)
if spec.batch {
l = newBatchLane(key, ch, spec.neverDrop, &batchWindow{after: s.after})
} else {
l = newDirectLane(key, ch, spec.neverDrop)
}
s.lanes[key] = l
start = true
}
if l.neverDrop && len(l.spill) > 0 {
s.spillLocked(l, item)
return l, start, false
}
select {
case l.ch <- item:
return l, start, false
default:
}
if l.neverDrop {
s.spillLocked(l, item)
return l, start, false
}
return l, start, true
}
// spillLocked 把条目追加进主人私聊车道的溢出区,并按**倍率**告警
// (16/32/64/128… 每翻一倍一条 WARN):固定水位只会在恰好等于时响一次,之后继续涨就
// 再无信号,而「她落后了多少」正是主人侧唯一可观测的量。调用方必须持有 s.mu。
func (s *laneSet) spillLocked(l *lane, item laneItem) {
l.spill = append(l.spill, item)
if n := len(l.spill); n >= ownerLaneBacklogWarn && isPowerOfTwo(n) {
s.log.Warn("owner private lane backlog is growing, still queueing",
"backlog", n, "lane", l.key,
"platform", item.msg.Platform, "channel", item.msg.ChannelID)
}
}
// isPowerOfTwo 报告 n 是否为 2 的幂(n ≥ 1)。
func isPowerOfTwo(n int) bool { return n > 0 && n&(n-1) == 0 }
// nextLaneItem 按 FIFO 取下一条:先取回插队首区(front),再把缓冲区取完,最后取
// 主人私聊的溢出区(三者的先后由 offer / requeueFront 的写入口径保证)。
// ok=false 表示此刻真的没有待办。
func (b *Bridge) nextLaneItem(l *lane) (laneItem, bool) {
b.lanes.mu.Lock()
if len(l.front) > 0 {
item := l.front[0]
l.front = l.front[1:]
b.lanes.mu.Unlock()
return item, true
}
b.lanes.mu.Unlock()
select {
case item := <-l.ch:
return item, true
default:
}
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
if len(l.spill) == 0 {
return laneItem{}, false
}
item := l.spill[0]
copy(l.spill, l.spill[1:])
l.spill = l.spill[:len(l.spill)-1]
return item, true
}
// takeAll 把车道上**此刻**排着的条目整片取出(顺序 = 到达顺序),供静默窗口的
// 批组装使用。它是合并阶段**唯一**的取队列入口(M23 蓝图 §4-1):
//
// - 只有车道的单消费者(该车道唯一的 worker)调用它 ⇒ 不存在两个消费者各抓一半;
// - 取出即「这批就是这些」,等待期间新到的继续落进队列,下一轮 takeAll 再取;
// - 它**不**做任何筛选:不丢、不重排、不改内容(窗口内零丢失的结构保证)。
func (b *Bridge) takeAll(l *lane) []laneItem {
b.lanes.mu.Lock()
out := l.front
l.front = nil
b.lanes.mu.Unlock()
for {
select {
case item := <-l.ch:
out = append(out, item)
continue
default:
}
b.lanes.mu.Lock()
if len(l.spill) > 0 {
out = append(out, l.spill...)
l.spill = nil
}
b.lanes.mu.Unlock()
return out
}
}
// requeueFront 把一批**尚未发车**的条目按原顺序插回队首,成为下一批。
//
// 它是「单批上限不是丢弃」的落点(M23 蓝图 §4-3):超量的**尾部**原样回插、保序、
// 在锁内、在发车之前。回插到 front 而不是写 ch:ch 有界且写满即拒收,
// 而 front 无界但只由 worker 在发车前写入,写入量与「刚拆出来的尾部」相等。
func (b *Bridge) requeueFront(l *lane, items []laneItem) {
if len(items) == 0 {
return
}
b.lanes.mu.Lock()
merged := make([]laneItem, 0, len(items)+len(l.front))
merged = append(merged, items...)
merged = append(merged, l.front...)
l.front = merged
b.lanes.mu.Unlock()
}
// retireLaneIfIdle 在车道已空时把它从注册表摘掉并报告 true(worker 随即退出)。
//
// 它必须与 `offer` / `requeueFront` 用同一把锁:与 offer 交错的话会出现「offer 拿到
// 车道 → worker 退休并删除 → offer 往一条没有 worker 的车道入队」的丢失。入队与
// 「查空」在同一把锁下,就不存在这个窗口。
func (b *Bridge) retireLaneIfIdle(l *lane) bool {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
if len(l.front) > 0 || len(l.ch) > 0 || len(l.spill) > 0 || b.lanes.lanes[l.key] != l {
return false
}
delete(b.lanes.lanes, l.key)
return true
}
// retireLane 无条件把车道从注册表摘掉(桥停止时的清理;未处理的条目随取消作废)。
func (b *Bridge) retireLane(l *lane) {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
if b.lanes.lanes[l.key] == l {
delete(b.lanes.lanes, l.key)
}
}
@@ -0,0 +1,878 @@
package channelbridge
// 本文件是 **M22 切片 LANEQUEUE** 的断言集:每条不变量至少一条断言,见 lane.go 顶部的
// 四条不变量清单。
//
// - ① 顺序:同一会话内回复顺序 = 到达顺序(TestLaneSerializesOneSessionInArrivalOrder;
// 主人私聊溢出区另有一条 TestLaneOwnerPrivateSpillKeepsArrivalOrder);
// - ② 一致性:同一会话绝无两个回合同时跑,不同会话可并行
// (TestLaneSerializesOneSessionInArrivalOrder + TestLaneRunsDifferentSessionsInParallel
// + TestLaneKeyNeverCollidesAcrossSessions —— 后者钉住「车道键与会话键同源」);
// - ③ 完整性:被丢的只有「超龄」与「背压」两类,两者都留计数 + 审计 + 日志
// (TestLaneDropsStaleInboundAndTracesIt / TestLaneRejectsNewestWhenFull),
// 主人私聊与非聊天事件另有豁免(TestLaneStaleGateExemptions);
// - ④ 最新性:排队中的消息在**开跑之后**才装配上下文,因此看得到前一轮刚落的历史
// (TestLaneTurnSeesContextAssembledAtStart)。
//
// 另有 TestLaneDeliveryNeverBlocksTheReadLoop:把「读循环 + 回合」的旧形状与新形状放在
// 一起对照 —— 全局回合槽占满时投递仍然立即返回,读循环照常读下一帧(心跳不再被堵)。
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// laneAdapter 是并发安全的最小适配器:车道让多个会话**并行**发回复,因此这里必须带锁
// (channelbridge_test.go 的 fakeAdapter 是单线程夹具,不能复用)。
type laneAdapter struct {
name string
mu sync.Mutex
sends []adapter.UnifiedMessage
handler adapter.MessageHandler
}
func (a *laneAdapter) Name() string { return a.name }
func (a *laneAdapter) Config() map[string]any { return map[string]any{"bot_token": "tok"} }
func (a *laneAdapter) Connect(context.Context) error { return nil }
func (a *laneAdapter) Disconnect(context.Context) error { return nil }
func (a *laneAdapter) IsConnected() bool { return true }
func (a *laneAdapter) HealthCheck(context.Context) error { return nil }
func (a *laneAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
}
func (a *laneAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
a.mu.Lock()
defer a.mu.Unlock()
a.sends = append(a.sends, resp.Messages...)
return nil
}
func (a *laneAdapter) SendProactive(_ context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(context.Background(), adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}
func (a *laneAdapter) SetMessageHandler(fn adapter.MessageHandler) {
a.mu.Lock()
a.handler = fn
a.mu.Unlock()
}
func (a *laneAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (a *laneAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
return nil, nil
}
// laneChatter 记录「谁在什么时候跑」并可在 Run 里阻塞,用来制造「回合还在跑」的窗口。
type laneChatter struct {
// onRun 在**记录之后、返回之前**调用;非 nil 时可用它阻塞或推进注入的时钟。
onRun func(user string)
mu sync.Mutex
runs []laneRun
inFlight int
maxInFlight int
perSession map[string]int
maxPerSess map[string]int
}
// laneRun 是一个已开跑的回合(当次用户文本 + 会话 + 模型可见消息)。
type laneRun struct {
user string
session string
messages []model.Message
}
func (c *laneChatter) Run(_ context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
use := lastUserText(msgs)
c.mu.Lock()
if c.perSession == nil {
c.perSession = map[string]int{}
c.maxPerSess = map[string]int{}
}
c.runs = append(c.runs, laneRun{user: use, session: sessionID, messages: msgs})
c.inFlight++
if c.inFlight > c.maxInFlight {
c.maxInFlight = c.inFlight
}
c.perSession[sessionID]++
if c.perSession[sessionID] > c.maxPerSess[sessionID] {
c.maxPerSess[sessionID] = c.perSession[sessionID]
}
c.mu.Unlock()
if c.onRun != nil {
c.onRun(use)
}
c.mu.Lock()
c.inFlight--
c.perSession[sessionID]--
c.mu.Unlock()
return &loop.Result{Text: "reply:" + use}, nil
}
func (c *laneChatter) userTexts() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, 0, len(c.runs))
for _, r := range c.runs {
out = append(out, r.user)
}
return out
}
func (c *laneChatter) runCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.runs)
}
func (c *laneChatter) maxConcurrent() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.maxInFlight
}
func (c *laneChatter) maxConcurrentIn(sessionID string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.maxPerSess[sessionID]
}
func (c *laneChatter) runAt(i int) laneRun {
c.mu.Lock()
defer c.mu.Unlock()
return c.runs[i]
}
// waitRuns 等到至少 n 个回合开跑(或超时失败)。
func (c *laneChatter) waitRuns(t *testing.T, n int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if c.runCount() >= n {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("only %d of %d turns started within %s (lane never drained?)", c.runCount(), n, timeout)
}
// laneSessions 按消息给出**稳定且互不相同**的隔离会话(同一发送者恒同一个会话)。
type laneSessions struct {
owner bool
}
func (s *laneSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) {
return session.Session{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SessionID: "sess-" + msg.ChannelID + "-" + msg.SenderID,
Owner: s.owner,
IsGroup: msg.IsGroup(),
MemWritable: s.owner,
}, nil
}
// laneClock 是可推进的注入时钟(陈旧度闸据此判定,测试因此不必真的等 30s)。
type laneClock struct {
mu sync.Mutex
t time.Time
}
func newLaneClock() *laneClock { return &laneClock{t: time.Unix(1_700_000_000, 0)} }
func (c *laneClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *laneClock) advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// advanceTo 把时钟推到 t(只前进不后退)。
//
// 窗口夹具用它表达「时间走到了某个截止时刻」:推进量 = 真的过了多久,
// 与「按请求时长累加」相比不会把时钟推过头(见 coalesceTimers 的说明)。
func (c *laneClock) advanceTo(t time.Time) {
c.mu.Lock()
if t.After(c.t) {
c.t = t
}
c.mu.Unlock()
}
// laneRecorder 收集 onRejected 审计(并发安全)。
type laneRecorder struct {
mu sync.Mutex
rejected []string
}
func (r *laneRecorder) record(_ adapter.UnifiedMessage, reason string) {
r.mu.Lock()
r.rejected = append(r.rejected, reason)
r.mu.Unlock()
}
func (r *laneRecorder) reasons() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.rejected...)
}
func lastUserText(msgs []model.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role != model.RoleUser {
continue
}
if t := lastText(msgs[i]); t != "" {
return t
}
}
return ""
}
func lastText(m model.Message) string {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
return c.Text
}
}
return ""
}
// strangerMsg 是一条**陌生人私聊**(非群、非主人):唯一闸门是流控,因此它必然进回合,
// 且不同发送者 = 不同会话。它是「车道并行」与「陈旧度闸」最干净的夹具。
func strangerMsg(sender, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: sender,
ChannelType: adapter.ChannelDirect,
SenderID: sender,
Sender: adapter.Actor{Role: adapter.RoleMember},
Content: content,
ContentType: adapter.ContentText,
}
}
// ownerMsg 是一条**主人私聊**:车道键恒为 main|web(inboundTargetFor 把它路由进主会话)。
func ownerMsg(content string) adapter.UnifiedMessage {
m := directMessage()
m.Content = content
return m
}
// newLaneBridge 构造一个**只跑 M22 车道语义**的桥接器:本文件(lane_test.go)钉的是
// 车道本身(顺序/串行/背压/陈旧度),因此默认把 M23 的静默窗口**关掉** ——
// 开着窗口时「2s 静默」会让这几条用例从毫秒级变成秒级,并且把「陈旧度何时判」的
// 时间口径搅在一起。窗口/合并/续跑自己的断言都在 coalesce_test.go。
//
// 需要窗口的用例显式传 Options.Coalesce(见 newCoalesceBridge)。
func newLaneBridge(t *testing.T, opts Options, ad *laneAdapter) *Bridge {
t.Helper()
if opts.Coalesce.For == nil {
opts.Coalesce.For = coalesceOffFor
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
if ad != nil {
b.Add(ad)
}
return b
}
// waitLaneIdle 等到注册表里没有活跃车道(队列已排空)或超时。
func waitLaneIdle(t *testing.T, b *Bridge, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if b.LaneStats().ActiveLanes == 0 {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("lanes never went idle: %+v", b.LaneStats())
}
// TestLaneSerializesOneSessionInArrivalOrder 断言不变量 ①(顺序)与 ②(一致性):
// 同一个会话(这里是主人私聊 ⇒ 同一条 main 车道)的五条消息**先来先跑、绝不同时跑**。
func TestLaneSerializesOneSessionInArrivalOrder(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
}, nil)
ad := &laneAdapter{name: "telegram"}
const n = 5
for i := 0; i < n; i++ {
b.onMessage(context.Background(), ownerMsg(fmt.Sprintf("m%d", i)), ad)
}
chat.waitRuns(t, n, 5*time.Second)
got := chat.userTexts()
want := []string{"m0", "m1", "m2", "m3", "m4"}
if len(got) != len(want) {
t.Fatalf("ran %d turns, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v", got, want)
}
}
// ②:同一会话绝不并发(主人私聊跨平台共用 main 车道,因此这里是会话级而非账号级)。
if m := chat.maxConcurrent(); m != 1 {
t.Errorf("max concurrent turns = %d, want 1 for one session", m)
}
if m := chat.maxConcurrentIn(history.MainSessionID); m != 1 {
t.Errorf("max concurrent turns in %s = %d, want 1", history.MainSessionID, m)
}
}
// TestLaneRunsDifferentSessionsInParallel 断言 ②的另一半:不同会话**互不排队** ——
// 两个陌生人各自私聊她,两个回合必须同时在跑(串行的话下面会以 maxInFlight=1 失败)。
func TestLaneRunsDifferentSessionsInParallel(t *testing.T) {
t.Parallel()
var mu sync.Mutex
entered := 0
both := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) {
mu.Lock()
entered++
if entered == 2 {
close(both)
}
mu.Unlock()
select {
case <-both:
case <-time.After(3 * time.Second):
// 串行了:返回让断言失败,而不是把测试挂死。
}
}
b := newLaneBridge(t, Options{Loop: chat, Sessions: &laneSessions{}, Owner: &user.OwnerProfile{}}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), strangerMsg("200", "a"), ad)
b.onMessage(context.Background(), strangerMsg("201", "b"), ad)
chat.waitRuns(t, 2, 5*time.Second)
if m := chat.maxConcurrent(); m != 2 {
t.Fatalf("max concurrent turns across two sessions = %d, want 2 (lanes must not share)", m)
}
}
// TestLaneDropsStaleInboundAndTracesIt 断言 ③ 的第一类丢弃:**轮到时已超龄 ⇒ 不补答**,
// 且丢得有声:计数 + onRejected 审计 + 带 source 的 WARN 日志。
func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
clock := newLaneClock()
rec := &laneRecorder{}
chat := &laneChatter{}
// 两条都投递完再让时钟越过阈值:第一轮**卡住**等这个信号,车道因此不会在两条之间
// 退休(退休后第二条会进一条新车,arrivedAt 取的是推进后的时钟 ⇒ 它永远不超龄,
// 用例假红)。这不是放宽断言 —— 「第二条超龄被丢」仍然由 stats/审计/日志三处断言。
bothQueued := make(chan struct{})
chat.onRun = func(user string) {
if user == "first" {
<-bothQueued
// 第一轮跑着的时候时间越过阈值:排在后面的那条等到自己开跑时已超龄。
clock.advance(defaultLaneStaleAfter + time.Second)
}
}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{},
Owner: &user.OwnerProfile{},
Now: clock.Now,
OnRejected: rec.record,
Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), strangerMsg("200", "first"), ad)
b.onMessage(context.Background(), strangerMsg("200", "stale by the time it runs"), ad)
close(bothQueued)
chat.waitRuns(t, 1, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedStale != 1 {
t.Fatalf("dropped stale = %d, want 1 (stats=%+v)", stats.DroppedStale, stats)
}
if reasons := rec.reasons(); len(reasons) != 1 || reasons[0] != laneDropReasonStale {
t.Fatalf("audit reasons = %v, want [%s]", reasons, laneDropReasonStale)
}
if got := chat.userTexts(); len(got) != 1 || got[0] != "first" {
t.Fatalf("turns = %v, want only the fresh first one (the stale one must not run)", got)
}
if out := logs.String(); !strings.Contains(out, "inbound dropped by session lane") ||
!strings.Contains(out, laneDropReasonStale) {
t.Fatalf("drop left no trace in the log:\n%s", out)
}
}
// TestLaneRejectsNewestWhenFull 断言 ③ 的第二类丢弃:车道满时**在门前**拒收这一条
// (队列里已有的条目一条都不动 —— M23 的 takeAll 依赖队列完整),且拒收留痕
// (计数 + 审计)。深度 1 时:m1 在跑、m2 已排上,m3 被拒收。
// 注意语义是「拒收最新、队列不动」,**不是**「挤掉最旧」(复审 P2-2 的口径澄清)。
func TestLaneRejectsNewestWhenFull(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "m1" {
<-gate // 第一个回合卡住,后面的条目只能在车道上等
}
}
rec := &laneRecorder{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{},
Owner: &user.OwnerProfile{},
LaneDepth: 1,
OnRejected: rec.record,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), strangerMsg("200", "m1"), ad)
chat.waitRuns(t, 1, 5*time.Second) // m1 已在跑(车道空出来)
b.onMessage(context.Background(), strangerMsg("200", "m2"), ad)
b.onMessage(context.Background(), strangerMsg("200", "m3"), ad) // 深度 1:m3 在门前被拒收
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedOverflow != 1 {
t.Fatalf("dropped overflow = %d, want 1 (stats=%+v)", stats.DroppedOverflow, stats)
}
if reasons := rec.reasons(); len(reasons) != 1 || reasons[0] != laneDropReasonOverflow {
t.Fatalf("audit reasons = %v, want [%s]", reasons, laneDropReasonOverflow)
}
if got := chat.userTexts(); len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
t.Fatalf("turns = %v, want [m1 m2] (m3 is the one rejected at the gate)", got)
}
}
// 🔴 TestLaneOwnerPrivateNeverDroppedOnOverflow 守住主人 2026-10-24 的裁定:
// **主人私聊永不因背压被吃掉**(「宁可晚回,不许不回」)。深度 1、第一轮卡住时连投四条,
// 四条必须一条不落地按到达顺序跑完 —— 溢出区只排队、绝不拒收。
// 变异检验:把主人私聊塞进群聊那条拒收路径 ⇒ 本用例立刻红(只剩两条回合)。
func TestLaneOwnerPrivateNeverDroppedOnOverflow(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "m1" {
<-gate
}
}
rec := &laneRecorder{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
LaneDepth: 1,
OnRejected: rec.record,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("m1"), ad)
chat.waitRuns(t, 1, 5*time.Second)
for _, m := range []string{"m2", "m3", "m4"} {
b.onMessage(context.Background(), ownerMsg(m), ad)
}
close(gate)
chat.waitRuns(t, 4, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedOverflow != 0 || stats.DroppedStale != 0 {
t.Fatalf("owner private must never be dropped: %+v", stats)
}
if reasons := rec.reasons(); len(reasons) != 0 {
t.Fatalf("owner private must not reach the audit drop track: %v", reasons)
}
got := chat.userTexts()
want := []string{"m1", "m2", "m3", "m4"}
if len(got) != len(want) {
t.Fatalf("turns = %v, want all %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v", got, want)
}
}
}
// TestLaneStaleGateExemptions 钉住陈旧度闸的两类豁免:**主人私聊**(迟到的回答仍是回答)
// 与**非聊天事件**(留存与审计的唯一来源)。两者都不许被算进丢弃计数。
func TestLaneStaleGateExemptions(t *testing.T) {
t.Parallel()
clock := newLaneClock()
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
clock.advance(defaultLaneStaleAfter + time.Second)
}
}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
Now: clock.Now,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("first"), ad)
b.onMessage(context.Background(), ownerMsg("late owner private"), ad)
chat.waitRuns(t, 2, 5*time.Second)
// 非聊天事件(ContentAction)同样豁免:它不产生回复,但必须被登记/留存。
event := strangerMsg("200", "")
event.ContentType = adapter.ContentAction
b.onMessage(context.Background(), event, ad)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedStale != 0 || stats.DroppedOverflow != 0 {
t.Fatalf("exempt messages must never be counted as dropped: %+v", stats)
}
if got := chat.userTexts(); len(got) < 2 || got[1] != "late owner private" {
t.Fatalf("owner private turns = %v, want the late one to run despite its age", got)
}
}
// TestLaneTurnSeesContextAssembledAtStart 断言 ④(最新性):一条在车道上排队的消息,
// 开跑时看到的是**当时**的历史 —— 前一轮刚落库的回复就在它的上下文里。
// 若上下文在入队时就被快照,第二条绝看不到 reply:first。
func TestLaneTurnSeesContextAssembledAtStart(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate // 让第二条在车道上等,第一轮的落库全在它之后
}
}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
History: newLaneHistory(),
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("first"), ad)
chat.waitRuns(t, 1, 5*time.Second)
b.onMessage(context.Background(), ownerMsg("second"), ad)
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
var assistant []string
for _, m := range chat.runAt(1).messages {
if m.Role == model.RoleAssistant {
assistant = append(assistant, lastText(m))
}
}
// 转录会把历史行渲染成段落(`<p>…</p>`),因此按包含判定,不按字面相等。
joined := strings.Join(assistant, "\n")
if !strings.Contains(joined, "reply:first") {
t.Fatalf("second turn assistant transcript = %q, want it to contain reply:first", joined)
}
}
// TestLaneDeliveryNeverBlocksTheReadLoop 是**读与回水解耦**的回归断言。
//
// 旧形状(报障现场):适配器的读循环调用 onMessage,onMessage 同步抢 16 个全局回合槽,
// 槽满即**停止读 socket** —— 心跳不被回、后续帧不被解析,消息堆在连接缓冲里,跑完一轮
// 再按条重放(「后面才回复前面的话」)。
//
// 新形状:投递立刻返回(进车道排队),全局回合槽在车道 worker 里取。这里把 16 个槽全部
// 占满,然后走一遍「读循环 → onMessage(一帧) → 心跳帧」:心跳必须立刻被读到,
// 而那一帧的回合一个都不许开跑(槽确实还满着)。
func TestLaneDeliveryNeverBlocksTheReadLoop(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
}, nil)
ad := &laneAdapter{name: "telegram"}
// 占满全局回合槽:此刻任何一个回合都无法开跑。
for i := 0; i < maxConcurrent; i++ {
b.sem <- struct{}{}
}
// 读循环的形状:串行处理每一帧;「心跳」是同一循环里的一帧(真实的 qq 适配器在
// readConn 里对 opPing 回 opPong,位置就在 dispatch 之前两行)。
type frame struct {
msg *adapter.UnifiedMessage
pong chan struct{}
}
frames := make(chan frame, 4)
loopCtx, stopReader := context.WithCancel(context.Background())
defer stopReader()
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
for {
select {
case <-loopCtx.Done():
return
case f := <-frames:
if f.pong != nil {
close(f.pong)
continue
}
b.onMessage(loopCtx, *f.msg, ad) // ← 投递必须立即返回
}
}
}()
slow := ownerMsg("slow turn")
frames <- frame{msg: &slow}
pong := make(chan struct{})
frames <- frame{pong: pong}
select {
case <-pong:
case <-time.After(2 * time.Second):
t.Fatal("read loop never reached the heartbeat frame: delivery is blocking the reader")
}
// 槽还满着 ⇒ 回合一个都不该开跑;投递却已经返回并把消息排进了车道。
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d while every global slot was taken, want 0", n)
}
// 让位:释放槽,回合照旧跑完(排队不等于丢弃)。
for i := 0; i < maxConcurrent; i++ {
<-b.sem
}
chat.waitRuns(t, 1, 5*time.Second)
stopReader()
<-readerDone
}
// laneHistory 是最小的历史存储替身:只实现入站流水线真正用到的两条(AppendFull / Recent),
// 其余方法由内嵌的 nil 接口兜住(被调用即 panic —— 测试因此不会静默走错路)。
type laneHistory struct {
history.HistoryStore
mu sync.Mutex
rows map[string][]history.Message
}
func newLaneHistory() *laneHistory {
return &laneHistory{rows: map[string][]history.Message{}}
}
func (h *laneHistory) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.rows[msg.SessionID] = append(h.rows[msg.SessionID], msg)
return msg, nil
}
func (h *laneHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
h.mu.Lock()
defer h.mu.Unlock()
rows := h.rows[sessionID]
if len(rows) > n {
rows = rows[len(rows)-n:]
}
return append([]history.Message(nil), rows...), nil
}
// kookGroupMsg 是一条 kook 群消息的**生产形状**:`ChannelID` 是 text channel(频道),
// `GroupID` 是 guild。发送者是白名单里的主人且这条**指向她本人**(`AddressedToMe`,
// 结构化字段),因此它必定进回合 —— 与真人「主人在群里 @ 她」同一条路。
//
// 它会进回合而**不受**非主人群聊的最小打扰分级影响,这正是我们要的:本用例要观察的
// 只有「车道怎么分」,不是「要不要回答」。
func kookGroupMsg(channelID, groupID, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "kook",
ChannelID: channelID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: "100",
Sender: adapter.Actor{Role: adapter.RoleOwner},
Content: content,
ContentType: adapter.ContentText,
AddressedToMe: true,
}
}
// TestLaneKeyNeverCollidesAcrossSessions 钉住复审 P1-1 的修法:**车道键与会话键同源**
// (`laneKey` 逐字就是 `session.Key`),因此「同一个 session」绝不可能有两条车道。
//
// 表里必须含复审给出的两条**真实反例**(它们在旧实现 `a.Name()+"|"+ConversationKey`
// 下会分成两条车道、两个回合并发):
// - kook:同一 guild 的两个 text channel(c1/c2)—— 会话层按 `group:<GroupID>` 判成
// 同一个群会话,旧键却按 `ChannelID` 拆开;
// - telegram:同一平台两个账号对同一对端(`telegram:conf0` / `telegram:conf1`)——
// 会话键只含 platform + channel,旧键却把账号名算进车道。
//
// 断言方式是**行为级**的(不只看键字符串):第一条回合卡住时,第二条必须还在排队;
// 而控制行(不同群)必须能真的并行,证明本修法没有把所有会话串成一条车道。
func TestLaneKeyNeverCollidesAcrossSessions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// 两条消息:同一 session 的必须共用一条车道;控制行则是两个不同 session。
first, second adapter.UnifiedMessage
// adapterNames 是两个账号的注册名(多账号场景不同,单账号/单频道场景相同)。
firstAdapter, secondAdapter string
// sessionOwner 是会话替身报告的身份车道:主人消息 true、陌生人私聊 false
// (与真实 SessionManager 由 actor 派生的口径一致;它决定最小打扰分级的分支)。
sessionOwner bool
// wantParallel 为 true 表示这两条属于**不同** session,必须能同时跑。
wantParallel bool
}{
{
name: "kook: 同 guild 的两个 text channel 是同一个群会话",
first: kookGroupMsg("c1", "g1", "a"),
second: kookGroupMsg("c2", "g1", "b"),
firstAdapter: "kook:conf0",
secondAdapter: "kook:conf0",
sessionOwner: true,
},
{
name: "telegram: 同平台两个账号对同一对端是同一个会话",
first: strangerMsg("200", "a"),
second: strangerMsg("200", "b"),
firstAdapter: "telegram:conf0",
secondAdapter: "telegram:conf1",
sessionOwner: false,
},
{
name: "控制组: 不同群仍是不同会话(必须能并行)",
first: kookGroupMsg("c1", "g1", "a"),
second: kookGroupMsg("c2", "g2", "b"),
firstAdapter: "kook:conf0",
secondAdapter: "kook:conf0",
sessionOwner: true,
wantParallel: true,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate } // 每个回合都卡住,便于观察「谁在跑」
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: c.sessionOwner},
Owner: testOwnerProfile("kook", "100"),
}, nil)
first := &laneAdapter{name: c.firstAdapter}
second := &laneAdapter{name: c.secondAdapter}
b.onMessage(context.Background(), c.first, first)
chat.waitRuns(t, 1, 5*time.Second) // 第一条已在跑(并卡住)
b.onMessage(context.Background(), c.second, second)
if c.wantParallel {
chat.waitRuns(t, 2, 3*time.Second) // 不同会话:第二条立刻开跑
} else {
// 同一会话:第二条必须还在排队 —— 给它足够的时间「如果会跑就跑起来」。
assertRunCountStays(t, chat, 1, 200*time.Millisecond)
}
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
})
}
}
// assertRunCountStays 在窗口期内反复确认回合数没有超过 want(用于断言「第二条没跑起来」)。
func assertRunCountStays(t *testing.T, c *laneChatter, want int, window time.Duration) {
t.Helper()
deadline := time.Now().Add(window)
for time.Now().Before(deadline) {
if n := c.runCount(); n > want {
t.Fatalf("turns started = %d, want at most %d: the second message must wait in the same lane", n, want)
}
time.Sleep(time.Millisecond)
}
}
// TestLaneOwnerPrivateSpillKeepsArrivalOrder 断言不变量 ①对**溢出区**同样成立:
// 主人私聊车道满了以后,新到的条目必须排在溢出区**末尾**,绝不能因为 `ch` 腾出一个位置
// 就超车到已在排队的条目前面。
//
// 复审实测的旧行为是 `[m1 m2 m3 m5 m4]`(到达顺序 m1..m5):m4 落进 spill 后,m1 结束让
// `ch` 空出一格,m5 于是先进 `ch` 而先跑。修法是「spill 非空后一律追加 spill」。
func TestLaneOwnerPrivateSpillKeepsArrivalOrder(t *testing.T) {
t.Parallel()
release := make(chan struct{}, 8)
chat := &laneChatter{}
chat.onRun = func(string) { <-release } // 每个回合都由测试逐个放行
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
LaneDepth: 2,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("m1"), ad)
chat.waitRuns(t, 1, 5*time.Second) // m1 在跑(卡住)
b.onMessage(context.Background(), ownerMsg("m2"), ad)
b.onMessage(context.Background(), ownerMsg("m3"), ad) // 深度 2:ch 满
b.onMessage(context.Background(), ownerMsg("m4"), ad) // 落 spill
release <- struct{}{} // 放 m1 走
chat.waitRuns(t, 2, 5*time.Second) // m2 开跑 ⇒ ch 空出一格
b.onMessage(context.Background(), ownerMsg("m5"), ad) // 关键交错:此刻新条目到达
for i := 0; i < 4; i++ { // 逐条放行剩下的 m2..m5
release <- struct{}{}
}
chat.waitRuns(t, 5, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
got := chat.userTexts()
want := []string{"m1", "m2", "m3", "m4", "m5"}
if len(got) != len(want) {
t.Fatalf("turns = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v (spill must never be overtaken)", got, want)
}
}
if stats := b.LaneStats(); stats.DroppedOverflow != 0 || stats.DroppedStale != 0 {
t.Fatalf("owner private must never be dropped: %+v", stats)
}
}
@@ -0,0 +1,108 @@
package channelbridge
// 本文件承载车道的**单消费者**:一个会话一个 worker,串行跑完队列里的每一条。
//
// 「回合在跑」在结构上就等于「worker 正忙」—— M23 的回合中续跑因此不需要任何
// turn-in-flight 标志:回合结束回到循环头,takeAll 自然把这段期间攒下的消息整片取出。
//
// direct 车道(主人私聊)在这里按 `l.batcher == nil` 分派到 runDirectLaneItem:
// 到达即投递、一条一条跑,**没有**任何窗口等待。
import (
"context"
"time"
)
// laneWorker 是某会话车道的唯一执行者:串行跑完每一条,队列空了就把车道交还注册表
// (空闲车道不占内存,也不会把「这个人再说话」变慢)。
//
// **回合中续跑(⑤)就落在这里**:不存在任何 "turn in flight" 标志 —— 「回合在跑」
// 在结构上就等于「worker 正忙」,跑回合期间新消息落进同一个队列,回合结束回到循环头,
// takeAll 自然把这段期间攒下的消息整片取出 ⇒ **一轮续跑**(不是 N 轮,也不是 0 条)。
func (b *Bridge) laneWorker(ctx context.Context, l *lane) {
defer b.wg.Done()
for {
// 桥已停:队列里还没发车的条目**如实登记**(绝不静默消失),然后退休。
if ctx.Err() != nil {
b.abandonLaneAtStop(l)
b.retireLane(l)
return
}
if item, ok := b.nextLaneItem(l); ok {
b.dispatchLaneItem(ctx, l, item)
continue
}
// 队列空了才谈退休:摘表与入队用同一把锁,因此「摘下」与「刚有人塞进来」不会
// 交错成条目丢失(见 retireLaneIfIdle 的说明)。
if b.retireLaneIfIdle(l) {
return
}
select {
case <-ctx.Done():
// 桥停了:把车道摘掉,未处理的条目随进程取消一起作废(Stop 的语义)。
b.retireLane(l)
return
case item := <-l.ch:
b.dispatchLaneItem(ctx, l, item)
}
}
}
// dispatchLaneItem 按**车道类型**分派:direct(主人私聊)一条一条跑;batch 走窗口
// 引擎(见 coalesce_lane.go 的 runBatchLaneItem)。
//
// 分派依据是 `l.batcher == nil` —— 一个**结构**判据,不是配置开关:direct 车道的
// 类型里根本没有 batcher 字段值,窗口代码对它不可达。
func (b *Bridge) dispatchLaneItem(ctx context.Context, l *lane, item laneItem) {
if l.batcher == nil {
b.runDirectLaneItem(ctx, l, item)
return
}
b.runBatchLaneItem(ctx, l, item)
}
// runDirectLaneItem 跑 direct 车道(= 主人私聊)的一条:先取全局回合槽(**在线程池里
// 等,不在读循环里等**),再过陈旧度闸,然后走逐条闸门 + 单条回合。**没有任何等待窗口**。
func (b *Bridge) runDirectLaneItem(ctx context.Context, l *lane, item laneItem) {
if !b.acquireTurn(ctx) {
return
}
defer b.releaseTurn()
waited := b.now().Sub(item.arrivedAt)
if b.staleInbound(item, waited) {
return
}
b.logLaneStart(item, waited, 1)
p, ok := b.prepareInbound(ctx, item)
if !ok {
l.lastTurnEnd = b.now()
return
}
b.runBatch(ctx, []preparedInbound{p})
l.lastTurnEnd = b.now()
}
// acquireTurn 取一个全局回合槽(maxConcurrent 个)。ctx 取消时返回 false:桥停了
// 就不再开新回合(carried 条目由 Stop 的诚实登记负责,见 coalesce_lane.go)。
func (b *Bridge) acquireTurn(ctx context.Context) bool {
select {
case b.sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
// releaseTurn 归还一个全局回合槽。
func (b *Bridge) releaseTurn() { <-b.sem }
// logLaneStart 记「这一批等了多久才开始跑」—— 「她为什么回得晚」在生产日志里最直接
// 的那个答案。与它同时出现在日志里的 `msg="inbound frame"`(适配器的入站帧 DEBUG)
// 则说明读循环在等的同时一直在读:两行合起来就是「回合没有堵住心跳」的证据。
func (b *Bridge) logLaneStart(item laneItem, waited time.Duration, messages int) {
b.lanes.log.Debug("lane turn started",
"waited", waited.String(), "messages", messages,
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "group", item.msg.GroupID)
}
+59 -13
View File
@@ -131,15 +131,27 @@ func hasHighValueKeyword(text string) bool {
} }
// ruleLightJudge 只依据廉价规则信号判断是否值得回复 // ruleLightJudge 只依据廉价规则信号判断是否值得回复
// (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示 // (设计/12 §6 策略二)。优先级:**指向她本人**(design/18 §2 触发①)、
// 名、高价值关键词;除此之外一律无结论(交给参与度引擎 / // 提及主人的显示名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
// 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是 // 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是
// 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断 // 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
// 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给 // 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
// 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不 // 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
// 推断身份(身份由 actor 元数据在会话层解析,红线)。 // 推断身份(身份由 actor 元数据在会话层解析,红线)。
//
// 触发①的判据与 owner 车道**同一份**(ownerAddressedToMe,见 ownerlane.go,
// 口径写死在 design/18 §2 触发① 的 2026-10-13 修订里):`@ 她本人 / @全体`
// 才必回,**不是「有任意提及」** —— 群里 @ 别人与 @ 她长得一模一样,
// 拿「Mentions 非空」当判据会让群员之间的互 @ 把她拽进每一段对话
// (2026-10-14 真机病灶:`@3889001246`、`@867587435` 两条都被必回)。
//
// 判据的后半句(SelfID 为空时退回「有提及」)**不可省**:kook / webhook 等尚未
// 填 SelfID 的平台 AddressedToMe 恒 false,只留前半句会把它们的群 @ 一并打死
// (「没有信号」≠「没在对我说话」)。反过来,判据**全部来自结构化字段**,
// 绝不匹配正文里的 "@<数字>" —— @ 她与 @ 别人在正文里长得一模一样,
// 那正是本病灶的成因。
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict { func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict {
if len(msg.Mentions) > 0 { if ownerAddressedToMe(msg) {
return verdictRespond return verdictRespond
} }
if b.owner != nil && b.owner.DisplayName != "" && if b.owner != nil && b.owner.DisplayName != "" &&
@@ -162,12 +174,22 @@ func isStrangerPrivate(sess session.Session) bool {
// shouldRespond 实现入站消息的最小打扰分级 // shouldRespond 实现入站消息的最小打扰分级
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息 // (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息
// 应驱动完整 agent 循环并回复,还是只登记并静默。 // 应驱动完整 agent 循环并回复,还是只登记并静默。判定顺序(M18 后的**权威顺序**):
// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」): //
// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说 // ① owner 车道(`actor.IsOwner()`,design/18 §11「乙 = C」):主人私聊恒回;
// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回, // 主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说(见 ownerlane.go)——
// 而主人群里不对她说的消息改为**进上下文不回**。sess 是 // 冷避不适用于主人,主人的 @/@全体仍必回。
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 // ② 平台/系统噪声 → 只登记。
// ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。
// ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、
// 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。
// 冷却期内唯一放行口是**真故障/求救信号穿透**(严格子集,非高价值关键词全表:
// 例外给的是穿透权,噪声会直接削弱冷避),且**不解除冷却**;
// 同句负面态度优先于该例外(塔 2026-10-15 定稿)。
// ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。
// ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。
//
// sess 是该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器, // 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。 // 用于取该账号的流控闸门(陌生人私聊的强制限流)。
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) { func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
@@ -201,11 +223,35 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
return true, "stranger private chat: normal reply" return true, "stranger private chat: normal reply"
} }
// 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或 // 策略二/五:基于规则的轻量判定。**指向她本人**(@她 / @全体)、提及主人
// 高价值关键词一律直接回;规则得不出结论的部分交给参与度 // 显示名或高价值关键词一律直接回;规则得不出结论的部分交给参与度
// 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传, // 引擎 / 轻量判定器 —— 群里 @ 别人的消息因此不会必回,而是落到
// 规则层不再因为它静默(裁决②)。 // 「参与度触发 / 概率」那一档(未触发则只登记)。**安静时段只作为信号**(inQuiet)
// 继续往下传,规则层不再因为它静默(裁决②)。
inQuiet := b.inQuietHours(b.now()) inQuiet := b.inQuietHours(b.now())
// design/18 §5 冷避**先于**「@她必回」(M18,主人 2026-10-14 裁决「给」:
// 群成员也能让她少说话):非主人**群**消息先过冷避闸 —— 命中负面态度就进
// COOLDOWN,冷却期内一律静默(两者都只登记、不进循环)。顺序上必须排在下一条
// 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true,
// 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。
//
// 冷却期内的**唯一**放行口是「真故障/求救信号穿透」(塔 2026-10-15 定稿):
// 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 正常回复;
// 判据是严格子集 coldShoulderPierceKeywords(**不是** ruleLightJudge 用的
// hasHighValueKeyword 全表 —— 穿透权落在全表上会让卡片里的 `bug` 把冷避按掉)。
// @她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**;
// 同句既有负面态度又有真故障信号时,**负面态度优先**(仍静默)。
// 口径细节见 engage.go 的 coldShoulderLocked(design/18 §5/§9 由塔同步)。
//
// 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔)
// 且与是否装配参与度引擎无关 —— 旧轻量判定车道的部署同样得到这个顺序。
if sess.IsGroup {
if quiet, reason := b.coldShoulderGate(msg, actor); quiet {
return false, reason
}
}
if b.ruleLightJudge(msg) == verdictRespond { if b.ruleLightJudge(msg) == verdictRespond {
return true, "rule signal: worth responding" return true, "rule signal: worth responding"
} }
@@ -110,6 +110,138 @@ func TestNonOwnerMentionResponds(t *testing.T) {
} }
} }
// memberLaneBridge 构建一条非主人群消息用的桥接器:主人档案为空(不命中白名单 ⇒
// 走非主人分级),未装配参与度引擎与 LightJudge ⇒ 规则得不出结论时失败关闭为静默,
// 使「必不必回」只由规则判据决定,用例因此确定。
func memberLaneBridge(t *testing.T, chat *stubChatter, hist *captureHistory) *Bridge {
t.Helper()
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b
}
// TestMemberLaneAddressedToHerCriterion 钉住 design/18 §2 触发①(2026-10-13 修订)
// 在**成员车道**上的落地。2026-10-14 真机报障:群员互相 @ 的消息也被她必回
// (`@3889001246`、`@867587435` 两条),病灶就是 `ruleLightJudge` 拿
// 「`Mentions` 非空」当「在叫她」。判据现在与 owner 车道同一份
// (`ownerAddressedToMe`):**指向她本人**(@她 / @全体)才必回。
//
// 「必不必回」用「完整循环有没有被驱动」(chat.sessionID)判定;
// 「不回」还必须是**登记但不回**(历史里恰好一条 user 记录),而不是消息被丢掉。
//
// 变异自证(把判据换回旧的 `if len(msg.Mentions) > 0`):
// - 「@ 别人」一例立刻变红(旧实现把 @ 别人也当成在叫她,循环被驱动);
// - 「AddressedToMe 已置位、提及表为空」一例同样变红(旧实现只认提及表,
// 结构化字段被无视 ⇒ @全体 那种「字段到位但 at 段没落表」的情形丢掉必回)。
func TestMemberLaneAddressedToHerCriterion(t *testing.T) {
t.Parallel()
const selfID = "3443115524" // 她自己的 self_id(2026-10-14 报障现场的同一个号)
cases := []struct {
name string
msg func() adapter.UnifiedMessage
wantReply bool
wantRecords int
}{
{
name: "mentioning someone else (wired) is not addressed to her",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("/今日猪猪", adapter.Mention{UserID: "3889001246"})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf() // @ 的不是她 ⇒ false
return m
},
wantReply: false,
wantRecords: 1,
},
{
name: "addressed to her by name (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@她 在吗", adapter.Mention{UserID: selfID})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
name: "mention all (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 都来看一眼", adapter.Mention{UserID: "all"})
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
// 结构化字段是**主判据**:@全体 的标记到位而 at 段没落进提及表时,
// AddressedToMe 仍为 true,必回语义不得丢。
name: "addressed flag set without mention list still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 集合")
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
hist := &captureHistory{}
b := memberLaneBridge(t, chat, hist)
b.process(context.Background(), tc.msg())
if got := chat.sessionID != ""; got != tc.wantReply {
t.Fatalf("循环被驱动 = %v, want %v(session=%q)", got, tc.wantReply, chat.sessionID)
}
if !tc.wantReply && len(hist.records()) != tc.wantRecords {
t.Fatalf("records = %d, want %d(只登记不回,不是丢掉)", len(hist.records()), tc.wantRecords)
}
})
}
}
// TestMemberLaneUnwiredChannelKeepsMentionFallback 钉住判据的后半句在**成员车道**上
// 同样成立:未接线渠道(SelfID 为空 ⇒ AddressedToMe 恒 false)上的群 @ 按**旧判据**
// 必回。「没有信号」绝不能被当成「没在对我说话」——否则 kook / webhook 等尚未填
// SelfID 的平台,群 @ 会被一并打死。这是 owner 车道 M11 reviewer 给死的写法,
// 本片只是把它接到成员车道上。
func TestMemberLaneUnwiredChannelKeepsMentionFallback(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
b := memberLaneBridge(t, chat, &captureHistory{})
m := nonOwnerGroupMsg("@别人 你好", adapter.Mention{UserID: "someone-else"})
// SelfID 留空 = 未接线 ⇒ AddressedToMe 恒 false(fail-closed)。
if m.AddressedToMe {
t.Fatalf("未接线渠道的 AddressedToMe = true, want false(fail-closed)")
}
b.process(context.Background(), m)
if chat.sessionID != "sess-g" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID)
}
}
func TestNonOwnerHighValueKeywordResponds(t *testing.T) { func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
t.Parallel() t.Parallel()
@@ -0,0 +1,178 @@
package channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」在 **IM 侧真流水线**上的落点,
// 两件必须同时成立的事:
// 1. **落库(= Web 主会话镜像)与出站气泡吃同一份文本** —— 主人私聊的回复会镜像进
// Web 主会话,若只修出站,面板上仍会看到字面 `\n`(那正是这条修复的判据);
// 2. 🔴 **`<br>` 仍是气泡分隔符,字面 `\n` 只做气泡内换行** —— 把字面 `\n` 升级成
// 气泡切分会把一条列表拆成七条消息,比原缺陷更吵。
//
// 样本是**真机原文**(主人 2026-10-24 报障那条乐理回复,库里的 assistant 行,
// 6 处字面 `\n` 全挤在同一个 <p> 里)。
// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。
const literalEscape = `\n`
// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、
// 5 个 <p>、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭</p>
<p>那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。</p>
<p>不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪</p>`
// TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条
// 回复的第三个 <p> 是一行字面 `F\n多利亚`;修复后同一个气泡里是**七行**,而落库
// 镜像与出站气泡是同一份文本。
func TestTurnReply_LiteralBreakRenderedForBothSides(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: realLiteralBreakReply}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
// ① 落库(= Web 主会话镜像):6 处字面 `\n` 全部变成真换行,段落结构一字不变。
want := strings.ReplaceAll(realLiteralBreakReply, literalEscape, "\n")
rec := assertAssistantReply(t, hist, want)
if got := strings.Count(rec.Content, literalEscape); got != 0 {
t.Fatalf("落库文本仍有 %d 处字面 `\\n` —— Web 面板上仍会显示 F\\n多利亚", got)
}
// 段落结构一字不变:仍是 5 个 <p>(字面 `\n` 绝不升级成段落或 <br>)。
if got := strings.Count(rec.Content, "<p>"); got != 5 {
t.Fatalf("落库文本 <p> 块 = %d, want 5(段落口径不得被改动)", got)
}
wantLines := strings.Count(realLiteralBreakReply, "\n") + 6
if got := strings.Count(rec.Content, "\n"); got != wantLines {
t.Fatalf("落库文本真换行 = %d, want %d(6 处字面 `\\n` 应各变成一个换行)", got, wantLines)
}
// ② 出站:5 条气泡(<p> 一级、<br> 二级的既有语义不变),第三条是七行。
if len(ad.sends) != 5 {
t.Fatalf("出站气泡 = %d 条, want 5(字面 `\\n` 绝不能切成消息): %+v", len(ad.sends), ad.sends)
}
scale := ad.sends[2].Content
lines := strings.Split(scale, "\n")
if len(lines) != 7 {
t.Fatalf("调式气泡 = %d 行, want 7(修前是 1 行字面 F\\n多利亚): %q", len(lines), scale)
}
if !strings.HasPrefix(lines[0], "伊奥尼亚:") || !strings.HasPrefix(lines[6], "洛克里亚:") {
t.Fatalf("调式气泡首尾行 = %q / %q, want 伊奥尼亚… / 洛克里亚…", lines[0], lines[6])
}
for i, sent := range ad.sends {
if strings.Contains(sent.Content, literalEscape) {
t.Fatalf("气泡[%d] 仍有字面 `\\n`: %q", i, sent.Content)
}
if strings.Contains(sent.Content, "<br") {
t.Fatalf("气泡[%d] 出现了 <br> —— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content)
}
}
// ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。
if !strings.Contains(rec.Content, scale) {
t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content)
}
}
// TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`<br>` 仍
// 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的
// 判据一个字节都没动。
func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + "乙<br>丙" + literalEscape + "丁"}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
assertAssistantReply(t, hist, "<p>甲\n乙<br>丙\n丁</p>")
if len(ad.sends) != 2 {
t.Fatalf("出站气泡 = %d 条, want 2(<br> 仍是消息分隔符): %+v", len(ad.sends), ad.sends)
}
if got, want := ad.sends[0].Content, "甲\n乙"; got != want {
t.Fatalf("气泡[0] = %q, want %q(气泡内换行)", got, want)
}
if got, want := ad.sends[1].Content, "丙\n丁"; got != want {
t.Fatalf("气泡[1] = %q, want %q(气泡内换行)", got, want)
}
}
// TestSendProactive_LiteralBreakRenderedForBothSides 覆盖 **IM 侧**的第三个助手文本
// 产出点:渠道桥的主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此
// 这条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。
//
// ⚠️ 范围边界:**Web 活跃客户端**那条主动推送(gateway 的 proactive →
// proactive_persist)**不经** NormalizeParagraphs,字面 `\n` 在那条路由上仍原样进库;
// 那是既有取舍、不属本片 scope(已由塔登记为后续切片)。本测试只断言 IM 这一侧。
func TestSendProactive_LiteralBreakRenderedForBothSides(t *testing.T) {
t.Parallel()
const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"
weixin := &fakeAdapter{name: "weixin-ilink", connected: true}
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: acct}}}
hist := &captureHistory{}
b := newProactiveBridgeHist(t, owner, hist, weixin)
b.bubbleGapFn = func() time.Duration { return 0 }
push := "第一行" + literalEscape + "第二行"
if err := b.SendProactive(context.Background(), proactiveMsg(push)); err != nil {
t.Fatalf("SendProactive: %v", err)
}
if len(weixin.sends) != 1 {
t.Fatalf("weixin 气泡 = %d 条, want 1(字面 `\\n` 不切气泡): %+v", len(weixin.sends), weixin.sends)
}
if got, want := weixin.sends[0].Content, "第一行\n第二行"; got != want {
t.Fatalf("推送气泡 = %q, want %q", got, want)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
}
if got, want := recs[0].Content, "<p>第一行\n第二行</p>"; got != want {
t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want)
}
}
// TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak 钉住一个**口径边界**(复审观察
// ②):两个连续的字面 `\n` 渲染后就是一个空行,而空行在写入侧规整里是**段落分隔** ⇒
// 两段 `<p>` ⇒ 两端各两条气泡。
//
// 语义上说得通(空行 = 段落),但它与「字面 `\n` 只做气泡内换行」不是同一句话 ——
// 这里显式钉住,让日后任何改动**有意**改这个气泡数,而不是悄悄变。
func TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + literalEscape + "乙"}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
// 落库(Web 镜像):两段 <p>,中间保留空行。
assertAssistantReply(t, hist, "<p>甲</p>\n\n<p>乙</p>")
if len(ad.sends) != 2 {
t.Fatalf("出站气泡 = %d 条, want 2(`\\n\\n` ⇒ 空行 ⇒ 段落分隔): %+v", len(ad.sends), ad.sends)
}
if got, want := ad.sends[0].Content, "甲"; got != want {
t.Fatalf("气泡[0] = %q, want %q", got, want)
}
if got, want := ad.sends[1].Content, "乙"; got != want {
t.Fatalf("气泡[1] = %q, want %q", got, want)
}
}
@@ -141,10 +141,17 @@ func TestStrangerPrivateFlowControlPerSender(t *testing.T) {
} }
} }
// TestStrangerPrivateSecondTurnRegisteredWhileOneRuns 钉住裁决③的后半句: // TestStrangerPrivateSecondTurnAnsweredNotThrottled 钉住 M23 对裁决③后半句的**改写**:
// 「同一发送者同时最多 1 个在跑的回合」。先用 AcquireTurn 占住该发送者的槽位(模拟 // 「同一发送者同时最多 1 个在跑的回合」这道闸仍然保留(runBatch 开跑前取、回合结束后还),
// 一个回合正在跑),再让下一条消息过门 —— 它必须只登记、不回,并留可见告警。 // 但「已经有回合在跑 ⇒ 这条只登记、不回」已经被 **排队**取代 —— 塔 2026-10-24 的裁定原文:
func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) { // 「陌生人私聊 `AcquireTurn` 那段由 M23 步 3 改(把「已有回合 ⇒ 不回」改成排队)」。
//
// 结构上「排队」由车道提供(同一会话一条车道、单消费者 ⇒ 不可能有两轮同时跑),
// 因此能走到「槽被占」的只剩并发路径(例如本用例直接调 process 而另一条路正占着槽)。
// 这时**绝不静默**:留一条带 source 的 WARN,消息照答(宁可晚回,不许不回)。
//
// 变异检验:把 runBatch 里的那个 WARN 分支改回 registerThrottled + return ⇒ 本用例必红。
func TestStrangerPrivateSecondTurnAnsweredNotThrottled(t *testing.T) {
t.Parallel() t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}} chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
@@ -161,16 +168,16 @@ func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
t.Cleanup(func() { gate.ReleaseTurn(msg) }) t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg) b.process(context.Background(), msg)
if chat.sessionID != "" { if chat.sessionID == "" {
t.Fatalf("已有回合在跑时仍开了第二个回合:session=%q", chat.sessionID) t.Fatal("回合槽被占时消息没有回答:M23 的口径是排队回答,不是不回")
} }
if got := len(hist.records()); got != 1 { if got := len(hist.records()); got != 2 {
t.Fatalf("被并发拦下的消息登记 %d 条,want 1", got) t.Fatalf("登记 %d 条,want 2(user + assistant:这条被回答了)", got)
} }
logged := logBuf.String() logged := logBuf.String()
if !strings.Contains(logged, "throttled by flow gate") || if !strings.Contains(logged, "answering anyway") ||
!strings.Contains(logged, "source=services/channelbridge") { !strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("并发拦下没有留下带 source 的可见日志:%q", logged) t.Fatalf("槽被占照答没有留下带 source 的可见日志:%q", logged)
} }
} }
+16
View File
@@ -7,12 +7,28 @@ import "context"
const backthinkListLimit = 50 const backthinkListLimit = 50
// PluginSummary 是管理列表中一个能力/插件的线上结构。 // PluginSummary 是管理列表中一个能力/插件的线上结构。
//
// Origin(v0.83 契约增量)回答「这条能力来自哪台上游 server」,供面板把 MCP 工具的
// 呈现按 server 分节(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①)。
// 它是**可选键**:宿主内置能力根本不实现来源,因此该键整体**缺席**(不是 `{}` 也不是
// `null`)。归属来自注册时的元数据,绝不解析 `name` 推断 —— MCP 工具名里没有任何前缀。
// 老客户端忽略未知键即零改动;严格解析器需要容忍/声明该可选字段。
type PluginSummary struct { type PluginSummary struct {
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` Kind string `json:"kind"`
Description string `json:"description"` Description string `json:"description"`
AllowInGroup bool `json:"allowInGroup"` AllowInGroup bool `json:"allowInGroup"`
AllowInStranger bool `json:"allowInStranger"` AllowInStranger bool `json:"allowInStranger"`
Origin *PluginOrigin `json:"origin,omitempty"`
}
// PluginOrigin 是一条能力的上游来源。Server 是 server 名(= plugins.json /
// MCP_SERVERS_JSON 的 name),Transport 是 stdio / sse / http;两者按 src 的实际
// 已知程度省略,Kind 恒有值(目前只有 "mcp")。
type PluginOrigin struct {
Kind string `json:"kind"`
Server string `json:"server,omitempty"`
Transport string `json:"transport,omitempty"`
} }
// ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts // ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts
+250
View File
@@ -0,0 +1,250 @@
package gateway
// 本文件承载**渠道静默窗口合并**(M23)的管理读写端点:
//
// GET /api/v1/admin/channels/coalesce 读:盘上的窗口参数 + 取值规则 + 只读事实
// PUT /api/v1/admin/channels/coalesce 写:三态补丁 + 原子落盘 + 热生效 + 审计
//
// 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
// 不该让她回三次 —— 但「等多久 / 最多攒多少」这类参数只该在面板上调,
// 不该为改一个 2.0s → 3.0s 去 SSH 改文件(与 memory-tidy 调优同一取向)。
//
// 四条口径(每条都在 admin_coalesce_test.go 里钉住):
//
// 1. **三态**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload`
// 且**盘上一个字节都不动**(校验全部发生在落盘之前,见
// runtimeconfig.ValidateCoalescePatch)。
// 2. **写端点带审计**(动作 `channels.coalesce`),`writeAuth`(owner|admin)——
// 与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面,
// 因此不是 owner-only;也不给 viewer(它是渠道调优,不是监控投影)。
// 3. **热生效**:桥每次发车前从**同一个** store 实例读生效值,因此保存后下一次发车
// 就用新值 —— 响应里的 `hot:true` 是事实,不是承诺。
// 4. **主人私聊是只读事实**:车道类型由 actor 元数据决定(判据 `ownerPrivateLane`:
// `!msg.IsGroup() && actor.IsOwner()` ⇒ direct 车道、**没有窗口对象、拿不到定时器
// 工厂**),本端点**没有任何键**能翻转它。
// `ownerPrivate:"never"` 就是这条事实的下发形状,面板据此只显示一行说明、不给开关。
//
// 零新 `.env` 键(design/13 §2.1):配置落在 `$DATA_DIR/config/coalesce.json`。
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// coalesceLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
coalesceLogSource = "gateway/channels-coalesce"
// coalesceAuditAction 是写端点落审计的动作名(与 channels.* 同族命名)。
coalesceAuditAction = "channels.coalesce"
// coalesceMaxBodyBytes 是写端点请求体的上限:这是一份给人改的小配置
// (6 个标量键 + 少量会话覆盖),正常请求体不到 1 KiB。
coalesceMaxBodyBytes = 32 << 10
// coalesceOwnerPrivateFact 是「主人私聊不受此设置影响」的**机器可读**形式。
// 它不是开关值:没有任何请求体字段能把它改成别的(改车道类型要改 actor 判定)。
coalesceOwnerPrivateFact = "never"
)
// CoalesceConfigStore 是「渠道静默窗口配置」的可选能力:生产装配的 `s.channels`
// 就是 cmd/cyrene 的 `*runtimeChannels`,它背后持有**与 bridge 同一个**
// `runtimeconfig.CoalesceStore`(因此写进内存即热生效)。
//
// 用可选接口而不是往 Deps/Server 加字段:渠道调优本来就在这个依赖面上,
// 加字段会让「谁持有什么」多一条没人复得到的路径(与 gateway 其它可选能力同一手法)。
type CoalesceConfigStore interface {
// CoalescePath 返回配置文件落点(面板如实回显改的是哪个文件)。
CoalescePath() string
// CoalescePresent 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
CoalescePresent() bool
// CoalesceSnapshot 返回盘上那份原值(未 clamp,供面板回显「你写进去的是什么」)。
CoalesceSnapshot() runtimeconfig.CoalesceFile
// ApplyCoalesce 应用三态补丁并原子落盘。返回是否真的改动了盘上的字节。
ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error)
}
// AdminCoalescePolicy 是一组窗口参数(生效值)。
type AdminCoalescePolicy struct {
Enabled bool `json:"enabled"`
WindowMs int `json:"windowMs"`
MaxWaitMs int `json:"maxWaitMs"`
MaxBatchMessages int `json:"maxBatchMessages"`
ApplyToStrangers bool `json:"applyToStrangers"`
StrangerWindowMs int `json:"strangerWindowMs"`
}
// AdminCoalesceRules 是取值规则(随读下发 ⇒ 面板表单与后端校验不可能两套规则)。
type AdminCoalesceRules struct {
// WindowMs 是允许区间(毫秒);越界会被后端 clamp 并留 WARN,而不是拒绝。
WindowMs [2]int `json:"windowMs"`
// MaxWaitMs 的下界**跟随窗口**(因此 [0] 是窗口下界、[1] 是绝对上限)。
MaxWaitMs [2]int `json:"maxWaitMs"`
// MaxBatchMessages 是单批条数区间(超出不是丢弃,是拆成下一批)。
MaxBatchMessages [2]int `json:"maxBatchMessages"`
// Clamp 说明越界值的处置("clamp"),使面板不必猜。
Clamp string `json:"clamp"`
// FlushReasons 是发车原因的闭集(面板/文档据此解释日志里的 flush_reason)。
FlushReasons []string `json:"flushReasons"`
}
// AdminCoalesceView 是 GET / PUT `/admin/channels/coalesce` 的 data。
type AdminCoalesceView struct {
// Path 是面板配置文件的落点(与 channels.json 同一棵配置树)。
Path string `json:"path"`
// Present 报告那份文件是否已存在(false = 还没写过,运行期正走代码默认)。
Present bool `json:"present"`
// Hot 报告这份配置是否**热生效**(桥每次发车前重读同一 store ⇒ true)。
Hot bool `json:"hot"`
// Default 是**按代码默认 + 盘上 default 段**归一后的生效值(面板的「当前值」)。
Default AdminCoalescePolicy `json:"default"`
// Patch 是盘上 default 段的**原值**:键缺位 ⇒ 不出现(= 跟随代码默认)。
// 有了它,面板的「留空 ⇒ 跟随默认 2000ms」才是照实回显而不是猜。
Patch map[string]any `json:"patch"`
// Conversations 是每会话覆盖的**生效值**(键 = `platform|channel`,可读形态)。
Conversations map[string]AdminCoalescePolicy `json:"conversations"`
// Rules 是取值规则;OwnerPrivate 是那条只读事实(永远 "never")。
Rules AdminCoalesceRules `json:"rules"`
OwnerPrivate string `json:"ownerPrivate"`
}
// adminCoalesceRead 是 GET 的处理函数:只读、不审计(与 /admin/config-facts 同档)。
func (s *Server) adminCoalesceRead(w http.ResponseWriter, _ *http.Request) {
store, ok := s.coalesceStore()
if !ok {
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_unavailable",
"渠道静默窗口配置面未接线:面板配置文件落点无法确定"))
return
}
writeJSON(w, http.StatusOK, buildCoalesceView(store))
}
// adminCoalesceWrite 是 PUT 的处理函数:解析 → 校验 → 落盘 → 回显真值 → 审计。
//
// 顺序是刻意的(口径 1):**任何**校验失败都在落盘之前返回 400,
// 因此「非法值 ⇒ 盘上一个字节都不动」是结构性的,不是靠回滚。
func (s *Server) adminCoalesceWrite(w http.ResponseWriter, r *http.Request) {
store, ok := s.coalesceStore()
if !ok {
s.auditClaims(r, coalesceAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_unavailable",
"渠道静默窗口配置面未接线:面板配置文件落点无法确定"))
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, coalesceMaxBodyBytes))
if err != nil {
s.auditClaims(r, coalesceAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
return
}
patch, err := parseCoalescePatch(body)
if err != nil {
s.auditClaims(r, coalesceAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
return
}
wrote := false
if !patch.Empty() {
wrote, err = store.ApplyCoalesce(patch)
if err != nil {
s.auditClaims(r, coalesceAuditAction, "error")
logger.WithSource(s.log, coalesceLogSource).Error(
"coalesce config write failed", "path", store.CoalescePath(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_write_failed",
"面板配置文件落盘失败(盘上内容未改动):%v", err))
return
}
}
log := logger.WithSource(s.log, coalesceLogSource)
if wrote {
log.Info("coalesce config saved (hot: the bridge rereads it before every departure)",
"path", store.CoalescePath(), "action", coalesceAuditAction)
} else {
log.Info("coalesce config unchanged (no bytes written)", "path", store.CoalescePath())
}
s.auditClaims(r, coalesceAuditAction, "ok")
writeJSON(w, http.StatusOK, buildCoalesceView(store))
}
// coalesceStore 解析「渠道静默窗口配置」的可选能力面。
func (s *Server) coalesceStore() (CoalesceConfigStore, bool) {
if s.channels == nil {
return nil, false
}
store, ok := s.channels.(CoalesceConfigStore)
if !ok || store.CoalescePath() == "" {
return nil, false
}
return store, true
}
// parseCoalescePatch 解析 PUT 请求体:严格解码(未知键即错),并做「敲错级」校验
// (负数)。越界的合法数字不在这里拒绝 —— 它们由运行期 clamp 并留 WARN。
func parseCoalescePatch(body []byte) (runtimeconfig.CoalescePatch, error) {
var patch runtimeconfig.CoalescePatch
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&patch); err != nil {
return runtimeconfig.CoalescePatch{}, fmt.Errorf("请求体不是合法的窗口参数补丁:%w", err)
}
if dec.More() {
return runtimeconfig.CoalescePatch{}, fmt.Errorf("请求体只允许一个 JSON 对象")
}
if patch.Default != nil {
if err := runtimeconfig.ValidateCoalescePatch(*patch.Default); err != nil {
return runtimeconfig.CoalescePatch{}, err
}
}
for key, conv := range patch.Conversations {
if conv == nil {
continue
}
if err := runtimeconfig.ValidateCoalescePatch(*conv); err != nil {
return runtimeconfig.CoalescePatch{}, fmt.Errorf("%s:%w", key, err)
}
}
return patch, nil
}
// buildCoalesceView 组装面板视图:盘上原值 + 生效值 + 取值规则 + 只读事实。
func buildCoalesceView(store CoalesceConfigStore) AdminCoalesceView {
file := store.CoalesceSnapshot()
view := AdminCoalesceView{
Path: store.CoalescePath(),
Present: store.CoalescePresent(),
Hot: true,
Default: adminCoalescePolicy(file.EffectiveDefault()),
Patch: file.Default.Values(),
Conversations: map[string]AdminCoalescePolicy{},
Rules: AdminCoalesceRules{
WindowMs: [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs},
MaxWaitMs: [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceMaxWaitMaxMs},
MaxBatchMessages: [2]int{runtimeconfig.CoalesceBatchMin, runtimeconfig.CoalesceBatchMax},
Clamp: "clamp",
FlushReasons: []string{"quiet", "max_wait", "all_stale", "off", "stop"},
},
OwnerPrivate: coalesceOwnerPrivateFact,
}
for key := range file.Conversations {
if policy, ok := file.EffectiveConversation(key); ok {
view.Conversations[key] = adminCoalescePolicy(policy)
}
}
return view
}
// adminCoalescePolicy 把 runtimeconfig 的策略投影成面板形状(字段一一对应)。
func adminCoalescePolicy(p runtimeconfig.CoalescePolicy) AdminCoalescePolicy {
return AdminCoalescePolicy{
Enabled: p.Enabled,
WindowMs: p.WindowMs,
MaxWaitMs: p.MaxWaitMs,
MaxBatchMessages: p.MaxBatchMessages,
ApplyToStrangers: p.ApplyToStrangers,
StrangerWindowMs: p.StrangerWindowMs,
}
}
@@ -0,0 +1,217 @@
package gateway
// 本文件锁定渠道静默窗口合并(M23)的读写端点
// (`GET|PUT /api/v1/admin/channels/coalesce`):
//
// ① 读侧如实回显:生效值(代码默认 + 盘上 default 段)+ 盘上原值 patch(缺位键不出现)
// + 取值规则 + 「主人私聊永远不受影响」这条只读事实 + hot:true;
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动;合法值 ⇒ 采用且保留同文件其它键与其它会话;
// 非法值(负数/未知键/类型错) ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
// ③ 热生效是事实(同一 store 实例被桥每次发车前重读),响应 `hot:true`;
// ④ RBAC 走 writeAuth(owner|admin),写落审计 `channels.coalesce`;
// ⑤ 请求体里**没有任何**键能翻转「主人私聊不走窗口」——那由 actor 元数据决定。
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// coalesceEnv 是一个把 coalesce 配置文件放进临时数据目录的网关测试环境。
type coalesceEnv struct {
url string
path string
jwt *auth.JWTService
store *runtimeconfig.CoalesceStore
}
// coalesceStubChannels 是 ChannelConfigStore + CoalesceConfigStore 的生产形状替身:
// 它把 coalesce 的读写直接委托给一个真的 runtimeconfig.CoalesceStore(因此落盘、
// 三态、热生效都是真实路径,而不是再写一套假的)。
type coalesceStubChannels struct {
store *runtimeconfig.CoalesceStore
}
func (c *coalesceStubChannels) Path() string { return "" }
func (c *coalesceStubChannels) Load() map[string][]config.ChannelAccount { return nil }
func (c *coalesceStubChannels) Reload(string) error { return nil }
func (c *coalesceStubChannels) CoalescePath() string { return c.store.Path() }
func (c *coalesceStubChannels) CoalescePresent() bool { return c.store.Present() }
func (c *coalesceStubChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
return c.store.Snapshot()
}
func (c *coalesceStubChannels) ApplyCoalesce(p runtimeconfig.CoalescePatch) (bool, error) {
return c.store.Apply(p)
}
// newCoalesceEnv 构造环境:coalesce.json 落在 t.TempDir()/config/coalesce.json。
func newCoalesceEnv(t *testing.T) *coalesceEnv {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config", "coalesce.json")
store := runtimeconfig.NewCoalesceStore(path, nil)
deps := Deps{DataDir: dir, Channels: &coalesceStubChannels{store: store}}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
return &coalesceEnv{
url: ts.URL + "/api/v1/admin/channels/coalesce",
path: path,
jwt: jwt,
store: store,
}
}
// disk 读回盘上的原始字节(「非法值不落盘」的证据就是它与之前相等 / 文件仍不存在)。
func (e *coalesceEnv) disk(t *testing.T) ([]byte, bool) {
t.Helper()
raw, err := os.ReadFile(e.path)
if err != nil {
if os.IsNotExist(err) {
return nil, false
}
t.Fatalf("read coalesce file: %v", err)
}
return raw, true
}
// view 解一次成功响应的 data。
func (e *coalesceEnv) view(t *testing.T, body []byte) AdminCoalesceView {
t.Helper()
var view AdminCoalesceView
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
t.Fatalf("decode view: %v (body=%s)", err, body)
}
return view
}
func TestAdminCoalesceReadShowsDefaultsAndRules(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, e.url, nil, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := e.view(t, body)
if !view.Hot || view.Present {
t.Fatalf("view = %+v, want hot:true and present:false for a fresh deployment", view)
}
if view.OwnerPrivate != "never" {
t.Fatalf("ownerPrivate = %q, want the read-only fact 'never'", view.OwnerPrivate)
}
// 缺位 ⇒ 生效值是代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24)。
if view.Default.WindowMs != 2000 || view.Default.MaxWaitMs != 6000 || view.Default.MaxBatchMessages != 24 {
t.Fatalf("default policy = %+v, want 2000/6000/24", view.Default)
}
if !view.Default.Enabled || !view.Default.ApplyToStrangers {
t.Fatalf("default policy = %+v, want both switches on by default", view.Default)
}
if len(view.Patch) != 0 {
t.Fatalf("patch = %v, want empty (nothing written on disk yet)", view.Patch)
}
if view.Rules.WindowMs != [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs} {
t.Fatalf("rules.windowMs = %v, want [300 15000]", view.Rules.WindowMs)
}
if len(view.Rules.FlushReasons) == 0 {
t.Fatalf("rules.flushReasons is empty, want the closed set of departure reasons")
}
}
func TestAdminCoalesceWriteIsThreeState(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner)
// ① 合法值 ⇒ 采用并落盘。
resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if view := e.view(t, body); view.Default.WindowMs != 3000 || !view.Default.Enabled || !view.Present {
t.Fatalf("after PUT default = %+v, want windowMs 3000, the rest at defaults and present:true", view.Default)
}
before, ok := e.disk(t)
if !ok {
t.Fatal("PUT reported success but no file was written")
}
// ② 缺位键 ⇒ 不动(同样的字节 ⇒ 连文件都不重写)。
resp, body = doRaw(t, http.MethodPut, e.url, `{}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty PUT status = %d, want 200", resp.StatusCode)
}
if after, _ := e.disk(t); string(after) != string(before) {
t.Fatalf("empty patch rewrote the file:\nbefore=%s\nafter=%s", before, after)
}
// ③ 非法值(负数)⇒ 400 且盘上零变更。
resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":-5}}`, token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("negative windowMs status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
if after, _ := e.disk(t); string(after) != string(before) {
t.Fatalf("invalid patch touched the file:\nbefore=%s\nafter=%s", before, after)
}
// ④ 未知键 ⇒ 400 且盘上零变更。
resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMsTypo":1}}`, token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown key status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if after, _ := e.disk(t); string(after) != string(before) {
t.Fatalf("unknown key touched the file:\nbefore=%s\nafter=%s", before, after)
}
}
func TestAdminCoalesceConversationOverridesAndDelete(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
token := signAny(t, e.jwt, "acct-admin", auth.RoleAdmin)
// 每会话覆盖:只有给出的键被采用,其余跟随全局生效值。
resp, body := doRaw(t, http.MethodPut, e.url,
`{"default":{"windowMs":2500},"conversations":{"telegram|group:-20":{"enabled":false}}}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := e.view(t, body)
conv, ok := view.Conversations["telegram|group:-20"]
if !ok {
t.Fatalf("conversation override missing from the view: %+v", view.Conversations)
}
if conv.Enabled || conv.WindowMs != 2500 {
t.Fatalf("conversation policy = %+v, want enabled:false and windowMs inherited (2500)", conv)
}
// null ⇒ 删掉该会话覆盖(回到全局)。
resp, body = doRaw(t, http.MethodPut, e.url, `{"conversations":{"telegram|group:-20":null}}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete override status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if view := e.view(t, body); len(view.Conversations) != 0 {
t.Fatalf("conversations = %+v, want none after the null delete", view.Conversations)
}
}
func TestAdminCoalesceWriteRequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
viewer := signAny(t, e.jwt, "acct-viewer", auth.RoleViewer)
resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer PUT status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
if _, ok := e.disk(t); ok {
t.Fatal("a forbidden write still created the config file")
}
}
@@ -26,6 +26,7 @@ import (
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手 // pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手
@@ -63,6 +64,9 @@ type pluginInstallConfig struct {
Args []string `json:"args"` Args []string `json:"args"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
Env map[string]string `json:"env"` Env map[string]string `json:"env"`
// Enabled 是安装时的初始启用态。缺省(nil)=启用;显式 false =装上但先不连
// (配置落盘、工具不注册,之后可由面板「启用」)。
Enabled *bool `json:"enabled"`
} }
// AdminInstalledPlugin 是管理列表中一个已安装插件的线上 // AdminInstalledPlugin 是管理列表中一个已安装插件的线上
@@ -78,6 +82,9 @@ type AdminInstalledPlugin struct {
APIKey string `json:"apiKey,omitempty"` APIKey string `json:"apiKey,omitempty"`
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
// Enabled 是这一台的启用态(plugins.json 的 `enabled`,缺省视为 true)。
// 停用 ≠ 卸载:条目仍在列表里,只是没连、没注册工具。
Enabled bool `json:"enabled"`
// Live 报告 MCP server 是否已连接、其工具是否已注册。 // Live 报告 MCP server 是否已连接、其工具是否已注册。
Live bool `json:"live"` Live bool `json:"live"`
// ToolCount 是活着的 server 已注册的工具数。 // ToolCount 是活着的 server 已注册的工具数。
@@ -134,6 +141,7 @@ func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, r
Args: req.Config.Args, Args: req.Config.Args,
APIKey: req.Config.APIKey, APIKey: req.Config.APIKey,
Env: req.Config.Env, Env: req.Config.Env,
Enabled: req.Config.Enabled,
}.Normalize() }.Normalize()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
s.auditClaims(r, "plugin.install", "error") s.auditClaims(r, "plugin.install", "error")
@@ -160,6 +168,7 @@ func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, r
"name": res.Config.Name, "name": res.Config.Name,
"kind": res.Config.Kind, "kind": res.Config.Kind,
"transport": res.Config.Transport, "transport": res.Config.Transport,
"enabled": res.Config.IsEnabled(),
"toolCount": res.ToolCount, "toolCount": res.ToolCount,
}) })
} }
@@ -200,6 +209,18 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured")) writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
return return
} }
// 🔴 单把 MCP 工具与 `mcp:<server>` 登记条目**不能**走这条裸删路径
// (DevDocs/audit/MCP工具按上游分组-2026-10-24.md §1.5-2):管理器
// Uninstall 只按 server 名/ID 匹配,落到这里就等于绕过插件管理器直接改注册表 ——
// 该 server 的 names 账本不同步(下次 refresh 会把工具当「新出现的」再注册回来,
// 或让 KindMCP 条目与工具各自消失一半)。判据是**注册时的来源章**,不是名字前缀。
if cap, err := reg.Get(name); err == nil {
if reason := s.serverScopedUninstall(name, cap); reason != "" {
s.auditClaims(r, "plugin.uninstall", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "plugin_uninstall_scope", "%s", reason))
return
}
}
if err := reg.Remove(name); err != nil { if err := reg.Remove(name); err != nil {
s.auditClaims(r, "plugin.uninstall", "error") s.auditClaims(r, "plugin.uninstall", "error")
if errors.Is(err, capreg.ErrNotFound) { if errors.Is(err, capreg.ErrNotFound) {
@@ -213,6 +234,59 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true}) writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true})
} }
// serverScopedUninstall 报告「这个名字必须按整台 server 处理、不能在这里单删」的原因;
// 返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。
//
// 它拦住两类真实事故(同一条根因:绕开插件管理器裸删):
// - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了;
// - `mcp:<server>` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。
//
// 文案按**接入路径**分流,因为两条路的入口不同(owner 2026-10-24 复审 M30-p2-1):
// - 由插件管理器托管(落在 plugins.json)⇒ 「MCP 服务器」页确实能启停/卸载它;
// - 由 `.env` 的 MCP_SERVERS_JSON 在启动期接入(cmd/cyrene/mcp.go,**不经** Manager)
// ⇒「MCP 服务器」页只列 manager 托管的那批,**看不到它**,指向那页就是假指引;
// 必须让主人去改 .env 并重启。
//
// 判据是**管理器持久清单里有没有这台 server**,不是名字前缀/传输推断出来的「路径猜测」。
func (s *Server) serverScopedUninstall(name string, cap capreg.Capability) string {
server := cap.Origin.Server
if cap.Kind == capreg.KindMCP {
if named, ok := pluginmgr.MCPServerName(name); ok {
server = named
}
return fmt.Sprintf("%q 是一台 MCP server 的登记条目;%s", name, s.uninstallPointer(server))
}
if cap.Origin.Kind == sdk.OriginKindMCP {
return fmt.Sprintf("%q 是 MCP server %q 的工具,单把工具不能单独卸载;%s", name, server, s.uninstallPointer(server))
}
return ""
}
// uninstallPointer 返回「这台 server 该去哪里启停/卸载」的**可操作**话术。
func (s *Server) uninstallPointer(server string) string {
if s.pluginManagerHasServer(server) {
return "请在「MCP 服务器」页启停或卸载该 server"
}
return "该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入(不在 plugins.json 里,后台面板不管理它)——" +
"要停用或移除,请改 .env 里的 MCP_SERVERS_JSON(把对应的 server 删掉或设 enabled=false),改完需重启生效"
}
// pluginManagerHasServer 报告该 server 名是否由插件管理器托管(= 落在 plugins.json,
// 因此「MCP 服务器」页看得见它)。`.env` 的 MCP_SERVERS_JSON 接入的 server 不经过
// Manager,这里如实回答 false。没有管理器(测试/未接线)时也回答 false —— 与
// 「面板看不见它」的事实一致,不给一个到不了的指引。
func (s *Server) pluginManagerHasServer(server string) bool {
if s.plugins == nil || server == "" {
return false
}
for _, it := range s.plugins.List() {
if it.Config.Name == server {
return true
}
}
return false
}
// adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置 // adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置
// 与每 server 工具数。它是读端点(owner/admin), // 与每 server 工具数。它是读端点(owner/admin),
// 绝不原样返回 API key 或敏感 env 值。 // 绝不原样返回 API key 或敏感 env 值。
@@ -240,6 +314,7 @@ func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin {
APIKey: cfg.APIKey, APIKey: cfg.APIKey,
Env: cfg.Env, Env: cfg.Env,
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
Enabled: cfg.IsEnabled(),
Live: it.Live, Live: it.Live,
ToolCount: it.ToolCount, ToolCount: it.ToolCount,
} }
+18
View File
@@ -39,6 +39,10 @@ func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
} }
// adminListPlugins 列出所有已注册能力(管理读)。 // adminListPlugins 列出所有已注册能力(管理读)。
//
// 每条能力带上它的上游来源(若有):来源章的读取面在这里一次性给出,
// 前端「插件与能力」页据此把工具按 server 分节。**策略字段与集合完全不变** ——
// 只是多了一个可选键,权限判定与模型可见的工具面都不读它。
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
out := make([]PluginSummary, 0) out := make([]PluginSummary, 0)
if s.caps != nil { if s.caps != nil {
@@ -49,12 +53,26 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
Description: cap.Description, Description: cap.Description,
AllowInGroup: cap.Policy.AllowInGroup, AllowInGroup: cap.Policy.AllowInGroup,
AllowInStranger: cap.Policy.AllowInStranger, AllowInStranger: cap.Policy.AllowInStranger,
Origin: pluginOriginOf(cap),
}) })
} }
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
// pluginOriginOf 把能力条目上的来源章映射为线上结构;零值(宿主内置 / 来源未知)
// 返回 nil,使 JSON 里**没有** origin 键 —— 缺席是「宿主内置」的表达。
func pluginOriginOf(cap capreg.Capability) *PluginOrigin {
if cap.Origin.IsZero() {
return nil
}
return &PluginOrigin{
Kind: cap.Origin.Kind,
Server: cap.Origin.Server,
Transport: cap.Origin.Transport,
}
}
// adminListChannels 列出已注册的渠道适配器(管理读)。当接线了 // adminListChannels 列出已注册的渠道适配器(管理读)。当接线了
// 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏, // 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏,
// 确保原始 token 绝不抵达客户端。 // 确保原始 token 绝不抵达客户端。
@@ -0,0 +1,331 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住「插件与能力」读面的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
// §2 方案① / §6 契约增量),以及与之配套的服务端拒绝:
//
// - 来自 MCP server 的工具带 `origin:{kind,server,transport}`;
// - 宿主内置工具的该键**缺席**(不是 `{}`、不是 `null`)—— 缺席才表示「没有上游」;
// - 只改呈现:`allowInGroup`/`allowInStranger` 逐字不变,`kind` 仍是 `tool`;
// - 对一把 MCP 工具调 `POST /admin/plugins/uninstall` 必须 400(绕开管理器裸删会让
// server 的 names 账本不同步),而普通能力的卸载行为不变。
// originStubTool 是自报来源的最小工具。
type originStubTool struct {
name string
origin sdk.Origin
}
func (t originStubTool) Name() string { return t.name }
func (t originStubTool) Description() string { return "MCP 工具" }
func (t originStubTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t originStubTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
func (t originStubTool) Origin() sdk.Origin { return t.origin }
// builtinStubTool 是宿主内置工具(不实现 sdk.OriginAware)。
type builtinStubTool struct{ name string }
func (t builtinStubTool) Name() string { return t.name }
func (t builtinStubTool) Description() string { return "内置工具" }
func (t builtinStubTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t builtinStubTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
// listPlugins 取管理读面的能力清单(保留原始 JSON 以便断言键的**缺席**)。
func listPlugins(t *testing.T, url, token string) []byte {
t.Helper()
resp, raw := doReq(t, http.MethodGet, url+"/api/v1/admin/plugins", nil, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw)
}
return raw
}
// TestAdminListPluginsExposesOrigin 是读面的主判据:MCP 工具带章、内置工具缺席。
func TestAdminListPluginsExposesOrigin(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
mcpOrigin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: mcpOrigin}); err != nil {
t.Fatalf("register mcp tool: %v", err)
}
if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil {
t.Fatalf("register builtin tool: %v", err)
}
raw := listPlugins(t, env.ts.URL, env.token)
var list struct {
Data []PluginSummary `json:"data"`
}
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatalf("decode list: %v (%s)", err, raw)
}
byName := make(map[string]PluginSummary, len(list.Data))
for _, it := range list.Data {
byName[it.Name] = it
}
mcpCap, ok := byName["read_file_mcp"]
if !ok {
t.Fatalf("MCP 工具不在清单里:%s", raw)
}
if mcpCap.Origin == nil {
t.Fatalf("MCP 工具没有 origin:%+v", mcpCap)
}
if *mcpCap.Origin != (PluginOrigin{Kind: "mcp", Server: "fs", Transport: "stdio"}) {
t.Fatalf("origin = %+v, want {mcp fs stdio}", *mcpCap.Origin)
}
// 呈现层新增不改既有语义:kind 与两个策略布尔逐字不变。
if mcpCap.Kind != "tool" || mcpCap.AllowInGroup || mcpCap.AllowInStranger {
t.Fatalf("既有字段被改了:%+v", mcpCap)
}
builtin, ok := byName["web_search"]
if !ok {
t.Fatalf("内置工具不在清单里:%s", raw)
}
if builtin.Origin != nil {
t.Fatalf("内置工具带上了 origin:%+v", builtin.Origin)
}
// 🔴 线上必须是「键缺席」而不是空对象:`"origin":{}` / `"origin":null` 都会被
// 前端/严格解析器读成「有来源但内容为空」。
entry := rawEntryOf(t, raw, "web_search")
if strings.Contains(entry, "origin") {
t.Fatalf("内置工具的条目里出现了 origin 键:%s", entry)
}
}
// rawEntryOf 从原始响应里截出某个名为 name 的条目的 JSON 片段,用于断言键的缺席
// (反序列化后的结构体无法区分「缺键」与「零值」)。
func rawEntryOf(t *testing.T, raw []byte, name string) string {
t.Helper()
var items []json.RawMessage
var envelope struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err == nil {
items = envelope.Data
} else if err := json.Unmarshal(raw, &items); err != nil {
t.Fatalf("decode raw list: %v (%s)", err, raw)
}
for _, item := range items {
var probe struct {
Name string `json:"name"`
}
if err := json.Unmarshal(item, &probe); err != nil {
t.Fatalf("decode item: %v (%s)", err, item)
}
if probe.Name == name {
return string(item)
}
}
t.Fatalf("条目 %q 不在清单里:%s", name, raw)
return ""
}
// TestAdminPluginUninstallRejectsMCPTool 是那条 high finding 的服务端一半:
// 单把 MCP 工具不能绕过插件管理器裸删(消息该指向哪,按接入路径另有专门用例)。
func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("register mcp tool: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "read_file_mcp"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400(单把 MCP 工具不能在这里卸载),body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if _, err := env.caps.Get("read_file_mcp"); err != nil {
t.Fatalf("被拒的卸载却把工具删了:%v", err)
}
}
// installMCPPlugin 通过 install 端点装一台 server(写进 plugins.json)。
// 用于「这台 server 由插件管理器托管」的路径判定。
func installMCPPlugin(t *testing.T, env *pluginTestEnv, name string) {
t.Helper()
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
"kind": "mcp",
"name": name,
"config": map[string]any{"transport": "http", "endpoint": env.mcpURL},
}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("install %q status = %d, body = %s", name, resp.StatusCode, raw)
}
}
// TestAdminPluginUninstallMCPToolPointsToPluginPage:server 在 plugins.json 里
// ⇒ 文案指向「MCP 服务器」页(那页确实能卸载它),**不**提 .env。
func TestAdminPluginUninstallMCPToolPointsToPluginPage(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, newFakeMCPHandler("", "add"))
installMCPPlugin(t, env, "calc")
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "add"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if !strings.Contains(string(raw), "MCP 服务器") {
t.Fatalf("plugins.json 装出来的 server 应指向「MCP 服务器」页:%s", raw)
}
if strings.Contains(string(raw), "MCP_SERVERS_JSON") {
t.Fatalf("plugins.json 路径不该提 .env 的 MCP_SERVERS_JSON:%s", raw)
}
if _, err := env.caps.Get("add"); err != nil {
t.Fatalf("被拒的卸载却把工具删了:%v", err)
}
}
// TestAdminPluginUninstallMCPToolPointsToEnvPath:server **不在** plugins.json
// ⇒ 它是 `.env` 的 MCP_SERVERS_JSON 启动期接入的,而「MCP 服务器」页只列 manager
// 托管的那批(看不见它)⇒ 文案必须让主人去改 .env,且**不得**指向那页。
func TestAdminPluginUninstallMCPToolPointsToEnvPath(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "envcalc", Transport: "stdio"}
if err := env.caps.RegisterTool(originStubTool{name: "env_add", origin: origin}); err != nil {
t.Fatalf("register env-path tool: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "env_add"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if !strings.Contains(string(raw), "MCP_SERVERS_JSON") {
t.Fatalf(".env 接入的 server 应指向 MCP_SERVERS_JSON:%s", raw)
}
if strings.Contains(string(raw), "MCP 服务器") {
t.Fatalf(".env 接入的 server 不得指向「MCP 服务器」页(那页看不到它):%s", raw)
}
if _, err := env.caps.Get("env_add"); err != nil {
t.Fatalf("被拒的卸载却把工具删了:%v", err)
}
}
// TestAdminPluginUninstallMCPEntryPointsToEnvPath:`mcp:<server>` 登记条目同样要按
// 接入路径给指引 —— env 路径下也**不得**指向「MCP 服务器」页。
func TestAdminPluginUninstallMCPEntryPointsToEnvPath(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
if err := env.caps.Register(capreg.Capability{
Name: "mcp:envcalc", Kind: capreg.KindMCP, Description: "MCP server envcalc", Value: struct{}{},
}); err != nil {
t.Fatalf("register mcp entry: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "mcp:envcalc"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if !strings.Contains(string(raw), "MCP_SERVERS_JSON") {
t.Fatalf(".env 接入的登记条目应指向 MCP_SERVERS_JSON:%s", raw)
}
if strings.Contains(string(raw), "MCP 服务器") {
t.Fatalf(".env 接入的登记条目不得指向「MCP 服务器」页:%s", raw)
}
if _, err := env.caps.Get("mcp:envcalc"); err != nil {
t.Fatalf("被拒的卸载却把登记条目删了:%v", err)
}
}
// TestAdminPluginUninstallStillRemovesPlainCapability:普通能力(技能/内置工具)
// 的卸载路径逐字不变 —— 拒绝只针对「有 MCP 来源」的条目。
func TestAdminPluginUninstallStillRemovesPlainCapability(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil {
t.Fatalf("register builtin tool: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "web_search"}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", resp.StatusCode, raw)
}
if _, err := env.caps.Get("web_search"); err == nil {
t.Fatal("普通能力没有被卸载")
}
}
// TestInstalledMCPServerToolsCarryOrigin 走**热装整链**(install 端点 → 插件管理器 →
// mcp.Registry → RegisterTool):装完那台 server 的工具在管理读面上带 origin,
// 而按 server 名卸载后条目**连 origin 一起消失**,不留孤儿。
//
// 它与 `internal/capability/mcp` 的盖章用例互补:那条钉的是两条接入路径的合流处
// (RegisterTool),这条钉的是运行时这条路径真的走到了那里。
func TestInstalledMCPServerToolsCarryOrigin(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, newFakeMCPHandler("", "add", "mul"))
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
"kind": "mcp",
"name": "calc",
"config": map[string]any{"transport": "http", "endpoint": env.mcpURL},
}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
}
var list struct {
Data []PluginSummary `json:"data"`
}
if err := json.Unmarshal(listPlugins(t, env.ts.URL, env.token), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
want := PluginOrigin{Kind: "mcp", Server: "calc", Transport: "http"}
tools := 0
for _, it := range list.Data {
if it.Kind != "tool" {
continue
}
tools++
if it.Origin == nil || *it.Origin != want {
t.Fatalf("工具 %q 的 origin = %+v, want %+v", it.Name, it.Origin, want)
}
}
if tools != 2 {
t.Fatalf("工具数 = %d, want 2(add/mul)", tools)
}
// 按 server 名卸载(这是**合法**路径,管理器会连工具带账本一起收),
// 卸载后工具条目连 origin 一起消失 —— 「停用 server ⇒ 工具消失」语义不变。
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "calc"}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
}
if left := env.caps.List(capreg.KindTool); len(left) != 0 {
t.Fatalf("卸载 server 后还剩 %d 把工具(不应留下带 origin 的孤儿)", len(left))
}
}
+16 -2
View File
@@ -9,6 +9,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
) )
@@ -43,10 +44,20 @@ type proactivePayload struct {
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回投递与落库 // SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回投递与落库
// 两件事的结果(切片 P0,`backthink.ProactiveDelivery`)。 // 两件事的结果(切片 P0,`backthink.ProactiveDelivery`)。
// //
// 顺序是刻意的 —— **先落库、再发帧**:主动消息必须成为主会话 // 顺序是刻意的 —— **先规整、再落库、再发帧**:主动消息必须成为主会话
// (`history.MainSessionID`)里的一条真实消息(assistant 角色),否则刷新/ // (`history.MainSessionID`)里的一条真实消息(assistant 角色),否则刷新/
// 重载后什么都补不回来(这正是「气泡恒落」的根);而帧要带上落库行的 id, // 重载后什么都补不回来(这正是「气泡恒落」的根);而帧要带上落库行的 id,
// 客户端才能把实时气泡对齐到历史行。 // 客户端才能把实时气泡对齐到历史行。规整必须落在落库**之前** —— 落库行是
// 权威文本,先落库后规整只会在库里留下脏的、而帧与历史行逐字对不上。
//
// 文本与其它三处**同源**(Web 会话回合 / IM 出站 / 主动推送 IM 侧):一律走
// `turnreply.NormalizeParagraphs`,本文件绝不另写一份实现(本仓已因「同一条
// 规则两份写法」栽过多次)。修前的真缺口正是这条路由**完全绕过**它(M19 复审
// 抓到):同一条主动消息走 IM 是规整过的、走 Web 实时推还是原样,半包 `<p>` 在
// 客户端「只要存在完整 `<p>` 块就不再按空行回退切分」的契约下整条塌成一坨。
// 规整是幂等的写入侧加法(已规范的文本逐字不变),因此 `<p>` = 一段一气泡、
// `<br>` = 气泡内分隔、`<action>`/`<narration>` 随所在段落进同一个 `<p>` 这些
// 既有语义一条不动。
// //
// 落库失败**不静默**:记一条带来源的 WARN,并把错误返回给调用方,同时 // 落库失败**不静默**:记一条带来源的 WARN,并把错误返回给调用方,同时
// Persisted=false —— 帧照发(实时气泡不能丢),但记录里绝不说「已推送」。 // Persisted=false —— 帧照发(实时气泡不能丢),但记录里绝不说「已推送」。
@@ -60,6 +71,9 @@ func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ba
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return backthink.ProactiveDelivery{}, err return backthink.ProactiveDelivery{}, err
} }
// 先规整、再落库:msg 是调用方传来的值副本,就地改写只影响本路由;
// 落库与推帧随后取的都是**同一个**字符串,两端因此逐字相同。
msg.Content = turnreply.NormalizeParagraphs(msg.Content)
persisted, persistErr := h.persistProactiveToMain(ctx, msg) persisted, persistErr := h.persistProactiveToMain(ctx, msg)
if persistErr != nil { if persistErr != nil {
h.log.Warn("persist proactive message to main session failed", h.log.Warn("persist proactive message to main session failed",
@@ -0,0 +1,146 @@
package gateway
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
)
// 本文件锁定 M19 复审抓到的真缺口(切片 M25):**Web 活跃客户端**这条主动推送
// 路由过去把 msg.Content 原样落库、原样推帧,**完全绕过**
// `turnreply.NormalizeParagraphs` —— 于是同一条主动消息走 IM 是规整过的
// (channelbridge/proactive.go 出口),走 Web 实时推还是原样:半包 `<p>`
// (只有末段被包)在客户端「只要存在完整 <p> 块就不再按空行回退切分」的契约下
// 整条塌成一坨、而不是多个气泡。
//
// 三条断言缺一不可:
// - 落库行吃的是**规整后**的文本(顺序:先规整、再落库 —— 否则库里还是脏的);
// - 推帧文本与落库行**逐字相同**(只断言「各自含换行」会漏掉两侧分叉);
// - 规整确实发生了(原文 ≠ 期望值),否则断言会退化成同义反复。
// proactiveHalfWrapped 是缺陷形状的输入:第一段裸文本用空行分隔,末段已包 `<p>`。
// 它同时带**字面换行**(`\n`)与半包段落 —— 正是主人 2026-10-24 报的那两类。
const proactiveHalfWrapped = "今晚早点睡。\n\n<p>明天还有早会。</p>"
// proactiveHalfWrappedFixed 是同一段文本经共享规整器后的规范形态:每段一个 <p>,
// 段间空行原样保留(客户端因此拆出两个气泡)。
const proactiveHalfWrappedFixed = "<p>今晚早点睡。</p>\n\n<p>明天还有早会。</p>"
// TestHub_SendProactive_NormalizesParagraphsAndFrameMatchesRow 是 M25 的主断言。
func TestHub_SendProactive_NormalizesParagraphsAndFrameMatchesRow(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
if _, err := hb.SendProactive(context.Background(), proactiveStoreMsg(proactiveHalfWrapped)); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条", len(rows))
}
row := rows[0]
if row.Content == proactiveHalfWrapped {
t.Fatalf("落库文本仍与原文逐字相同 ⇒ 规整没生效(本路由绕过了 NormalizeParagraphs)")
}
if row.Content != proactiveHalfWrappedFixed {
t.Fatalf("落库文本 =\n%q\nwant\n%q\n(Web 活跃路由必须与其它三处同源:先规整再落库)",
row.Content, proactiveHalfWrappedFixed)
}
frame := readFakeFrame(t, owner)
if frame.Type != wsProactiveMessage {
t.Fatalf("frame type = %q, want %q", frame.Type, wsProactiveMessage)
}
data, ok := frame.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frame.Data)
}
content, _ := data["content"].(string)
if content != row.Content {
t.Fatalf("推帧文本与落库行必须逐字相同:\nframe=%q\nrow =%q", content, row.Content)
}
}
// TestHub_SendProactive_KeepsExistingRenderSemantics 钉住「不许改语义」:规整只做
// 段落包裹,`<br>` 仍是气泡内分隔(由客户端按既有契约渲染)、`<action>` 仍随所在
// 段落进**同一个** `<p>`(Web 灰斜体、IM 出口剥除,两条都不许被拆散)。
func TestHub_SendProactive_KeepsExistingRenderSemantics(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
raw := "先点点头。<br>再说一句。\n\n<action>把杯子放下</action>"
if _, err := hb.SendProactive(context.Background(), proactiveStoreMsg(raw)); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条", len(rows))
}
row := rows[0]
want := turnreply.NormalizeParagraphs(raw)
if row.Content != want {
t.Fatalf("落库文本 =\n%q\nwant\n%q", row.Content, want)
}
if !strings.Contains(row.Content, "<br>") {
t.Fatalf("文本里的 <br> 被改掉了:%q(它仍是气泡内分隔,不许被吞)", row.Content)
}
if !strings.Contains(row.Content, "<p><action>把杯子放下</action></p>") {
t.Fatalf("action 段必须随所在段落进同一个 <p>:%q", row.Content)
}
frame := readFakeFrame(t, owner)
data, ok := frame.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frame.Data)
}
if content, _ := data["content"].(string); content != row.Content {
t.Fatalf("推帧文本与落库行必须逐字相同:\nframe=%q\nrow =%q", content, row.Content)
}
}
// TestProactiveRoutes_NormalizeSameText 是「同源」的可执行证据:同一条主动消息分别
// 走 Web 活跃路由与 IM 桥路由,两侧落库文本必须逐字相同 —— 两条路由各写一份规整
// 实现就迟早漂移,本仓已因此栽过多次。
func TestProactiveRoutes_NormalizeSameText(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
hb.register(newOwnerConn(hb, 8))
bridge, _ := newProactiveTestBridge(t, store, nil)
msg := proactiveStoreMsg(proactiveHalfWrapped)
if _, err := hb.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("active route err = %v", err)
}
if rows := mainSessionRows(t, store); len(rows) != 1 {
t.Fatalf("活跃路由之后主会话有 %d 条,want 1", len(rows))
}
if err := bridge.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("im route err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 2 {
t.Fatalf("两条路由各跑一次后主会话有 %d 条,want 2", len(rows))
}
// rows 最新优先:第一条是 IM 路由刚写的,第二条是活跃路由写的。
if rows[0].Content != rows[1].Content {
t.Fatalf("两条路由的落库文本必须逐字相同(同一份规整):\nim =%q\nweb =%q",
rows[0].Content, rows[1].Content)
}
if rows[0].Content != proactiveHalfWrappedFixed {
t.Fatalf("两条路由的文本 =\n%q\nwant\n%q", rows[0].Content, proactiveHalfWrappedFixed)
}
}
@@ -24,7 +24,8 @@ import (
// 三条口径: // 三条口径:
// - **角色恒为 assistant**:这是她单方面开口说的话,不是主人的输入 —— 塞成 // - **角色恒为 assistant**:这是她单方面开口说的话,不是主人的输入 —— 塞成
// user 角色会污染「谁说了什么」,也会在历史里冒充主人发言(红线)。 // user 角色会污染「谁说了什么」,也会在历史里冒充主人发言(红线)。
// - **正文用调用方已经净化过的文本**(backthink 的 stripInternalMarkers 出口), // - **正文用调用方已经净化并规整过的文本**(backthink 的 stripInternalMarkers 出口,
// 再过 `turnreply.NormalizeParagraphs` —— 见 SendProactive 的「先规整、再落库」),
// 系统元数据(决策标记、渠道选择)绝不进正文,也绝不新增任何隐藏字段 —— // 系统元数据(决策标记、渠道选择)绝不进正文,也绝不新增任何隐藏字段 ——
// 注入面因此与「气泡里看到的那句话」逐字相同。 // 注入面因此与「气泡里看到的那句话」逐字相同。
// - **turn_id 留空**:主动推送不属于任何一路回合(她单方面开口,没有对应的 // - **turn_id 留空**:主动推送不属于任何一路回合(她单方面开口,没有对应的
@@ -83,8 +83,10 @@ func TestHub_SendProactive_PersistsAssistantMessageToMainSession(t *testing.T) {
if row.SessionID != history.MainSessionID { if row.SessionID != history.MainSessionID {
t.Fatalf("session = %q, want %q", row.SessionID, history.MainSessionID) t.Fatalf("session = %q, want %q", row.SessionID, history.MainSessionID)
} }
if row.Content != "记得喝水哦" { // 正文经共享规整器(turnreply.NormalizeParagraphs):纯散文被包成一个 `<p>`
t.Fatalf("content = %q, want the proactive text verbatim", row.Content) // ——与 Web 回合 / IM 出站 / IM 侧主动推送同一份口径(M25:本路由曾绕过它)。
if row.Content != "<p>记得喝水哦</p>" {
t.Fatalf("content = %q, want the normalized proactive text", row.Content)
} }
if row.TurnID != "" { if row.TurnID != "" {
t.Fatalf("turn_id = %q, want empty(主动推送不属于任何一路回合)", row.TurnID) t.Fatalf("turn_id = %q, want empty(主动推送不属于任何一路回合)", row.TurnID)
+5 -2
View File
@@ -52,8 +52,11 @@ func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
if !ok { if !ok {
t.Fatalf("frame data type = %T, want map[string]any", ev.Data) t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
} }
if data["content"] != "记得喝水哦" { // 正文经共享规整器(turnreply.NormalizeParagraphs):纯散文也会被包成
t.Fatalf("frame content = %q, want the proactive text", data["content"]) // 一个 `<p>` —— 这条路由与 Web 回合 / IM 出站 / IM 侧主动推送**同源**,
// 未包裹的裸文本同样会包上(M25 修的就是本路由曾绕过它)。
if data["content"] != "<p>记得喝水哦</p>" {
t.Fatalf("frame content = %q, want the normalized proactive text", data["content"])
} }
if data["sender"] != "Cyrene" { if data["sender"] != "Cyrene" {
t.Fatalf("frame sender = %q, want Cyrene", data["sender"]) t.Fatalf("frame sender = %q, want Cyrene", data["sender"])
+7
View File
@@ -269,6 +269,13 @@ func (s *Server) registerAdminRoutes(
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。 // 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead))) mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite))) mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
// 渠道静默窗口合并(M23;见 admin_coalesce.go):读回 `$DATA_DIR/config/coalesce.json`
// 的窗口参数 + 取值规则 + 「主人私聊不受影响」这条只读事实;写是同一条路径上的
// **三态补丁**(键缺位 = 不动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
// + 审计(channels.coalesce)。两条都走 writeAuth(owner|admin)—— 与上面那条
// memory-tidy 调优同档:写的是面板配置文件,不是身份/凭据面。
mux.Handle("GET /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceRead)))
mux.Handle("PUT /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceWrite)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit))) mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession))) mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag))) mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
@@ -0,0 +1,196 @@
package gateway
import (
"context"
"fmt"
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// 本文件实现 design/05 §2/§5/§9 要求的**结果回填主会话**(M32)。修复前的断点不是
// 传输(帧发了、hub 订阅了、前端 handler 也注册了),而是「**回填这一环根本没有
// 实现**」:后台批次结算后的载荷只被中继成一个 WS 帧,落到面板的旁路日志面 ——
// `chat_messages` 无行、模型下一轮也看不到,于是「她拍了个子代理出去,但没有下文」。
//
// 回填被拆成**互不冒充**的两条面(项目红线:人格 = 身份,子代理的结论绝不能读成
// 她自己说的话):
//
// - **可观测 / 历史面**:往父会话写一条 `role = "system"` 的注记行(**绝不**是
// user / assistant),内容以 `subagentNoteMarker` 开头并点名 `sub-<id>`,随后按
// 既有 `message_persisted` 帧扇出 ⇒ 刷新/重连后仍在(design/05 §9「提示用户」);
// - **模型面**:同一行被下一回合的转录播种(`transcript.Read`)读进上下文,因此
// 模型在**下一次**组装时看到的是**系统注记**(`model.RoleSystem`),不是一句
// 「她说过的话」,也不是她自己的语气。
//
// 落库口径(防「看着有、库里没有」):
//
// - **角色恒为 system**:`chat_messages.role` 的合法取值是 user/assistant/system
// (0003 迁移的列注释),子代理结论既不是主人说的、也不是她说的;
// - **PersonaID 留空**:这条注记的作者**不是任何角色**,写上角色 id 等于让客户端
// 用那个角色的头像渲染「她说过」;
// - **TurnID 留空**:后台批次可能在派发它的那个回合结束**之后**才结算,它不属于
// 任何一路回合(与主动推送的同一取舍),空值诚实地表示这一点;
// - **绝不被记忆抽取**:抽取输入是回合的 `msgs`(含播种的转录行),因此这条注记
// 会出现在抽取器面前。排除由 `WithoutSubagentNotes`(subagent_backfill_extract.go)
// 在**组合根**套在提取器上完成 —— 判据是元数据(role=system + 注记标记),不是
// 提示词;否则子代理结论会被当成对话吞进记忆库(那条污染线刚查过)。
//
// 刻意**不做**的事:不改 `subagent_result` 帧形状、不把它加进 `frameReachesAll`、
// 不新开路由/帧族、不往注记里注入任何人设。
const backfillLogScope = "subagent-backfill"
// SubagentNoteStore 是回填所需的最小历史写面。*history.Store 满足它;测试替身
// 只需实现这一个方法。
type SubagentNoteStore interface {
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
}
// SubagentNoteSink 是回填的下行出口(`message_persisted` / `channel_persisted`
// 的既有扇出)。*Server 满足它;为 nil 时只落库不推帧(落库仍是唯一事实来源)。
type SubagentNoteSink interface {
BroadcastPersisted(msg history.Message)
}
// SubagentBackfillDeps 是回填订阅者的装配输入(组合根按实际依赖填充)。
type SubagentBackfillDeps struct {
// Bus 是进程内事件总线(与调度器、网关 hub **同一个实例**)。
Bus bus.Bus
// History 是父会话注记的落库面。为 nil 时装配即失败(没有落库面就没有回填)。
History SubagentNoteStore
// Broadcast 是该注记的实时扇出出口。为 nil 时只落库。
Broadcast SubagentNoteSink
// Log 是日志基底;来源由 NewSubagentBackfill 统一盖成
// `source=gateway/subagent-backfill`。
Log *slog.Logger
}
// SubagentBackfill 是 `orchestration.SubagentResultSubject` 的**独立订阅者**:它把
// 后台批次的结果写成父会话的一条系统注记并扇出。
//
// 它刻意**不是** hub 的一部分(hub 只负责投递,写入会话属会话编排职责,混进去会让
// 「投递」与「落库」耦合),也与 hub 的中继互不相识:两者订阅同一个主题,各干各的
// 一半(hub 出帧、本订阅者落行)。
//
// 并发:总线对每条订阅串行派发(bus 包文档),因此 `lastDropped` 不需要加锁。
type SubagentBackfill struct {
bus bus.Bus
history SubagentNoteStore
broadcast SubagentNoteSink
log *slog.Logger
// dropProbe 是总线的可选背压计数器(*bus.busImpl 的 Dropped())。总线在缓冲区
// 满时**丢弃事件且不告诉任何人**,而丢掉的恰恰就是回填事件 —— 因此每次成功
// 处理一条事件时顺手读一次计数,涨了就记一条 WARN(负证据变成正面可见)。
dropProbe dropProbe
lastDropped uint64
}
// dropProbe 是总线背压计数器的可选窄视图。
type dropProbe interface {
Dropped() uint64
}
// NewSubagentBackfill 构建回填订阅者。History 为 nil 时返回错误:一个只会丢结果的
// 订阅者比没有订阅者更糟(它会让「已接线」看起来成立)。
func NewSubagentBackfill(deps SubagentBackfillDeps) (*SubagentBackfill, error) {
if deps.History == nil {
return nil, fmt.Errorf("gateway: subagent backfill requires a history store")
}
if deps.Bus == nil {
return nil, fmt.Errorf("gateway: subagent backfill requires a bus")
}
probe, _ := deps.Bus.(dropProbe)
return &SubagentBackfill{
bus: deps.Bus,
history: deps.History,
broadcast: deps.Broadcast,
log: logger.Scoped(sourcedLog(deps.Log), source, backfillLogScope),
dropProbe: probe,
}, nil
}
// Start 把订阅者挂上总线。ctx 取消即自动退订(进程关停时 `bus.Close` 是另一道)。
//
// 订阅失败**必须**可见:返回包装后的错误,由组合根记 WARN —— 回填静默缺席正是
// 这次缺陷的形状。
func (b *SubagentBackfill) Start(ctx context.Context) error {
if b == nil || b.bus == nil {
return fmt.Errorf("gateway: subagent backfill is not wired")
}
if ctx == nil {
ctx = context.Background()
}
if _, err := b.bus.Subscribe(ctx, orchestration.SubagentResultSubject, b.Handle); err != nil {
return fmt.Errorf("gateway: subscribe %s: %w", orchestration.SubagentResultSubject, err)
}
b.log.Info("subagent backfill subscribed",
"subject", orchestration.SubagentResultSubject, "role", history.RoleSystem)
return nil
}
// Handle 是总线处理器:把一条后台批次结果写成父会话的系统注记并扇出。
//
// 它绝不返回错误(总线会忽略返回值,只把失败留在没人看的地方):每一种失败都在
// 原地记带来源的日志,且**绝不**为了「有话可说」而编造一条注记。
func (b *SubagentBackfill) Handle(ctx context.Context, e bus.Event) error {
if b == nil || e.Subject != orchestration.SubagentResultSubject {
return nil
}
ev, ok := e.Payload.(*orchestration.SubagentResultEvent)
if !ok || ev == nil {
b.log.Warn("subagent backfill: unexpected payload type; result not persisted",
"subject", e.Subject, "type", fmt.Sprintf("%T", e.Payload))
return nil
}
// 内嵌的 *SpawnResult 为 nil 时,任何字段访问都会空指针 —— 而总线会吞掉 panic,
// 那等于把这条结果静默丢掉。因此显式拒绝并留痕。
if ev.SpawnResult == nil {
b.log.Warn("subagent backfill: result payload carries no spawn result; not persisted",
"session", ev.SessionID)
return nil
}
if ev.SessionID == "" {
b.log.Warn("subagent backfill: result has no parent session; not persisted",
"status", ev.Status, "agents", len(ev.Agents))
return nil
}
note := subagentNote(ev)
full, err := b.history.AppendFull(ctx, history.Message{
SessionID: ev.SessionID,
Role: history.RoleSystem,
Content: note,
Mode: modeChat,
})
if err != nil {
b.log.Error("subagent backfill: persist result note",
"err", err, "session", ev.SessionID, "agents", len(ev.Agents))
return nil
}
if b.broadcast != nil {
b.broadcast.BroadcastPersisted(full)
}
b.log.Info("subagent result backfilled",
"session", ev.SessionID, "message", full.ID, "status", ev.Status,
"agents", len(ev.Agents), "runes", len([]rune(note)))
b.reportDrops()
return nil
}
// reportDrops 把「总线自上次以来丢过多少条事件」记成一条 WARN。它在成功处理一条
// 回填事件之后触发,因此这条负面事实至少会在下一次投递时浮出,而不是永远消失。
func (b *SubagentBackfill) reportDrops() {
if b.dropProbe == nil {
return
}
now := b.dropProbe.Dropped()
if now == b.lastDropped {
return
}
b.log.Warn("bus dropped events (subagent results may be missing)",
"dropped_total", now, "dropped_since_last", now-b.lastDropped,
"subject", orchestration.SubagentResultSubject)
b.lastDropped = now
}
@@ -0,0 +1,116 @@
package gateway
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是「子代理结果注记**绝不进记忆抽取**」这道防线(M32)。它是
// `memory.Extractor` 的装饰器,由**组合根**套在循环的提取器上
// (`cmd/cyrene/wire_runtime.go` 的 `Extractor:` 一行)。
//
// 为什么需要它(不是理论问题,是「看着有、库里没有」的反面):
//
// - 回填注记是一条**已落库的系统消息**,因此它会被下一回合的转录播种
// (`transcript.Read`)读进该回合的 `msgs`,而循环在回合落定时把这整份 `msgs`
// 交给提取器(`loop.extractMemoryAsync`)。不挡,子代理的结论就会被当成对话,
// 由 LLM 提取器判断「值得记住」后写进记忆库 —— 那正是刚查过的那条污染线
// (84% 记忆来自内部会话),绝不能再添一条。
// - **判据必须是元数据**(AGENTS §9「身份/判据靠元数据,不靠提示词」):这里按
// `role == system` **且** 正文以 `subagentNoteMarker` 开头来识别,两者都是
// **结构性事实**(该标记由本包的注记写入方唯一产出,与 loop 的 `summaryMarker`
// 同一手法),绝不靠「提示词里让模型别提取」。
//
// 它只影响**喂给提取器的输入**:落库行、`message_persisted` 帧、下一回合模型看到的
// 系统注记、历史接口全部逐字不变 —— 被挡掉的只有「这条注记变成一条记忆」这一件事。
// memoryAsyncExtractor 是 memory.LLMExtractor 的异步能力(与 loop 包内部的
// asyncExtractor 同形)。装饰器必须在**装饰后仍然异步**,否则一次慢提取会从后台
// goroutine 掉回回合落定的同步路径上,直接拖慢回复。
type memoryAsyncExtractor interface {
ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error)
}
// WithoutSubagentNotes 用「剔除子代理结果注记」装饰 inner。inner 为 nil 时返回 nil
// (未配置提取器的部署因此逐字不变:循环那边同样是 nil 即跳过)。
//
// 内层支持异步(LLM 提取器)时返回的装饰器也支持异步;确定性提取器
// (ManualExtractor)不实现它,因此保持原有的就地执行路径。
func WithoutSubagentNotes(inner memory.Extractor) memory.Extractor {
if inner == nil {
return nil
}
sync := &subagentNoteFreeExtractor{inner: inner}
if _, ok := inner.(memoryAsyncExtractor); ok {
return &subagentNoteFreeAsyncExtractor{subagentNoteFreeExtractor: sync}
}
return sync
}
// subagentNoteFreeExtractor 是同步装饰器:抽取前把注记从对话里剔掉。
type subagentNoteFreeExtractor struct {
inner memory.Extractor
}
// Extract 在剔除注记后的对话上运行内层提取器。
func (e *subagentNoteFreeExtractor) Extract(ctx context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
return e.inner.Extract(ctx, dialogueOnly(conversation))
}
// subagentNoteFreeAsyncExtractor 是异步装饰器:异步通道原样透传,只是输入先被剔除。
type subagentNoteFreeAsyncExtractor struct {
*subagentNoteFreeExtractor
}
// ExtractAsync 把剔除注记后的对话交给内层的异步提取。内层意外不支持异步时退化为
// 同步执行并包成一个立即关闭的通道 —— 语义仍正确,只是不再后台跑(该分支不可达:
// 构造器只在它支持异步时才选本类型)。
func (e *subagentNoteFreeAsyncExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) {
if async, ok := e.inner.(memoryAsyncExtractor); ok {
return async.ExtractAsync(ctx, dialogueOnly(conversation))
}
out := make(chan memory.AsyncResult, 1)
items, err := e.Extract(ctx, conversation)
out <- memory.AsyncResult{Items: items, Err: err}
close(out)
return out, nil
}
// dialogueOnly 返回剔除全部子代理结果注记后的对话副本。没有注记时原样返回入参
// (不做任何分配 —— 绝大多数回合因此零成本)。
func dialogueOnly(conversation []model.Message) []model.Message {
first := -1
for i, m := range conversation {
if isSubagentNote(m) {
first = i
break
}
}
if first < 0 {
return conversation
}
out := make([]model.Message, 0, len(conversation)-(first/2+1))
out = append(out, conversation[:first]...)
for _, m := range conversation[first+1:] {
if isSubagentNote(m) {
continue
}
out = append(out, m)
}
return out
}
// isSubagentNote 报告一条消息是否是子代理结果注记。判据是**元数据 + 结构性标记**:
// `role == system` **且** 正文以 `subagentNoteMarker` 开头。只认标记而不认 role 会
// 误伤主人自己打的那串字;只认 role 会把压缩摘要(同样是 system 角色,且是对话的
// 合法压缩,理应继续参与抽取)一起挡掉。
func isSubagentNote(m model.Message) bool {
if m.Role != model.RoleSystem {
return false
}
text, _ := messageTextImage(m)
return strings.HasPrefix(strings.TrimSpace(text), subagentNoteMarker)
}
@@ -0,0 +1,208 @@
package gateway
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
)
// 本文件锁住 M32 的第二道红线:**子代理结果绝不进记忆抽取**。
//
// 它不是「提示词里让模型别提取」,而是一条元数据判据(role=system + 注记标记):
// 抽取器的输入在到达内层之前就被剔干净。测试因此断言的是**内层实际收到了什么**
// —— 这正是缺陷的判定点(那条线刚查过:84% 记忆来自内部会话,不能再添污染源)。
// captureExtractor 记录每次抽取收到的对话,返回预设条目。
type captureExtractor struct {
mu sync.Mutex
seen [][]model.Message
items []memory.MemoryItem
}
func (e *captureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.seen = append(e.seen, append([]model.Message(nil), conversation...))
return e.items, nil
}
func (e *captureExtractor) last() []model.Message {
e.mu.Lock()
defer e.mu.Unlock()
if len(e.seen) == 0 {
return nil
}
return e.seen[len(e.seen)-1]
}
// captureAsyncExtractor 是带异步能力的捕获提取器(对应 memory.LLMExtractor)。
type captureAsyncExtractor struct {
*captureExtractor
}
func (e *captureAsyncExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) {
items, err := e.Extract(ctx, conversation)
out := make(chan memory.AsyncResult, 1)
out <- memory.AsyncResult{Items: items, Err: err}
close(out)
return out, nil
}
// noteMessage 返回一条与回填落库行**同源**的系统注记模型消息:它经过
// `transcript.Message` 的映射,因此走的就是下一回合真正播种的那条路。
func noteMessage(t *testing.T) model.Message {
t.Helper()
row := history.Message{
ID: "msg-note",
SessionID: "web",
Role: history.RoleSystem,
Content: subagentNote(settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "子代理的结论"})),
}
return transcript.Message(row)
}
// userMessage 构造一条普通主人输入。
func userMessage(text string) model.Message {
return model.TextMessage(model.RoleUser, text)
}
// TestWithoutSubagentNotesStripsBackfilledNotes 锁住同步路径:注记在内层提取器看到
// 之前就被剔掉,其余对话逐字保留,提取结果原样透传。
func TestWithoutSubagentNotesStripsBackfilledNotes(t *testing.T) {
t.Parallel()
inner := &captureExtractor{items: []memory.MemoryItem{{Content: "记住的事"}}}
wrapped := WithoutSubagentNotes(inner)
conv := []model.Message{
userMessage("你好"),
noteMessage(t),
model.TextMessage(model.RoleAssistant, "在的"),
}
items, err := wrapped.Extract(context.Background(), conv)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(items) != 1 || items[0].Content != "记住的事" {
t.Fatalf("items = %+v, want the inner extractor's result passed through", items)
}
seen := inner.last()
if len(seen) != 2 {
t.Fatalf("inner extractor saw %d messages, want 2 (the note must be removed): %+v", len(seen), seen)
}
for _, m := range seen {
if strings.HasPrefix(modelText(m), subagentNoteMarker) {
t.Fatalf("inner extractor saw the sub-agent note %q: it would be ingested as dialogue", modelText(m))
}
}
if modelText(seen[1]) != "在的" {
t.Fatalf("conversation order changed: %+v", seen)
}
}
// TestWithoutSubagentNotesKeepsCompactionSummary 锁住**不过度剔除**:压缩摘要同样是
// system 角色(loop 的 summaryMarker),它是对话的合法压缩,必须继续参与抽取。
func TestWithoutSubagentNotesKeepsCompactionSummary(t *testing.T) {
t.Parallel()
inner := &captureExtractor{}
wrapped := WithoutSubagentNotes(inner)
summary := model.TextMessage(model.RoleSystem, "[前文摘要 · history summary]\n\n之前聊过口味")
if _, err := wrapped.Extract(context.Background(), []model.Message{summary, userMessage("我喜欢淡一点")}); err != nil {
t.Fatalf("Extract: %v", err)
}
if got := len(inner.last()); got != 2 {
t.Fatalf("inner extractor saw %d messages, want both the summary and the user turn", got)
}
}
// TestWithoutSubagentNotesKeepsAsync 锁住「装饰之后仍然异步」:慢提取绝不能从后台
// goroutine 掉回回合落定的同步路径上(那会直接拖慢回复)。
func TestWithoutSubagentNotesKeepsAsync(t *testing.T) {
t.Parallel()
inner := &captureAsyncExtractor{&captureExtractor{}}
wrapped := WithoutSubagentNotes(inner)
async, ok := wrapped.(memoryAsyncExtractor)
if !ok {
t.Fatal("the decorator dropped ExtractAsync: a slow LLM extraction would now block turn settlement")
}
ch, err := async.ExtractAsync(context.Background(), []model.Message{noteMessage(t), userMessage("你好")})
if err != nil {
t.Fatalf("ExtractAsync: %v", err)
}
<-ch
seen := inner.last()
if len(seen) != 1 || modelText(seen[0]) != "你好" {
t.Fatalf("async extractor saw %+v, want only the user turn", seen)
}
}
// TestWithoutSubagentNotesIsNoopWithoutNotes 锁住零成本路径:没有注记时原样返回入参
// (不分配、不改顺序),且 nil 提取器仍是 nil(未配置提取器的部署逐字不变)。
func TestWithoutSubagentNotesIsNoopWithoutNotes(t *testing.T) {
t.Parallel()
if got := WithoutSubagentNotes(nil); got != nil {
t.Fatalf("WithoutSubagentNotes(nil) = %v, want nil", got)
}
conv := []model.Message{userMessage("你好")}
got := dialogueOnly(conv)
if len(got) != len(conv) {
t.Fatalf("dialogueOnly changed a note-free conversation: %+v", got)
}
if &got[0] != &conv[0] {
t.Fatal("a note-free conversation must be returned as-is (no copy on the hot path)")
}
}
// TestBackfilledNoteStaysOutOfMemoryExtraction 是这条防线的**端到端**接缝断言:
// 真回填落库 → 转录播种 → 抽取。注记在整条链上看得到(历史与模型上下文都有它),
// 但抽取器**一次也看不到**它。
func TestBackfilledNoteStaysOutOfMemoryExtraction(t *testing.T) {
t.Parallel()
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store := &noteStore{}
bf, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: b, History: store})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
if err := bf.Handle(context.Background(), bus.Event{
Subject: orchestration.SubagentResultSubject,
Payload: settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "子代理的结论"}),
}); err != nil {
t.Fatalf("Handle: %v", err)
}
rows := store.rows()
if len(rows) != 1 {
t.Fatalf("rows = %+v, want one", rows)
}
// 下一回合的模型上下文 = 播种的转录行(这就是 loop 交给提取器的 msgs 的来源)。
nextTurn := []model.Message{transcript.Message(rows[0]), userMessage("接着说")}
inner := &captureExtractor{}
if _, err := WithoutSubagentNotes(inner).Extract(context.Background(), nextTurn); err != nil {
t.Fatalf("Extract: %v", err)
}
seen := inner.last()
if len(seen) != 1 {
t.Fatalf("extractor input = %+v, want only the user turn", seen)
}
if !strings.Contains(modelText(seen[0]), "接着说") {
t.Fatalf("extractor lost the real dialogue: %+v", seen)
}
}
// modelText 返回一条模型消息的正文(测试窄视图)。
func modelText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more