test(kernel): 补「插件挂载恒定排在内置之后」护栏(欠了四轮) - #55
Merged
Conversation
added 2 commits
August 31, 2026 17:13
两个装配根都靠最后一行 `for (const bundle of opts.pluginMounts ?? []) register(bundle)` 保证这条,有实现、有注释、**零测试**。`pluginMounts` 此前只在 run-assembly.test.ts 里 被用来验作用域校验(注册到错误 scope 抛错),没有任何断言管顺序。 ## 坏了会零症状,但后果分两档 | 挂载点语义 | 顺序错了会怎样 | |---|---| | `concat`(context:prepare / session:start) | 拼接顺序**模型可见** → 「装了哪个插件」决定提示词长什么样 | | `firstWin`(context:compact / tool:before / turn:end) | 插件排前面**整个顶掉内置** → 压缩该发生时不发生,上下文涨到 413 | `test/unit/policies/pluginMountOrder.test.ts`(8 例):结构层(describe() 的相对位置) + 行为层(concat 真拼出来的顺序、firstWin 下插件根本没被调用)。 四处变异全命中:run 级挪到最前 / 插到内置中间 / 会话级挪到 compaction 前 / splitPluginMounts 反转。 ## 写这类护栏踩的三个坑(都写进注释了) 1.⚠️ **顺序断言最容易写成空断言。** 插件与内置**没挂在同一个挂载点上**时, 「插件排在最后」恒成立,顺序怎么改都绿。第一版会话级让插件挂 session:start、 内置只挂 context:compact,两者无交集 → **变异 M3 实测逃逸**。改用 context:compact 做对照才有内容。run 级同理:不传 consumeNotifications 时 context:prepare 上 一个内置都没有(那道 `length > 1` 防空断言当场抓住了第一版)。 2.⚠️ **`dispatch` 把 handler 异常 warn 后跳过**,所以「内置赢了」与「内置炸了、插件顶上」 在返回值上分不出来。第一版 payload 写错,内置在 `p.path.length` 抛 TypeError 被吞, 于是赢的是插件——而 `expect(out).toBeDefined()` 照样通过。断言 firstWin 必须同时收 warn。 3.⚠️ **fixture 上 `as unknown as MountBundle` 就是自己关掉类型检查。** 第一版加了, 于是把 `run` 写成 `handler` 时 TS 一声不响,跑起来 mount.run 是 undefined → TypeError → 被静默吞掉 → 表现成「返回值是 undefined」,排查一轮才定位。去掉 cast 后 TS 立刻又抓出 `memFs().fs`(实际叫 `port`)。 ## 刻意不做:`enforce: pre/post` 现在没有任何插件需要排到内置之前。为假想需求新增 API 表面的代价是永久的。 文档写清:将来若真要,抄 Vite 两档桶,不要抄 VSCode 数字优先级(必然退化成 `order: 999999` 军备竞赛)。 ## 验证 - typecheck / vitest 退出码均 0,**948 passed** - 变异 4/4 命中 - eval set 41 → 44,新增第十类「注册顺序即语义」(extension-before-builtin / firstwin-hijacked / handler-error-swallowed,落在新文件 `src/hooks/registry.ts`)。 这一类的判据不在被审代码上,在**消费端怎么用这个数组**:同一个顺序错误对 join 型 消费者是「外部看到的文本变了」,对 firstWin 型是「内置根本不执行」,差一个数量级。 新场景 `registration-order-review` 命中 3/44。 - CLI 真机:3 turn、零失败调用,与上一轮 FIX 基线一致。 (本轮无 src 改动,按规则可省;仍跑了一遍。) 顺带观察:`apps/cli/test/configDiscovery.e2e.test.ts` 在全量并发下撞 5s 超时, 单跑 975ms 通过。它起 pnpm 子进程,5s 预算对机器负载太敏感——非本轮引入,记一笔。
补跑 live **全套**(此前只跑了新增的那一个场景)时逮到: `derived-agent.live.test.ts` 把 `deepseek-v4-flash-0731-ali` 放在候选首位, 而 `code-review.eval.live` 与 `skills.verify.live` 都是 pro 优先——**全仓只有这一个文件 跑在最不稳的模型上**。 它已经三次以不同形态假红:一次「路径越界」、两次模型把 DSML 工具调用标记当纯文本吐出来 (`<|DSML|invoke name="file_read">` 直接进了 output)。此前两次我都当成随机噪声重跑过去了, 这次才去看候选列表,发现是稳定的配置差异。 判据:本文件断言的是**代码属性**(数据沿 workflow DAG 流动、不产生悬空 tool_use), 不是模型能力。让它跑在最不可靠的模型上,等于让它因为与被测内容无关的原因变红—— 一个只在 30% 的时候变红的护栏,实际效果是训练人忽略它。 flash 的耗时方差本身也大到不像在省时间:同一用例实测 23.6s vs 142.8s。 换 pro 后整个文件 4/4 通过,workflow 那例 66s。 ## 验证(三层全绿,退出码均为 0) - typecheck 0 - 单测 948 passed - live **全套** 19 passed / 4 files(此前是 18 passed + 1 failed) ## 顺带记一笔 我这轮往 `evalProject.ts` 的 FILES 里加了 `src/hooks/registry.ts`,那是**所有 eval 场景 共享的被审工程**,等于动了每个场景的输入。当时只跑了新场景(10 skipped)就汇报"跑了回归", 是不完整的——共享 fixture 的改动必须跑全套。
Owner
Author
补跑 live 全套,逮到一个反复三次的假红(
|
| 文件 | 候选首位 |
|---|---|
code-review.eval.live.test.ts |
deepseek-v4-pro-0813-ali |
skills.verify.live.test.ts |
deepseek-v4-pro-0813-ali |
derived-agent.live.test.ts |
deepseek-v4-flash-0731-ali |
全仓只有这一个文件跑在最不稳的模型上。它已经三次以不同形态假红:一次「路径越界」、两次 DSML 裸标记。前两次我都当随机噪声重跑过去了。
判据
本文件断言的是代码属性(数据沿 workflow DAG 流动、不产生悬空 tool_use),不是模型能力。让它跑在最不可靠的模型上,等于让它因为与被测内容无关的原因变红——一个只在 30% 的时候变红的护栏,实际效果是训练人忽略它。
flash 的耗时方差本身也大到不像在省时间:同一用例实测 23.6s vs 142.8s。
改成 pro 优先后整个文件 4/4 通过(workflow 那例 66s)。
三层全绿(退出码均为 0)
| 层 | 结果 |
|---|---|
| typecheck | 0 |
| 单测 | 948 passed |
| live 全套 | 19 passed / 4 files(此前 18 passed + 1 failed) |
| CLI 真机 | 3 turn、零失败调用 |
eval 各场景:readonly 41/44、async 4、robustness 4、trust 4、cross-file 3、numeric 3、layering 3、duplication 4、loader 3、registration-order 3、derived-fork 4。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
债的确切形状
两个装配根(
buildDefaultMounts每 run /buildSessionScopedMounts每会话)都靠最后一行保证「插件挂载恒定排在内置之后」。有实现、有注释、零测试。
pluginMounts此前只在run-assembly.test.ts里被用来验作用域校验(注册到错误 scope 抛错),没有任何断言管顺序。坏了零症状,但后果分两档
concat(context:prepare/session:start)firstWin(context:compact/tool:before/turn:end)第二档是这轮才想清楚的。原来只把它当"提示词顺序"问题,实际上
context:compact是 firstWin——插件插到前面,内置压缩策略根本不执行。test/unit/policies/pluginMountOrder.test.ts(8 例):结构层(describe()的相对位置)+ 行为层(concat 真拼出来的顺序、firstWin 下插件根本没被调用)。变异 4/4 命中。写这类护栏踩的三个坑(都留在注释里)
① 顺序断言最容易写成空断言。 插件与内置没挂在同一个挂载点上时,「插件排在最后」恒成立,顺序怎么改都绿。第一版会话级让插件挂
session:start、内置只挂context:compact,两者无交集 → 变异 M3 实测逃逸。改用context:compact做对照才有内容。run 级同理:不传
consumeNotifications时context:prepare上一个内置都没有——那道expect(prepare.length).toBeGreaterThan(1)防空断言当场抓住了第一版。写完顺序断言先确认这个挂载点上真的既有内置又有插件。②
dispatch把 handler 异常 warn 后跳过,所以「内置赢了」与「内置炸了、插件顶上」在返回值上分不出来。第一版 payload 写错,内置在p.path.length抛 TypeError 被吞,于是赢的是插件——而expect(out).toBeDefined()照样通过。断言 firstWin 必须同时收 warn。③ fixture 上
as unknown as MountBundle就是自己关掉类型检查。 第一版加了,于是把run写成handler时 TS 一声不响,跑起来mount.run是undefined→ TypeError → 被静默吞掉 → 表现成「返回值是 undefined」,排查一轮才定位到字段名。去掉 cast 后 TS 立刻又抓出memFs().fs(实际叫port)。刻意不做:
enforce: pre/post我自己在设计记录里写过「建议抄 Vite 两档桶」,但现在没有任何插件需要排到内置之前。为假想需求新增 API 表面的代价是永久的,所以这轮只补护栏。
文档写清了将来的方向:抄 Vite 的
enforce: "pre" | "post"(默认 post),不要抄 VSCode 的数字优先级——那必然退化成order: 999999的军备竞赛。验证
splitPluginMounts反转)src/hooks/registry.ts:extension-before-builtinbuild把 extensions 的 push 放在内置之前,违反文件头写死的约定firstwin-hijackedresolveOnce取第一个非空就返回,配上颠倒的注册表 = 内置整个被顶掉handler-error-swallowedcatch {}里什么都不做,「炸了」与「正常返回空」调用方分不出这一类的判据不在被审代码上,在消费端怎么用这个数组:同一个顺序错误,对
join型消费者是「外部看到的文本变了」,对firstWin型是「内置根本不执行」,严重程度差一个数量级。所以提示词把模型推向「谁在用这个数组、怎么用」,而不是「这段代码对不对」。新场景registration-order-review命中 3/44。src/改动(纯测试 + 文档 + eval fixture),按规则可省,仍跑了一遍。顺带记一笔(非本轮引入)
apps/cli/test/configDiscovery.e2e.test.ts在全量并发下撞 5s 超时,单跑 975ms 通过。它 spawn pnpm 子进程,5s 预算对机器负载太敏感。