dsh-router-core 0.4.6 → 0.4.7

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 (63) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/lib/client-registry.js +146 -106
  5. package/lib/client.js +146 -106
  6. package/lib/ext-prompt/index.js +182 -17
  7. package/lib/index.js +2732 -55
  8. package/lib/types/ext/contract.d.ts +20 -1
  9. package/lib/types/ext-prompt/content.d.ts +7 -1
  10. package/lib/types/ext-prompt/mount.d.ts +12 -1
  11. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  12. package/lib/types/ext-prompt/render.d.ts +14 -3
  13. package/lib/types/ext-prompt/sections.d.ts +95 -0
  14. package/lib/types/protocol/anthropic.d.ts +35 -0
  15. package/lib/types/protocol/chat.d.ts +25 -0
  16. package/lib/types/protocol/entry.d.ts +137 -0
  17. package/lib/types/protocol/gemini.d.ts +57 -0
  18. package/lib/types/protocol/index.d.ts +13 -0
  19. package/lib/types/protocol/responses.d.ts +41 -0
  20. package/lib/types/protocol/sse.d.ts +41 -0
  21. package/lib/types/protocol/stream.d.ts +92 -0
  22. package/lib/types/protocol/types.d.ts +224 -0
  23. package/lib/types/router/index.d.ts +42 -1
  24. package/lib/types/router/types.d.ts +19 -0
  25. package/lib/types/shared.d.ts +11 -0
  26. package/lib/types/suppliers/contract.d.ts +8 -0
  27. package/package.json +6 -6
  28. package/src/client/ExtDetail.tsx +25 -5
  29. package/src/client/group-controls.test.ts +53 -0
  30. package/src/client/group-controls.ts +43 -0
  31. package/src/client/router.css +20 -1
  32. package/src/ext/contract.ts +20 -1
  33. package/src/ext-prompt/assembly.test.ts +63 -0
  34. package/src/ext-prompt/content.ts +8 -10
  35. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  36. package/src/ext-prompt/index.ts +50 -2
  37. package/src/ext-prompt/mount.test.ts +18 -1
  38. package/src/ext-prompt/mount.ts +22 -7
  39. package/src/ext-prompt/plugin.ts +64 -1
  40. package/src/ext-prompt/render.test.ts +1 -1
  41. package/src/ext-prompt/render.ts +17 -6
  42. package/src/ext-prompt/section-controls.test.ts +148 -0
  43. package/src/ext-prompt/section-filter.test.ts +140 -0
  44. package/src/ext-prompt/sections.test.ts +123 -0
  45. package/src/ext-prompt/sections.ts +193 -0
  46. package/src/ext-route.test.ts +16 -0
  47. package/src/index.ts +109 -1
  48. package/src/protocol/anthropic.ts +662 -0
  49. package/src/protocol/chat.ts +272 -0
  50. package/src/protocol/entry.ts +440 -0
  51. package/src/protocol/gemini.ts +548 -0
  52. package/src/protocol/index.ts +31 -0
  53. package/src/protocol/protocol.test.ts +728 -0
  54. package/src/protocol/responses.ts +551 -0
  55. package/src/protocol/sse.ts +94 -0
  56. package/src/protocol/stream.ts +195 -0
  57. package/src/protocol/types.ts +249 -0
  58. package/src/protocol/wire-e2e.test.ts +208 -0
  59. package/src/protocol/wiring.test.ts +225 -0
  60. package/src/router/index.ts +142 -23
  61. package/src/router/types.ts +19 -0
  62. package/src/shared.ts +11 -0
  63. package/src/suppliers/contract.ts +8 -0
@@ -16,6 +16,15 @@ import type { ExtStoreService, RouterExt } from '../ext/contract.ts'
16
16
  import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
17
17
  import { PROMPT_LOGO_URL } from './logo.ts'
18
18
  import { effectiveText, resolveCategories, resolveEnabledCategories, type PromptExtData } from './render.ts'
19
+ import {
20
+ OWN_SECTION_NAME,
21
+ disabledSectionNames,
22
+ groupLabelOf,
23
+ sectionControlId,
24
+ sectionNameOf,
25
+ sectionTitleOf,
26
+ type SectionEntry,
27
+ } from './sections.ts'
19
28
 
20
29
  /** 从对象里去掉某个键(不改原对象)。`writeData` 是整块替换 ⇒ 删一个键要重写。 */
21
30
  function omit<T extends Record<string, unknown>>(obj: T, key: string): Partial<T> {
@@ -41,6 +50,15 @@ export const EXT_PROMPT_ID = 'prompt'
41
50
  export function createPromptExt(deps: {
42
51
  isSystemPromptReady: () => boolean
43
52
  store?: ExtStoreService
53
+ /**
54
+ * 当前已装配的段落清单(最近一次 `assemble` 的快照,来自 waterfall 缓存)。
55
+ *
56
+ * ⚠️ **必选,不给缺省**:缺省成 `() => []` 的话,`index.ts` 忘接这个参数
57
+ * 就是"段清单恒空"的静默失效(面板无段、过滤不生效,但一切照常跑)——
58
+ * 让类型红比让它静默强。**空清单是合法值**(还没预热完/宿主一段都没有),
59
+ * 与"没接线"的区分靠这里不给缺省,不靠运行时猜。
60
+ */
61
+ getSections: () => SectionEntry[]
44
62
  }): RouterExt {
45
63
  const store = deps.store
46
64
  /**
@@ -92,7 +110,7 @@ export function createPromptExt(deps: {
92
110
  const on = resolveEnabledCategories(true, data)
93
111
  // ⚠️ 走 `resolveCategories`(内置 + 自建 + 排序),不是内置常量 ——
94
112
  // 否则自建条目与拖动排序**只停在面板上**,不进 prompt。
95
- return resolveCategories(data).map((c) => {
113
+ const categories = resolveCategories(data).map((c) => {
96
114
  const t = effectiveText(c.id, data?.text?.[c.id], c)
97
115
  return {
98
116
  id: c.id,
@@ -109,11 +127,56 @@ export function createPromptExt(deps: {
109
127
  custom: c.custom,
110
128
  overridden: c.overridden,
111
129
  on: on.has(c.id),
130
+ // ⚠️ 准则**不标 group**:页头 `description` 已经是它的组头(+「添加」按钮),
131
+ // 再标一组就是同一件事显示两处(反查重复)。无组 = 平铺,行为不变。
112
132
  }
113
133
  })
134
+ // ② 宿主段落条目 —— 面板按 `group` 分节;`sortable: false` 不给拖
135
+ // (排序落盘不认段 id,拖了也是假交互);`editable` 缺省 = 只读
136
+ // (别人的段我们不提供改写,只开关)。
137
+ const off = disabledSectionNames(data)
138
+ const sections = deps.getSections()
139
+ // 防御过滤:自己的准则段恒不列(清单源已排除,这里再拦一道 ——
140
+ // 列了它就是"同一件事的第二个开关",两处状态必然漂移)。
141
+ .filter((s) => s.name !== OWN_SECTION_NAME)
142
+ .map((s) => ({
143
+ id: sectionControlId(s.name),
144
+ // 标题去掉组前缀(组名给了语境):组「工具说明」下的 `write`,
145
+ // 比孤零零的 `tool:write` 好读;完整名放 `detail`(报问题时可指认)。
146
+ title: sectionTitleOf(s.name),
147
+ on: !off.has(s.name),
148
+ detail: s.name,
149
+ // ⚠️ 空原文要**说清是空的**,不能给空串(2026-10-21 真机实测):
150
+ // `deployment:persona-prefix` / `persona-suffix` 的 provider 在未配置
151
+ // 人设时返回 "",面板上就是一条没有正文的行 —— 用户只能盲切。
152
+ // 这不是"忘了给原文",是这一段此刻**确实没内容**,说清楚才谈得上判断。
153
+ body: s.text === '' ? '(此段当前为空)' : s.text,
154
+ group: groupLabelOf(s.name),
155
+ sortable: false,
156
+ }))
157
+ return [...categories, ...sections]
114
158
  },
115
159
  setControl: (controlId, on) => {
116
160
  if (!store) return false
161
+ // ---- 段落开关(`sec:` 前缀):写 `data.sections`,与分类开关分键 ----
162
+ const secName = sectionNameOf(controlId)
163
+ if (secName !== null) {
164
+ // 只认**当前清单里**的段(清单 = 装配事实),且永远不关自己那段
165
+ // (它已有总开关 + 分类开关,再给一个入口就是同一件事的两个开关)。
166
+ if (secName === OWN_SECTION_NAME) return false
167
+ if (!deps.getSections().some((s) => s.name === secName)) return false
168
+ const data = readData()
169
+ const sections = { ...(data.sections ?? {}) }
170
+ if (on) delete sections[secName]
171
+ else sections[secName] = false
172
+ const next: PromptExtData = { ...data }
173
+ // 只存覆盖值(见 `PromptExtData.sections`):全开时不留空对象键。
174
+ if (Object.keys(sections).length > 0) next.sections = sections
175
+ else delete next.sections
176
+ store.writeData(EXT_PROMPT_ID, next)
177
+ return true
178
+ }
179
+ // ---- 分类开关(原路径)----
117
180
  // ⚠️ **只改这一个键,不重写整块**:整块重写会顺手把 data 抽屉里的其它字段
118
181
  // 抹掉(`store.test.ts` 有一条「置开关不能把 data 冲掉」的同款纪律)。
119
182
  const data = readData()
@@ -143,7 +143,7 @@ test('★ 渲染出来的第一行是整段标题(拆分时差点丢的就是
143
143
  // ⚠️ 原文是 `[准则 …] ·身份气质:…`,标题在方括号里、**不属于任何单条分类**
144
144
  // ⇒ 按分隔符切分时天然被丢掉
145
145
  // (它只出现在 content.ts 头注,没进任何 body)。
146
- // 丢了的后果:模型看到 14 条**没有名字**的规则,不知道这套东西是什么。
146
+ // 丢了的后果:模型看到一堆**没有名字**的规则,不知道这套东西是什么。
147
147
  const text = renderPromptText(true, undefined)
148
148
  assert.ok(text.startsWith(PROMPT_TITLE), `第一行不是标题;开头是:${text.slice(0, 30)}`)
149
149
  assert.equal(text.split('\n')[0], PROMPT_TITLE, '标题必须在最前面 —— 它要解释后面的规则')
@@ -25,7 +25,7 @@ export interface PromptExtData {
25
25
  text?: Record<string, PromptCategoryOverride>
26
26
  /**
27
27
  * 用户自建的准则条目。**与内置的 `PROMPT_CATEGORIES` 分开存** ——
28
- * 内置那 14 条是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
28
+ * 内置条目是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
29
29
  * 落盘里又有一条"的归属问题:升级时新加的内置条目会和用户的旧数据打架。
30
30
  *
31
31
  * 存的是**完整条目**(不是覆盖值):自建的东西没有"内置版本"可回退。
@@ -34,12 +34,23 @@ export interface PromptExtData {
34
34
  /**
35
35
  * 展示顺序:`[id, …]`,含内置与自建。
36
36
  *
37
- * ⚠️ **只存 id 顺序,不重排内置数组本身**:内置 14 条的次序是 `content.ts`
37
+ * ⚠️ **只存 id 顺序,不重排内置数组本身**:内置条目的次序是 `content.ts`
38
38
  * 里的代码顺序,它同时是"准则作为一份文档的阅读顺序";让用户拖动去改它,
39
39
  * 等于让代码顺序随某个用户的操作漂移 —— 别人拿到新版本看到的次序就变了。
40
40
  * 落盘只记"这次会话里按什么顺序显示",代码顺序永远是兜底。
41
41
  */
42
42
  order?: string[]
43
+ /**
44
+ * 段落级开关:`{ [sectionName]: boolean }`,**只存关闭项**(`false` = 关)。
45
+ *
46
+ * 缺项 = 开(默认全开)——与 `text` 的"只存覆盖值"同一纪律:宿主的段落随
47
+ * 宿主/别的插件增删,存全文表就得在升级后逐条对账;只记"用户主动关了谁"
48
+ * 让新增段落自然落回默认(开),用户的选择不会被静默迁移。
49
+ *
50
+ * key 是**段的注册名**(如 `tool:write`),不是控制条 id(`sec:` 前缀是
51
+ * 传输层编码,落盘用裸名,见 sections.ts)。
52
+ */
53
+ sections?: Record<string, boolean>
43
54
  }
44
55
 
45
56
  /** 一条分类上用户改过的字段(只记改了的那些)。 */
@@ -103,7 +114,7 @@ export function resolveEnabledCategories(
103
114
  * 渲染成**一个**段落文本(多分类拼一起)。
104
115
  *
105
116
  * 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
106
- * 是一段连续的准则 —— 分成 14 个 section 会让 KV cache 每个分类各占一段、
117
+ * 是一段连续的准则 —— 分成多个 section 会让 KV cache 每个分类各占一段、
107
118
  * 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
108
119
  *
109
120
  * @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
@@ -128,10 +139,10 @@ export function renderCategories(
128
139
  // 且只在**至少有一条规则开启**时才出现 —— 全关时整段返回空串被平台丢弃,
129
140
  // 留着标题就变成"有一套叫准则 v5 的东西"却一条规则都没有。
130
141
  // 标题放在**前面**:它要解释后面这些规则是什么。
131
- // ⚠️ **分类之间空一行**(2026-09-29 用户拍板)。单 `\n` 时 15 条规则在窄栏里
132
- // 密排成一块,肉眼看不出是 15 条独立的规则 —— 那正是"全都跑成一行"的观感
142
+ // ⚠️ **分类之间空一行**(2026-09-29 用户拍板)。单 `\n` 时各条规则在窄栏里
143
+ // 密排成一块,肉眼看不出是各自独立的规则 —— 那正是"全都跑成一行"的观感
133
144
  // 来源(实测系统提示词里 `\n` 一直都在,是**排版**问题不是**内容**问题)。
134
- // 空行让每条规则成为独立段落。代价是 13 个换行符(~13 token,可忽略),
145
+ // 空行让每条规则成为独立段落。代价只是分隔换行(十几个 token,可忽略),
135
146
  // 且不改变前缀位置 ⇒ KV cache 行为不变。
136
147
  // ⚠️ 标题由**显式参数**决定,**不靠 `categories === PROMPT_CATEGORIES` 这种
137
148
  // 引用相等**(2026-09-29 实测踩过):接入自建条目后调用方传的是
@@ -0,0 +1,148 @@
1
+ /**
2
+ * 段落总控的**接线**判据 —— 三件事必须真的串上,缺一件就静默失效:
3
+ * ① 段落进了 `controls`(面板看得见);
4
+ * ② 段开关落盘到 `data.sections`,且**不碰** `categories`(两套开关不串味);
5
+ * ③ `system-prompt/assemble` waterfall 真的把关闭的段删掉了(这是"可开关"
6
+ * 的唯一真判据 —— 只测 ①② 会漏掉"面板能点、但 prompt 里照旧")。
7
+ *
8
+ * ⚠️ 本文件里"真删段"的验证用**假宿主装配链**:注册一个监听器、手工跑一次
9
+ * waterfall(`ctx.waterfall` 真实现来自 cordis),而不是调真 `assemble()`。
10
+ * 理由:真 `assemble` 需要整套 `dsh-system-prompt` 服务与 scope 环境,
11
+ * 会把判据变成"宿主内部实现测试";而本插件的责任边界只是"监听器拿到
12
+ * assembly 后返回什么"。监听器的返回形状 = 宿主采信的形状(`return transformed`)。
13
+ */
14
+ import test from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
17
+ import { OWN_SECTION_NAME, SECTION_CONTROL_PREFIX } from './sections.ts'
18
+ import type { PromptExtData } from './render.ts'
19
+ import type { ExtStoreService } from '../ext/contract.ts'
20
+
21
+ /** 记录写盘的内存 store。 */
22
+ function fakeStore(initial: { enabled?: boolean; data?: PromptExtData } = {}): ExtStoreService & {
23
+ data: () => PromptExtData | undefined
24
+ writes: PromptExtData[]
25
+ } {
26
+ let data = initial.data
27
+ let enabled = initial.enabled ?? true
28
+ const writes: PromptExtData[] = []
29
+ return {
30
+ isEnabled: () => enabled,
31
+ // 真实签名带 id(`setEnabled(id, enabled)`)——第一版漏了它,tsc 才报
32
+ // "string 不能赋给 boolean"。桩的形状必须与契约逐字一致,否则测的是
33
+ // 一个不存在的接口。
34
+ setEnabled: (_id: string, v: boolean) => { enabled = v },
35
+ readData: <T>() => data as T | undefined,
36
+ writeData: (_id, d) => { data = d as PromptExtData; writes.push(d as PromptExtData) },
37
+ data: () => data,
38
+ writes,
39
+ }
40
+ }
41
+
42
+ const SAMPLE = [
43
+ { name: 'harness:identity', text: 'You are an AI agent.' },
44
+ { name: 'tool:write', text: 'write tool spec' },
45
+ { name: 'tool:edit', text: 'edit tool spec' },
46
+ ]
47
+
48
+ test('★ 段落以 `sec:` 前缀进 controls,带组名、原文、且不可拖不可编辑', () => {
49
+ const ext = createPromptExt({
50
+ isSystemPromptReady: () => true,
51
+ getSections: () => SAMPLE,
52
+ store: fakeStore(),
53
+ })
54
+ const secRows = (ext.controls ?? []).filter((c) => c.id.startsWith(SECTION_CONTROL_PREFIX))
55
+ assert.equal(secRows.length, 3, '段落没进 controls ⇒ 面板列不出来')
56
+ const write = secRows.find((c) => c.id === `${SECTION_CONTROL_PREFIX}tool:write`)!
57
+ assert.equal(write.title, 'write', '标题应去掉组前缀')
58
+ assert.equal(write.detail, 'tool:write', '完整段名要能指认(报问题时用)')
59
+ assert.equal(write.body, 'write tool spec', '没原文 = 用户盲切')
60
+ assert.equal(write.group, '工具说明')
61
+ assert.equal(write.on, true, '默认全开')
62
+ assert.equal(write.sortable, false, '段 id 不在排序落盘范围,可拖就是假交互')
63
+ assert.notEqual(write.editable, true, '别人的段不给改写入口,只开关')
64
+ })
65
+
66
+ test('准则分类仍在 controls 前列,且**不带** group(页头已是它的组头)', () => {
67
+ const ext = createPromptExt({
68
+ isSystemPromptReady: () => true,
69
+ getSections: () => SAMPLE,
70
+ store: fakeStore(),
71
+ })
72
+ const rows = ext.controls ?? []
73
+ const plain = rows.filter((c) => c.group === undefined)
74
+ assert.ok(plain.length > 0, '准则分类丢了 ⇒ 原功能被新功能挤掉')
75
+ assert.ok(plain.every((c) => c.editable === true), '准则分类应保持可编辑')
76
+ assert.equal(rows[0]!.group, undefined, '列表首条应是准则(无组节渲染在最前)')
77
+ })
78
+
79
+ test('★ 段开关落盘到 data.sections:关 = 存 false,开 = 删键(只存覆盖值)', () => {
80
+ const store = fakeStore()
81
+ const ext = createPromptExt({
82
+ isSystemPromptReady: () => true,
83
+ getSections: () => SAMPLE,
84
+ store,
85
+ })
86
+ assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, false), true)
87
+ assert.deepEqual(store.data()?.sections, { 'tool:write': false })
88
+ // 开回来 ⇒ 键消失(不留 `true`,否则落盘会随用户点击无限膨胀)
89
+ assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, true), true)
90
+ assert.equal(store.data()?.sections, undefined, '全开后仍留着 sections 键')
91
+ })
92
+
93
+ test('★ 段开关不碰分类开关(两套开关各自一个键)', () => {
94
+ const store = fakeStore({ data: { categories: { identity: false } } })
95
+ const ext = createPromptExt({
96
+ isSystemPromptReady: () => true,
97
+ getSections: () => SAMPLE,
98
+ store,
99
+ })
100
+ ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, false)
101
+ assert.deepEqual(store.data()?.categories, { identity: false }, '写段开关把分类开关冲掉了')
102
+ assert.deepEqual(store.data()?.sections, { 'tool:write': false })
103
+ // 反向:写分类开关不能冲掉段开关
104
+ ext.setControl!('identity', true)
105
+ assert.deepEqual(store.data()?.sections, { 'tool:write': false }, '写分类开关把段开关冲掉了')
106
+ })
107
+
108
+ test('注入:清单外的段 id 拒写(旧数据/手改 ext.json 不该被面板认成有效开关)', () => {
109
+ const store = fakeStore()
110
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
111
+ assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, false), false)
112
+ assert.equal(store.data(), undefined, '拒了却写了盘')
113
+ })
114
+
115
+ test('★ 自己的准则段拒绝被当成段落开关(同一件事不能有两个开关)', () => {
116
+ const store = fakeStore()
117
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => SAMPLE, store })
118
+ assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}${OWN_SECTION_NAME}`, false), false)
119
+ // 即使清单源里混进了它,controls 也不列
120
+ const ext2 = createPromptExt({
121
+ isSystemPromptReady: () => true,
122
+ getSections: () => [{ name: OWN_SECTION_NAME, text: 'guideline' }, ...SAMPLE],
123
+ store,
124
+ })
125
+ const ids = (ext2.controls ?? []).map((c) => c.id)
126
+ assert.equal(ids.includes(`${SECTION_CONTROL_PREFIX}${OWN_SECTION_NAME}`), false,
127
+ '准则段被列了 ⇒ 面板上同一件事出现两个开关')
128
+ })
129
+
130
+ test('段清单为空 ⇒ 不产出段条目(宿主没段/预热没完成时面板不显示空组)', () => {
131
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store: fakeStore() })
132
+ assert.equal((ext.controls ?? []).some((c) => c.id.startsWith(SECTION_CONTROL_PREFIX)), false)
133
+ })
134
+
135
+ test('段条目的 on 反映已存关闭项(面板刷新要看到真实状态)', () => {
136
+ const ext = createPromptExt({
137
+ isSystemPromptReady: () => true,
138
+ getSections: () => SAMPLE,
139
+ store: fakeStore({ data: { sections: { 'tool:edit': false } } }),
140
+ })
141
+ const on = Object.fromEntries((ext.controls ?? []).map((c) => [c.id, c.on]))
142
+ assert.equal(on[`${SECTION_CONTROL_PREFIX}tool:edit`], false)
143
+ assert.equal(on[`${SECTION_CONTROL_PREFIX}tool:write`], true)
144
+ })
145
+
146
+ test('ext id 未漂移(落盘键跟着它,改了用户关段表就全丢)', () => {
147
+ assert.equal(EXT_PROMPT_ID, 'prompt')
148
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `system-prompt/assemble` waterfall 的**真删段**判据。
3
+ *
4
+ * ⚠️ 为什么这条闸门非有不可(本仓反复栽过的形状):面板能列、开关能落盘,
5
+ * 但"关掉之后模型到底还看不看得到这一段"是**另一件事**。只测前两者会得到一个
6
+ * 全绿的假象,而线上行为是"关了没用"。本仓同类教训:`docs` 里记的
7
+ * "apply 里 inject 不可靠 ⇒ 准则一次都没进过 prompt"(面板全正常、prompt 里没有)。
8
+ *
9
+ * 做法:用**真 cordis Context**(`ctx.on` + `ctx.waterfall` 真实现)跑一次
10
+ * 装配链,断言监听器返回的 assembly 里段真的少了。监听器的返回值就是宿主采信
11
+ * 的结果(宿主 `assemble()` 源码:`const transformed = await this.ctx.waterfall(...)`
12
+ * 后 `return transformed`),所以这里验的是**我们这一侧的责任边界**。
13
+ */
14
+ import test from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import { OWN_SECTION_NAME, disabledSectionNames, filterSections } from './sections.ts'
17
+ import type { PromptExtData } from './render.ts'
18
+
19
+ /** 装配出的一条段(宿主形状的最小面)。 */
20
+ interface Assembled {
21
+ name: string
22
+ text: string
23
+ }
24
+
25
+ /**
26
+ * 按 `index.ts` 里监听器的**同一形状**接一条装配链,返回 waterfall 的结果。
27
+ *
28
+ * ⚠️ 刻意复制那 6 行而不是从 `index.ts` 导出一个可测函数:`index.ts` 的
29
+ * 监听器需要真 `systemPrompt` service 才能注册(它读 `systemPromptOf(ctx)`),
30
+ * 为它造全套宿主桩会让判据变成"测桩"。这里验的是**逻辑链**:先取 next()、
31
+ * 再按落盘过滤;复制带来的漂移风险由"两边注释互相指认"兜住。
32
+ */
33
+ async function runAssembly(
34
+ ctx: { on: (n: string, f: unknown) => unknown; waterfall: (n: string, ...a: unknown[]) => Promise<unknown> },
35
+ opts: { enabled: boolean; data?: PromptExtData; sections: Assembled[] },
36
+ ): Promise<Assembled[]> {
37
+ ctx.on('system-prompt/assemble', async (
38
+ _assembly: unknown,
39
+ _context: unknown,
40
+ next: () => Promise<{ sections: Assembled[] }>,
41
+ ) => {
42
+ const result = await next()
43
+ if (!opts.enabled) return result
44
+ const off = disabledSectionNames(opts.data)
45
+ if (off.size === 0) return result
46
+ return { ...result, sections: filterSections(result.sections, off) }
47
+ })
48
+ // 真 waterfall:最后一个参数是终态回调(返回 assembly),前面的参数原样透传给监听器
49
+ // ⚠️ 派发的事件名必须与注册的**逐字相同**:我第一版这里写 'assemble',
50
+ // 而上面注册的是 'system-prompt/assemble' ⇒ 监听器根本没被调用,
51
+ // 断言报"关了却还在"。那种失败**看起来像功能没实现**,实际是判据自己
52
+ // 接错了线 —— 事件名是字符串、拼错不报错,只让闸门测了个空。
53
+ const out = await ctx.waterfall(
54
+ 'system-prompt/assemble',
55
+ { sections: opts.sections },
56
+ {},
57
+ async () => ({ sections: opts.sections }),
58
+ ) as { sections: Assembled[] }
59
+ return out.sections
60
+ }
61
+
62
+ const ALL: Assembled[] = [
63
+ { name: OWN_SECTION_NAME, text: '准则正文' },
64
+ { name: 'harness:identity', text: 'You are an AI agent.' },
65
+ { name: 'tool:write', text: 'write spec' },
66
+ { name: 'tool:edit', text: 'edit spec' },
67
+ ]
68
+
69
+ async function freshCtx() {
70
+ const { Context } = await import('@deepseek-ai/cordis')
71
+ const ctx = new Context()
72
+ return ctx as unknown as {
73
+ on: (n: string, f: unknown) => unknown
74
+ waterfall: (n: string, ...a: unknown[]) => Promise<unknown>
75
+ }
76
+ }
77
+
78
+ test('★ 关掉的段真的从装配结果里消失("可开关"的唯一真判据)', async () => {
79
+ const ctx = await freshCtx()
80
+ const out = await runAssembly(ctx, {
81
+ enabled: true,
82
+ data: { sections: { 'tool:write': false } },
83
+ sections: ALL,
84
+ })
85
+ const names = out.map((s) => s.name)
86
+ assert.equal(names.includes('tool:write'), false, '关了却还在 ⇒ 面板开关是装饰')
87
+ assert.equal(names.includes('tool:edit'), true, '没关的被误删 ⇒ 开关粒度错了')
88
+ })
89
+
90
+ test('★ 准则段永不被 waterfall 删掉(关它走的是总开关/分类开关)', async () => {
91
+ const ctx = await freshCtx()
92
+ const out = await runAssembly(ctx, {
93
+ enabled: true,
94
+ // 手改 ext.json 把准则段写进关闭项:也不许删
95
+ data: { sections: { [OWN_SECTION_NAME]: false, 'tool:write': false } },
96
+ sections: ALL,
97
+ })
98
+ const names = out.map((s) => s.name)
99
+ assert.equal(names.includes(OWN_SECTION_NAME), true, '准则段被删 ⇒ 分类开关全部失效')
100
+ assert.equal(names.includes('tool:write'), false)
101
+ })
102
+
103
+ test('★ 总开关关 ⇒ 原样放行(扩展关 = 什么都不做,宿主段落自然注入)', async () => {
104
+ const ctx = await freshCtx()
105
+ const out = await runAssembly(ctx, {
106
+ enabled: false,
107
+ data: { sections: { 'tool:write': false } },
108
+ sections: ALL,
109
+ })
110
+ assert.deepEqual(out.map((s) => s.name), ALL.map((s) => s.name),
111
+ '总开关关掉后仍然删段 ⇒ 用户关了扩展但提示词还在被改')
112
+ })
113
+
114
+ test('全开(落盘无关闭项)⇒ 一个段都不动', async () => {
115
+ const ctx = await freshCtx()
116
+ const out = await runAssembly(ctx, { enabled: true, sections: ALL })
117
+ assert.deepEqual(out.map((s) => s.name), ALL.map((s) => s.name))
118
+ })
119
+
120
+ test('关闭项全是失效名字 ⇒ 装配结果不受影响(宿主换版本后的旧数据)', async () => {
121
+ const ctx = await freshCtx()
122
+ const out = await runAssembly(ctx, {
123
+ enabled: true,
124
+ data: { sections: { 'gone:forever': false } },
125
+ sections: ALL,
126
+ })
127
+ assert.deepEqual(out.map((s) => s.name), ALL.map((s) => s.name))
128
+ })
129
+
130
+ test('多个监听器串联时我们的过滤不被前一个吞掉(waterfall 顺序语义)', async () => {
131
+ const ctx = await freshCtx()
132
+ // 先注册一个"什么都不做、原样返回"的旁观者
133
+ ctx.on('system-prompt/assemble', async (_a: unknown, _c: unknown, next: () => Promise<{ sections: Assembled[] }>) => next())
134
+ const out = await runAssembly(ctx, {
135
+ enabled: true,
136
+ data: { sections: { 'tool:edit': false } },
137
+ sections: ALL,
138
+ })
139
+ assert.equal(out.map((s) => s.name).includes('tool:edit'), false)
140
+ })
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 段落总控的**纯逻辑**判据(`sections.ts`)。
3
+ *
4
+ * 守三件事,每件都对着一种"静默失效":
5
+ * ① 控制条 id 与段名的**往返**(编码错了 ⇒ 面板点的开关落到别人的段上);
6
+ * ② 过滤**只删该删的**(自己那段恒放行、清单外的旧名字无害跳过);
7
+ * ③ 分组的**归组与次序**(组的先后 = prompt 次序,不能被字母序打乱)。
8
+ */
9
+ import test from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import {
12
+ OWN_SECTION_NAME,
13
+ SECTION_CONTROL_PREFIX,
14
+ createSectionCache,
15
+ disabledSectionNames,
16
+ filterSections,
17
+ groupLabelOf,
18
+ groupSections,
19
+ sectionControlId,
20
+ sectionNameOf,
21
+ sectionTitleOf,
22
+ } from './sections.ts'
23
+
24
+ test('控制条 id ↔ 段名往返(`sec:` 是传输层编码,落盘用裸名)', () => {
25
+ assert.equal(sectionControlId('tool:write'), `${SECTION_CONTROL_PREFIX}tool:write`)
26
+ assert.equal(sectionNameOf(sectionControlId('tool:write')), 'tool:write')
27
+ // 含冒号的段名不能被"按第一个冒号切"吃掉后半截
28
+ assert.equal(sectionNameOf(sectionControlId('mcp:my-server:tools')), 'mcp:my-server:tools')
29
+ })
30
+
31
+ test('非段条目(准则分类)不被当成段落 —— 否则分类开关会写到 segments 里', () => {
32
+ assert.equal(sectionNameOf('identity'), null)
33
+ assert.equal(sectionNameOf('cu-1'), null)
34
+ assert.equal(sectionNameOf(''), null)
35
+ })
36
+
37
+ test('★ 只认显式 false:缺项 = 没碰过 = 开(与分类开关同一纪律)', () => {
38
+ const off = disabledSectionNames({ sections: { 'tool:write': false, 'tool:read': true } })
39
+ assert.equal(off.has('tool:write'), true)
40
+ assert.equal(off.has('tool:read'), false, 'true 是"开着",不该算进关闭集合')
41
+ assert.equal(disabledSectionNames(undefined).size, 0, '没记录 = 全开')
42
+ assert.equal(disabledSectionNames({}).size, 0)
43
+ })
44
+
45
+ test('★ 过滤只删关闭的段,且保持原序', () => {
46
+ const all = [{ name: 'a' }, { name: 'b' }, { name: 'c' }]
47
+ assert.deepEqual(filterSections(all, new Set(['b'])).map((s) => s.name), ['a', 'c'])
48
+ assert.deepEqual(filterSections(all, new Set()).map((s) => s.name), ['a', 'b', 'c'])
49
+ })
50
+
51
+ test('★ 自己的准则段恒放行(它已有总开关+分类开关,不能再被段开关关一次)', () => {
52
+ // 即使落盘里被写进了关闭项(手改 ext.json),也不许把它过滤掉
53
+ const all = [{ name: OWN_SECTION_NAME }, { name: 'tool:write' }]
54
+ const kept = filterSections(all, new Set([OWN_SECTION_NAME, 'tool:write']))
55
+ // 准则段必须留下、被关的 tool:write 必须走 —— 两个方向都断言,
56
+ // 只断"准则段在"的话,过滤器退化成"全都放行"也能过。
57
+ assert.deepEqual(kept.map((s) => s.name), [OWN_SECTION_NAME],
58
+ '准则段被过滤 ⇒ 分类开关全部看起来失效')
59
+ })
60
+
61
+ test('清单外的旧名字无害:宿主换版本后旧关闭项不该报错', () => {
62
+ assert.doesNotThrow(() => filterSections([{ name: 'a' }], new Set(['gone:forever'])))
63
+ })
64
+
65
+ test('缓存:自己的段不进清单,且返回值是拷贝(调用方改不动缓存)', () => {
66
+ const cache = createSectionCache()
67
+ cache.update([{ name: OWN_SECTION_NAME, text: 'x' }, { name: 'tool:write', text: 'write spec' }])
68
+ const list = cache.list()
69
+ assert.deepEqual(list.map((e) => e.name), ['tool:write'], '准则段进了清单 ⇒ 面板多一个重复开关')
70
+ list[0]!.text = 'mutated'
71
+ assert.equal(cache.list()[0]!.text, 'write spec', 'list() 返回内部引用 ⇒ 面板改动污染后续读')
72
+ })
73
+
74
+ test('分组:组的先后 = 首次出现序(不按字母重排)', () => {
75
+ const groups = groupSections([
76
+ { name: 'deployment:persona-prefix', text: '' },
77
+ { name: 'tool:bash', text: '' },
78
+ { name: 'tool:edit', text: '' },
79
+ { name: 'plan:policy', text: '' },
80
+ ])
81
+ assert.deepEqual(groups.map((g) => g.label), ['部署人设', '工具说明', '计划模式'])
82
+ assert.deepEqual(groups[1]!.items.map((i) => i.name), ['tool:bash', 'tool:edit'], '组内顺序被改 ⇒ 面板顺序与 prompt 顺序脱钩')
83
+ })
84
+
85
+ test('★ 分组落名以真宿主实测为准(2026-10-21,9 个段名逐个钉)', () => {
86
+ // 这组断言直接抄 3081 上真宿主返回的段名 —— 我第一版按"看起来像"的前缀
87
+ // 脑补过两处(harness / app),真机一看就对不上。
88
+ const actual: [string, string][] = [
89
+ ['deployment:persona-prefix', '部署人设'],
90
+ ['deployment:persona-suffix', '部署人设'],
91
+ ['tool:pty', '工具说明'],
92
+ ['tool:session-query', '工具说明'],
93
+ ['mcp:rea', 'MCP 服务'],
94
+ ['ui:deliverable-file-references', '界面与交付'],
95
+ ['mcp-resource-servers', 'MCP 资源'],
96
+ // ⚠️ 这两个**刻意不并入任何美名组**:`harness:source` 是运行环境说明
97
+ // (不是开场身份),`app:web-surface` 是 Web GUI 说明(不是"界面与交付",
98
+ // 那个组是给 `ui:` 的)。归错组比留真实前缀更误导。
99
+ ['harness:source', 'harness'],
100
+ ['app:web-surface', 'app'],
101
+ ]
102
+ for (const [name, label] of actual) {
103
+ assert.equal(groupLabelOf(name), label, `${name} 归组错了`)
104
+ }
105
+ })
106
+
107
+ test('已知前缀用中文组名、未知前缀原样、无冒号归「其他」', () => {
108
+ assert.equal(groupLabelOf('tool:write'), '工具说明')
109
+ assert.equal(groupLabelOf('plan:policy'), '计划模式')
110
+ // 宿主新加的段(映射表还没有):组名原样显示,条目照样能列能开关
111
+ assert.equal(groupLabelOf('web:surface'), 'web')
112
+ assert.equal(groupLabelOf('unknown-thing'), '其他')
113
+ })
114
+
115
+ test('条目标题去掉组前缀(组名已经给了语境)', () => {
116
+ assert.equal(sectionTitleOf('tool:write'), 'write')
117
+ assert.equal(sectionTitleOf('mcp:my-server'), 'my-server')
118
+ assert.equal(sectionTitleOf('mcp-resource-servers'), 'mcp-resource-servers', '无冒号时不能被切成空串')
119
+ })
120
+
121
+ test('空清单 ⇒ 空分组(不产出空壳组)', () => {
122
+ assert.deepEqual(groupSections([]), [])
123
+ })