Skip to content

test(kernel): 补「插件挂载恒定排在内置之后」护栏(欠了四轮) - #55

Merged
fujiwarazz merged 2 commits into
feat/agent-skillsfrom
test/plugin-mount-order
Aug 31, 2026
Merged

test(kernel): 补「插件挂载恒定排在内置之后」护栏(欠了四轮)#55
fujiwarazz merged 2 commits into
feat/agent-skillsfrom
test/plugin-mount-order

Conversation

@fujiwarazz

Copy link
Copy Markdown
Owner

Stacked on #54feat/agent-skills)。这是五步计划的最后一步,这条债欠了四轮。

债的确切形状

两个装配根(buildDefaultMounts 每 run / buildSessionScopedMounts 每会话)都靠最后一行

for (const bundle of opts.pluginMounts ?? []) register(bundle);

保证「插件挂载恒定排在内置之后」。有实现、有注释、零测试。 pluginMounts 此前只在 run-assembly.test.ts 里被用来验作用域校验(注册到错误 scope 抛错),没有任何断言管顺序。

坏了零症状,但后果分两档

挂载点语义 顺序错了会怎样
concatcontext:prepare / session:start 拼接顺序模型可见 → 「装了哪个插件」决定提示词长什么样
firstWincontext:compact / tool:before / turn:end 插件排前面整个顶掉内置 → 压缩该发生时不发生,上下文一路涨到 413

第二档是这轮才想清楚的。原来只把它当"提示词顺序"问题,实际上 context:compact 是 firstWin——插件插到前面,内置压缩策略根本不执行。

test/unit/policies/pluginMountOrder.test.ts(8 例):结构层(describe() 的相对位置)+ 行为层(concat 真拼出来的顺序、firstWin 下插件根本没被调用)。变异 4/4 命中。

写这类护栏踩的三个坑(都留在注释里)

① 顺序断言最容易写成空断言。 插件与内置没挂在同一个挂载点上时,「插件排在最后」恒成立,顺序怎么改都绿。第一版会话级让插件挂 session:start、内置只挂 context:compact,两者无交集 → 变异 M3 实测逃逸。改用 context:compact 做对照才有内容。

run 级同理:不传 consumeNotificationscontext: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.runundefined → TypeError → 被静默吞掉 → 表现成「返回值是 undefined」,排查一轮才定位到字段名。去掉 cast 后 TS 立刻又抓出 memFs().fs(实际叫 port)。

刻意不做:enforce: pre/post

我自己在设计记录里写过「建议抄 Vite 两档桶」,但现在没有任何插件需要排到内置之前。为假想需求新增 API 表面的代价是永久的,所以这轮只补护栏。

文档写清了将来的方向:抄 Vite 的 enforce: "pre" | "post"(默认 post),不要抄 VSCode 的数字优先级——那必然退化成 order: 999999 的军备竞赛。

验证

  • typecheck / vitest 退出码均 0,948 passed
  • 变异 4/4 命中(run 级挪到最前 / 插到内置中间 / 会话级挪到 compaction 前 / splitPluginMounts 反转)
  • eval set 41 → 44,新增第十类「注册顺序即语义」,落在新文件 src/hooks/registry.ts
id 形状
extension-before-builtin build 把 extensions 的 push 放在内置之前,违反文件头写死的约定
firstwin-hijacked resolveOnce 取第一个非空就返回,配上颠倒的注册表 = 内置整个被顶掉
handler-error-swallowed catch {} 里什么都不做,「炸了」与「正常返回空」调用方分不出

这一类的判据不在被审代码上,在消费端怎么用这个数组:同一个顺序错误,对 join 型消费者是「外部看到的文本变了」,对 firstWin 型是「内置根本不执行」,严重程度差一个数量级。所以提示词把模型推向「谁在用这个数组、怎么用」,而不是「这段代码对不对」。新场景 registration-order-review 命中 3/44。

顺带记一笔(非本轮引入)

apps/cli/test/configDiscovery.e2e.test.ts 在全量并发下撞 5s 超时,单跑 975ms 通过。它 spawn pnpm 子进程,5s 预算对机器负载太敏感。

zhangzihao 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 的改动必须跑全套。
@fujiwarazz

Copy link
Copy Markdown
Owner Author

补跑 live 全套,逮到一个反复三次的假红(950d230

原提交里我只跑了新增的那一个 eval 场景(-t "注册顺序专项",另外 10 个 skipped)就汇报「跑了回归」。这是不完整的——本轮往 evalProject.tsFILES 里加了 src/hooks/registry.ts,那是所有 eval 场景共享的被审工程,等于动了每个场景的输入。共享 fixture 的改动必须跑全套。

补跑后 1 failed

FAIL derived-agent.live.test.ts > workflow:两个入口并行读,第三个 agent 综合它们的结论
AssertionError: expected '[agent inspect · agent_inspect-mth137…' to contain 'PINEAPPLE-42'
+ <|DSML|tool_calls>
+ <|DSML|invoke name="file_read">

模型把 DSML 工具调用标记当纯文本吐了出来,不是真的 tool call。

不是随机,是配置差异

重跑 2/2 通过,看着像 flake。但这次去翻了候选列表:

文件 候选首位
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。

@fujiwarazz
fujiwarazz merged commit 091bc2a into feat/agent-skills Aug 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant