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 13788 additions and 646 deletions

No files matched your search

+1 -1
View File
@@ -81,6 +81,6 @@
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
- 🔴 阶段验证:后端 `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」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
- 🔴 **提交一律逐文件 `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** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
| **合计** | — | **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。
+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)
continue
}
logFlowControl(log, built)
out = append(out, built)
}
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>_* 适配器,加上
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
@@ -174,8 +225,18 @@ type namedAdapter struct {
denyGroups []string
allowUsers []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
directMsgPerMin int
msgPerMin int
+62 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
@@ -114,6 +115,12 @@ func (a *app) wireChannels() error {
ownerLane := runtimeconfig.NewOwnerLaneStore(
runtimeconfig.DefaultOwnerLanePath(docDataDir),
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{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
@@ -168,6 +175,15 @@ func (a *app) wireChannels() error {
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: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
@@ -207,6 +223,7 @@ func (a *app) wireChannels() error {
fallback: factory.fallback,
bridge: bridge,
owner: a.owner,
coalesce: coalesceStore,
log: a.log,
}
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
@@ -227,11 +244,55 @@ type runtimeChannels struct {
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile
log *slog.Logger
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
coalesce *runtimeconfig.CoalesceStore
log *slog.Logger
}
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 {
accounts := r.loadAccounts()
r.applyAdminIDs(accounts)
+13
View File
@@ -165,6 +165,12 @@ func (a *app) wireFrontend() error {
// backthink.New 时网关还没组装。
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
}
@@ -203,6 +209,13 @@ func wirePanelHub(a *app) {
})
a.plugins.AttachPanels(hub)
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)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(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/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
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/pkg/plugins/sdk"
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
entries[0].Panel.ID != search.PanelIDProviders {
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
// 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
// mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
// 「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() {
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
}
}
// viewer 拿到的必须是裁掉 form 的只读投影。
// viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
viewer, err := hub.List(auth.RoleViewer)
if err != nil {
t.Fatalf("List(viewer): %v", err)
}
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
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)。
+1 -1
View File
@@ -324,7 +324,7 @@ func (a *app) wireRuntime() error {
OnTurnEnd: a.onTurnEnd,
Memory: store,
Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(),
Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
Checkpoint: checkpoint,
Tracer: tracer,
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)
}
}
+16 -3
View File
@@ -2,14 +2,27 @@
// 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,
// 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";
const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, "dist");
const dest = resolve(here, "..", "..", "internal", "webui", "dist");
const placeholder = ".gitkeep";
await rm(dest, { recursive: true, force: true });
await cp(src, dest, { recursive: true });
try {
await cp(src, dest, { recursive: true });
} finally {
await mkdir(dest, { recursive: true });
await writeFile(join(dest, placeholder), "");
}
console.log(`webui: copied ${src} -> ${dest}`);
+205 -43
View File
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
type CapKind = "tool" | "skill" | "channel";
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
channel: "渠道 Channel",
};
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
// 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
// channel block shows identity + a pointer rather than the policy switches.
@@ -27,7 +36,44 @@ 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.
*
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
function policyColumn(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
): AdminColumn<AdminPluginRecord> {
return {
key: "policy",
title: "环境授权(owner)",
render: (p) => (
<span className="flex items-center gap-4">
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInGroup}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
</label>
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInStranger}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
</label>
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span>
),
};
}
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
function registeredColumns(
isOwner: boolean,
busy: string | null,
@@ -38,31 +84,7 @@ function registeredColumns(
{ 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",
title: "环境授权(owner)",
render: (p) => (
<span className="flex items-center gap-4">
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInGroup}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
</label>
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInStranger}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
</label>
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span>
),
},
policyColumn(isOwner, busy, setPolicy),
{
key: "actions",
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 {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000);
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
return groups;
}, [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 serverCols = mcpServerColumns();
const otherCols = otherColumns();
return (
<div className="space-y-5">
<PageHeader
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}
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<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>
{(["tool", "skill"] as CapKind[]).map((kind) => {
const rows = byKind[kind];
return (
<div key={kind} className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS[kind]}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span>
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS.tool}</Tag>
<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>
<AdminTable
columns={toolSkillColumns}
rows={loading ? null : rows}
columns={mcpToolCols}
rows={loading ? null : group.rows}
loading={loading}
error={error}
emptyTitle={`无${KIND_LABELS[kind]}`}
rowKey={(p) => `${kind}:${p.name}`}
emptyTitle="无工具"
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 className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
rowKey={(p) => `channel:${p.name}`}
/>
</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>
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice>
<Notice>
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
</Notice>
</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 { channelListState, channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { ChannelCoalesceCard } from "./CoalesceCard";
import type { AdapterNav } from "./useAdapterNav";
/** 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" })}
/>
) : null}
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
<ChannelCoalesceCard />
</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;
}
/** 一条能力的上游来源(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. */
export interface AdminPluginRecord {
name: string;
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
description: string;
allowInGroup: boolean;
allowInStranger: boolean;
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
origin?: CapabilityOrigin;
}
/** 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"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// ToolInfo 是 MCP 服务器暴露的一把工具。
@@ -60,6 +62,17 @@ func (t *mcpTool) Name() string { return t.info.Name }
// Description 返回 MCP 工具描述。
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。
func (t *mcpTool) Parameters() (map[string]any, error) {
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
// 规范首选),否则走旧式 SSE。
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:"
)
// 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,
// whether a live server currently backs it, and how many tools it registered.
type Installed struct {
@@ -61,6 +74,10 @@ type Manager struct {
mu sync.Mutex
servers map[string]*server
// observed 是每台 server 最近一次被观察到的运行期事实(成功连上的时刻、最近一次失败
// 的文本与时刻)。它与 servers(活没活)是两个不同的事实:一台连不上的 server 不在
// servers 里,但它的失败记录必须留着 —— 否则「为什么挂了」在快照里就是一个空。
observed map[string]observed
// panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。
// 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把
// 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。
@@ -80,6 +97,7 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager
path: path,
log: log,
servers: make(map[string]*server),
observed: make(map[string]observed),
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
// rewriting the file it just read). changeReason labels the panel change pushed
// 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) {
cfg = cfg.Normalize()
if err := cfg.Validate(); err != nil {
@@ -129,8 +152,13 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
if prev != nil {
m.stop(ctx, prev)
}
if !cfg.IsEnabled() {
return m.installDisabled(cfg, persist, prev)
}
srv, err := m.start(ctx, cfg)
if err != nil {
// 失败也要留痕:状态快照要能说出「为什么挂了」,而不是把失败悄悄丢进日志。
m.noteFailure(cfg, err)
m.restore(prev)
return Installed{}, err
}
@@ -142,11 +170,30 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
}
}
m.put(srv)
m.noteSeen(cfg)
m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount)
m.markInstalled(cfg, changeReason)
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
// 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.
@@ -166,6 +213,7 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) {
panelName = srv.cfg.Name
}
m.forgetPanels(panelName)
m.forgetObserved(panelName)
m.log.Info("plugin uninstalled", "name", name)
}
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.
// On a registration failure it cleans up and returns the error.
func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) {
@@ -389,6 +329,8 @@ func (m *Manager) restore(prev *server) {
return
}
m.put(srv)
// 回滚成功也是一次成功连接:快照的 lastSeenAt 不该停留在这之前的时刻。
m.noteSeen(prev.cfg)
}
// take removes and returns the live server named name, or nil.
@@ -62,6 +62,15 @@ type fakeMCP struct {
mu sync.Mutex
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) {
@@ -88,6 +97,7 @@ func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
f.mu.Lock()
f.auth = r.Header.Get("Authorization")
f.hits++
tools := append([]string(nil), f.tools...)
f.mu.Unlock()
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,供卸载/停用复用),并把它报给
// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。
func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) {
key := PanelPluginID(cfg)
if key == "" {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
key := m.rememberPanelKey(cfg)
if key == "" || isReservedPanelKey(key) {
return
}
m.mu.Lock()
m.panelKeys[cfg.Name] = key
hub := m.panels
m.mu.Unlock()
hub := m.panelsHub()
if hub == nil {
return
}
@@ -308,6 +304,51 @@ func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason str
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;没有记录
// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。
func (m *Manager) forgetPanels(name string) {
@@ -325,6 +366,9 @@ func (m *Manager) forgetPanels(name string) {
return
}
}
if isReservedPanelKey(key) {
return
}
hub.MarkUninstalled(key)
}
@@ -343,5 +387,8 @@ func (m *Manager) setPanelsEnabled(name string, on bool) {
return
}
}
if isReservedPanelKey(key) {
return
}
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
// defaults to zero, which the policy engine reads as owner-only.
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/
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
// 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.
//
// 它是**来源章的唯一汇点**(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 {
if t == nil {
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
@@ -233,10 +246,21 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
Name: t.Name(),
Kind: KindTool,
Description: t.Description(),
Origin: originOf(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:
// 面板保存后下一轮即用新接线,不必重启)。
//
@@ -270,6 +294,10 @@ func (r *Registry) ReplaceTool(t sdk.Tool) error {
Description: t.Description(),
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
Policy: cur.Policy,
// Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源
// 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的
// 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。
Origin: originOf(t),
Value: t,
}
return nil
+113 -98
View File
@@ -13,21 +13,32 @@ import (
// 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
// 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)
// GateVerdict reports how the gate classified one inbound message.
type GateVerdict struct {
// Proceed reports whether the message should be delivered downstream.
Proceed bool
// Reason is a short token describing a drop: "rate_limited", "review_pruned"
// or "capped". It is empty when Proceed is true.
// Reason is a short token describing a drop. After M24 it is always
// "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
// DroppedMsg is the message that failed admission (nil when Proceed is true).
DroppedMsg *UnifiedMessage
// Pruned lists every message the gate dropped during this admission. On the
// review path it is the noise the reviewer rejected; on the cap path it is the
// oldest messages evicted to honor the total cap. Each is recorded to the
// OnPruned audit hook before being dropped, so nothing is lost silently.
// Pruned lists every message the gate dropped during this admission: on the cap
// path it is the oldest messages evicted to honor the total cap. Each is recorded
// to the 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
}
@@ -56,15 +67,23 @@ type FlowGateOptions struct {
MsgPerMin int
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6
// 策略四 / 裁决 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
// FIFO. Zero disables the cap.
// is invoked — in the background — to prune noise; with no Reviewer the oldest
// messages are evicted FIFO. Zero disables the cap.
MaxMessages int
// Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow.
Window time.Duration
// 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
// 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
// ("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).
@@ -80,6 +99,13 @@ const (
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
// that keeps review from firing on every message.
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
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6
@@ -128,6 +154,10 @@ type flowSession struct {
groupHits []time.Time
directHits []time.Time
buffer []queuedMsg
// lastReview 是**最近一次审查被派发**的时刻(不是完成时刻,也无论成败):
// 写在这里而不是「成功之后」,失败路径才同样被窗口节流(修复前它只在
// err == nil 分支置位,而 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满之后
// 每条入站帧都付一次 ~6 s 的模型调用)。
lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
@@ -148,6 +178,14 @@ type flowSession struct {
//
// 两个策略的计数都按会话独立(裁决 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),
// so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
@@ -160,24 +198,48 @@ type FlowGate struct {
reviewAt int
window time.Duration
review Reviewer
reviewBudget int
onPruned func(UnifiedMessage, string)
now func() time.Time
mu sync.Mutex
totalHits []time.Time
sessions map[string]*flowSession
admitted uint64
seq uint64
// reviewHits 是本适配器在窗口内**派发**审查的时刻,用于账号级预算
// (defaultReviewPerWindow)。
reviewHits []time.Time
// reviewInFlight 表示本适配器已有一次审查在后台跑:同一适配器至多一次在飞,
// 使后台审查有界(不会因为多个群同时越阈而并发起 N 个模型调用)。
reviewInFlight bool
sessions map[string]*flowSession
admitted uint64
seq uint64
}
// 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
// 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 {
seq uint64
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,
// except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate {
@@ -188,6 +250,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
maxMessages: opts.MaxMessages,
window: opts.Window,
review: opts.Review,
reviewBudget: opts.ReviewPerMin,
onPruned: opts.OnPruned,
now: opts.Now,
sessions: make(map[string]*flowSession),
@@ -195,22 +258,24 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
if g.window <= 0 {
g.window = defaultRateWindow
}
if g.maxMessages > 0 {
g.reviewAt = g.maxMessages * defaultReviewPct / 100
if g.reviewAt < 1 {
g.reviewAt = 1
}
if g.reviewBudget <= 0 {
g.reviewBudget = defaultReviewPerWindow
}
g.reviewAt = ReviewAtFor(g.maxMessages)
if g.now == nil {
g.now = time.Now
}
return g
}
// Admit applies the rate limits then the per-conversation cap/review gate to one
// inbound message. It returns a verdict telling the caller whether to proceed;
// messages that the gate drops are returned (and audited) so nothing is lost
// silently.
// Admit applies the rate limits then the per-conversation cap gate to one inbound
// message. It returns a verdict telling the caller whether to proceed; messages
// that the gate drops are returned (and audited) so nothing is lost silently.
//
// **它从不同步调用审查模型**:限流与上限是 O(1) 的判定,审查只是一次「后台修剪」
// 的派发。修复前这里同步等 `noise_review`(p50 5.3 s),而五路入站里有三路
// (qq-ws、kook-ws、weixin 长轮询)是「单读 goroutine + 同步派发」,于是消息、
// 心跳、探活回执共享同一条 6 s 队列 —— 主人口中「消息一直排队」的机制。
//
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
@@ -218,48 +283,55 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now()
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
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
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
// (downgraded) instead of blocking.
// (downgraded) instead of blocking. 这一段的语义与修复前**一字不改**:
// 被限流的消息根本不进待处理集合,也永远不会因为审查被「捞回来」。
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
st := g.sessionLocked(key)
st.groupHits = g.trimHits(st.groupHits, now)
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)
}
if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now)
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)
}
if g.maxMessages <= 0 {
return GateVerdict{Proceed: true}
return GateVerdict{Proceed: true}, nil
}
// 策略四(会话级): add to this conversation's pending set, then prune when it
// nears the cap.
// 策略四(会话级): add to this conversation's pending set, then enforce the cap
// synchronously. 审查只被**派发**(下面的 scheduleReviewLocked),结论稍后由
// 后台落回 —— 准入读的是「当前已有结论」。
st := g.sessionLocked(key)
g.seq++
seq := g.seq
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
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 {
n := len(st.buffer) - g.maxMessages
var evicted []queuedMsg
@@ -267,18 +339,12 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped")
}
job := g.scheduleReviewLocked(st, key, now)
if !bufferHasSeq(st.buffer, seq) {
reason := "capped"
if containsSeq(dropped, seq) {
reason = "review_pruned"
}
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
c := msg
v.DroppedMsg = &c
return v
}
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
// 本条一定还在缓冲里:上限按新旧顺序丢最旧,而它是最新的(maxMessages ≥ 1)。
// 于是修复后准入路径上唯一能让消息 Proceed=false 的判据就是上面的限流 —— 审查
// (已移到后台)与上限(丢最旧)都只影响**已经放行过的**历史条目,只留审计痕迹。
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}, job
}
// 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.
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-g.window)
@@ -407,27 +464,6 @@ func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
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
// dropped ones.
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]...)
}
// 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.
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
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
}
+54 -31
View File
@@ -2,6 +2,7 @@ package adapter
import (
"context"
"sync"
"testing"
"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()
now, _ := fixedClock(time.Unix(1000, 0))
var audited []string
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 10,
Window: time.Minute,
@@ -130,9 +137,7 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
// Keep only the first two messages; drop the rest as noise.
return []int{0, 1}, nil
},
OnPruned: func(_ UnifiedMessage, reason string) {
audited = append(audited, reason)
},
OnPruned: audit.hook(),
})
// 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)
}
}
if len(audited) != 0 {
t.Fatalf("review fired below threshold: %v", audited)
waitReviewIdle(t, g)
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,
// including the just-admitted message 7.
// The 8th message crosses the 80% threshold → the review is dispatched in the
// background; the message itself is still admitted (admission never waits).
v := g.Admit(context.Background(), groupMsg(7))
if v.Proceed {
t.Fatal("just-admitted message 7 must be pruned by the reviewer")
if !v.Proceed {
t.Fatalf("the message that crosses the threshold must still be admitted, got %+v", v)
}
if v.Reason != "review_pruned" {
t.Fatalf("reason = %q, want review_pruned", v.Reason)
if v.Reason != "" {
t.Fatalf("reason = %q, want empty", v.Reason)
}
if len(v.Pruned) != 6 {
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
waitReviewIdle(t, g)
// 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 {
t.Fatalf("audit count = %d, want 6", len(audited))
}
for _, r := range audited {
if r != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", r)
for _, e := range audit.snapshot() {
if e.reason != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
}
}
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) {
t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
reviews := 0
g := NewFlowGate(FlowGateOptions{
MaxMessages: 3,
Window: time.Minute,
Now: now,
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
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.
for i := 0; i < 3; i++ {
g.Admit(context.Background(), groupMsg(i))
}
if reviews != 1 {
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews)
waitReviewIdle(t, g)
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.
for i := 3; i < 6; i++ {
for i := 3; i < 9; i++ {
g.Admit(context.Background(), groupMsg(i))
}
if reviews != 1 {
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews)
waitReviewIdle(t, g)
if got := count(); got != 1 {
t.Fatalf("reviews within window = %d, want 1 (throttled)", got)
}
// A fresh window allows a review again.
*clock = clock.Add(2 * time.Minute)
for i := 6; i < 9; i++ {
for i := 9; i < 12; i++ {
g.Admit(context.Background(), groupMsg(i))
}
if reviews != 2 {
t.Fatalf("reviews after window roll = %d, want 2", reviews)
waitReviewIdle(t, g)
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 == "" {
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 {
@@ -155,7 +155,7 @@ func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
return nil, fmt.Errorf("session: scan list: %w", err)
}
key := platform + "\x00" + cid
key := sessionKey(platform, cid)
if cached, ok := m.lookupCached(key); ok {
sessions = append(sessions, cached)
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"
"os"
"path/filepath"
"sort"
"strings"
)
@@ -61,10 +62,30 @@ type PluginConfig struct {
// Env is extra environment for a stdio subprocess. Values under a sensitive
// key (token/secret/password/key...) are redacted by MaskedPlugin.
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 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.
func DefaultPluginsPath(dataDir string) string {
return filepath.Join(dataDir, pluginsFileName)
@@ -245,6 +266,64 @@ func maskEnv(env map[string]string) map[string]string {
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
// one, otherwise same id.
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)
}
}
// 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
}
// 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关 给出**报告里要那个数字**:闸开与闸关时
// `Add` 的墙钟 p50/p95(多轮取样,最坏可复现场景:每轮都扫满候选且都不合并)。
//
@@ -131,7 +143,7 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
base := tidyTestTime(t)
var reviewer *PreInsertReviewer
if gateOn {
reviewer = newGateReviewer(t, store, ReviewConfig{}, nil)
reviewer = newGateReviewer(t, store, ReviewConfig{GateBudget: measureGateBudget}, nil)
store.AttachIngestReviewer(reviewer)
}
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)
if gateOn {
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateChecked != seeded+rounds {
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并):%+v", s)
// 「实测场景成立」= 每一轮都真扫真判、不合并、也**没有 fail-open**。预算本身由
// 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
+19 -3
View File
@@ -205,25 +205,41 @@ func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
}
// await 阻塞到批次结算,并把合并结果作为 design/05 §2「结束后写执行总结 → 返回主会话」
// 的交接广播出去。它运行在脱离的 goroutine 上;总线为 nil 或 id 未知时安静结束。
// 的交接广播出去。它运行在脱离的 goroutine 上。
//
// 🔴 **每一条出口都要留下痕迹**(M29 勘测 §7 的可观测性缺口):修复前「发布成功零
// 日志、无总线静默 return」,于是「到底发没发」只能靠负证据推断(这次缺陷最费时的
// 地方)。现在成功发布记一条带来源的 INFO,缺总线与发布失败各记一条 WARN ——
// 「压根没发 / 发了但失败 / 发了」三态都能从日志正面判定。
//
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
// 归属到父会话的实时流,而不是发一张无归属的帧。
// 归属到父会话的实时流,而不是发一张无归属的帧;gateway 的回填订阅者也按它写
// 父会话的系统注记(M32)。
func (t *SpawnTool) await(ids []string, parentSession string) {
res, err := t.sched.Wait(context.Background(), ids)
if err != nil {
t.log.Warn("sub-agent batch wait failed", "count", len(ids), "error", err)
if res == nil {
t.log.Warn("sub-agent results not published: batch produced no result",
"count", len(ids), "session", parentSession)
return
}
}
b := t.sched.Bus()
if b == nil {
t.log.Warn("sub-agent results not published: no bus",
"count", len(ids), "session", parentSession)
return
}
payload := &SubagentResultEvent{SessionID: parentSession, SpawnResult: doneSpawnResult(res)}
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 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果,
@@ -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)。
//
// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本按段落口径原样返回。
//
// 开头先跑 `RenderLiteralLineBreaks`(literal_breaks.go):模型把换行写成字面 `\n`
// 时,落库/WS/REST/IM 出站要拿到**同一份**真换行文本。这一步刻意放在**围栏 skip
// 之前** —— 否则含代码围栏的消息里,围栏外那些字面 `\n` 就一律不渲染了;围栏与
// 行内代码内部由那个函数自己保护,段落口径的 skip 不受影响。
func NormalizeParagraphs(text string) string {
text = RenderLiteralLineBreaks(text)
if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) {
return text
}
@@ -18,10 +18,19 @@ import (
// 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。
func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
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.holdSelfWake = make(chan struct{})
b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w)
// 放行卡住的那一轮,好让 Stop 的 t.Cleanup 能排空 worker。
defer close(w.holdSelfWake)
// 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。
before := mustStatus(t, b)
@@ -54,21 +63,38 @@ func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
if !receipt.Accepted {
t.Fatalf("收据 = %+v, want Accepted", receipt)
}
// 等这一轮真的被 worker 取走并卡进 Think(= 槽已空、worker 已忙):这是下一段的**前置件**,
// 等不到说明 worker 没被调度(那是另一个失败,由此处明确报出)。
w.waitFor(t, 2, 5*time.Second)
after := mustStatus(t, b)
if after.Used != receipt.Used || after.Used != 1 {
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 {
t.Fatalf("WakeNow 合并: %v", err)
}
merged := mustStatus(t, b)
if merged.Used != 1 {
t.Fatalf("合并后 Used = %d, want 1(合并不额外占额度)", merged.Used)
if merged.Used != 2 {
t.Fatalf("合并后 Used = %d, want 2(合并不额外占额度,停在 Limit)", merged.Used)
}
if merged.Limit != 2 {
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 内部
// 用 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) {
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()
b, _ := startSelfWake(t, Config{SelfWakeLimit: 1, SelfWakeWindow: window}, w)
firstAt := time.Now()
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "第一次"}); err != nil || !got.Accepted {
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 {
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: "窗口过了"})
if err != nil {
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/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/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
@@ -139,6 +140,22 @@ type Options struct {
// Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now,
// 使依赖时间的行为在测试中可注入。
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」)的**读取**接缝:
// 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖
// (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。
@@ -325,6 +342,17 @@ type Bridge struct {
wg sync.WaitGroup
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。当必需依赖缺失时返回错误,
@@ -400,5 +428,12 @@ func New(opts Options) (*Bridge, error) {
voiceAudioStore: opts.VoiceAudioStore,
cancel: func() {},
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
}
@@ -59,6 +59,13 @@ func (f *fakeAdapter) Capabilities() adapter.Capabilities
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
// 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 {
sessionID string
messages []model.Message
@@ -75,6 +82,9 @@ func (s *stubChatter) Run(ctx context.Context, sessionID string, msgs []model.Me
}
// stubSessions 记录入站消息并返回固定会话。
//
// 与 stubChatter 同理:只在同步路径上被调用,因此不加锁;若将来从车道 worker 触碰,
// 必须补锁 + 补一条 -race 用例。
type stubSessions struct {
msg adapter.UnifiedMessage
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(design/18 红线)。
//
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
// 口径见 coldShoulderLocked。
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
@@ -168,21 +174,14 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage,
defer st.mu.Unlock()
now := b.now()
// design/18 §5 冷避优先:群成员表达负面态度是
// 明确的「安静」信号 → COOLDOWN,即使正处在参与中。直接 @ / 高价值
// 关键词已在上方返回,因此只有真正「内容值得回复」
// 的消息(或主人消息,它根本不会走到这里)才能覆盖它。
if b.negativeAttitude(msg.Content) {
b.enterCooldown(st, cfg, now)
b.log.Debug("engage cooldown (negative attitude)",
"group", key, "until", st.cooldownUntil)
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"
// design/18 §5 冷避优先:群成员表达负面态度是明确的「安静」信号 →
// COOLDOWN,即使正处在参与中。判据本体与 shouldRespond 的冷避闸
// (coldShoulderGate)**只有这一份**;那里在廉价规则之前先跑它,因此
// @她 / 高价值关键词都拦不住冷避(M18,主人 2026-10-14 裁决「给」)。
// 这里保留调用是**纵深防御**:engageDecision 单看仍然自带冷避优先语义,
// 将来若多出别的调用点也不会漏掉它。
if quiet, reason := b.coldShoulderLocked(msg, actor, key, st, cfg, now); quiet {
return false, reason
}
// ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断
@@ -331,32 +330,6 @@ func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
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 把消息截短为状态机所用的有界话题标记。
func contentSnippet(text string) string {
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)
}
// 直接 @ 仍会打断冷却(design/18 §5:除非 @ 她)。
// M18(主人 2026-10-14 裁决「给」):冷却期内**连 @ 也不回** ——
// 冷避优先于「@她必回」,群成员的一句「少说点」不被随后的 @ 作废。
// (旧口径「@ 打断冷却」正是本片修掉的偏离;见 design/18 §5 与
// engage_coldshoulder_test.go 的端到端用例。)
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID)
if chat.sessionID != "" || judge.calls != 0 {
t.Fatalf("mention during cooldown: session=%q calls=%d, want \"\"/0 (@ no longer breaks the cold shoulder)",
chat.sessionID, judge.calls)
}
// 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。
+9 -19
View File
@@ -2,10 +2,8 @@ package channelbridge
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
@@ -201,22 +199,14 @@ func truncateText(s string, n int) string {
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) {
var idx []int
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
return adapter.ParseReviewIndices(s, bound)
}
@@ -3,6 +3,7 @@ package channelbridge
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
@@ -329,3 +330,88 @@ func TestParseKeepIndices(t *testing.T) {
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 (
"context"
"errors"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"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)
}
// onMessage 是面向适配器的入站回调。它在有界 goroutine 中运行
// 流水线,使适配器的读取循环不会因缓慢的 agent 轮次而阻塞。
// 适配器被显式携带,使流水线知道是哪个账号收到了
// onMessage 是面向适配器的入站回调。它**只投递、不跑回合**:过完流控闸就把消息投进
// 该会话自己的回合车道(lane.go)并立即返回,因此适配器的读循环(WS 单线程读 socket /
// iLink 长轮询 / webhook 处理器)永远不会被一轮缓慢的 agent 调用阻塞 —— 心跳与其它帧
// 照常被读、被解析。适配器被显式携带,使流水线知道是哪个账号收到了
// 消息(多账号回复路由 + 按账号允许/拒绝,design/16
// §4.6 §5)。
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 {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// 在 Stop 设置 closed 所用的同一把锁下 Add 工作项,使
// 并发的 Stop 要么在等待前看到我们的 Add,要么我们
// 观察到 closed 后退出,不泄露 goroutine。
b.mu.Lock()
if b.closed {
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)
}()
// M22(切片 LANEQUEUE)——**读与回水解耦**:流控闸在读循环里仍然是同步的
// (一次滑动窗口判断,微秒级;洪泛正是在这里被挡掉,不该让它进车道占内存),
// 闸后立刻把消息投进**该会话自己的回合车道**并返回,读循环因此马上可以继续读
// socket —— 心跳与其它帧不再被一轮模型调用阻塞(此前这里同步抢 16 个全局回合槽,
// 槽满即停止读 socket,这正是「消息一直排队、后面才回复前面的话」的现场)。
//
// 入队非阻塞、有界(背压见 lane.go 的 offer):群聊/陌生人车道满时**在门前拒收
// 这一条**并留痕(WARN + 计数 + 审计),队列里已有的条目一条不动;主人私聊永不
// 拒收,溢到 spill 只排队。回合本身在 laneWorker 里跑,全局并发上限
// (maxConcurrent)也搬到那里取。
b.enqueueInbound(ctx, msg, a, actor)
}
// process 用平台与消息匹配的适配器运行入站流水线
@@ -126,109 +114,20 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom 针对指定的账号适配器运行完整入站流水线:
// 解析会话/actor、应用允许/拒绝黑白名单、以聊天文本
// 驱动 agent 循环,并把循环的回复经接收该消息的同一
// 适配器送回去。身份由会话层结构化解析;
// 桥接器绝不读取消息内容来判定发送者是谁
// (项目红线)。
// processFrom 针对指定的账号适配器运行完整入站流水线:逐条过闸门(prepareInbound)
// 然后跑**一条**回合(runBatch)。身份由会话层结构化解析;桥接器绝不读取消息内容来
// 判定发送者是谁(项目红线)。
//
// 它是**同步**便捷路径(`process` 与测试用)。真实的渠道入站走车道
// (enqueueInbound → laneWorker → prepareInbound + runBatch),因为只有车道能提供
// 「同一会话串行」与「静默窗口合并」这两件事。
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
// 语音 → 文本归一是入站流水线的**第一件事**(切片 IM-ASR;design/06 §1.1):
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
//
// 它同时给出这条语音的**来源与三态结论**(切片 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)
item := laneItem{msg: msg, a: a, arrivedAt: b.now()}
p, ok := b.prepareInbound(ctx, item)
if !ok {
return
}
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)
b.runBatch(ctx, []preparedInbound{p})
}
// blacklistDrop 对非主人消息应用黑白名单,返回
@@ -271,99 +170,6 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
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 解析本回合的可见回复、持久化并经接收该消息的适配器
// 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字,
// 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 ——
@@ -371,7 +177,7 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。
//
// turnID 是这次入站回合的权威标识(见 runInboundTurn):助手回复与刚落的用户消息
// turnID 是这次入站回合的权威标识(见 coalesce_batch.go 的 runBatch):助手回复与刚落的用户消息
// 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。
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) {
+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 只依据廉价规则信号判断是否值得回复
// (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示
// 名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
// (设计/12 §6 策略二)。优先级:**指向她本人**(design/18 §2 触发①)、
// 提及主人的显示名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
// 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是
// 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
// 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
// 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
// 推断身份(身份由 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 {
if len(msg.Mentions) > 0 {
if ownerAddressedToMe(msg) {
return verdictRespond
}
if b.owner != nil && b.owner.DisplayName != "" &&
@@ -162,12 +174,22 @@ func isStrangerPrivate(sess session.Session) bool {
// shouldRespond 实现入站消息的最小打扰分级
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息
// 应驱动完整 agent 循环并回复,还是只登记并静默。
// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」):
// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说
// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回,
// 而主人群里不对她说的消息改为**进上下文不回**。sess 是
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 应驱动完整 agent 循环并回复,还是只登记并静默。判定顺序(M18 后的**权威顺序**):
//
// ① owner 车道(`actor.IsOwner()`,design/18 §11「乙 = C」):主人私聊恒回;
// 主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说(见 ownerlane.go)——
// 冷避不适用于主人,主人的 @/@全体仍必回。
// ② 平台/系统噪声 → 只登记。
// ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。
// ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、
// 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。
// 冷却期内唯一放行口是**真故障/求救信号穿透**(严格子集,非高价值关键词全表:
// 例外给的是穿透权,噪声会直接削弱冷避),且**不解除冷却**;
// 同句负面态度优先于该例外(塔 2026-10-15 定稿)。
// ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。
// ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。
//
// sess 是该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。
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"
}
// 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或
// 高价值关键词一律直接回;规则得不出结论的部分交给参与度
// 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传,
// 规则层不再因为它静默(裁决②)。
// 策略二/五:基于规则的轻量判定。**指向她本人**(@她 / @全体)、提及主人
// 显示名或高价值关键词一律直接回;规则得不出结论的部分交给参与度
// 引擎 / 轻量判定器 —— 群里 @ 别人的消息因此不会必回,而是落到
// 「参与度触发 / 概率」那一档(未触发则只登记)。**安静时段只作为信号**(inQuiet)
// 继续往下传,规则层不再因为它静默(裁决②)。
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 {
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) {
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 钉住裁决③的后半句:
// 「同一发送者同时最多 1 个在跑的回合」。先用 AcquireTurn 占住该发送者的槽位(模拟
// 一个回合正在跑),再让下一条消息过门 —— 它必须只登记、不回,并留可见告警。
func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
// TestStrangerPrivateSecondTurnAnsweredNotThrottled 钉住 M23 对裁决③后半句的**改写**:
// 「同一发送者同时最多 1 个在跑的回合」这道闸仍然保留(runBatch 开跑前取、回合结束后还),
// 但「已经有回合在跑 ⇒ 这条只登记、不回」已经被 **排队**取代 —— 塔 2026-10-24 的裁定原文:
// 「陌生人私聊 `AcquireTurn` 那段由 M23 步 3 改(把「已有回合 ⇒ 不回」改成排队)」。
//
// 结构上「排队」由车道提供(同一会话一条车道、单消费者 ⇒ 不可能有两轮同时跑),
// 因此能走到「槽被占」的只剩并发路径(例如本用例直接调 process 而另一条路正占着槽)。
// 这时**绝不静默**:留一条带 source 的 WARN,消息照答(宁可晚回,不许不回)。
//
// 变异检验:把 runBatch 里的那个 WARN 分支改回 registerThrottled + return ⇒ 本用例必红。
func TestStrangerPrivateSecondTurnAnsweredNotThrottled(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
@@ -161,16 +168,16 @@ func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Fatalf("已有回合在跑时仍开了第二个回合:session=%q", chat.sessionID)
if chat.sessionID == "" {
t.Fatal("回合槽被占时消息没有回答:M23 的口径是排队回答,不是不回")
}
if got := len(hist.records()); got != 1 {
t.Fatalf("被并发拦下的消息登记 %d 条,want 1", got)
if got := len(hist.records()); got != 2 {
t.Fatalf("登记 %d 条,want 2(user + assistant:这条被回答了)", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "throttled by flow gate") ||
if !strings.Contains(logged, "answering anyway") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("并发拦下没有留下带 source 的可见日志:%q", logged)
t.Fatalf("槽被占照答没有留下带 source 的可见日志:%q", logged)
}
}
+21 -5
View File
@@ -7,12 +7,28 @@ import "context"
const backthinkListLimit = 50
// PluginSummary 是管理列表中一个能力/插件的线上结构。
//
// Origin(v0.83 契约增量)回答「这条能力来自哪台上游 server」,供面板把 MCP 工具的
// 呈现按 server 分节(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①)。
// 它是**可选键**:宿主内置能力根本不实现来源,因此该键整体**缺席**(不是 `{}` 也不是
// `null`)。归属来自注册时的元数据,绝不解析 `name` 推断 —— MCP 工具名里没有任何前缀。
// 老客户端忽略未知键即零改动;严格解析器需要容忍/声明该可选字段。
type PluginSummary struct {
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
AllowInGroup bool `json:"allowInGroup"`
AllowInStranger bool `json:"allowInStranger"`
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
AllowInGroup bool `json:"allowInGroup"`
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
+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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手
@@ -63,6 +64,9 @@ type pluginInstallConfig struct {
Args []string `json:"args"`
APIKey string `json:"apiKey"`
Env map[string]string `json:"env"`
// Enabled 是安装时的初始启用态。缺省(nil)=启用;显式 false =装上但先不连
// (配置落盘、工具不注册,之后可由面板「启用」)。
Enabled *bool `json:"enabled"`
}
// AdminInstalledPlugin 是管理列表中一个已安装插件的线上
@@ -78,6 +82,9 @@ type AdminInstalledPlugin struct {
APIKey string `json:"apiKey,omitempty"`
Env map[string]string `json:"env,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
// Enabled 是这一台的启用态(plugins.json 的 `enabled`,缺省视为 true)。
// 停用 ≠ 卸载:条目仍在列表里,只是没连、没注册工具。
Enabled bool `json:"enabled"`
// Live 报告 MCP server 是否已连接、其工具是否已注册。
Live bool `json:"live"`
// ToolCount 是活着的 server 已注册的工具数。
@@ -134,6 +141,7 @@ func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, r
Args: req.Config.Args,
APIKey: req.Config.APIKey,
Env: req.Config.Env,
Enabled: req.Config.Enabled,
}.Normalize()
if err := cfg.Validate(); err != nil {
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,
"kind": res.Config.Kind,
"transport": res.Config.Transport,
"enabled": res.Config.IsEnabled(),
"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"))
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 {
s.auditClaims(r, "plugin.uninstall", "error")
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})
}
// 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 列出运行时安装的插件,附其脱敏配置
// 与每 server 工具数。它是读端点(owner/admin),
// 绝不原样返回 API key 或敏感 env 值。
@@ -240,6 +314,7 @@ func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin {
APIKey: cfg.APIKey,
Env: cfg.Env,
CreatedAt: cfg.CreatedAt,
Enabled: cfg.IsEnabled(),
Live: it.Live,
ToolCount: it.ToolCount,
}
+18
View File
@@ -39,6 +39,10 @@ func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
}
// adminListPlugins 列出所有已注册能力(管理读)。
//
// 每条能力带上它的上游来源(若有):来源章的读取面在这里一次性给出,
// 前端「插件与能力」页据此把工具按 server 分节。**策略字段与集合完全不变** ——
// 只是多了一个可选键,权限判定与模型可见的工具面都不读它。
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
out := make([]PluginSummary, 0)
if s.caps != nil {
@@ -49,12 +53,26 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
Description: cap.Description,
AllowInGroup: cap.Policy.AllowInGroup,
AllowInStranger: cap.Policy.AllowInStranger,
Origin: pluginOriginOf(cap),
})
}
}
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 列出已注册的渠道适配器(管理读)。当接线了
// 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏,
// 确保原始 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/channel/adapter"
"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"
)
@@ -43,10 +44,20 @@ type proactivePayload struct {
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回投递与落库
// 两件事的结果(切片 P0,`backthink.ProactiveDelivery`)。
//
// 顺序是刻意的 —— **先落库、再发帧**:主动消息必须成为主会话
// 顺序是刻意的 —— **先规整、再落库、再发帧**:主动消息必须成为主会话
// (`history.MainSessionID`)里的一条真实消息(assistant 角色),否则刷新/
// 重载后什么都补不回来(这正是「气泡恒落」的根);而帧要带上落库行的 id,
// 客户端才能把实时气泡对齐到历史行。
// 客户端才能把实时气泡对齐到历史行。规整必须落在落库**之前** —— 落库行是
// 权威文本,先落库后规整只会在库里留下脏的、而帧与历史行逐字对不上。
//
// 文本与其它三处**同源**(Web 会话回合 / IM 出站 / 主动推送 IM 侧):一律走
// `turnreply.NormalizeParagraphs`,本文件绝不另写一份实现(本仓已因「同一条
// 规则两份写法」栽过多次)。修前的真缺口正是这条路由**完全绕过**它(M19 复审
// 抓到):同一条主动消息走 IM 是规整过的、走 Web 实时推还是原样,半包 `<p>` 在
// 客户端「只要存在完整 `<p>` 块就不再按空行回退切分」的契约下整条塌成一坨。
// 规整是幂等的写入侧加法(已规范的文本逐字不变),因此 `<p>` = 一段一气泡、
// `<br>` = 气泡内分隔、`<action>`/`<narration>` 随所在段落进同一个 `<p>` 这些
// 既有语义一条不动。
//
// 落库失败**不静默**:记一条带来源的 WARN,并把错误返回给调用方,同时
// Persisted=false —— 帧照发(实时气泡不能丢),但记录里绝不说「已推送」。
@@ -60,6 +71,9 @@ func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ba
if err := ctx.Err(); err != nil {
return backthink.ProactiveDelivery{}, err
}
// 先规整、再落库:msg 是调用方传来的值副本,就地改写只影响本路由;
// 落库与推帧随后取的都是**同一个**字符串,两端因此逐字相同。
msg.Content = turnreply.NormalizeParagraphs(msg.Content)
persisted, persistErr := h.persistProactiveToMain(ctx, msg)
if persistErr != nil {
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**:这是她单方面开口说的话,不是主人的输入 —— 塞成
// user 角色会污染「谁说了什么」,也会在历史里冒充主人发言(红线)。
// - **正文用调用方已经净化过的文本**(backthink 的 stripInternalMarkers 出口),
// - **正文用调用方已经净化并规整过的文本**(backthink 的 stripInternalMarkers 出口,
// 再过 `turnreply.NormalizeParagraphs` —— 见 SendProactive 的「先规整、再落库」),
// 系统元数据(决策标记、渠道选择)绝不进正文,也绝不新增任何隐藏字段 ——
// 注入面因此与「气泡里看到的那句话」逐字相同。
// - **turn_id 留空**:主动推送不属于任何一路回合(她单方面开口,没有对应的
@@ -83,8 +83,10 @@ func TestHub_SendProactive_PersistsAssistantMessageToMainSession(t *testing.T) {
if row.SessionID != history.MainSessionID {
t.Fatalf("session = %q, want %q", row.SessionID, history.MainSessionID)
}
if row.Content != "记得喝水哦" {
t.Fatalf("content = %q, want the proactive text verbatim", row.Content)
// 正文经共享规整器(turnreply.NormalizeParagraphs):纯散文被包成一个 `<p>`
// ——与 Web 回合 / IM 出站 / IM 侧主动推送同一份口径(M25:本路由曾绕过它)。
if row.Content != "<p>记得喝水哦</p>" {
t.Fatalf("content = %q, want the normalized proactive text", row.Content)
}
if 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 {
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
}
if data["content"] != "记得喝水哦" {
t.Fatalf("frame content = %q, want the proactive text", data["content"])
// 正文经共享规整器(turnreply.NormalizeParagraphs):纯散文也会被包成
// 一个 `<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" {
t.Fatalf("frame sender = %q, want Cyrene", data["sender"])
+7
View File
@@ -269,6 +269,13 @@ func (s *Server) registerAdminRoutes(
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
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)))
// 渠道静默窗口合并(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/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
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