dsh-structured 0.9.4 → 0.9.9

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.
@@ -1,582 +1,590 @@
1
- import assert from 'node:assert/strict'
2
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import test from 'node:test'
6
-
7
- import {
8
- BUILTIN_STYLE_TEXT,
9
- Config,
10
- GENUI_ROUTING_TEXT,
11
- MAX_SESSION_ENTRIES,
12
- TAIL_REMINDER_TEXT,
13
- activeStyle,
14
- apply,
15
- detectGenui,
16
- handleCommand,
17
- isEnabledFor,
18
- isSubagentContext,
19
- loadSessions,
20
- pruneSessions,
21
- safeSectionText,
22
- saveSessions,
23
- sessionIdOf,
24
- setEnabledFor,
25
- stateFile,
26
- statusText,
27
- styleFile,
28
- } from '../src/index.js'
29
- import { classify, firstScreenStructured } from '../scripts/measure-prose.mjs'
30
-
31
- function isolate() {
32
- const home = mkdtempSync(join(tmpdir(), 'dsh-structured-test-'))
33
- const saved = { dir: process.env.DSH_PROFILE_DIR, profile: process.env.DSH_PROFILE }
34
- process.env.DSH_HOME = home
35
- delete process.env.DSH_PROFILE_DIR
36
- delete process.env.DSH_PROFILE
37
- return {
38
- home,
39
- cleanup: () => {
40
- if (saved.dir === undefined) delete process.env.DSH_PROFILE_DIR
41
- else process.env.DSH_PROFILE_DIR = saved.dir
42
- if (saved.profile === undefined) delete process.env.DSH_PROFILE
43
- else process.env.DSH_PROFILE = saved.profile
44
- rmSync(home, { recursive: true, force: true })
45
- },
46
- }
47
- }
48
-
49
- function mainContext(sessionId) {
50
- return { agent: { id: sessionId, session: { id: sessionId, header: { delegationDepth: 0 } } } }
51
- }
52
-
53
- function fakeCtx() {
54
- const sections = []
55
- const commands = []
56
- const warnings = []
57
- const ctx = {
58
- logger: { warn: (...args) => warnings.push(args) },
59
- effect: (fn) => {
60
- const disposer = fn()
61
- return typeof disposer === 'function' ? disposer : () => {}
62
- },
63
- systemPrompt: {
64
- section: (section) => {
65
- sections.push(section)
66
- return () => {}
67
- },
68
- getSectionOrder: () => 9900,
69
- },
70
- commands: {
71
- register: (definition) => {
72
- commands.push(definition)
73
- return () => {}
74
- },
75
- },
76
- }
77
- return { ctx, sections, commands, warnings }
78
- }
79
-
80
- test('内置规则覆盖首句结论、组件、清晰度三件事', () => {
81
- assert.match(BUILTIN_STYLE_TEXT, /首句给结论/)
82
- assert.match(BUILTIN_STYLE_TEXT, /清单或表格/)
83
- assert.match(BUILTIN_STYLE_TEXT, /标题即结论/)
84
- assert.match(BUILTIN_STYLE_TEXT, /表格优先 ≤5 行/)
85
- assert.match(BUILTIN_STYLE_TEXT, /不设硬性篇幅上限/)
86
- assert.match(BUILTIN_STYLE_TEXT, /不为了字数删信息/)
87
- assert.match(BUILTIN_STYLE_TEXT, /过程叙事/)
88
- assert.match(BUILTIN_STYLE_TEXT, /推断/)
89
- assert.match(BUILTIN_STYLE_TEXT, /短答不套卡/)
90
- assert.match(BUILTIN_STYLE_TEXT, /代码\/补丁例外/)
91
- assert.match(BUILTIN_STYLE_TEXT, /不强制标题卡/)
92
- assert.match(TAIL_REMINDER_TEXT, /代码\/diff 回复例外/)
93
- assert.doesNotMatch(BUILTIN_STYLE_TEXT, /500\s*字|≤\s*500|字数上限/)
94
- assert.match(BUILTIN_STYLE_TEXT, /可复制内容用代码块/)
95
- assert.match(TAIL_REMINDER_TEXT, /加粗标题/)
96
- assert.match(TAIL_REMINDER_TEXT, /清晰优先/)
97
- assert.doesNotMatch(TAIL_REMINDER_TEXT, /500\s*字|≤\s*500|字数上限/)
98
- assert.match(BUILTIN_STYLE_TEXT, /判断/)
99
- assert.match(BUILTIN_STYLE_TEXT, /指标/)
100
- assert.match(BUILTIN_STYLE_TEXT, /风险/)
101
- assert.match(BUILTIN_STYLE_TEXT, /下一步/)
102
- assert.match(BUILTIN_STYLE_TEXT, /✅ 完成/)
103
- assert.match(GENUI_ROUTING_TEXT, /dsh-ui/)
104
- assert.match(TAIL_REMINDER_TEXT, /即使后文要用组件也一样/)
105
- assert.match(TAIL_REMINDER_TEXT, /不得用组件充当首卡/)
106
- assert.match(GENUI_ROUTING_TEXT, /不得用它的 title 充当首卡/)
107
- assert.match(GENUI_ROUTING_TEXT, /以本节为准/)
108
- })
109
-
110
- test('detectGenui:profile 清单不再作为 auto 依据,config 可强制', () => {
111
- const box = isolate()
112
- try {
113
- const profileDir = join(box.home, 'profiles', 'test')
114
- mkdirSync(profileDir, { recursive: true })
115
- writeFileSync(
116
- join(profileDir, 'package.json'),
117
- JSON.stringify({ dependencies: { '@changfenhuang/dsh-genui': '0.13.0' } }),
118
- 'utf8',
119
- )
120
- process.env.DSH_PROFILE = 'test'
121
- assert.equal(detectGenui({}), false)
122
- assert.equal(detectGenui({ genui: 'off' }), false)
123
- assert.equal(detectGenui({ genui: 'on' }), true)
124
- assert.equal(detectGenui({ genui: 'auto' }), false)
125
- } finally {
126
- box.cleanup()
127
- }
128
- })
129
-
130
- test('detectGenui:clientModules 实时图优先', () => {
131
- const ctx = {
132
- get: (name) =>
133
- name === 'clientModules'
134
- ? { clientPath: (id) => (id === 'genui' ? '/plugins/genui/client.js' : undefined) }
135
- : undefined,
136
- }
137
- assert.equal(detectGenui({}, ctx), true)
138
- assert.equal(detectGenui({ genui: 'off' }, ctx), false)
139
- })
140
-
141
- test('statusText 显示 GenUI 联动状态', () => {
142
- const box = isolate()
143
- try {
144
- const sessions = {}
145
- assert.match(
146
- statusText({ sessionId: 'session-a', sessions, defaultEnabled: true, genui: true }).toString(),
147
- /GenUI 联动:已检测/,
148
- )
149
- assert.match(
150
- statusText({ sessionId: 'session-a', sessions, defaultEnabled: true, genui: false }),
151
- /GenUI 联动:未检测/,
152
- )
153
- } finally {
154
- box.cleanup()
155
- }
156
- })
157
-
158
- test('Config:visual 默认开启,且可显式关闭', () => {
159
- const value = (x) => (x && typeof x.get === 'function' ? x.get() : x)
160
- const defaults = Config({})
161
- assert.equal(value(defaults.visual), true)
162
- assert.equal(value(defaults.defaultEnabled), true)
163
- assert.equal(value(defaults.tailReminder), true)
164
- assert.equal(value(defaults.includeSubagents), false)
165
- assert.equal(value(defaults.genui), 'auto')
166
- assert.equal(value(Config({ visual: false }).visual), false)
167
- assert.equal(value(Config({ defaultEnabled: false }).defaultEnabled), false)
168
- assert.match(
169
- statusText({ sessionId: 'session-a', sessions: {}, defaultEnabled: true, visual: false }),
170
- /视觉层:已关闭/,
171
- )
172
- })
173
-
174
- test('无状态文件时按默认值开启', () => {
175
- const box = isolate()
176
- try {
177
- assert.deepEqual(loadSessions(), {})
178
- assert.equal(isEnabledFor({}, true, 'session-a'), true)
179
- assert.equal(isEnabledFor({}, false, 'session-a'), false)
180
- assert.equal(isEnabledFor({ 'session-a': { enabled: false, at: 1 } }, true, 'session-a'), false)
181
- } finally {
182
- box.cleanup()
183
- }
184
- })
185
-
186
- test('会话开关原子落盘并可重新读取', () => {
187
- const box = isolate()
188
- try {
189
- const sessions = {}
190
- const result = setEnabledFor(sessions, true, 'session-a', false)
191
- assert.equal(result.ok, true)
192
- assert.equal(existsSync(stateFile()), true)
193
- assert.equal(loadSessions()['session-a'].enabled, false)
194
- const leftovers = readdirSync(join(box.home, 'dsh-structured')).filter((name) => name.includes('.tmp-'))
195
- assert.deepEqual(leftovers, [])
196
- } finally {
197
- box.cleanup()
198
- }
199
- })
200
-
201
- test('apply:外部修改 state.json 下一轮生效', () => {
202
- const box = isolate()
203
- try {
204
- const { ctx, sections } = fakeCtx()
205
- apply(ctx, { defaultEnabled: true })
206
- const context = mainContext('session-live')
207
- assert.match(sections[0].text(context), /首句给结论/)
208
- const result = saveSessions({ 'session-live': { enabled: false, at: Date.now() } }, true)
209
- assert.equal(result.ok, true)
210
- assert.equal(sections[0].text(context), '')
211
- } finally {
212
- box.cleanup()
213
- }
214
- })
215
-
216
- test('状态文件损坏时回落为空会话表而不是抛错', () => {
217
- const box = isolate()
218
- try {
219
- const sessions = {}
220
- setEnabledFor(sessions, true, 'session-a', false)
221
- writeFileSync(stateFile(), '{not json', 'utf8')
222
- assert.deepEqual(loadSessions(), {})
223
- } finally {
224
- box.cleanup()
225
- }
226
- })
227
-
228
- test('状态写入失败不抛错,只返回失败', () => {
229
- const box = isolate()
230
- try {
231
- // 把状态文件路径占成目录,mkdir 会失败,从而模拟写盘失败。
232
- const file = stateFile()
233
- mkdirSync(file, { recursive: true })
234
- const result = setEnabledFor({}, true, 'session-a', true)
235
- assert.equal(result.ok, false)
236
- assert.ok(result.error)
237
- } finally {
238
- box.cleanup()
239
- }
240
- })
241
-
242
- test('会话表有界且优先保留显式翻转', () => {
243
- const sessions = {}
244
- for (let i = 0; i < MAX_SESSION_ENTRIES + 10; i++) {
245
- sessions['session-' + i] = { enabled: i === 0 ? false : true, at: i }
246
- }
247
- const pruned = pruneSessions(sessions, true)
248
- assert.equal(Object.keys(pruned).length, MAX_SESSION_ENTRIES)
249
- assert.equal(pruned['session-0'].enabled, false)
250
- })
251
-
252
- test('style.md 非空时整份覆盖内置文本,空文件回落内置', () => {
253
- const box = isolate()
254
- try {
255
- assert.equal(activeStyle().source, 'built-in')
256
- mkdirSync(join(box.home, 'dsh-structured'), { recursive: true })
257
- writeFileSync(styleFile(), '# 我的规则\n只写两行。\n', 'utf8')
258
- const custom = activeStyle()
259
- assert.equal(custom.source, 'custom')
260
- assert.equal(custom.text, '# 我的规则\n只写两行。')
261
- writeFileSync(styleFile(), ' \n', 'utf8')
262
- assert.equal(activeStyle().source, 'built-in')
263
- assert.equal(activeStyle().text, BUILTIN_STYLE_TEXT)
264
- } finally {
265
- box.cleanup()
266
- }
267
- })
268
-
269
- test('会话 id 解析覆盖 agent.session.id / agent.id / 缺失', () => {
270
- assert.equal(sessionIdOf({ agent: { session: { id: 'session-a' } } }), 'session-a')
271
- assert.equal(sessionIdOf({ agent: { id: 'session-b' } }), 'session-b')
272
- assert.equal(sessionIdOf({ scope: { id: 'session-c' } }), 'session-c')
273
- assert.equal(sessionIdOf({}), null)
274
- assert.equal(sessionIdOf(undefined), null)
275
- })
276
-
277
- test('子代理会话识别:origin / delegationDepth;用户 fork 不跳过', () => {
278
- assert.equal(isSubagentContext({ agent: { session: { header: { origin: 'subagent' } } } }), true)
279
- assert.equal(isSubagentContext({ agent: { session: { header: { parentSession: 'session-parent' } } } }), false)
280
- assert.equal(isSubagentContext({ agent: { session: { header: { delegationDepth: 2 } } } }), true)
281
- assert.equal(isSubagentContext({ agent: { session: { header: { delegationDepth: 0 } } } }), false)
282
- assert.equal(isSubagentContext({}), false)
283
- })
284
-
285
- test('fail-open:section 求值异常时返回空串并回调错误', () => {
286
- let reported = null
287
- const text = safeSectionText({
288
- enabled: true,
289
- eligible: true,
290
- styleText: () => {
291
- throw new Error('boom')
292
- },
293
- onError: (error) => {
294
- reported = error
295
- },
296
- })
297
- assert.equal(text, '')
298
- assert.match(String(reported), /boom/)
299
- assert.equal(safeSectionText({ enabled: false, eligible: true, styleText: () => 'x' }), '')
300
- assert.equal(safeSectionText({ enabled: true, eligible: false, styleText: () => 'x' }), '')
301
- })
302
-
303
- test('命令:status / on / off / 未知参数 / 无会话 id', () => {
304
- const box = isolate()
305
- try {
306
- const sessions = {}
307
- const api = { sessions, defaultEnabled: true }
308
- const status = handleCommand({ agent: { id: 'session-a' }, rawInput: 'status' }, api)
309
- assert.equal(status.kind, 'success')
310
- assert.match(status.text, /已开启(本会话)/)
311
-
312
- const off = handleCommand({ agent: { id: 'session-a' }, rawInput: 'off' }, api)
313
- assert.equal(off.kind, 'success')
314
- assert.match(off.text, /已关闭/)
315
- assert.equal(isEnabledFor(sessions, true, 'session-a'), false)
316
- assert.equal(loadSessions()['session-a'].enabled, false)
317
-
318
- const on = handleCommand({ agent: { id: 'session-a' }, rawInput: 'on' }, api)
319
- assert.equal(on.kind, 'success')
320
- assert.equal(isEnabledFor(sessions, true, 'session-a'), true)
321
-
322
- const bad = handleCommand({ agent: { id: 'session-a' }, rawInput: 'maybe' }, api)
323
- assert.equal(bad.kind, 'error')
324
- assert.match(bad.text, /未知参数/)
325
-
326
- const noSession = handleCommand({ rawInput: 'off' }, api)
327
- assert.equal(noSession.kind, 'error')
328
- } finally {
329
- box.cleanup()
330
- }
331
- })
332
-
333
- test('apply:注册主 section 与尾部提醒,按会话开关求值', () => {
334
- const box = isolate()
335
- try {
336
- const { ctx, sections, commands } = fakeCtx()
337
- apply(ctx, { defaultEnabled: true })
338
- assert.equal(sections.length, 2)
339
- assert.equal(sections[0].name, 'dsh-structured:style')
340
- assert.equal(sections[1].name, 'dsh-structured:reminder')
341
-
342
- const a = mainContext('session-a')
343
- assert.match(sections[0].text(a), /首句给结论/)
344
- assert.match(sections[1].text(a), /加粗标题/)
345
-
346
- const off = commands[0].handler({ agent: { id: 'session-a' }, rawInput: 'off' })
347
- assert.equal(off.kind, 'success')
348
- assert.equal(sections[0].text(a), '')
349
- assert.equal(sections[1].text(a), '')
350
-
351
- // 另一个会话不受影响。
352
- assert.match(sections[0].text(mainContext('session-b')), /首句给结论/)
353
-
354
- // 子代理默认不注入。
355
- const sub = { agent: { id: 'uuid-sub', session: { id: 'uuid-sub', header: { origin: 'subagent' } } } }
356
- assert.equal(sections[0].text(sub), '')
357
- } finally {
358
- box.cleanup()
359
- }
360
- })
361
-
362
- test('fail-open:上下文 getter 抛错时 section 返回空串而不是打断组装', () => {
363
- const box = isolate()
364
- try {
365
- const { ctx, sections } = fakeCtx()
366
- apply(ctx, { defaultEnabled: true })
367
- const evil = {
368
- get agent() {
369
- throw new Error('getter boom')
370
- },
371
- }
372
- assert.doesNotThrow(() => sections[0].text(evil))
373
- assert.equal(sections[0].text(evil), '')
374
- assert.equal(sections[1].text(evil), '')
375
- } finally {
376
- box.cleanup()
377
- }
378
- })
379
-
380
- test('apply:注册失败不抛出,降级为不注入', () => { const box = isolate()
381
- try {
382
- const { ctx } = fakeCtx()
383
- ctx.systemPrompt.section = () => {
384
- throw new Error('duplicate section')
385
- }
386
- ctx.commands.register = () => {
387
- throw new Error('duplicate command')
388
- }
389
- assert.doesNotThrow(() => apply(ctx, { defaultEnabled: true }))
390
- } finally {
391
- box.cleanup()
392
- }
393
- })
394
-
395
- test('apply:展开配置尾部提醒 + 子代理注入', () => {
396
- const box = isolate()
397
- try {
398
- const { ctx, sections } = fakeCtx()
399
- apply(ctx, { defaultEnabled: false, tailReminder: false, includeSubagents: true })
400
- assert.equal(sections.length, 1)
401
- const sub = { agent: { id: 'uuid-sub', session: { id: 'uuid-sub', header: { origin: 'subagent' } } } }
402
- // defaultEnabled=false 时子代理也不注入;关键是没有因配置组合而抛错。
403
- assert.equal(sections[0].text(sub), '')
404
- } finally {
405
- box.cleanup()
406
- }
407
- })
408
-
409
- test('散文率分类器:表格/清单算结构化,长散文算违规', () => {
410
- const structured = '结论:先做 A。\n\n| 项 | 取舍 |\n| --- | --- |\n| A | 快 |\n| B | 稳 |'
411
- assert.equal(classify(structured).structured, true)
412
- assert.equal(classify(structured).prose, false)
413
-
414
- const list = '先给结论。\n\n- 第一点,带一个数字 42。\n- 第二点,带一个路径 G:\\x。\n- 第三点,带一个结果。'
415
- assert.equal(classify(list).structured, true)
416
-
417
- const prose =
418
- '先做 A,因为 A 的路径更短。然后做 B,因为 B 可以回滚。最后做 C,因为 C 能覆盖异常情况。' +
419
- '这三步按顺序执行,每一步都要留记录,避免后面无法定位问题。整套流程大约需要十分钟,做完之后再做一次检查。' +
420
- '检查内容包括日志、返回码和残留文件,任何一项不符合预期都要回退到上一步重新执行,直到全部通过为止。'
421
- assert.equal(classify(prose).substantive, true)
422
- assert.equal(classify(prose).prose, true)
423
- })
424
-
425
- test('散文率分类器:首屏卡片算结构化', () => {
426
- const card =
427
- '> **Phase 1 完成,Phase 2 前 4 项已落地**\n' +
428
- '> 28/28 测试通过,卡片与表格样式已装进 desktop。下一步跑 12 条验收,再做明暗和窄屏回归,最后整理截图并发布。'
429
- assert.equal(firstScreenStructured(card), true)
430
- assert.equal(classify(card).structured, true)
431
- assert.equal(classify(card).prose, false)
432
- })
433
-
434
- test('首屏口径:结构出现在 300 字符之后不算首屏结构化', () => {
435
- const late = '这是一段很长的解释。'.repeat(40) + '\n\n- 第一点\n- 第二点\n'
436
- assert.equal(firstScreenStructured(late, 300), false)
437
- assert.equal(firstScreenStructured(late, 2000), true)
438
- assert.equal(classify(late).structured, false)
439
- assert.equal(classify(late, { whole: true }).structured, true)
440
- })
441
-
442
- test('apply:没有 commands 服务时注入照常工作', () => {
443
- const box = isolate()
444
- try {
445
- const sections = []
446
- const ctx = {
447
- logger: { warn: () => {} },
448
- effect: (fn) => {
449
- const disposer = fn()
450
- return typeof disposer === 'function' ? disposer : () => {}
451
- },
452
- systemPrompt: {
453
- section: (section) => {
454
- sections.push(section)
455
- return () => {}
456
- },
457
- getSectionOrder: () => 9900,
458
- },
459
- }
460
- assert.doesNotThrow(() => apply(ctx, { defaultEnabled: true }))
461
- assert.equal(sections.length, 2)
462
- assert.match(sections[0].text(mainContext('session-a')), /首句给结论/)
463
- } finally {
464
- box.cleanup()
465
- }
466
- })
467
-
468
- test('apply:getSectionOrder 抛错时回落 9900 且不抛', () => {
469
- const box = isolate()
470
- try {
471
- const { ctx, sections } = fakeCtx()
472
- ctx.systemPrompt.getSectionOrder = () => {
473
- throw new Error('unknown order')
474
- }
475
- apply(ctx, { defaultEnabled: true })
476
- assert.equal(sections[0].order, 9900)
477
- assert.equal(sections[1].order, 10300)
478
- } finally {
479
- box.cleanup()
480
- }
481
- })
482
-
483
- test('apply:includeSubagents=true 时子代理也注入', () => {
484
- const box = isolate()
485
- try {
486
- const { ctx, sections } = fakeCtx()
487
- apply(ctx, { defaultEnabled: true, includeSubagents: true })
488
- const sub = { agent: { id: 'uuid-sub', session: { id: 'uuid-sub', header: { origin: 'subagent' } } } }
489
- assert.match(sections[0].text(sub), /首句给结论/)
490
- } finally {
491
- box.cleanup()
492
- }
493
- })
494
-
495
- test('apply:genui=on 时注入 dsh-ui 路由,off 时不注入', () => { const box = isolate()
496
- try {
497
- const on = fakeCtx()
498
- apply(on.ctx, { defaultEnabled: true, genui: 'on' })
499
- assert.match(on.sections[0].text(mainContext('session-a')), /dsh-ui/)
500
-
501
- const off = fakeCtx()
502
- apply(off.ctx, { defaultEnabled: true, genui: 'off' })
503
- assert.doesNotMatch(off.sections[0].text(mainContext('session-a')), /dsh-ui/)
504
- } finally {
505
- box.cleanup()
506
- }
507
- })
508
-
509
- test('apply:clientModules 检测到 GenUI 时自动追加 dsh-ui', () => {
510
- const box = isolate()
511
- try {
512
- const { ctx, sections } = fakeCtx()
513
- ctx.get = (name) =>
514
- name === 'clientModules'
515
- ? { clientPath: (id) => (id === 'genui' ? '/plugins/genui/client.js' : undefined) }
516
- : undefined
517
- apply(ctx, { defaultEnabled: true, genui: 'auto' })
518
- assert.match(sections[0].text(mainContext('session-a')), /dsh-ui/)
519
- } finally {
520
- box.cleanup()
521
- }
522
- })
523
-
524
- test('client:卡片类型与归属标记存在', () => {
525
- const client = readFileSync('src/client.js', 'utf8')
526
- assert.match(client, /data-dsh-structured-card-type/)
527
- assert.match(client, /CARD_TYPES/)
528
- assert.match(client, /--ds-card-icon/)
529
- for (const label of ['判断', '指标', '风险', '下一步', '验收', '重点']) {
530
- assert.match(client, new RegExp(label))
531
- }
532
- })
533
-
534
- test('client:视觉层可由 configForms 的 visual 设置关闭', () => {
535
- const client = readFileSync('src/client.js', 'utf8')
536
- assert.match(client, /configForms/)
537
- assert.match(client, /getSnapshot/)
538
- assert.match(client, /readValue\(value\.visual\) !== false/)
539
- assert.match(client, /applyVisual/)
540
- assert.doesNotMatch(client, /state\.json/)
541
- })
542
-
543
- test('构建产物 lib/index.js 与 lib/client.js 均 = banner + src', () => {
544
- const hostSrc = readFileSync('src/index.js', 'utf8')
545
- const hostLib = readFileSync('lib/index.js', 'utf8')
546
- const hostLines = hostLib.split('\n')
547
- assert.match(hostLines[0], /^\/\/ Generated from src\/index\.js/)
548
- assert.equal(hostLines.slice(2).join('\n'), hostSrc)
549
-
550
- const clientSrc = readFileSync('src/client.js', 'utf8')
551
- const clientLib = readFileSync('lib/client.js', 'utf8')
552
- const clientLines = clientLib.split('\n')
553
- assert.match(clientLines[0], /^\/\/ Generated from src\/client\.js/)
554
- assert.equal(clientLines.slice(2).join('\n'), clientSrc)
555
- })
556
-
557
- test('仓库边界:不 append 消息、不碰 pre-step / 工具服务、files 含 patch', () => {
558
- const source = readFileSync('src/index.js', 'utf8')
559
- // 注释里解释「刻意不做 pre-step」不算违规,只看可执行代码。
560
- const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
561
- assert.doesNotMatch(code, /agent\/pre-step/)
562
- assert.doesNotMatch(code, /messages\s*\.\s*(push|splice|append)/)
563
- assert.doesNotMatch(code, /ctx\.tools/)
564
- assert.doesNotMatch(code, /toolRuntime/)
565
- assert.match(code, /export const inject = \['systemPrompt'\]/)
566
- assert.doesNotMatch(code, /inject = \[[^\]]*'commands'/)
567
- assert.match(code, /interpolate: false/)
568
-
569
- const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
570
- assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
571
- assert.ok(pkg.files.includes('cordis.patch.yml'))
572
- assert.ok(pkg.files.includes('lib'))
573
- assert.equal(pkg.dsh.client.platform, 'web')
574
- assert.equal(pkg.exports['./client'], './lib/client.js')
575
- assert.match(pkg.engines.dsh, /0\.1\.7-rc\.2/)
576
- assert.equal(pkg.engines.dsh, '>=0.1.7-rc.2 <0.4.0-0')
577
- assert.equal(pkg.dsh.compatibility.dshReleases['0.2.1-rc.2'], 'compatible')
578
- assert.equal(pkg.dsh.compatibility.dshReleases['0.2.1-rc.3'], 'compatible')
579
- assert.equal(pkg.dsh.compatibility.dshReleases['0.3.0-rc.1'], 'compatible')
580
- assert.ok(existsSync('cordis.patch.yml'))
581
- assert.ok(existsSync('lib/client.js'))
582
- })
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import test from 'node:test'
6
+
7
+ import {
8
+ BUILTIN_STYLE_TEXT,
9
+ Config,
10
+ GENUI_ROUTING_TEXT,
11
+ MAX_SESSION_ENTRIES,
12
+ TAIL_REMINDER_TEXT,
13
+ activeStyle,
14
+ apply,
15
+ detectGenui,
16
+ handleCommand,
17
+ isEnabledFor,
18
+ isSubagentContext,
19
+ loadSessions,
20
+ pruneSessions,
21
+ safeSectionText,
22
+ saveSessions,
23
+ sessionIdOf,
24
+ setEnabledFor,
25
+ stateFile,
26
+ statusText,
27
+ styleFile,
28
+ } from '../src/index.js'
29
+ import { classify, firstScreenStructured } from '../scripts/measure-prose.mjs'
30
+
31
+ test('0.9.7 公式规避规则:金额用转义/反引号,不写裸 $...$', () => {
32
+ assert.match(BUILTIN_STYLE_TEXT, /金额\/美元符号/)
33
+ assert.match(BUILTIN_STYLE_TEXT, /\\\$/)
34
+ assert.match(BUILTIN_STYLE_TEXT, /反引号/)
35
+ assert.match(BUILTIN_STYLE_TEXT, /不要写裸 `\$\.\.\.\$`/)
36
+ })
37
+
38
+
39
+ function isolate() {
40
+ const home = mkdtempSync(join(tmpdir(), 'dsh-structured-test-'))
41
+ const saved = { dir: process.env.DSH_PROFILE_DIR, profile: process.env.DSH_PROFILE }
42
+ process.env.DSH_HOME = home
43
+ delete process.env.DSH_PROFILE_DIR
44
+ delete process.env.DSH_PROFILE
45
+ return {
46
+ home,
47
+ cleanup: () => {
48
+ if (saved.dir === undefined) delete process.env.DSH_PROFILE_DIR
49
+ else process.env.DSH_PROFILE_DIR = saved.dir
50
+ if (saved.profile === undefined) delete process.env.DSH_PROFILE
51
+ else process.env.DSH_PROFILE = saved.profile
52
+ rmSync(home, { recursive: true, force: true })
53
+ },
54
+ }
55
+ }
56
+
57
+ function mainContext(sessionId) {
58
+ return { agent: { id: sessionId, session: { id: sessionId, header: { delegationDepth: 0 } } } }
59
+ }
60
+
61
+ function fakeCtx() {
62
+ const sections = []
63
+ const commands = []
64
+ const warnings = []
65
+ const ctx = {
66
+ logger: { warn: (...args) => warnings.push(args) },
67
+ effect: (fn) => {
68
+ const disposer = fn()
69
+ return typeof disposer === 'function' ? disposer : () => {}
70
+ },
71
+ systemPrompt: {
72
+ section: (section) => {
73
+ sections.push(section)
74
+ return () => {}
75
+ },
76
+ getSectionOrder: () => 9900,
77
+ },
78
+ commands: {
79
+ register: (definition) => {
80
+ commands.push(definition)
81
+ return () => {}
82
+ },
83
+ },
84
+ }
85
+ return { ctx, sections, commands, warnings }
86
+ }
87
+
88
+ test('内置规则覆盖首句结论、组件、清晰度三件事', () => {
89
+ assert.match(BUILTIN_STYLE_TEXT, /首句给结论/)
90
+ assert.match(BUILTIN_STYLE_TEXT, /清单或表格/)
91
+ assert.match(BUILTIN_STYLE_TEXT, /标题即结论/)
92
+ assert.match(BUILTIN_STYLE_TEXT, /表格优先 ≤5 行/)
93
+ assert.match(BUILTIN_STYLE_TEXT, /不设硬性篇幅上限/)
94
+ assert.match(BUILTIN_STYLE_TEXT, /不为了字数删信息/)
95
+ assert.match(BUILTIN_STYLE_TEXT, /过程叙事/)
96
+ assert.match(BUILTIN_STYLE_TEXT, /推断/)
97
+ assert.match(BUILTIN_STYLE_TEXT, /短答不套卡/)
98
+ assert.match(BUILTIN_STYLE_TEXT, /代码\/补丁例外/)
99
+ assert.match(BUILTIN_STYLE_TEXT, /不强制标题卡/)
100
+ assert.match(TAIL_REMINDER_TEXT, /代码\/diff 回复例外/)
101
+ assert.doesNotMatch(BUILTIN_STYLE_TEXT, /500\s*字|≤\s*500|字数上限/)
102
+ assert.match(BUILTIN_STYLE_TEXT, /可复制内容用代码块/)
103
+ assert.match(TAIL_REMINDER_TEXT, /加粗标题/)
104
+ assert.match(TAIL_REMINDER_TEXT, /清晰优先/)
105
+ assert.doesNotMatch(TAIL_REMINDER_TEXT, /500\s*字|≤\s*500|字数上限/)
106
+ assert.match(BUILTIN_STYLE_TEXT, /判断/)
107
+ assert.match(BUILTIN_STYLE_TEXT, /指标/)
108
+ assert.match(BUILTIN_STYLE_TEXT, /风险/)
109
+ assert.match(BUILTIN_STYLE_TEXT, /下一步/)
110
+ assert.match(BUILTIN_STYLE_TEXT, /✅ 完成/)
111
+ assert.match(GENUI_ROUTING_TEXT, /dsh-ui/)
112
+ assert.match(TAIL_REMINDER_TEXT, /即使后文要用组件也一样/)
113
+ assert.match(TAIL_REMINDER_TEXT, /不得用组件充当首卡/)
114
+ assert.match(GENUI_ROUTING_TEXT, /不得用它的 title 充当首卡/)
115
+ assert.match(GENUI_ROUTING_TEXT, /以本节为准/)
116
+ })
117
+
118
+ test('detectGenui:profile 清单不再作为 auto 依据,config 可强制', () => {
119
+ const box = isolate()
120
+ try {
121
+ const profileDir = join(box.home, 'profiles', 'test')
122
+ mkdirSync(profileDir, { recursive: true })
123
+ writeFileSync(
124
+ join(profileDir, 'package.json'),
125
+ JSON.stringify({ dependencies: { '@changfenhuang/dsh-genui': '0.13.0' } }),
126
+ 'utf8',
127
+ )
128
+ process.env.DSH_PROFILE = 'test'
129
+ assert.equal(detectGenui({}), false)
130
+ assert.equal(detectGenui({ genui: 'off' }), false)
131
+ assert.equal(detectGenui({ genui: 'on' }), true)
132
+ assert.equal(detectGenui({ genui: 'auto' }), false)
133
+ } finally {
134
+ box.cleanup()
135
+ }
136
+ })
137
+
138
+ test('detectGenui:clientModules 实时图优先', () => {
139
+ const ctx = {
140
+ get: (name) =>
141
+ name === 'clientModules'
142
+ ? { clientPath: (id) => (id === 'genui' ? '/plugins/genui/client.js' : undefined) }
143
+ : undefined,
144
+ }
145
+ assert.equal(detectGenui({}, ctx), true)
146
+ assert.equal(detectGenui({ genui: 'off' }, ctx), false)
147
+ })
148
+
149
+ test('statusText 显示 GenUI 联动状态', () => {
150
+ const box = isolate()
151
+ try {
152
+ const sessions = {}
153
+ assert.match(
154
+ statusText({ sessionId: 'session-a', sessions, defaultEnabled: true, genui: true }).toString(),
155
+ /GenUI 联动:已检测/,
156
+ )
157
+ assert.match(
158
+ statusText({ sessionId: 'session-a', sessions, defaultEnabled: true, genui: false }),
159
+ /GenUI 联动:未检测/,
160
+ )
161
+ } finally {
162
+ box.cleanup()
163
+ }
164
+ })
165
+
166
+ test('Config:visual 默认开启,且可显式关闭', () => {
167
+ const value = (x) => (x && typeof x.get === 'function' ? x.get() : x)
168
+ const defaults = Config({})
169
+ assert.equal(value(defaults.visual), true)
170
+ assert.equal(value(defaults.defaultEnabled), true)
171
+ assert.equal(value(defaults.tailReminder), true)
172
+ assert.equal(value(defaults.includeSubagents), false)
173
+ assert.equal(value(defaults.genui), 'auto')
174
+ assert.equal(value(Config({ visual: false }).visual), false)
175
+ assert.equal(value(Config({ defaultEnabled: false }).defaultEnabled), false)
176
+ assert.match(
177
+ statusText({ sessionId: 'session-a', sessions: {}, defaultEnabled: true, visual: false }),
178
+ /视觉层:已关闭/,
179
+ )
180
+ })
181
+
182
+ test('无状态文件时按默认值开启', () => {
183
+ const box = isolate()
184
+ try {
185
+ assert.deepEqual(loadSessions(), {})
186
+ assert.equal(isEnabledFor({}, true, 'session-a'), true)
187
+ assert.equal(isEnabledFor({}, false, 'session-a'), false)
188
+ assert.equal(isEnabledFor({ 'session-a': { enabled: false, at: 1 } }, true, 'session-a'), false)
189
+ } finally {
190
+ box.cleanup()
191
+ }
192
+ })
193
+
194
+ test('会话开关原子落盘并可重新读取', () => {
195
+ const box = isolate()
196
+ try {
197
+ const sessions = {}
198
+ const result = setEnabledFor(sessions, true, 'session-a', false)
199
+ assert.equal(result.ok, true)
200
+ assert.equal(existsSync(stateFile()), true)
201
+ assert.equal(loadSessions()['session-a'].enabled, false)
202
+ const leftovers = readdirSync(join(box.home, 'dsh-structured')).filter((name) => name.includes('.tmp-'))
203
+ assert.deepEqual(leftovers, [])
204
+ } finally {
205
+ box.cleanup()
206
+ }
207
+ })
208
+
209
+ test('apply:外部修改 state.json 下一轮生效', () => {
210
+ const box = isolate()
211
+ try {
212
+ const { ctx, sections } = fakeCtx()
213
+ apply(ctx, { defaultEnabled: true })
214
+ const context = mainContext('session-live')
215
+ assert.match(sections[0].text(context), /首句给结论/)
216
+ const result = saveSessions({ 'session-live': { enabled: false, at: Date.now() } }, true)
217
+ assert.equal(result.ok, true)
218
+ assert.equal(sections[0].text(context), '')
219
+ } finally {
220
+ box.cleanup()
221
+ }
222
+ })
223
+
224
+ test('状态文件损坏时回落为空会话表而不是抛错', () => {
225
+ const box = isolate()
226
+ try {
227
+ const sessions = {}
228
+ setEnabledFor(sessions, true, 'session-a', false)
229
+ writeFileSync(stateFile(), '{not json', 'utf8')
230
+ assert.deepEqual(loadSessions(), {})
231
+ } finally {
232
+ box.cleanup()
233
+ }
234
+ })
235
+
236
+ test('状态写入失败不抛错,只返回失败', () => {
237
+ const box = isolate()
238
+ try {
239
+ // 把状态文件路径占成目录,mkdir 会失败,从而模拟写盘失败。
240
+ const file = stateFile()
241
+ mkdirSync(file, { recursive: true })
242
+ const result = setEnabledFor({}, true, 'session-a', true)
243
+ assert.equal(result.ok, false)
244
+ assert.ok(result.error)
245
+ } finally {
246
+ box.cleanup()
247
+ }
248
+ })
249
+
250
+ test('会话表有界且优先保留显式翻转', () => {
251
+ const sessions = {}
252
+ for (let i = 0; i < MAX_SESSION_ENTRIES + 10; i++) {
253
+ sessions['session-' + i] = { enabled: i === 0 ? false : true, at: i }
254
+ }
255
+ const pruned = pruneSessions(sessions, true)
256
+ assert.equal(Object.keys(pruned).length, MAX_SESSION_ENTRIES)
257
+ assert.equal(pruned['session-0'].enabled, false)
258
+ })
259
+
260
+ test('style.md 非空时整份覆盖内置文本,空文件回落内置', () => {
261
+ const box = isolate()
262
+ try {
263
+ assert.equal(activeStyle().source, 'built-in')
264
+ mkdirSync(join(box.home, 'dsh-structured'), { recursive: true })
265
+ writeFileSync(styleFile(), '# 我的规则\n只写两行。\n', 'utf8')
266
+ const custom = activeStyle()
267
+ assert.equal(custom.source, 'custom')
268
+ assert.equal(custom.text, '# 我的规则\n只写两行。')
269
+ writeFileSync(styleFile(), ' \n', 'utf8')
270
+ assert.equal(activeStyle().source, 'built-in')
271
+ assert.equal(activeStyle().text, BUILTIN_STYLE_TEXT)
272
+ } finally {
273
+ box.cleanup()
274
+ }
275
+ })
276
+
277
+ test('会话 id 解析覆盖 agent.session.id / agent.id / 缺失', () => {
278
+ assert.equal(sessionIdOf({ agent: { session: { id: 'session-a' } } }), 'session-a')
279
+ assert.equal(sessionIdOf({ agent: { id: 'session-b' } }), 'session-b')
280
+ assert.equal(sessionIdOf({ scope: { id: 'session-c' } }), 'session-c')
281
+ assert.equal(sessionIdOf({}), null)
282
+ assert.equal(sessionIdOf(undefined), null)
283
+ })
284
+
285
+ test('子代理会话识别:origin / delegationDepth;用户 fork 不跳过', () => {
286
+ assert.equal(isSubagentContext({ agent: { session: { header: { origin: 'subagent' } } } }), true)
287
+ assert.equal(isSubagentContext({ agent: { session: { header: { parentSession: 'session-parent' } } } }), false)
288
+ assert.equal(isSubagentContext({ agent: { session: { header: { delegationDepth: 2 } } } }), true)
289
+ assert.equal(isSubagentContext({ agent: { session: { header: { delegationDepth: 0 } } } }), false)
290
+ assert.equal(isSubagentContext({}), false)
291
+ })
292
+
293
+ test('fail-open:section 求值异常时返回空串并回调错误', () => {
294
+ let reported = null
295
+ const text = safeSectionText({
296
+ enabled: true,
297
+ eligible: true,
298
+ styleText: () => {
299
+ throw new Error('boom')
300
+ },
301
+ onError: (error) => {
302
+ reported = error
303
+ },
304
+ })
305
+ assert.equal(text, '')
306
+ assert.match(String(reported), /boom/)
307
+ assert.equal(safeSectionText({ enabled: false, eligible: true, styleText: () => 'x' }), '')
308
+ assert.equal(safeSectionText({ enabled: true, eligible: false, styleText: () => 'x' }), '')
309
+ })
310
+
311
+ test('命令:status / on / off / 未知参数 / 无会话 id', () => {
312
+ const box = isolate()
313
+ try {
314
+ const sessions = {}
315
+ const api = { sessions, defaultEnabled: true }
316
+ const status = handleCommand({ agent: { id: 'session-a' }, rawInput: 'status' }, api)
317
+ assert.equal(status.kind, 'success')
318
+ assert.match(status.text, /已开启(本会话)/)
319
+
320
+ const off = handleCommand({ agent: { id: 'session-a' }, rawInput: 'off' }, api)
321
+ assert.equal(off.kind, 'success')
322
+ assert.match(off.text, /已关闭/)
323
+ assert.equal(isEnabledFor(sessions, true, 'session-a'), false)
324
+ assert.equal(loadSessions()['session-a'].enabled, false)
325
+
326
+ const on = handleCommand({ agent: { id: 'session-a' }, rawInput: 'on' }, api)
327
+ assert.equal(on.kind, 'success')
328
+ assert.equal(isEnabledFor(sessions, true, 'session-a'), true)
329
+
330
+ const bad = handleCommand({ agent: { id: 'session-a' }, rawInput: 'maybe' }, api)
331
+ assert.equal(bad.kind, 'error')
332
+ assert.match(bad.text, /未知参数/)
333
+
334
+ const noSession = handleCommand({ rawInput: 'off' }, api)
335
+ assert.equal(noSession.kind, 'error')
336
+ } finally {
337
+ box.cleanup()
338
+ }
339
+ })
340
+
341
+ test('apply:注册主 section 与尾部提醒,按会话开关求值', () => {
342
+ const box = isolate()
343
+ try {
344
+ const { ctx, sections, commands } = fakeCtx()
345
+ apply(ctx, { defaultEnabled: true })
346
+ assert.equal(sections.length, 2)
347
+ assert.equal(sections[0].name, 'dsh-structured:style')
348
+ assert.equal(sections[1].name, 'dsh-structured:reminder')
349
+
350
+ const a = mainContext('session-a')
351
+ assert.match(sections[0].text(a), /首句给结论/)
352
+ assert.match(sections[1].text(a), /加粗标题/)
353
+
354
+ const off = commands[0].handler({ agent: { id: 'session-a' }, rawInput: 'off' })
355
+ assert.equal(off.kind, 'success')
356
+ assert.equal(sections[0].text(a), '')
357
+ assert.equal(sections[1].text(a), '')
358
+
359
+ // 另一个会话不受影响。
360
+ assert.match(sections[0].text(mainContext('session-b')), /首句给结论/)
361
+
362
+ // 子代理默认不注入。
363
+ const sub = { agent: { id: 'uuid-sub', session: { id: 'uuid-sub', header: { origin: 'subagent' } } } }
364
+ assert.equal(sections[0].text(sub), '')
365
+ } finally {
366
+ box.cleanup()
367
+ }
368
+ })
369
+
370
+ test('fail-open:上下文 getter 抛错时 section 返回空串而不是打断组装', () => {
371
+ const box = isolate()
372
+ try {
373
+ const { ctx, sections } = fakeCtx()
374
+ apply(ctx, { defaultEnabled: true })
375
+ const evil = {
376
+ get agent() {
377
+ throw new Error('getter boom')
378
+ },
379
+ }
380
+ assert.doesNotThrow(() => sections[0].text(evil))
381
+ assert.equal(sections[0].text(evil), '')
382
+ assert.equal(sections[1].text(evil), '')
383
+ } finally {
384
+ box.cleanup()
385
+ }
386
+ })
387
+
388
+ test('apply:注册失败不抛出,降级为不注入', () => { const box = isolate()
389
+ try {
390
+ const { ctx } = fakeCtx()
391
+ ctx.systemPrompt.section = () => {
392
+ throw new Error('duplicate section')
393
+ }
394
+ ctx.commands.register = () => {
395
+ throw new Error('duplicate command')
396
+ }
397
+ assert.doesNotThrow(() => apply(ctx, { defaultEnabled: true }))
398
+ } finally {
399
+ box.cleanup()
400
+ }
401
+ })
402
+
403
+ test('apply:展开配置尾部提醒 + 子代理注入', () => {
404
+ const box = isolate()
405
+ try {
406
+ const { ctx, sections } = fakeCtx()
407
+ apply(ctx, { defaultEnabled: false, tailReminder: false, includeSubagents: true })
408
+ assert.equal(sections.length, 1)
409
+ const sub = { agent: { id: 'uuid-sub', session: { id: 'uuid-sub', header: { origin: 'subagent' } } } }
410
+ // defaultEnabled=false 时子代理也不注入;关键是没有因配置组合而抛错。
411
+ assert.equal(sections[0].text(sub), '')
412
+ } finally {
413
+ box.cleanup()
414
+ }
415
+ })
416
+
417
+ test('散文率分类器:表格/清单算结构化,长散文算违规', () => {
418
+ const structured = '结论:先做 A。\n\n| 项 | 取舍 |\n| --- | --- |\n| A | 快 |\n| B | 稳 |'
419
+ assert.equal(classify(structured).structured, true)
420
+ assert.equal(classify(structured).prose, false)
421
+
422
+ const list = '先给结论。\n\n- 第一点,带一个数字 42。\n- 第二点,带一个路径 G:\\x。\n- 第三点,带一个结果。'
423
+ assert.equal(classify(list).structured, true)
424
+
425
+ const prose =
426
+ '先做 A,因为 A 的路径更短。然后做 B,因为 B 可以回滚。最后做 C,因为 C 能覆盖异常情况。' +
427
+ '这三步按顺序执行,每一步都要留记录,避免后面无法定位问题。整套流程大约需要十分钟,做完之后再做一次检查。' +
428
+ '检查内容包括日志、返回码和残留文件,任何一项不符合预期都要回退到上一步重新执行,直到全部通过为止。'
429
+ assert.equal(classify(prose).substantive, true)
430
+ assert.equal(classify(prose).prose, true)
431
+ })
432
+
433
+ test('散文率分类器:首屏卡片算结构化', () => {
434
+ const card =
435
+ '> **Phase 1 完成,Phase 2 前 4 项已落地**\n' +
436
+ '> 28/28 测试通过,卡片与表格样式已装进 desktop。下一步跑 12 条验收,再做明暗和窄屏回归,最后整理截图并发布。'
437
+ assert.equal(firstScreenStructured(card), true)
438
+ assert.equal(classify(card).structured, true)
439
+ assert.equal(classify(card).prose, false)
440
+ })
441
+
442
+ test('首屏口径:结构出现在 300 字符之后不算首屏结构化', () => {
443
+ const late = '这是一段很长的解释。'.repeat(40) + '\n\n- 第一点\n- 第二点\n'
444
+ assert.equal(firstScreenStructured(late, 300), false)
445
+ assert.equal(firstScreenStructured(late, 2000), true)
446
+ assert.equal(classify(late).structured, false)
447
+ assert.equal(classify(late, { whole: true }).structured, true)
448
+ })
449
+
450
+ test('apply:没有 commands 服务时注入照常工作', () => {
451
+ const box = isolate()
452
+ try {
453
+ const sections = []
454
+ const ctx = {
455
+ logger: { warn: () => {} },
456
+ effect: (fn) => {
457
+ const disposer = fn()
458
+ return typeof disposer === 'function' ? disposer : () => {}
459
+ },
460
+ systemPrompt: {
461
+ section: (section) => {
462
+ sections.push(section)
463
+ return () => {}
464
+ },
465
+ getSectionOrder: () => 9900,
466
+ },
467
+ }
468
+ assert.doesNotThrow(() => apply(ctx, { defaultEnabled: true }))
469
+ assert.equal(sections.length, 2)
470
+ assert.match(sections[0].text(mainContext('session-a')), /首句给结论/)
471
+ } finally {
472
+ box.cleanup()
473
+ }
474
+ })
475
+
476
+ test('apply:getSectionOrder 抛错时回落 9900 且不抛', () => {
477
+ const box = isolate()
478
+ try {
479
+ const { ctx, sections } = fakeCtx()
480
+ ctx.systemPrompt.getSectionOrder = () => {
481
+ throw new Error('unknown order')
482
+ }
483
+ apply(ctx, { defaultEnabled: true })
484
+ assert.equal(sections[0].order, 9900)
485
+ assert.equal(sections[1].order, 10300)
486
+ } finally {
487
+ box.cleanup()
488
+ }
489
+ })
490
+
491
+ test('apply:includeSubagents=true 时子代理也注入', () => {
492
+ const box = isolate()
493
+ try {
494
+ const { ctx, sections } = fakeCtx()
495
+ apply(ctx, { defaultEnabled: true, includeSubagents: true })
496
+ const sub = { agent: { id: 'uuid-sub', session: { id: 'uuid-sub', header: { origin: 'subagent' } } } }
497
+ assert.match(sections[0].text(sub), /首句给结论/)
498
+ } finally {
499
+ box.cleanup()
500
+ }
501
+ })
502
+
503
+ test('apply:genui=on 时注入 dsh-ui 路由,off 时不注入', () => { const box = isolate()
504
+ try {
505
+ const on = fakeCtx()
506
+ apply(on.ctx, { defaultEnabled: true, genui: 'on' })
507
+ assert.match(on.sections[0].text(mainContext('session-a')), /dsh-ui/)
508
+
509
+ const off = fakeCtx()
510
+ apply(off.ctx, { defaultEnabled: true, genui: 'off' })
511
+ assert.doesNotMatch(off.sections[0].text(mainContext('session-a')), /dsh-ui/)
512
+ } finally {
513
+ box.cleanup()
514
+ }
515
+ })
516
+
517
+ test('apply:clientModules 检测到 GenUI 时自动追加 dsh-ui', () => {
518
+ const box = isolate()
519
+ try {
520
+ const { ctx, sections } = fakeCtx()
521
+ ctx.get = (name) =>
522
+ name === 'clientModules'
523
+ ? { clientPath: (id) => (id === 'genui' ? '/plugins/genui/client.js' : undefined) }
524
+ : undefined
525
+ apply(ctx, { defaultEnabled: true, genui: 'auto' })
526
+ assert.match(sections[0].text(mainContext('session-a')), /dsh-ui/)
527
+ } finally {
528
+ box.cleanup()
529
+ }
530
+ })
531
+
532
+ test('client:卡片类型与归属标记存在', () => {
533
+ const client = readFileSync('src/client.js', 'utf8')
534
+ assert.match(client, /data-dsh-structured-card-type/)
535
+ assert.match(client, /CARD_TYPES/)
536
+ assert.doesNotMatch(client, /--ds-card-icon/)
537
+ for (const label of ['判断', '指标', '风险', '下一步', '验收', '重点']) {
538
+ assert.match(client, new RegExp(label))
539
+ }
540
+ })
541
+
542
+ test('client:视觉层可由 configForms 的 visual 设置关闭', () => {
543
+ const client = readFileSync('src/client.js', 'utf8')
544
+ assert.match(client, /configForms/)
545
+ assert.match(client, /getSnapshot/)
546
+ assert.match(client, /readValue\(value\.visual\) !== false/)
547
+ assert.match(client, /applyVisual/)
548
+ assert.doesNotMatch(client, /state\.json/)
549
+ })
550
+
551
+ test('构建产物 lib/index.js 与 lib/client.js 均 = banner + src', () => {
552
+ const hostSrc = readFileSync('src/index.js', 'utf8')
553
+ const hostLib = readFileSync('lib/index.js', 'utf8')
554
+ const hostLines = hostLib.split('\n')
555
+ assert.match(hostLines[0], /^\/\/ Generated from src\/index\.js/)
556
+ assert.equal(hostLines.slice(2).join('\n'), hostSrc)
557
+
558
+ const clientSrc = readFileSync('src/client.js', 'utf8')
559
+ const clientLib = readFileSync('lib/client.js', 'utf8')
560
+ const clientLines = clientLib.split('\n')
561
+ assert.match(clientLines[0], /^\/\/ Generated from src\/client\.js/)
562
+ assert.equal(clientLines.slice(2).join('\n'), clientSrc)
563
+ })
564
+
565
+ test('仓库边界:不 append 消息、不碰 pre-step / 工具服务、files 含 patch', () => {
566
+ const source = readFileSync('src/index.js', 'utf8')
567
+ // 注释里解释「刻意不做 pre-step」不算违规,只看可执行代码。
568
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
569
+ assert.doesNotMatch(code, /agent\/pre-step/)
570
+ assert.doesNotMatch(code, /messages\s*\.\s*(push|splice|append)/)
571
+ assert.doesNotMatch(code, /ctx\.tools/)
572
+ assert.doesNotMatch(code, /toolRuntime/)
573
+ assert.match(code, /export const inject = \['systemPrompt'\]/)
574
+ assert.doesNotMatch(code, /inject = \[[^\]]*'commands'/)
575
+ assert.match(code, /interpolate: false/)
576
+
577
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
578
+ assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
579
+ assert.ok(pkg.files.includes('cordis.patch.yml'))
580
+ assert.ok(pkg.files.includes('lib'))
581
+ assert.equal(pkg.dsh.client.platform, 'web')
582
+ assert.equal(pkg.exports['./client'], './lib/client.js')
583
+ assert.match(pkg.engines.dsh, /0\.1\.7-rc\.2/)
584
+ assert.equal(pkg.engines.dsh, '>=0.1.7-rc.2 <0.4.0-0')
585
+ assert.equal(pkg.dsh.compatibility.dshReleases['0.2.1-rc.2'], 'compatible')
586
+ assert.equal(pkg.dsh.compatibility.dshReleases['0.2.1-rc.3'], 'compatible')
587
+ assert.equal(pkg.dsh.compatibility.dshReleases['0.3.0-rc.1'], 'compatible')
588
+ assert.ok(existsSync('cordis.patch.yml'))
589
+ assert.ok(existsSync('lib/client.js'))
590
+ })