dsh-router-core 0.4.1 → 0.4.3

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 (53) hide show
  1. package/README.md +18 -15
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +65 -3
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +298 -119
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +57 -0
  15. package/lib/types/ext-prompt/index.d.ts +53 -0
  16. package/lib/types/ext-prompt/logo.d.ts +17 -0
  17. package/lib/types/ext-prompt/mount.d.ts +51 -0
  18. package/lib/types/ext-prompt/plugin.d.ts +32 -0
  19. package/lib/types/ext-prompt/render.d.ts +105 -0
  20. package/lib/types/host-base-url.d.ts +31 -0
  21. package/lib/types/host-version.d.ts +7 -8
  22. package/lib/types/index.d.ts +1 -1
  23. package/lib/types/llm/adapter.d.ts +6 -1
  24. package/lib/types/shared.d.ts +33 -0
  25. package/package.json +17 -7
  26. package/src/client/EndpointTab.tsx +15 -18
  27. package/src/client/ExtDetail.tsx +552 -9
  28. package/src/client/index.tsx +7 -28
  29. package/src/client/router.css +132 -62
  30. package/src/ext/contract.ts +111 -0
  31. package/src/ext-prompt/assembly.test.ts +79 -0
  32. package/src/ext-prompt/content.ts +174 -0
  33. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  34. package/src/ext-prompt/index.ts +104 -0
  35. package/src/ext-prompt/locale/en.json +6 -0
  36. package/src/ext-prompt/locale/zh.json +6 -0
  37. package/src/ext-prompt/logo.svg +14 -0
  38. package/src/ext-prompt/logo.ts +18 -0
  39. package/src/ext-prompt/mount.test.ts +95 -0
  40. package/src/ext-prompt/mount.ts +79 -0
  41. package/src/ext-prompt/plugin.ts +223 -0
  42. package/src/ext-prompt/render.test.ts +256 -0
  43. package/src/ext-prompt/render.ts +216 -0
  44. package/src/ext-prompt/row.json +5 -0
  45. package/src/ext-route.test.ts +78 -0
  46. package/src/host-base-url.test.ts +129 -0
  47. package/src/host-base-url.ts +43 -0
  48. package/src/host-version.test.ts +21 -28
  49. package/src/host-version.ts +39 -39
  50. package/src/index.ts +230 -96
  51. package/src/llm/adapter.test.ts +19 -42
  52. package/src/llm/adapter.ts +32 -50
  53. package/src/shared.ts +34 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 提示词内容层 —— 准则 v5 的**逐字**副本,按原分隔拆成分类。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么一字不改
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 这些字是**用户自己写的准则**,不是本插件的文案。任何"顺手润色"都是擅自
8
+ * 改写用户的规则 —— 改出来的版本和用户心里的版本不一致,而用户看不见差异,
9
+ * 于是准则开始按**另一份**文本执行。所以这里是逐字复制,**不做任何措辞优化**。
10
+ *
11
+ * 拆分只为「可开关」,不为了重排:顺序与原文出现顺序一致,`order` 也按此递增。
12
+ *
13
+ * ⚠️ **本文件是唯一事实源**。准则原本存放在 Mnemon 记忆系统的 `USER.md`;
14
+ * 那份已经在插件验证生效后删除(否则新旧两份注入,每轮白烧一次 token 且
15
+ * 永远有两份可能漂移的规则)。**改动准则 = 只改这里。**
16
+ */
17
+
18
+ /** 一个可独立开关的提示词分类。 */
19
+ export interface PromptCategory {
20
+ /** 稳定 id:面板开关与 `router.extStore` 数据块的键,**改名会丢用户配置**。 */
21
+ readonly id: string
22
+ /** 面板显示名。 */
23
+ readonly title: string
24
+ /**
25
+ * 是否默认开启。
26
+ *
27
+ * 默认**关**的只有「可以按项目关掉」的那几条(领地/结构/交付/执行);
28
+ * 身份、底线、收口这类是准则的骨架,一律默认开 —— 默认关掉等于静默移除规则。
29
+ */
30
+ readonly defaultOn: boolean
31
+ /** 段落正文(逐字)。空串 = 该分类不产出任何 prompt 文本。 */
32
+ readonly body: string
33
+ }
34
+
35
+ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
36
+ {
37
+ id: 'identity',
38
+ title: '身份气质',
39
+ defaultOn: true,
40
+ body:
41
+ '身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;默认中文,直接给能跑的东西。',
42
+ },
43
+ {
44
+ id: 'scope',
45
+ title: '视野',
46
+ defaultOn: true,
47
+ body:
48
+ '视野:动手前看这块在整体里的位置与形状——属于哪块、并进去还是新建;只盯局部最优,加总就是全局最乱。',
49
+ },
50
+ {
51
+ id: 'ladder',
52
+ title: '懒人梯子',
53
+ defaultOn: true,
54
+ body:
55
+ '梯子:停在第一个成立的档——①真需要吗②库里有现成的③标准库④平台原生⑤已装依赖⑥一行⑦才写最少能跑的。' +
56
+ '改前读全被碰的代码、端到端走真实流程(从用户可达的入口进——绕过入口的验证=没验入口)、' +
57
+ 'grep 每个调用方(改契约后再 grep——改动会制造新的对齐义务)。',
58
+ },
59
+ {
60
+ id: 'worktree',
61
+ title: '领地',
62
+ defaultOn: true,
63
+ body:
64
+ '领地:一对话=一任务单元=一个 git worktree(唯一形态;分支/克隆是另外的情况),' +
65
+ '开工建、领地内做、收尾合并后删——边界必须物理;在主工作区直接开工是制造问题的第一步。',
66
+ },
67
+ {
68
+ id: 'closure',
69
+ title: '全局收口',
70
+ defaultOn: true,
71
+ body:
72
+ '收口(与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;' +
73
+ '②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位;④前三问都空时处置一件存量(删/并/归档)。' +
74
+ '做完那块区域该比动手前更整或更少。',
75
+ },
76
+ {
77
+ id: 'hardening',
78
+ title: '固化',
79
+ defaultOn: true,
80
+ body:
81
+ '固化:每次造成用户可见损失的失败,当天固化为可自动重放的检查——会变红(注入违规必须失败)、可重放(一条命令)、' +
82
+ '归属清晰(失败信息指向违规处);面向他人的检查只警告不阻断。不固化的教训等于决定再犯一次。',
83
+ },
84
+ {
85
+ id: 'verdifiable',
86
+ title: '可判定',
87
+ defaultOn: true,
88
+ body:
89
+ '可判定:只有清单和会变红的闸门生效,形容词不生效。新约束一律写成"动作+闸门"。',
90
+ },
91
+ {
92
+ id: 'root-cause',
93
+ title: '修根因',
94
+ defaultOn: true,
95
+ body:
96
+ '修 bug 修根因(改共享函数一次;只补工单点名的路径会留兄弟调用方继续坏)。',
97
+ },
98
+ {
99
+ id: 'tradeoff',
100
+ title: '取舍',
101
+ defaultOn: true,
102
+ body:
103
+ '取舍:同体积选边界正确的;复杂需求先反问「真要 X,还是 Y 就够了」。',
104
+ },
105
+ {
106
+ id: 'redline',
107
+ title: '底线',
108
+ defaultOn: true,
109
+ body:
110
+ '底线:不建没被要求的抽象;最短能跑的 diff 才算赢,前提是真看懂问题。砍掉的边角写明天花板+升级路径。' +
111
+ '绝不用不可逆的批量破坏命令(reset --hard/checkout ./stash/clean -fd)——无论工作区里是谁的工作,丢了就是丢了。',
112
+ },
113
+ {
114
+ id: 'structure',
115
+ title: '结构',
116
+ defaultOn: true,
117
+ body:
118
+ '结构:先让结构对再让代码短;命名/边界/单一职责优先于行数;依赖单向无环、公共逻辑收敛一处;' +
119
+ '单文件单功能,软顶函数 ~40 行/文件 ~300 行。',
120
+ },
121
+ {
122
+ id: 'no-cheap',
123
+ title: '绝不偷懒',
124
+ defaultOn: true,
125
+ body:
126
+ '绝不偷懒:边界输入校验、防数据丢失、安全、可访问性、真实硬件校准;非平凡逻辑留个能跑的检查。',
127
+ },
128
+ {
129
+ id: 'delivery',
130
+ title: '交付',
131
+ defaultOn: true,
132
+ body:
133
+ '交付:先说这次让整体变好了什么再说加了什么,至多三行。' +
134
+ '提交=精确暂存:暂存清单==本单元改动清单,逐文件 add,绝不用 add -A/.;暂存里出现不认识的文件就停——先查证再提交。',
135
+ },
136
+ {
137
+ id: 'execution',
138
+ title: '执行顺序',
139
+ defaultOn: true,
140
+ body:
141
+ '执行:拉记忆→看全貌→建领地→定位任务→查结构行号→声明「任务/文件/验证/提交」→读码取切片→' +
142
+ '循既有骨架→验证以闸门为准(混合状态下的绿灯不作数)→收口复查全貌→提交。',
143
+ },
144
+ ] as const
145
+
146
+ /**
147
+ * 整段的标题(**与分类无关**)。
148
+ *
149
+ * ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
150
+ * **不属于任何单条分类**,按分隔符切分时自然被丢掉
151
+ * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
152
+ * **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
153
+ *
154
+ * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
155
+ * 要关掉整个准则,用 ext 的总开关。
156
+ *
157
+ * ⚠️ 2026-09-29 用户把副题去掉(原 `[准则 v5 · 懒人梯子 + 全局收口 + 固化]`)——
158
+ * 那三个词是**内容**(三条纪律的名字),挂在标题上等于"标题在重复正文",
159
+ * 而正文里本来就有各自的分类标题。改成短标题,分类名留给下面每一行。
160
+ */
161
+ export const PROMPT_TITLE = '[准则 v5]'
162
+
163
+ /**
164
+ * 分类的默认开关状态。
165
+ *
166
+ * ⚠️ **已保存的配置优先**:用户在面板改过的分类以 `extStore` 里的为准,
167
+ * 这里只在「用户从没碰过这一类」时提供默认值。所以**改 `defaultOn` 不会
168
+ * 覆盖用户已有选择**(这是刻意的 —— 否则改默认值等于静默改用户配置)。
169
+ */
170
+ export function defaultToggles(): Record<string, boolean> {
171
+ const out: Record<string, boolean> = {}
172
+ for (const c of PROMPT_CATEGORIES) out[c.id] = c.defaultOn
173
+ return out
174
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * 分层提示词扩展(`router.ext` 侧)测试 —— 锁住四件事:
3
+ * 1. 登记进 `router.ext` 表,带上可显示的身份(名字/说明/就绪);
4
+ * 2. **幂等**:同一个 id 已在表里就不顶掉别人的登记;
5
+ * 3. `source: 'builtin'`(随核心分发 ⇒ 插件页的原生行,自绘节不重复列);
6
+ * 4. **准则段落真的挂上了** —— ext-test 没有这一条,本扩展多做执行面,必须验。
7
+ *
8
+ * 为什么第 4 条不可省:扩展的「卡片出现与否」取决于它在不在这张表里,而**准则
9
+ * 有没有进 prompt** 取决于段落挂没挂 —— 两者是**不同的失效**,都静默且都不报错。
10
+ * 只测登记会漏掉「卡片亮着、准则没进 prompt」这个最难查的情况。
11
+ */
12
+ import test from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { readFileSync } from 'node:fs'
15
+ import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
16
+ import { PROMPT_LOGO_URL } from './logo.ts'
17
+ import { apply, name as CORDIS_NAME, inject as CORDIS_INJECT } from './index.ts'
18
+ import { PROMPT_SECTION_NAME } from './mount.ts'
19
+ import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
20
+ import type { RouterExtService } from '../ext/contract.ts'
21
+
22
+ test('扩展器带上面板要用的身份', () => {
23
+ const ext = createPromptExt({ isSystemPromptReady: () => true })
24
+ assert.equal(ext.id, EXT_PROMPT_ID)
25
+ assert.equal(ext.name, '分层提示词')
26
+ assert.equal(ext.source, 'builtin', '随核心分发必须标 builtin,否则插件页会重复列它')
27
+ })
28
+
29
+ test('★ `description` 是整段标题(不是自我介绍,也不内插分类数)', () => {
30
+ // ⚠️ 这条断言**换了两次内容**,值得留档(2026-09-29):
31
+ // ① 最初写的是「没有说明,插件页那一行就没有副标题」——**前提是错的**,
32
+ // 那一行的副标题来自 `locale/*.json`,与 `description` 无关;
33
+ // ② 改成「`description` 必须是 undefined」(当时用户嫌「拆成 14 个分类」冗余);
34
+ // ③ 现在又要求**顶部显示标题** ⇒ 改成「等于 PROMPT_TITLE」。
35
+ // 两次反复的共同教训:**默认值/文案该是什么由用户拍板,判据只负责让改动可见**,
36
+ // 所以断言跟着改、并在注释里留着反复,免得下一个人以为是"又改坏了"。
37
+ const ext = createPromptExt({ isSystemPromptReady: () => true })
38
+ assert.equal(ext.description, PROMPT_TITLE, '详情页顶部要显示的就是整段标题')
39
+ // ⚠️ 尤其**不许**内插 `${PROMPT_CATEGORIES.length}`:加减分类后文案不跟着变,
40
+ // 就是同一件事的两个来源(「反查重复」)。
41
+ assert.doesNotMatch(String(ext.description), /\d+\s*个/,
42
+ 'description 里内插了分类数量 ⇒ 加减分类后会与真实数量漂移')
43
+ })
44
+
45
+ test('★ 插件页那一行的说明仍然在(来自 locale,不是 description)', () => {
46
+ // 「删 description」不等于「把说明也删了」:宿主读的是 locale 资源。
47
+ // 只写 host 半边的话,这一行会**没有副标题** —— 而不打开就不知道它是干什么的。
48
+ const locale = JSON.parse(
49
+ readFileSync(new URL('./locale/zh.json', import.meta.url), 'utf8'),
50
+ ) as { meta?: { title?: string; description?: string } }
51
+ assert.ok((locale.meta?.title ?? '').length > 0, 'locale 缺 title')
52
+ assert.ok((locale.meta?.description ?? '').length > 0, '插件页那一行没有副标题')
53
+ })
54
+
55
+ test('注入:systemPrompt 不可用 ⇒ 报 not ready(不能静默不生效)', () => {
56
+ const ext = createPromptExt({ isSystemPromptReady: () => false })
57
+ const st = ext.getState()
58
+ assert.equal(st.ready, false, '无处注入却报就绪 = 卡片亮着、准则没进 prompt')
59
+ assert.ok((st.detail ?? '').length > 0, '不报还不说原因 = 面板上无从判断')
60
+ })
61
+
62
+ test('cordis 身份与 patch 里声明的 id 逐字相同(否则 loader 认不出这一行)', () => {
63
+ assert.equal(CORDIS_NAME, 'dsh-router-ext-prompt')
64
+ })
65
+
66
+ test('★ patch 里真的声明了这一行,且 id/name 逐字对得上', () => {
67
+ // ⚠️ **必须真读 patch 文件**:只断言自己的常量的话,patch 写错了照样全绿
68
+ // —— 判据不碰被它守的东西,等于没守。
69
+ // 症状会非常隐蔽:构建通过、测试通过、装上后插件页**没有这一行**。
70
+ const patch = readFileSync(new URL('../../cordis.patch.yml', import.meta.url), 'utf8')
71
+ const block = patch.match(
72
+ new RegExp(`- id: ${CORDIS_NAME}\\s*\\n\\s*name: '([^']+)'(\\s*\\n\\s*(disabled:.*)?)?\\n`),
73
+ )
74
+ assert.ok(block, `cordis.patch.yml 里没有 id=${CORDIS_NAME} 的行`)
75
+ assert.equal(block![1], 'dsh-router-core/ext-prompt', 'patch 的 name 必须是能解析的子路径说明符')
76
+ })
77
+
78
+ test('★ 那一行默认关闭(与 ext-test 同款)', () => {
79
+ // `disabled: true` = 行默认关 ⇒ loader 不 import ⇒ 扩展不进 `router.ext` 表 ⇒
80
+ // 卡片不出现、准则不进 prompt。
81
+ //
82
+ // ⚠️ **这里来回改过一次**(2026-09-29):先按"准则是基础设施"改成默认开启,
83
+ // 又改回默认关闭。**用户对默认值的取舍优先于我的判断** —— 判据跟着改,
84
+ // 不是反过来。注释里留着这次反复,是为了让下一个人知道它**曾经**是默认开、
85
+ // 以及为什么现在不是(不是"忘了改回来")。
86
+ const patch = readFileSync(new URL('../../cordis.patch.yml', import.meta.url), 'utf8')
87
+ const block = patch.match(
88
+ new RegExp(`- id: ${CORDIS_NAME}[^]*?\\n(\\s*name: '([^']+)'\\n([^\\n]*))`),
89
+ )
90
+ assert.ok(block, 'patch 里找不到这一行')
91
+ assert.match(block![3] ?? '', /disabled:\s*true/,
92
+ '这一行不带 disabled: true ⇒ 装上就自动注入准则(与用户当前拍板相反)')
93
+ })
94
+
95
+ /**
96
+ * 真 cordis 夹具 —— **不用假 ctx**(2026-09-29 换掉)。
97
+ *
98
+ * ⚠️ 上一版的假 ctx `inject` 会**立即**回调 apply 里注册的回调。那恰好把真正的
99
+ * bug 掩盖掉了:`ctx.inject(deps, cb)` 对已就绪的 service 其实要等
100
+ * `ctx.start()` 之后才回调,所以旧实现里准则一次都没挂上,而假 ctx 全绿。
101
+ * ⇒ 判据的替身比被守的东西更宽容 = 判据无效。
102
+ * 真 cordis 会如实复现时序(本机 4.0.4 实测:provide 后 inject 不触发,
103
+ * start() 之后才触发)。
104
+ */
105
+ async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
106
+ const { Context } = await import('@deepseek-ai/cordis')
107
+ const table: RouterExtService = {}
108
+ const sections: { name: string; order: number; text: string | (() => string) }[] = []
109
+ const ctx = new Context()
110
+ ctx.provide('systemPrompt', {
111
+ section: (s: { name: string; order: number; text: string | (() => string) }) => {
112
+ sections.push(s)
113
+ return () => {}
114
+ },
115
+ getSectionOrder: () => 100,
116
+ })
117
+ ctx.provide('router.ext', table)
118
+ ctx.provide('router.extStore', {
119
+ isEnabled: () => opts.enabled ?? true,
120
+ setEnabled: () => {},
121
+ readData: () => opts.data,
122
+ writeData: () => {},
123
+ })
124
+ return { ctx, table, sections }
125
+ }
126
+
127
+ test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', async () => {
128
+ const { ctx, table } = await realCtx()
129
+ apply(ctx as unknown as Parameters<typeof apply>[0])
130
+ assert.equal(table[EXT_PROMPT_ID]?.name, '分层提示词')
131
+ })
132
+
133
+ test('幂等:同一个 id 已在表里时不顶掉别人的登记', async () => {
134
+ const { ctx, table } = await realCtx()
135
+ apply(ctx as unknown as Parameters<typeof apply>[0])
136
+ const first = table[EXT_PROMPT_ID]
137
+ apply(ctx as unknown as Parameters<typeof apply>[0])
138
+ assert.equal(table[EXT_PROMPT_ID], first, '重复登记不该换成一个新对象')
139
+ })
140
+
141
+ test('★ apply 把准则挂成了 system prompt 段落(ext-test 没这层,必须单独验)', async () => {
142
+ const { ctx, sections } = await realCtx()
143
+ apply(ctx as unknown as Parameters<typeof apply>[0])
144
+ assert.equal(sections.length, 1, '准则没挂上 = 卡片亮着但 prompt 里没有准则')
145
+ assert.equal(sections[0]!.name, PROMPT_SECTION_NAME)
146
+ assert.equal(typeof sections[0]!.text, 'function', '静态文本 ⇒ 分类开关永远不生效')
147
+ })
148
+
149
+ test('注入:总开关关时段落文本为空(平台据此丢弃该段)', async () => {
150
+ const { ctx, sections } = await realCtx({ enabled: false })
151
+ apply(ctx as unknown as Parameters<typeof apply>[0])
152
+ const text = (sections[0]!.text as () => string)()
153
+ assert.equal(text, '', '总开关关时必须返回空串,而不是注入一段空白')
154
+ })
155
+
156
+ test('注入:用户关掉某分类 ⇒ 该分类不进 prompt(可开关的正向判据)', async () => {
157
+ const { ctx, sections } = await realCtx({ enabled: true, data: { categories: { identity: false } } })
158
+ apply(ctx as unknown as Parameters<typeof apply>[0])
159
+ const text = (sections[0]!.text as () => string)()
160
+ assert.equal(text.includes('大肥鱼'), false, 'identity 关了就不该出现')
161
+ assert.ok(text.includes('视野'), '其他分类照常')
162
+ })
163
+
164
+ // ── 面板编辑文本(2026-09-29)────────────────────────────────
165
+ test('★ 每条都标 editable,面板才知道该不该给「修改」按钮', () => {
166
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store: fakeStore({ categories: {} }) })
167
+ for (const c of ext.controls ?? []) assert.equal(c.editable, true, `${c.id} 没标 editable`)
168
+ assert.equal(typeof ext.setControlText, 'function', '没有 setControlText ⇒ 面板点了必然 400')
169
+ })
170
+
171
+ test('★ 编辑后 controls 立刻反映新标题/新正文(每次读都重算)', () => {
172
+ const store = fakeStore({ categories: {}, text: { identity: { title: '我是谁', body: '我是一个测试。' } } })
173
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
174
+ const c = (ext.controls ?? []).find((x) => x.id === 'identity')!
175
+ assert.equal(c.title, '我是谁')
176
+ assert.equal(c.body, '我是一个测试。')
177
+ })
178
+
179
+ test('★ controls 不是快照:改完之后**再读一次**要看到新值', () => {
180
+ // ⚠️ 2026-09-29 实测踩的 bug:`controls: CATEGORIES.map(...)` 那个 map 在
181
+ // `createPromptExt()` 时就跑完了 ⇒ 编辑落盘后 prompt 变了、**面板拿到的还是
182
+ // 旧标题**(用户看到"保存了又变回去")。而我当时注释写着"现算不缓存"。
183
+ // ⇒ 判据必须**跨两次读**才抓得住(只读一次的写法抓不到)。
184
+ const store = fakeStore({ categories: {} })
185
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
186
+ const before = (ext.controls ?? []).find((x) => x.id === 'ladder')!
187
+ assert.equal(before.title, '懒人梯子')
188
+ ext.setControlText?.('ladder', { title: '我的梯子' })
189
+ const after = (ext.controls ?? []).find((x) => x.id === 'ladder')!
190
+ assert.equal(after.title, '我的梯子', 'controls 是快照 ⇒ 面板刷新后编辑内容会「变回去」')
191
+ })
192
+
193
+ test('setControlText 只写覆盖值,且不碰 categories(同块的另一种数据)', () => {
194
+ const store = fakeStore({ categories: { identity: false }, keep: 'me' })
195
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
196
+ assert.equal(ext.setControlText?.('identity', { title: '新标题' }), true)
197
+ const after = store.readData<Record<string, unknown>>('')
198
+ assert.deepEqual(after?.categories, { identity: false }, '改标题把开关状态冲掉了')
199
+ assert.equal(after?.keep, 'me', '把 data 抽屉里别的字段抹了')
200
+ assert.deepEqual(after?.text, { identity: { title: '新标题' } }, '只该存改过的字段')
201
+ })
202
+
203
+ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢别人的)', () => {
204
+ // ⚠️ 注入验证过:`text: { [id]: patch }` 这种写法**全绿** —— 它的破坏
205
+ // 只在"用户改过两条以上"时显形,而单人单条测试看不出来。
206
+ const store = fakeStore({
207
+ categories: {},
208
+ text: { identity: { title: 'A' }, scope: { body: 'B' } },
209
+ })
210
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
211
+ ext.setControlText?.('ladder', { body: '新内容' })
212
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
213
+ identity: { title: 'A' },
214
+ scope: { body: 'B' },
215
+ ladder: { body: '新内容' },
216
+ }, '改一条把别人覆盖的抹了')
217
+ })
218
+
219
+ test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,不是替换)', () => {
220
+ const store = fakeStore({ categories: {}, text: { identity: { title: 'A' } } })
221
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
222
+ ext.setControlText?.('identity', { body: 'B' })
223
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
224
+ identity: { title: 'A', body: 'B' },
225
+ })
226
+ })
227
+
228
+ test('注入:空标题/空正文返回 false(不写盘)', () => {
229
+ const store = fakeStore({ categories: {} })
230
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
231
+ for (const bad of [{ title: ' ' }, { title: '' }, { body: '' }]) {
232
+ assert.equal(ext.setControlText?.('identity', bad), false, `空串被接受了:${JSON.stringify(bad)}`)
233
+ }
234
+ assert.equal(store.readData<Record<string, unknown>>('')?.text, undefined, '失败的写入留下了痕迹')
235
+ })
236
+
237
+ test('注入:未知 controlId 返回 false', () => {
238
+ const store = fakeStore({ categories: {} })
239
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
240
+ assert.equal(ext.setControlText?.('__evil__', { title: 'x' }), false)
241
+ })
242
+
243
+ /**
244
+ * 假 store:**整块替换**语义,与真实的 `ExtStore.writeData` 一致
245
+ * (`this.byId[id] = { …, data: value }`)。合并的替身会让「只改一个键」与
246
+ * 「整块重写」观测上完全一样 ⇒ 注入违规时测试照样全绿(2026-09-29 栽过)。
247
+ */
248
+ function fakeStore(initial: Record<string, unknown> | undefined) {
249
+ const box = { data: initial }
250
+ return {
251
+ isEnabled: () => true,
252
+ setEnabled: () => {},
253
+ readData: <T,>(_id: string) => box.data as T | undefined,
254
+ writeData: (_id: string, value: unknown) => { box.data = value as Record<string, unknown> },
255
+ } as unknown as import('../ext/contract.ts').ExtStoreService
256
+ }
257
+
258
+ // ── 排序 / 自建 / 还原 / 删除(2026-09-29)──────────────────
259
+ test('★ controls 走合成列表:自建条目出现、标 custom、内置标 overridden', () => {
260
+ const store = fakeStore({
261
+ categories: {},
262
+ custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
263
+ text: { ladder: { body: '改过的' } },
264
+ })
265
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
266
+ const list = ext.controls ?? []
267
+ assert.equal(list.length, PROMPT_CATEGORIES.length + 1, '自建条目没出现')
268
+ assert.equal(list.find((c) => c.id === 'cu-1')?.custom, true)
269
+ assert.equal(list.find((c) => c.id === 'ladder')?.overridden, true, '改过的内置条目要标 overridden(还原按钮可点)')
270
+ assert.equal(list.find((c) => c.id === 'identity')?.overridden, false, '没改过的不该标(还原按钮置灰)')
271
+ })
272
+
273
+ test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)', () => {
274
+ const store = fakeStore({ categories: {} })
275
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
276
+ const id = ext.addCustomControl?.('我的准则', '做完了就收。')
277
+ assert.ok(id !== null && typeof id === 'string', '没返回新 id')
278
+ assert.notEqual(id, 'identity', '新 id 撞上了内置条目')
279
+ assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '落盘后没出现在 controls 里')
280
+ })
281
+
282
+ test('注入:空标题/空正文新增 ⇒ 返回 null(不写盘)', () => {
283
+ const store = fakeStore({ categories: {} })
284
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
285
+ assert.equal(ext.addCustomControl?.(' ', 'x'), null)
286
+ assert.equal(ext.addCustomControl?.('x', ''), null)
287
+ assert.equal(store.readData<Record<string, unknown>>('')?.custom, undefined, '失败的写入留下痕迹')
288
+ })
289
+
290
+ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并清干净关联数据', () => {
291
+ const store = fakeStore({
292
+ categories: { 'cu-1': true, identity: false },
293
+ text: { 'cu-1': { body: 'x' } },
294
+ order: ['cu-1', 'ladder'],
295
+ custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
296
+ })
297
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
298
+ assert.equal(ext.removeCustomControl?.('identity'), false, '内置条目不该被删(它是代码的一部分)')
299
+ assert.equal(ext.removeCustomControl?.('ladder'), false, '不存在的条目应被拒')
300
+ assert.equal(ext.removeCustomControl?.('cu-1'), true)
301
+ const after = store.readData<Record<string, unknown>>('') ?? {}
302
+ assert.deepEqual(after.custom, [], '自建条目没删掉')
303
+ assert.deepEqual(after.categories, { identity: false }, '删一条把别的开关状态弄丢了')
304
+ assert.deepEqual(after.order, ['ladder'], 'order 里留了幽灵 id')
305
+ })
306
+
307
+ test('★ 还原:只对被改过的内置条目成功,且回到内置文本', () => {
308
+ const store = fakeStore({ categories: {}, text: { ladder: { body: '改过的' } } })
309
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
310
+ assert.equal(ext.resetControlText?.('identity'), false, '没改过的内置条目不该「可还原」')
311
+ assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目没有「内置版本」可回退')
312
+ assert.equal(ext.resetControlText?.('ladder'), true)
313
+ // ⚠️ 必须用 `deepEqual`:`assert.equal` 比的是**引用**,对象永远不等 ——
314
+ // 我第一版就写成这样,判据一直红而代码是对的。
315
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {}, '覆盖没被丢掉')
316
+ assert.equal((ext.controls ?? []).find((c) => c.id === 'ladder')?.overridden, false)
317
+ })
318
+
319
+ test('★ 排序:只存 id,且过滤掉不存在的 id(脏数据不留幽灵)', () => {
320
+ const store = fakeStore({ categories: {} })
321
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
322
+ assert.equal(ext.setControlOrder?.(['__ghost__', 'ladder', 'ladder']), true)
323
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.order, ['ladder'], '幽灵/重复 id 没被过滤')
324
+ })
325
+
326
+ // ── 真实初始状态:`ext.json` 里只有 `{enabled:true}`,没有 `data` ──
327
+ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () => {
328
+ // ⚠️ 这是 2026-09-30 实测的真 bug:`ext.json` 里 `prompt` 只有 `{enabled:true}`
329
+ // (用户没改过任何东西 ⇒ 没有 `data` 键),而我把 `readData()` 返回 undefined
330
+ // 当成"存储不可用"直接拒 ⇒ 「添加准则」永远报 `title and body are required`
331
+ // ——而 title/body 明明都传了,报错还指向错误的字段,排查成本极高。
332
+ const store = fakeStore(undefined) // **没有 data**
333
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
334
+ const id = ext.addCustomControl?.('我的准则', '做完就收。')
335
+ assert.ok(id !== null, '初始状态下添加失败(真实用户第一次用就是这个状态)')
336
+ assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '写盘了但 controls 里没有')
337
+ })
338
+
339
+ test('★ 四个写入口在"无 data"时都可用(不只是 add)', () => {
340
+ const store = fakeStore(undefined)
341
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
342
+ assert.equal(ext.setControl?.('ladder', false), true, '开关在初始状态下不可用')
343
+ assert.equal(ext.setControlText?.('ladder', { body: 'x' }), true, '改文本在初始状态下不可用')
344
+ assert.equal(ext.setControlOrder?.(['ladder']), true, '排序在初始状态下不可用')
345
+ })
346
+
347
+ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false', () => {
348
+ // ⚠️ 这个用例第一版**测不出来**:我的假 store 把 `readData: () => undefined`
349
+ // 写死了,于是 setControlText 写进去的东西**读不回来**,还原永远失败 ——
350
+ // 而代码是对的。**假 store 比被守的东西更宽容 = 判据无效**(第三次栽在这)。
351
+ // ⇒ 假 store 必须**真的读回自己写的东西**:`fakeStore` 的 `box` 已在做。
352
+ const store = fakeStore({ categories: {} })
353
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
354
+ assert.equal(ext.resetControlText?.('identity'), false, '没改过的却说可还原')
355
+ ext.setControlText?.('ladder', { body: '改过的' })
356
+ assert.equal(ext.resetControlText?.('ladder'), true, '改过的却还原不了')
357
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {}, '覆盖没被清掉')
358
+ })
359
+
360
+ // ── op 成功必须回整张表(2026-09-30)───────────────────────
361
+ test('★ 契约层:controls 上带 custom 标记(面板据此决定给删除还是还原)', () => {
362
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
363
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
364
+ const mine = (ext.controls ?? []).find((c) => c.id === 'cu-1')
365
+ const builtin = (ext.controls ?? []).find((c) => c.id === 'identity')
366
+ assert.equal(mine?.custom, true, '自建条目没标 custom ⇒ 面板给"还原"而不是"删除"')
367
+ assert.equal(builtin?.custom, false, '内置条目不该标 custom')
368
+ assert.notEqual(mine?.custom, undefined, 'custom 必须**显式**为 false,缺省与"未传"无法区分')
369
+ })
370
+
371
+ // ── 自建条目的写入口(2026-09-30 实测的「自定义开关报错」)──
372
+ test('★ 自建条目的开关可切(判据要用合成列表,不能只用内置常量)', () => {
373
+ // ⚠️ 真实 bug:改动前 `setControl` 用 `PROMPT_CATEGORIES.some(...)` 判存在性,
374
+ // 而自建条目的 id 不在内置常量里 ⇒ **恒被拒**,报 `unknown controlId`
375
+ // ⇒ 用户看到「自定义的开关控制报错」。而它**在 controls 列表里显示着** ——
376
+ // 列表用 `resolveCategories`、写入判据用 `PROMPT_CATEGORIES`,两处来源不一致。
377
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
378
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
379
+ assert.equal((ext.controls ?? []).some((c) => c.id === 'cu-1'), true, '列表里没有它')
380
+ assert.equal(ext.setControl?.('cu-1', false), true, '列表里有、却切不了')
381
+ assert.equal(store.readData<Record<string, unknown>>('')?.categories && (store.readData<{ categories: Record<string, boolean> }>('')?.categories?.['cu-1']), false, '开关没落盘')
382
+ })
383
+
384
+ test('★ 自建条目的文本可改(同一处来源不一致)', () => {
385
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
386
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
387
+ assert.equal(ext.setControlText?.('cu-1', { title: '新标题' }), true, '自建条目改不了标题')
388
+ assert.equal((ext.controls ?? []).find((c) => c.id === 'cu-1')?.title, '新标题')
389
+ })
390
+
391
+ test('还原仍然只允许内置条目(自建没有「内置版本」可回退)', () => {
392
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
393
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
394
+ assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目不该能被「还原」')
395
+ })
396
+
397
+ // ── 图标(2026-09-30)────────────────────────────────────────
398
+ test('★ 图标能被真实渲染(base64 必须解得出合法 SVG)', () => {
399
+ // ⚠️ 为什么需要这条:我第一版**手编**了一串 base64,解出来是乱码
400
+ // (`tept-Gradient`、`#17118=D`…)。而客户端的 `onError` 会把加载失败的图
401
+ // **静默藏掉**、退回默认闪电图标 ⇒ 页面"看起来正常",图标却根本没换 ——
402
+ // **编造的常量不会报错,这是它最险的地方**。
403
+ // ⇒ 判据钉住"能往返成 `<svg` 开头的合法文档"。
404
+ assert.ok(PROMPT_LOGO_URL.startsWith('data:image/svg+xml;base64,'), '必须是内联 data URI(离线可用)')
405
+ const decoded = Buffer.from(PROMPT_LOGO_URL.split(',')[1] ?? '', 'base64').toString('utf8')
406
+ assert.ok(decoded.trimStart().startsWith('<svg'), 'base64 解不出 SVG')
407
+ assert.ok(decoded.includes('</svg>'), 'SVG 没闭合')
408
+ // 属性引号必须成对:手编最容易在这里错(如我第一版那个 `fill="#17118=D"` ——
409
+ // 少一个引号,后面整段被当成属性值)。
410
+ // ⚠️ 检测的是**引号总数为偶数**,不是"引号后跟属性名" —— 属性之间本来就该有
411
+ // 空格(`width="64" height="64"`),我第一版正则把合法的也判成错位了。
412
+ assert.equal((decoded.match(/"/g) ?? []).length % 2, 0, 'SVG 属性引号不成对(手编 base64 的典型症状)')
413
+ assert.doesNotMatch(decoded, /tept|#17118=D/, '出现了已知的编造残留')
414
+ // ⚠️ 只检查"解得出 `<svg`"**不够**:`<svg></svg>` 语法完全合法、却什么都没画 ——
415
+ // 注入验证时它就是从这条判据下面溜过去的。⇒ 还要有**实际图元**。
416
+ assert.match(decoded, /<(rect|path|circle|g|line|polyline|polygon)\b/,
417
+ 'SVG 里没有任何图元 —— 合法但画不出东西(空壳图标等于没换)')
418
+ })
419
+
420
+ test('图标挂在 ext 上(不设的话面板退回默认闪电)', () => {
421
+ const ext = createPromptExt({ isSystemPromptReady: () => true })
422
+ assert.equal(ext.icon, PROMPT_LOGO_URL)
423
+ })
424
+
425
+ test('logo.svg 源文件与 base64 一致(改了图忘了重生成就会漂)', () => {
426
+ // 源文件是唯一可编辑的形态;base64 是它的派生物。两者不一致 = 有人改了 svg
427
+ // 却没重新生成 ⇒ 面板显示的还是旧图,而且**没有任何报错**。
428
+ const src = readFileSync(new URL('./logo.svg', import.meta.url), 'utf8')
429
+ const decoded = Buffer.from(PROMPT_LOGO_URL.split(',')[1] ?? '', 'base64').toString('utf8')
430
+ const norm = (s: string): string => s.replace(/\s+/g, ' ').trim()
431
+ assert.equal(norm(decoded), norm(src), 'logo.svg 改了但 base64 没重新生成')
432
+ })