dsh-router-core 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +156 -76
  2. package/cordis.patch.yml +2 -7
  3. package/docs/ext.md +4 -18
  4. package/docs/protocols.md +198 -0
  5. package/lib/client-registry.js +193 -668
  6. package/lib/client.js +193 -668
  7. package/lib/ext-prompt/index.js +202 -32
  8. package/lib/index.js +2805 -547
  9. package/lib/types/ext/contract.d.ts +20 -1
  10. package/lib/types/ext-prompt/content.d.ts +18 -1
  11. package/lib/types/ext-prompt/index.d.ts +7 -9
  12. package/lib/types/ext-prompt/mount.d.ts +14 -3
  13. package/lib/types/ext-prompt/plugin.d.ts +13 -5
  14. package/lib/types/ext-prompt/render.d.ts +14 -3
  15. package/lib/types/ext-prompt/sections.d.ts +95 -0
  16. package/lib/types/protocol/anthropic.d.ts +35 -0
  17. package/lib/types/protocol/chat.d.ts +25 -0
  18. package/lib/types/protocol/entry.d.ts +137 -0
  19. package/lib/types/protocol/gemini.d.ts +57 -0
  20. package/lib/types/protocol/index.d.ts +13 -0
  21. package/lib/types/protocol/responses.d.ts +41 -0
  22. package/lib/types/protocol/sse.d.ts +41 -0
  23. package/lib/types/protocol/stream.d.ts +92 -0
  24. package/lib/types/protocol/types.d.ts +224 -0
  25. package/lib/types/router/index.d.ts +42 -1
  26. package/lib/types/router/types.d.ts +19 -0
  27. package/lib/types/shared.d.ts +11 -0
  28. package/lib/types/suppliers/contract.d.ts +8 -0
  29. package/lib/types/suppliers/registry.d.ts +0 -1
  30. package/package.json +6 -15
  31. package/src/client/ExtDetail.tsx +26 -18
  32. package/src/client/group-controls.test.ts +53 -0
  33. package/src/client/group-controls.ts +43 -0
  34. package/src/client/index.tsx +0 -17
  35. package/src/client/router-components.test.ts +1 -1
  36. package/src/client/router.css +23 -355
  37. package/src/ext/contract.ts +20 -1
  38. package/src/ext-prompt/assembly.test.ts +63 -0
  39. package/src/ext-prompt/content.ts +25 -22
  40. package/src/ext-prompt/ext-prompt.test.ts +41 -31
  41. package/src/ext-prompt/index.ts +57 -11
  42. package/src/ext-prompt/mount.test.ts +18 -1
  43. package/src/ext-prompt/mount.ts +24 -9
  44. package/src/ext-prompt/plugin.ts +67 -6
  45. package/src/ext-prompt/render.test.ts +1 -1
  46. package/src/ext-prompt/render.ts +17 -6
  47. package/src/ext-prompt/section-controls.test.ts +148 -0
  48. package/src/ext-prompt/section-filter.test.ts +140 -0
  49. package/src/ext-prompt/sections.test.ts +123 -0
  50. package/src/ext-prompt/sections.ts +193 -0
  51. package/src/ext-route.test.ts +16 -0
  52. package/src/index.ts +109 -1
  53. package/src/protocol/anthropic.ts +662 -0
  54. package/src/protocol/chat.ts +272 -0
  55. package/src/protocol/entry.ts +440 -0
  56. package/src/protocol/gemini.ts +548 -0
  57. package/src/protocol/index.ts +31 -0
  58. package/src/protocol/protocol.test.ts +728 -0
  59. package/src/protocol/responses.ts +551 -0
  60. package/src/protocol/sse.ts +94 -0
  61. package/src/protocol/stream.ts +195 -0
  62. package/src/protocol/types.ts +249 -0
  63. package/src/protocol/wire-e2e.test.ts +208 -0
  64. package/src/protocol/wiring.test.ts +225 -0
  65. package/src/router/index.ts +144 -26
  66. package/src/router/models-cache.test.ts +5 -6
  67. package/src/router/supplier-switch.test.ts +1 -1
  68. package/src/router/types.ts +19 -0
  69. package/src/shared.ts +11 -0
  70. package/src/suppliers/contract.ts +8 -0
  71. package/src/suppliers/registry.ts +0 -71
  72. package/lib/contract-D4BWZ7Xe.js +0 -13
  73. package/lib/ext-test/index.js +0 -36
  74. package/lib/ext-test/locale/en.json +0 -6
  75. package/lib/ext-test/locale/zh.json +0 -6
  76. package/lib/ext-test/package.json +0 -5
  77. package/lib/types/ext-test/index.d.ts +0 -21
  78. package/lib/types/ext-test/plugin.d.ts +0 -23
  79. package/lib/types/suppliers/probe.d.ts +0 -160
  80. package/src/client/ExtTestPanel.tsx +0 -392
  81. package/src/client/comp-button-scope.test.ts +0 -58
  82. package/src/client/comp-panel-layout.test.ts +0 -58
  83. package/src/client/ext-panels.test.ts +0 -44
  84. package/src/client/ext-panels.ts +0 -49
  85. package/src/client/model-choice.test.ts +0 -41
  86. package/src/client/model-choice.ts +0 -37
  87. package/src/ext-test/ext-test.test.ts +0 -60
  88. package/src/ext-test/index.ts +0 -35
  89. package/src/ext-test/locale/en.json +0 -6
  90. package/src/ext-test/locale/zh.json +0 -6
  91. package/src/ext-test/plugin.ts +0 -34
  92. package/src/ext-test/row.json +0 -5
  93. package/src/suppliers/probe.test.ts +0 -266
  94. package/src/suppliers/probe.ts +0 -475
@@ -18,6 +18,7 @@ import { SystemPrompt, renderPrompt, type PromptAssembly } from '@deepseek-ai/ds
18
18
  import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
19
19
  import { renderPromptText } from './render.ts'
20
20
  import { PROMPT_CATEGORIES } from './content.ts'
21
+ import { createSectionCache, disabledSectionNames, filterSections } from './sections.ts'
21
22
 
22
23
  /** 起一个真 systemPrompt 并挂上本扩展。 */
23
24
  async function assemble(
@@ -77,3 +78,65 @@ test('装配结果与 renderPromptText 的内容一致(渲染逻辑只有一
77
78
  const expected = renderPromptText(true, undefined)
78
79
  assert.ok(text.includes(expected), '装配结果里找不到 renderPromptText 的输出 ⇒ 两处实现漂移了')
79
80
  })
81
+
82
+ // ── 段落总控:真宿主装配链上的"关了就没了"(2026-10-21)──────────
83
+ /**
84
+ * ⚠️ 这里才是段落总控的**终局判据**。前面 `section-filter.test.ts` 验的是
85
+ * "监听器按规则返回了什么",而"宿主真把那个返回值当成最终装配"是**另一件事**
86
+ * —— 宿主源码里 waterfall 之后还有 complete-section 恢复、runtime-context
87
+ * 抑制等收尾,任何一处改动都可能让我们删的段回来。只有跑真 `assemble()`
88
+ * 才能钉住。
89
+ */
90
+ async function assembleWithSections(
91
+ opts: { masterOn?: boolean; data?: unknown },
92
+ ): Promise<{ names: string[]; text: string; dispose: () => void }> {
93
+ const ctx = new Context()
94
+ new SystemPrompt(ctx, { includeHarnessIdentity: true, personaPrefix: 'PERSONA-PREFIX-SENTINEL' })
95
+ let enabled = true
96
+ const cache = createSectionCache()
97
+ // 与 index.ts 的监听器**同一形状**:先 next()、喂清单、再按落盘过滤
98
+ const offAssemble = ctx.on('system-prompt/assemble', async (assembly, _c, next) => {
99
+ const result = await next()
100
+ cache.update(result.sections)
101
+ if ((opts.masterOn ?? enabled) !== true) return result
102
+ const off = disabledSectionNames(opts.data as never)
103
+ if (off.size === 0) return result
104
+ return { ...result, sections: filterSections(result.sections, off) }
105
+ })
106
+ // 本扩展自己的准则段(带正文哨兵,便于断言它没被段开关删掉)
107
+ const dispose = mountPromptSection({
108
+ getSystemPrompt: () => ctx.systemPrompt,
109
+ isEnabled: () => true,
110
+ readData: () => undefined,
111
+ })
112
+ await new Promise((r) => setTimeout(r, 10))
113
+ const a = await ctx.systemPrompt.assemble({})
114
+ return {
115
+ names: a.sections.map((s) => s.name),
116
+ text: renderPrompt(a),
117
+ dispose: () => { offAssemble(); dispose(); void ctx },
118
+ }
119
+ }
120
+
121
+ test('★ 真装配链:关掉某段落 ⇒ 它真的不在最终 prompt 里', async () => {
122
+ const on = await assembleWithSections({})
123
+ // 宿主自带的段(关掉 includeHarnessIdentity 也仍在表里的话更好,但优先用我们自己的)
124
+ assert.ok(on.names.includes(PROMPT_SECTION_NAME), `准则段不在装配里:${on.names.join(', ')}`)
125
+ const target = on.names.find((n) => n === 'deployment:persona-prefix')
126
+ assert.ok(target !== undefined, `找不到宿主 persona 段,实际段名:${on.names.join(', ')}`)
127
+ on.dispose()
128
+
129
+ const off = await assembleWithSections({ data: { sections: { [target]: false } } })
130
+ assert.equal(off.names.includes(target), false, `真装配链里 ${target} 关了却还在`)
131
+ assert.equal(off.text.includes('PERSONA-PREFIX-SENTINEL'), false, '段没了但正文还在 ⇒ 过滤没生效')
132
+ // 反向:别的段与准则不能陪葬
133
+ assert.ok(off.names.includes(PROMPT_SECTION_NAME), '关别人的段时把准则段一起删了')
134
+ off.dispose()
135
+ })
136
+
137
+ test('★ 真装配链:总开关关 ⇒ 一个段都不动(扩展关 = 不干预宿主)', async () => {
138
+ const r = await assembleWithSections({ masterOn: false, data: { sections: { 'deployment:persona-prefix': false } } })
139
+ assert.ok(r.names.includes('deployment:persona-prefix'), '总开关关掉后仍在过滤 ⇒ 关了还生效')
140
+ assert.ok(r.text.includes('PERSONA-PREFIX-SENTINEL'), '总开关关掉后 persona 正文不见了')
141
+ r.dispose()
142
+ })
@@ -20,6 +20,23 @@
20
20
  * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
21
  * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
22
  * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
23
+ *
24
+ * ⚠️ 2026-10-17 用户点名的两处改动:
25
+ * ① **删掉「固化」分类**(整条移除,id `hardening` 不复存在)。用户已存的
26
+ * `categories`/`text`/`order` 里残留的 `hardening` 键无须迁移 ——
27
+ * `resolveCategories` 规则 2 会忽略不认识的 id。
28
+ * ② 身份气质:「默认中文」→「回复、思考过程必须使用中文」。
29
+ *
30
+ * ⚠️ 2026-10-27 用户点名的四处改动:
31
+ * ① **删掉「可判定」分类**(整条移除,id `verdifiable`);已存配置里的残留键
32
+ * 由 `resolveCategories` 忽略,无须迁移。
33
+ * ② **删掉「绝不偷懒」分类**(整条移除,id `no-cheap`)——用户判定它与别处重复。
34
+ * ③ 执行顺序重写:去掉「查结构行号 / 声明任务四件套 / 读码取切片 / 循既有骨架 /
35
+ * 以闸门为准」几段,改为「循既有架构/提出更优架构 → 验证以测试/e2e 验收」;
36
+ * 并在首尾补上「读文档」与「更新文档」—— 任务不是从零开始,也不以提交收尾。
37
+ * ④ **新增「文档」分类**(id `doc`):用户口述「文档不写流水账、只写权威内容」
38
+ * + 点名范围「docs/ 下的调研、设计、任务」,本文件只做润色。
39
+ * 排在「全局收口」之后 —— 收口查"文档过期没有",这条管"文档该写什么"。
23
40
  */
24
41
 
25
42
  /** 一个可独立开关的提示词分类。 */
@@ -45,7 +62,7 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
45
62
  title: '身份气质',
46
63
  defaultOn: true,
47
64
  body:
48
- '身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;默认中文,直接给能跑的东西。',
65
+ '身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;回复、思考过程必须使用中文,直接给能跑的东西。',
49
66
  },
50
67
  {
51
68
  id: 'scope',
@@ -81,19 +98,12 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
81
98
  '做完那块区域该比动手前更整或更少。',
82
99
  },
83
100
  {
84
- id: 'hardening',
85
- title: '固化',
86
- defaultOn: true,
87
- body:
88
- '固化:只对数据丢失/安全这类真损失补检查——会变红(注入违规必须失败)、可重放(一条命令)、' +
89
- '归属清晰(失败信息指向违规处);普通 bug 修好即止,不额外造闸门。',
90
- },
91
- {
92
- id: 'verdifiable',
93
- title: '可判定',
101
+ id: 'doc',
102
+ title: '文档',
94
103
  defaultOn: true,
95
104
  body:
96
- '可判定:只有清单和会变红的闸门生效,形容词不生效。新约束一律写成"动作+闸门"。',
105
+ '文档:docs/ 里的调研/设计/任务文档,只写权威内容——结论、约束、怎么用;' +
106
+ '不写流水账(过程、试错、时间线),历史归 git。',
97
107
  },
98
108
  {
99
109
  id: 'root-cause',
@@ -125,13 +135,6 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
125
135
  '结构:先让结构对再让代码短;命名/边界/单一职责优先于行数;依赖单向无环、公共逻辑收敛一处;' +
126
136
  '单文件单功能,软顶函数 ~40 行/文件 ~300 行。',
127
137
  },
128
- {
129
- id: 'no-cheap',
130
- title: '绝不偷懒',
131
- defaultOn: true,
132
- body:
133
- '绝不偷懒:边界输入校验、防数据丢失、安全、可访问性、真实硬件校准;非平凡逻辑留个能跑的检查。',
134
- },
135
138
  {
136
139
  id: 'delivery',
137
140
  title: '交付',
@@ -145,8 +148,8 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
145
148
  title: '执行顺序',
146
149
  defaultOn: true,
147
150
  body:
148
- '执行:拉记忆→看全貌→建领地→定位任务→查结构行号→声明「任务/文件/验证/提交」→读码取切片→' +
149
- '循既有骨架→验证以闸门为准(混合状态下的绿灯不作数)→收口复查全貌→提交。',
151
+ '执行:拉记忆→读文档→看全貌→定位任务→建领地→循既有架构/提出更优架构→验证以测试/e2e 验收→' +
152
+ '收口复查全貌→更新文档→提交。',
150
153
  },
151
154
  ] as const
152
155
 
@@ -155,7 +158,7 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
155
158
  *
156
159
  * ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
157
160
  * **不属于任何单条分类**,按分隔符切分时自然被丢掉
158
- * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
161
+ * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到一堆
159
162
  * **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
160
163
  *
161
164
  * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
@@ -3,7 +3,7 @@
3
3
  * 1. 登记进 `router.ext` 表,带上可显示的身份(名字/说明/就绪);
4
4
  * 2. **幂等**:同一个 id 已在表里就不顶掉别人的登记;
5
5
  * 3. `source: 'builtin'`(随核心分发 ⇒ 插件页的原生行,自绘节不重复列);
6
- * 4. **准则段落真的挂上了** —— ext-test 没有这一条,本扩展多做执行面,必须验。
6
+ * 4. **准则段落真的挂上了** —— 这是本扩展唯一的执行面,必须单独验。
7
7
  *
8
8
  * 为什么第 4 条不可省:扩展的「卡片出现与否」取决于它在不在这张表里,而**准则
9
9
  * 有没有进 prompt** 取决于段落挂没挂 —— 两者是**不同的失效**,都静默且都不报错。
@@ -20,7 +20,7 @@ import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
20
20
  import type { RouterExtService } from '../ext/contract.ts'
21
21
 
22
22
  test('扩展器带上面板要用的身份', () => {
23
- const ext = createPromptExt({ isSystemPromptReady: () => true })
23
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
24
24
  assert.equal(ext.id, EXT_PROMPT_ID)
25
25
  assert.equal(ext.name, '分层提示词')
26
26
  assert.equal(ext.source, 'builtin', '随核心分发必须标 builtin,否则插件页会重复列它')
@@ -34,7 +34,7 @@ test('★ `description` 是整段标题(不是自我介绍,也不内插分
34
34
  // ③ 现在又要求**顶部显示标题** ⇒ 改成「等于 PROMPT_TITLE」。
35
35
  // 两次反复的共同教训:**默认值/文案该是什么由用户拍板,判据只负责让改动可见**,
36
36
  // 所以断言跟着改、并在注释里留着反复,免得下一个人以为是"又改坏了"。
37
- const ext = createPromptExt({ isSystemPromptReady: () => true })
37
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
38
38
  assert.equal(ext.description, PROMPT_TITLE, '详情页顶部要显示的就是整段标题')
39
39
  // ⚠️ 尤其**不许**内插 `${PROMPT_CATEGORIES.length}`:加减分类后文案不跟着变,
40
40
  // 就是同一件事的两个来源(「反查重复」)。
@@ -53,7 +53,7 @@ test('★ 插件页那一行的说明仍然在(来自 locale,不是 descript
53
53
  })
54
54
 
55
55
  test('注入:systemPrompt 不可用 ⇒ 报 not ready(不能静默不生效)', () => {
56
- const ext = createPromptExt({ isSystemPromptReady: () => false })
56
+ const ext = createPromptExt({ isSystemPromptReady: () => false, getSections: () => [] })
57
57
  const st = ext.getState()
58
58
  assert.equal(st.ready, false, '无处注入却报就绪 = 卡片亮着、准则没进 prompt')
59
59
  assert.ok((st.detail ?? '').length > 0, '不报还不说原因 = 面板上无从判断')
@@ -75,7 +75,7 @@ test('★ patch 里真的声明了这一行,且 id/name 逐字对得上', () =
75
75
  assert.equal(block![1], 'dsh-router-core/ext-prompt', 'patch 的 name 必须是能解析的子路径说明符')
76
76
  })
77
77
 
78
- test('★ 那一行默认关闭(与 ext-test 同款)', () => {
78
+ test('★ 那一行默认关闭', () => {
79
79
  // `disabled: true` = 行默认关 ⇒ loader 不 import ⇒ 扩展不进 `router.ext` 表 ⇒
80
80
  // 卡片不出现、准则不进 prompt。
81
81
  //
@@ -102,17 +102,27 @@ test('★ 那一行默认关闭(与 ext-test 同款)', () => {
102
102
  * 真 cordis 会如实复现时序(本机 4.0.4 实测:provide 后 inject 不触发,
103
103
  * start() 之后才触发)。
104
104
  */
105
- async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
105
+ async function realCtx(opts: { enabled?: boolean; data?: unknown; assembled?: { name: string; text: string }[] } = {}) {
106
106
  const { Context } = await import('@deepseek-ai/cordis')
107
107
  const table: RouterExtService = {}
108
108
  const sections: { name: string; order: number; text: string | (() => string) }[] = []
109
+ const assembled = opts.assembled ?? []
110
+ let assembleCalls = 0
109
111
  const ctx = new Context()
110
112
  ctx.provide('systemPrompt', {
111
113
  section: (s: { name: string; order: number; text: string | (() => string) }) => {
112
114
  sections.push(s)
113
115
  return () => {}
114
116
  },
115
- getSectionOrder: () => 100,
117
+ // ⚠️ 这里曾经有个 `getSectionOrder: () => 100` 的桩:没人调用它(`mount.ts`
118
+ // 自己定 order),而 100 已经不是本插件的段落 order(2026-10-21 改为 -2000
119
+ // 放到最前)⇒ 留着就是一句与事实不符的注脚,删掉。
120
+ // 段落总控的预热/清单源(apply 里 `void sp.assemble()`);返回值直接决定
121
+ // 面板清单,测试用 `assembled` 注入。
122
+ assemble: async () => {
123
+ assembleCalls++
124
+ return { sections: assembled.map((s) => ({ ...s })) }
125
+ },
116
126
  })
117
127
  ctx.provide('router.ext', table)
118
128
  ctx.provide('router.extStore', {
@@ -121,7 +131,7 @@ async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
121
131
  readData: () => opts.data,
122
132
  writeData: () => {},
123
133
  })
124
- return { ctx, table, sections }
134
+ return { ctx, table, sections, getAssembleCalls: () => assembleCalls }
125
135
  }
126
136
 
127
137
  test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', async () => {
@@ -138,7 +148,7 @@ test('幂等:同一个 id 已在表里时不顶掉别人的登记', async () =
138
148
  assert.equal(table[EXT_PROMPT_ID], first, '重复登记不该换成一个新对象')
139
149
  })
140
150
 
141
- test('★ apply 把准则挂成了 system prompt 段落(ext-test 没这层,必须单独验)', async () => {
151
+ test('★ apply 把准则挂成了 system prompt 段落', async () => {
142
152
  const { ctx, sections } = await realCtx()
143
153
  apply(ctx as unknown as Parameters<typeof apply>[0])
144
154
  assert.equal(sections.length, 1, '准则没挂上 = 卡片亮着但 prompt 里没有准则')
@@ -163,14 +173,14 @@ test('注入:用户关掉某分类 ⇒ 该分类不进 prompt(可开关的
163
173
 
164
174
  // ── 面板编辑文本(2026-09-29)────────────────────────────────
165
175
  test('★ 每条都标 editable,面板才知道该不该给「修改」按钮', () => {
166
- const ext = createPromptExt({ isSystemPromptReady: () => true, store: fakeStore({ categories: {} }) })
176
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store: fakeStore({ categories: {} }) })
167
177
  for (const c of ext.controls ?? []) assert.equal(c.editable, true, `${c.id} 没标 editable`)
168
178
  assert.equal(typeof ext.setControlText, 'function', '没有 setControlText ⇒ 面板点了必然 400')
169
179
  })
170
180
 
171
181
  test('★ 编辑后 controls 立刻反映新标题/新正文(每次读都重算)', () => {
172
182
  const store = fakeStore({ categories: {}, text: { identity: { title: '我是谁', body: '我是一个测试。' } } })
173
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
183
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
174
184
  const c = (ext.controls ?? []).find((x) => x.id === 'identity')!
175
185
  assert.equal(c.title, '我是谁')
176
186
  assert.equal(c.body, '我是一个测试。')
@@ -182,7 +192,7 @@ test('★ controls 不是快照:改完之后**再读一次**要看到新值',
182
192
  // 旧标题**(用户看到"保存了又变回去")。而我当时注释写着"现算不缓存"。
183
193
  // ⇒ 判据必须**跨两次读**才抓得住(只读一次的写法抓不到)。
184
194
  const store = fakeStore({ categories: {} })
185
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
195
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
186
196
  const before = (ext.controls ?? []).find((x) => x.id === 'ladder')!
187
197
  assert.equal(before.title, '懒人梯子')
188
198
  ext.setControlText?.('ladder', { title: '我的梯子' })
@@ -192,7 +202,7 @@ test('★ controls 不是快照:改完之后**再读一次**要看到新值',
192
202
 
193
203
  test('setControlText 只写覆盖值,且不碰 categories(同块的另一种数据)', () => {
194
204
  const store = fakeStore({ categories: { identity: false }, keep: 'me' })
195
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
205
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
196
206
  assert.equal(ext.setControlText?.('identity', { title: '新标题' }), true)
197
207
  const after = store.readData<Record<string, unknown>>('')
198
208
  assert.deepEqual(after?.categories, { identity: false }, '改标题把开关状态冲掉了')
@@ -207,7 +217,7 @@ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢
207
217
  categories: {},
208
218
  text: { identity: { title: 'A' }, scope: { body: 'B' } },
209
219
  })
210
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
220
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
211
221
  ext.setControlText?.('ladder', { body: '新内容' })
212
222
  assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
213
223
  identity: { title: 'A' },
@@ -218,7 +228,7 @@ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢
218
228
 
219
229
  test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,不是替换)', () => {
220
230
  const store = fakeStore({ categories: {}, text: { identity: { title: 'A' } } })
221
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
231
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
222
232
  ext.setControlText?.('identity', { body: 'B' })
223
233
  assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
224
234
  identity: { title: 'A', body: 'B' },
@@ -227,7 +237,7 @@ test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,
227
237
 
228
238
  test('注入:空标题/空正文返回 false(不写盘)', () => {
229
239
  const store = fakeStore({ categories: {} })
230
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
240
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
231
241
  for (const bad of [{ title: ' ' }, { title: '' }, { body: '' }]) {
232
242
  assert.equal(ext.setControlText?.('identity', bad), false, `空串被接受了:${JSON.stringify(bad)}`)
233
243
  }
@@ -236,7 +246,7 @@ test('注入:空标题/空正文返回 false(不写盘)', () => {
236
246
 
237
247
  test('注入:未知 controlId 返回 false', () => {
238
248
  const store = fakeStore({ categories: {} })
239
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
249
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
240
250
  assert.equal(ext.setControlText?.('__evil__', { title: 'x' }), false)
241
251
  })
242
252
 
@@ -262,7 +272,7 @@ test('★ controls 走合成列表:自建条目出现、标 custom、内置标
262
272
  custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
263
273
  text: { ladder: { body: '改过的' } },
264
274
  })
265
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
275
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
266
276
  const list = ext.controls ?? []
267
277
  assert.equal(list.length, PROMPT_CATEGORIES.length + 1, '自建条目没出现')
268
278
  assert.equal(list.find((c) => c.id === 'cu-1')?.custom, true)
@@ -272,7 +282,7 @@ test('★ controls 走合成列表:自建条目出现、标 custom、内置标
272
282
 
273
283
  test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)', () => {
274
284
  const store = fakeStore({ categories: {} })
275
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
285
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
276
286
  const id = ext.addCustomControl?.('我的准则', '做完了就收。')
277
287
  assert.ok(id !== null && typeof id === 'string', '没返回新 id')
278
288
  assert.notEqual(id, 'identity', '新 id 撞上了内置条目')
@@ -281,7 +291,7 @@ test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)',
281
291
 
282
292
  test('注入:空标题/空正文新增 ⇒ 返回 null(不写盘)', () => {
283
293
  const store = fakeStore({ categories: {} })
284
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
294
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
285
295
  assert.equal(ext.addCustomControl?.(' ', 'x'), null)
286
296
  assert.equal(ext.addCustomControl?.('x', ''), null)
287
297
  assert.equal(store.readData<Record<string, unknown>>('')?.custom, undefined, '失败的写入留下痕迹')
@@ -294,7 +304,7 @@ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并
294
304
  order: ['cu-1', 'ladder'],
295
305
  custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
296
306
  })
297
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
307
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
298
308
  assert.equal(ext.removeCustomControl?.('identity'), false, '内置条目不该被删(它是代码的一部分)')
299
309
  assert.equal(ext.removeCustomControl?.('ladder'), false, '不存在的条目应被拒')
300
310
  assert.equal(ext.removeCustomControl?.('cu-1'), true)
@@ -306,7 +316,7 @@ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并
306
316
 
307
317
  test('★ 还原:只对被改过的内置条目成功,且回到内置文本', () => {
308
318
  const store = fakeStore({ categories: {}, text: { ladder: { body: '改过的' } } })
309
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
319
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
310
320
  assert.equal(ext.resetControlText?.('identity'), false, '没改过的内置条目不该「可还原」')
311
321
  assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目没有「内置版本」可回退')
312
322
  assert.equal(ext.resetControlText?.('ladder'), true)
@@ -318,7 +328,7 @@ test('★ 还原:只对被改过的内置条目成功,且回到内置文本'
318
328
 
319
329
  test('★ 排序:只存 id,且过滤掉不存在的 id(脏数据不留幽灵)', () => {
320
330
  const store = fakeStore({ categories: {} })
321
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
331
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
322
332
  assert.equal(ext.setControlOrder?.(['__ghost__', 'ladder', 'ladder']), true)
323
333
  assert.deepEqual(store.readData<Record<string, unknown>>('')?.order, ['ladder'], '幽灵/重复 id 没被过滤')
324
334
  })
@@ -330,7 +340,7 @@ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () =
330
340
  // 当成"存储不可用"直接拒 ⇒ 「添加准则」永远报 `title and body are required`
331
341
  // ——而 title/body 明明都传了,报错还指向错误的字段,排查成本极高。
332
342
  const store = fakeStore(undefined) // **没有 data**
333
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
343
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
334
344
  const id = ext.addCustomControl?.('我的准则', '做完就收。')
335
345
  assert.ok(id !== null, '初始状态下添加失败(真实用户第一次用就是这个状态)')
336
346
  assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '写盘了但 controls 里没有')
@@ -338,7 +348,7 @@ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () =
338
348
 
339
349
  test('★ 四个写入口在"无 data"时都可用(不只是 add)', () => {
340
350
  const store = fakeStore(undefined)
341
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
351
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
342
352
  assert.equal(ext.setControl?.('ladder', false), true, '开关在初始状态下不可用')
343
353
  assert.equal(ext.setControlText?.('ladder', { body: 'x' }), true, '改文本在初始状态下不可用')
344
354
  assert.equal(ext.setControlOrder?.(['ladder']), true, '排序在初始状态下不可用')
@@ -350,7 +360,7 @@ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false
350
360
  // 而代码是对的。**假 store 比被守的东西更宽容 = 判据无效**(第三次栽在这)。
351
361
  // ⇒ 假 store 必须**真的读回自己写的东西**:`fakeStore` 的 `box` 已在做。
352
362
  const store = fakeStore({ categories: {} })
353
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
363
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
354
364
  assert.equal(ext.resetControlText?.('identity'), false, '没改过的却说可还原')
355
365
  ext.setControlText?.('ladder', { body: '改过的' })
356
366
  assert.equal(ext.resetControlText?.('ladder'), true, '改过的却还原不了')
@@ -360,7 +370,7 @@ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false
360
370
  // ── op 成功必须回整张表(2026-09-30)───────────────────────
361
371
  test('★ 契约层:controls 上带 custom 标记(面板据此决定给删除还是还原)', () => {
362
372
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
363
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
373
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
364
374
  const mine = (ext.controls ?? []).find((c) => c.id === 'cu-1')
365
375
  const builtin = (ext.controls ?? []).find((c) => c.id === 'identity')
366
376
  assert.equal(mine?.custom, true, '自建条目没标 custom ⇒ 面板给"还原"而不是"删除"')
@@ -375,7 +385,7 @@ test('★ 自建条目的开关可切(判据要用合成列表,不能只用
375
385
  // ⇒ 用户看到「自定义的开关控制报错」。而它**在 controls 列表里显示着** ——
376
386
  // 列表用 `resolveCategories`、写入判据用 `PROMPT_CATEGORIES`,两处来源不一致。
377
387
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
378
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
388
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
379
389
  assert.equal((ext.controls ?? []).some((c) => c.id === 'cu-1'), true, '列表里没有它')
380
390
  assert.equal(ext.setControl?.('cu-1', false), true, '列表里有、却切不了')
381
391
  assert.equal(store.readData<Record<string, unknown>>('')?.categories && (store.readData<{ categories: Record<string, boolean> }>('')?.categories?.['cu-1']), false, '开关没落盘')
@@ -383,14 +393,14 @@ test('★ 自建条目的开关可切(判据要用合成列表,不能只用
383
393
 
384
394
  test('★ 自建条目的文本可改(同一处来源不一致)', () => {
385
395
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
386
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
396
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
387
397
  assert.equal(ext.setControlText?.('cu-1', { title: '新标题' }), true, '自建条目改不了标题')
388
398
  assert.equal((ext.controls ?? []).find((c) => c.id === 'cu-1')?.title, '新标题')
389
399
  })
390
400
 
391
401
  test('还原仍然只允许内置条目(自建没有「内置版本」可回退)', () => {
392
402
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
393
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
403
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
394
404
  assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目不该能被「还原」')
395
405
  })
396
406
 
@@ -418,7 +428,7 @@ test('★ 图标能被真实渲染(base64 必须解得出合法 SVG)', () =>
418
428
  })
419
429
 
420
430
  test('图标挂在 ext 上(不设的话面板退回默认闪电)', () => {
421
- const ext = createPromptExt({ isSystemPromptReady: () => true })
431
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
422
432
  assert.equal(ext.icon, PROMPT_LOGO_URL)
423
433
  })
424
434
 
@@ -2,19 +2,25 @@
2
2
  * 分层提示词扩展的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
3
3
  * 原生「包含的组件」里的那一行。
4
4
  *
5
- * 壳的形状与 `ext-test` 同款(cordis 身份 + 登记,实现 = `plugin.ts`),
6
- * 但**多一件执行面的事**:挂 system prompt 段落。ext-test 不做拦截、只登记;
7
- * 本扩展要把准则真注入,所以额外等 `systemPrompt` service 就绪后挂 section。
5
+ * 壳的形状 = cordis 身份 + 登记,实现 = `plugin.ts`;**执行面的事**是挂
6
+ * system prompt 段落 —— 所以额外等 `systemPrompt` service 就绪后挂 section。
8
7
  *
9
- * **默认开启**(patch 里不带 `disabled`,与三行内置供应商一致):
10
- * 协作准则是每次协作都要的基础设施,不是可选增强。与 `ext-test` 的
11
- * `disabled: true` **刻意不同** —— 自检是「想跑才跑」的工具,准则不是;
12
- * 让准则默认缺席,会变成「用户不知道它存在 ⇒ 协作按另一套规则执行」。
8
+ * **默认关闭**(patch 里 `disabled: true`):开不开由用户决定。行关闭时 loader
9
+ * 根本不 import 本模块,扩展就不在 `router.ext` 表里 ——「打开才出现这张卡片」
10
+ * 是天然的,不需要额外状态位。
13
11
  *
14
12
  * 登记时把扩展开关置为开:本扩展**没有独立的运行期开关**,它的开关就是这一行
15
13
  * (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
16
14
  */
17
15
  import type { Context } from '@deepseek-ai/cordis'
16
+ // ⚠️ **只为一个目的而 import type**:让 cordis 的 `Events` 增强
17
+ // (`system-prompt/assemble` / `system-prompt/change`)进入本文件所在的编译单元。
18
+ // 不 import 的话,`ctx.on('system-prompt/assemble', …)` 的参数不是
19
+ // `keyof Events` ⇒ **声明构建(tsconfig.build.json,host 半边)直接报错**,
20
+ // 而主 tsconfig 因为 include 了 `assembly.test.ts`(它 import 了同一个包)
21
+ // 恰好把增强带进来 ⇒ `tsc --noEmit` 全绿、`npm run build` 才红。
22
+ // 这类"两种配置下结论不同"的坑,靠显式依赖修,不靠碰巧。
23
+ import type {} from '@deepseek-ai/dsh-system-prompt'
18
24
  import {
19
25
  currentExts,
20
26
  currentExtStore,
@@ -24,6 +30,7 @@ import {
24
30
  import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
25
31
  import { mountPromptSection, type SystemPromptFace } from './mount.ts'
26
32
  import type { PromptExtData } from './render.ts'
33
+ import { createSectionCache, disabledSectionNames, filterSections } from './sections.ts'
27
34
 
28
35
  /** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
29
36
  export const name = 'dsh-router-ext-prompt'
@@ -43,8 +50,8 @@ function systemPromptOf(sctx: Context): SystemPromptFace | undefined {
43
50
  *
44
51
  * ⚠️⚠️ **必须导出这个数组,且 `apply` 里直接用 `ctx.systemPrompt`**(2026-09-29 修)
45
52
  * ——我原先写成「在 apply 里 `ctx.inject(['systemPrompt'], cb)`」,**准则一直不进
46
- * prompt**:面板上卡片、开关、原文全都正常(`GET /router/api/ext` 实测有 14 条
47
- * controls),但模型看到的 system prompt 里没有它。
53
+ * prompt**:面板上卡片、开关、原文全都正常(`GET /router/api/ext` 里 controls 一条
54
+ * 不少),但模型看到的 system prompt 里没有它。
48
55
  *
49
56
  * 根因(本机 cordis 4.0.4 实测):`ctx.inject(deps, cb)` 的回调**不是同步触发** ——
50
57
  * provide 之后立刻 `inject`,回调要等到下一个异步点才跑。插件的 `apply` 是**同步
@@ -76,7 +83,7 @@ export function apply(ctx: Context): void {
76
83
  const table = currentExts(ctx)
77
84
  const store = currentExtStore(ctx)
78
85
 
79
- // ① 准则段落。`section()` 注册一个**provider 函数**作为 text ——
86
+ // ① 准则段落。`section()` 注册一个 **provider 函数**作为 text ——
80
87
  // 每次装配现算,所以改开关立即生效,无需重注册。
81
88
  const dispose = mountPromptSection({
82
89
  getSystemPrompt: () => systemPromptOf(ctx),
@@ -84,13 +91,50 @@ export function apply(ctx: Context): void {
84
91
  readData: () => store?.readData<PromptExtData>(EXT_PROMPT_ID),
85
92
  })
86
93
 
87
- // ② 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
94
+ // ② 段落总控:挂 `system-prompt/assemble` waterfall —— 每轮装配时
95
+ // ⑴ 先把**未过滤的全量段落**喂给清单缓存(面板的事实源,在过滤前取 ⇒
96
+ // 关掉的段仍列在面板上,不会"关了就消失、开不回来");
97
+ // ⑵ 再按落盘的关段表删掉关闭的段(宿主以监听器返回值为权威结果
98
+ // —— `assemble()` 的 `return transformed`,删掉的段不会被恢复)。
99
+ //
100
+ // ⚠️ 总开关关 ⇒ 原样放行:扩展关 = 本扩展什么都不做,宿主段落恢复
101
+ // 自然注入(与准则段"关总开关 ⇒ 段文本空 ⇒ 整段被丢"同一语义)。
102
+ // ⚠️ 监听器注册在**无 scope 标签**的 ctx 上,而派发走
103
+ // `scopeTarget`(dsh-scope):无标签监听器收一切 scope 的装配
104
+ // —— 这正是宿主"全局注册、参与所有 agent 装配"的通道,与准则
105
+ // section 进 prompt 同一条 scope 规则。
106
+ const cache = createSectionCache()
107
+ let disposeAssemble: (() => boolean) | undefined
108
+ let disposeChange: (() => boolean) | undefined
109
+ const sp = systemPromptOf(ctx)
110
+ if (sp) {
111
+ disposeAssemble = ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
112
+ const result = await next()
113
+ cache.update(result.sections)
114
+ if (store?.isEnabled(EXT_PROMPT_ID) !== true) return result
115
+ const off = disabledSectionNames(store.readData<PromptExtData>(EXT_PROMPT_ID))
116
+ if (off.size === 0) return result
117
+ return { ...result, sections: filterSections(result.sections, off) }
118
+ })
119
+ // 段在**没有新装配时**增删(插件开关变化等)⇒ 面板清单不会自己更新,
120
+ // 主动跑一次 assemble 刷新(它会再走上面的 waterfall ⇒ 缓存唯一写入点)。
121
+ disposeChange = ctx.on('system-prompt/change', () => {
122
+ void sp.assemble().catch(() => { /* 预热失败不致命:下次装配自然补上 */ })
123
+ })
124
+ // 预热:一次对话都还没发生时,面板也要有清单(无 scope 的 global 近似)。
125
+ void sp.assemble().catch(() => { /* 同上 */ })
126
+ }
127
+
128
+ // ③ 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
88
129
  if (table !== undefined && table[EXT_PROMPT_ID] === undefined) {
89
130
  table[EXT_PROMPT_ID] = createPromptExt({
90
131
  isSystemPromptReady: () => !!systemPromptOf(ctx),
91
132
  // ⚠️ **store 必须在这时就能拿到**:拿不到的话面板上的分类开关显示可点、
92
133
  // 点了 400 —— 「看起来能用但不能用」比「不显示」更难查。
93
134
  store,
135
+ // 段清单来自 waterfall 缓存(若 ② 没挂上 ⇒ 空清单 ⇒ 段组不显示,
136
+ // 但不许这里给缺省 —— 见 createPromptExt 的 deps 注释)。
137
+ getSections: () => cache.list(),
94
138
  })
95
139
  store?.setEnabled(EXT_PROMPT_ID, true)
96
140
  // 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
@@ -99,6 +143,8 @@ export function apply(ctx: Context): void {
99
143
 
100
144
  ctx.effect(() => () => {
101
145
  dispose()
146
+ disposeAssemble?.()
147
+ disposeChange?.()
102
148
  if (table !== undefined) delete table[EXT_PROMPT_ID]
103
149
  })
104
150
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import test from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
- import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
10
+ import { mountPromptSection, PROMPT_SECTION_NAME, OFFICIAL_LOWEST_SECTION_ORDER } from './mount.ts'
11
11
  import { EXT_PROMPT_ID } from './plugin.ts'
12
12
 
13
13
  /** 记录注册内容的假 systemPrompt。 */
@@ -25,6 +25,11 @@ function fakeSystemPrompt() {
25
25
  disposals++
26
26
  }
27
27
  },
28
+ // 本文件测的是注册契约,不碰装配;给最小合法实现满足 `SystemPromptFace`
29
+ //(段清单与过滤的单测在 sections.test.ts / ext-prompt.test.ts)。
30
+ async assemble() {
31
+ return { sections: [] }
32
+ },
28
33
  }
29
34
  }
30
35
 
@@ -35,6 +40,18 @@ test('注册恰好一个段落,名字稳定', () => {
35
40
  assert.equal(sp.registered[0]!.name, PROMPT_SECTION_NAME)
36
41
  })
37
42
 
43
+ test('★ 段落 order 排在官方最低槽之前(2026-10-21 用户点名:准则放最前)', () => {
44
+ // 判据不写"是最小的"(谁将来再往前挪就误伤),写成**必须低于官方第一方最低槽**:
45
+ // 第一方开场白 `harness:identity` 在 -1000,别的部署可能没关它。
46
+ const sp = fakeSystemPrompt()
47
+ mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
48
+ const order = sp.registered[0]!.order
49
+ assert.ok(
50
+ order < OFFICIAL_LOWEST_SECTION_ORDER,
51
+ `order=${order} 未低于官方最低槽 ${OFFICIAL_LOWEST_SECTION_ORDER} ⇒ 准则排不到最前`,
52
+ )
53
+ })
54
+
38
55
  test('⚠️ text 必须是 provider 函数而不是静态字符串(否则改开关不生效)', () => {
39
56
  const sp = fakeSystemPrompt()
40
57
  mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })