Compare commits
39
Commits
0c2322bb92
...
dfac06bbeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfac06bbeb | ||
|
|
210c8b999f | ||
|
|
f56d74c363 | ||
|
|
1ae9158250 | ||
|
|
7d9b2132bb | ||
|
|
20cf3e0a93 | ||
|
|
da6a365222 | ||
|
|
3acc81cec8 | ||
|
|
bd49080bd1 | ||
|
|
0fe88a44c0 | ||
|
|
4c9cabce1b | ||
|
|
ca8577f05b | ||
|
|
679c8abf1d | ||
|
|
c608dd4afb | ||
|
|
86aef7b454 | ||
|
|
fd8a62edf5 | ||
|
|
e3e8878ead | ||
|
|
7ea2850f18 | ||
|
|
187eb327d6 | ||
|
|
cfb8700b61 | ||
|
|
2142de92f2 | ||
|
|
a25e0c8870 | ||
|
|
960d7b46fd | ||
|
|
e91b868a5c | ||
|
|
ec6c98d26a | ||
|
|
76d972f0f2 | ||
|
|
a1d162febf | ||
|
|
1d3965aa80 | ||
|
|
2421a8dac9 | ||
|
|
4518385f44 | ||
|
|
6b0af81136 | ||
|
|
9654ce8861 | ||
|
|
fa573663c1 | ||
|
|
4ed5925296 | ||
|
|
28967d3435 | ||
|
|
13a0519115 | ||
|
|
fe53e029ea | ||
|
|
6432088586 | ||
|
|
5969e700e1 |
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 的纯声明并列。
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 := ¬eCaptureStore{}
|
||||
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()
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 := ¬eStore{}
|
||||
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
Reference in new issue
Block a user