dsh-soul 0.7.0 → 0.7.2
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.
- package/LICENSE +21 -0
- package/README.md +32 -19
- package/README_EN.md +36 -19
- package/assets/personas/custom-placeholder.webp +0 -0
- package/assets/personas/placeholder.svg +16 -0
- package/assets/personas//344/270/245/346/240/274/344/273/243/347/240/201/345/256/241/351/230/205/350/200/205.webp +0 -0
- package/assets/personas//345/244/264/350/204/221/351/243/216/346/232/264/344/274/231/344/274/264.webp +0 -0
- package/assets/personas//346/225/231/345/255/246/345/236/213/350/256/262/350/247/243/350/200/205.webp +0 -0
- package/assets/personas//346/236/201/347/256/200/344/270/273/344/271/211/350/200/205.webp +0 -0
- package/assets/personas//350/207/252/351/251/261/345/236/213/345/215/217/344/275/234/350/200/205.webp +0 -0
- package/assets/personas//350/213/217/346/240/274/346/213/211/345/272/225/345/274/217/346/217/220/351/227/256/350/200/205.webp +0 -0
- package/assets/personas//350/265/204/346/267/261/346/236/266/346/236/204/345/270/210.webp +0 -0
- package/client/index.mjs +1169 -214
- package/index.mjs +732 -85
- package/lib/config.mjs +42 -6
- package/lib/injection.mjs +2 -2
- package/lib/personas.mjs +62 -25
- package/lib/store.mjs +125 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +13 -2
- package/scripts/lib/skip-report.mjs +73 -0
- package/scripts/verify-client.mjs +1319 -0
- package/scripts/verify-compat.mjs +58 -9
- package/scripts/verify-config.mjs +2116 -44
- package/scripts/verify-e2e-prompt.mjs +627 -0
- package/scripts/verify-live-prompt.mjs +445 -0
- package/scripts/verify-nav-icon.mjs +35 -7
- package/scripts/verify-store.mjs +310 -0
- package/scripts/verify-trail.mjs +21 -8
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/verify-live-prompt.mjs — 宿主实现层探针(配置生效链路的底座)
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:「改配置 → 下一轮生效」依赖两条通道,本脚本钉住其中的底座 ——
|
|
6
|
+
* section 的 `text` provider 在每次装配时都被重新求值。这属于**宿主行为**:
|
|
7
|
+
* 一旦宿主改成缓存 section 文本,这条通道会**静默失效**(不报错,只是配置
|
|
8
|
+
* 不再经它生效 —— 最难排查的一类问题)。
|
|
9
|
+
*
|
|
10
|
+
* 注意(0.7.1 的实测教训):本脚本 20 项全绿,只证明「宿主每次装配都会重新求值
|
|
11
|
+
* 并提交变化后的文本」这一层成立,**不等于**会话进行中改配置就一定生效 ——
|
|
12
|
+
* 0.7.1 曾据此移除活动会话注入,实测出现了「会话进行中改配置不生效」,
|
|
13
|
+
* 因此注入已恢复(见 verify-e2e-prompt.mjs 的 E13 / E16)。本脚本保留为
|
|
14
|
+
* 宿主行为探针:DSH 升级后跑一次,可确认这条底座机制没有被改掉。
|
|
15
|
+
*
|
|
16
|
+
* 三层证据:
|
|
17
|
+
* A 真实服务 —— 真实 `SystemPrompt` + 真实 Cordis Context:`assemble()` 每次
|
|
18
|
+
* 调用都重新求值函数式 `text`(无缓存、不需要重新注册);
|
|
19
|
+
* B 真实投影 —— 原样切片 `dsh-agent-loop` 的 `SystemPromptProjection`:
|
|
20
|
+
* 文本变化才提交新的 system 快照,相等则不提交(不堆消息);
|
|
21
|
+
* C 源文本契约 —— `preStep` 每个 step 都调 `assemble`、`assemble` 对函数式
|
|
22
|
+
* text 不舍缓存。
|
|
23
|
+
* 每层都配**判断力对照**:把关键条件改坏,对应用例必须失败 —— 否则断言没有判断力。
|
|
24
|
+
*
|
|
25
|
+
* 用法:
|
|
26
|
+
* node scripts/verify-live-prompt.mjs # 自动定位 DSH
|
|
27
|
+
* node scripts/verify-live-prompt.mjs --dsh <目录> # 指定 DSH 安装目录
|
|
28
|
+
* 退出码:0 = 通过(或定位不到 DSH,跳过);1 = 存在失败项。
|
|
29
|
+
*/
|
|
30
|
+
import { createRequire } from 'node:module'
|
|
31
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
33
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
34
|
+
import { homedir } from 'node:os'
|
|
35
|
+
import { assertCount, skipExit } from './lib/skip-report.mjs'
|
|
36
|
+
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
38
|
+
const PKG_DIR = resolve(HERE, '..')
|
|
39
|
+
|
|
40
|
+
// 本脚本要跑多少项断言(A 层 6 + A 判断力 2 + B 层 6 + B 判断力 3 + C 层 3)。跳过的
|
|
41
|
+
// 提示与 --strict 都依赖它;B 层若因宿主实现变化而跳过,末尾的校验会让本次运行失败 ——
|
|
42
|
+
// 「宿主改了实现」正是这条链路最需要被通知的事,不该安静地少跑几项还报通过。
|
|
43
|
+
const EXPECTED_ASSERTIONS = 20
|
|
44
|
+
|
|
45
|
+
const argv = process.argv.slice(2)
|
|
46
|
+
const argValue = (flag) => {
|
|
47
|
+
const i = argv.indexOf(flag)
|
|
48
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ==================== 定位 DSH ====================
|
|
52
|
+
|
|
53
|
+
// 锚点顺序决定「这一跑到底验证了哪一份宿主」——此前它是隐式的、且以 process.cwd() 开头,
|
|
54
|
+
// 而 createRequire 会从锚点**向上逐级找 node_modules**:换个目录跑就可能解析到另一份 DSH
|
|
55
|
+
// 安装(本机同时存在 D:\ 下的 0.2.1-alpha.1 与用户主目录 pnpm store 里的 0.1.0-rc.8,
|
|
56
|
+
// 后者不支持 section 的 interpolate 选项)。同一份插件因此在不同目录跑出不同结论 ——
|
|
57
|
+
// 对一份「护栏」脚本来说,这本身就是缺陷。
|
|
58
|
+
//
|
|
59
|
+
// 现在的顺序:显式指定 > 装了本插件的 profile(真正会加载这份代码的运行时)
|
|
60
|
+
// > 其它 profile > 插件自身 node_modules > cwd(最后兜底)。
|
|
61
|
+
function profilesDir() {
|
|
62
|
+
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function anchors() {
|
|
66
|
+
const list = [join(PKG_DIR, 'node_modules'), PKG_DIR]
|
|
67
|
+
const explicit = explicitDshDir()
|
|
68
|
+
if (explicit) list.unshift(resolve(explicit))
|
|
69
|
+
|
|
70
|
+
const profiles = profilesDir()
|
|
71
|
+
if (existsSync(profiles)) {
|
|
72
|
+
let names = []
|
|
73
|
+
try {
|
|
74
|
+
names = readdirSync(profiles)
|
|
75
|
+
} catch {
|
|
76
|
+
/* 读不到就只用其它锚点 */
|
|
77
|
+
}
|
|
78
|
+
// 装了 dsh-soul 的 profile 排前面:那份运行时才是这份代码真正的去处
|
|
79
|
+
const hosts = names.filter((p) => existsSync(join(profiles, p, 'node_modules', 'dsh-soul')))
|
|
80
|
+
const others = names.filter((p) => !hosts.includes(p))
|
|
81
|
+
for (const p of [...hosts, ...others]) list.push(join(profiles, p))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
list.push(process.cwd())
|
|
85
|
+
return list
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function explicitDshDir() {
|
|
89
|
+
return argValue('--dsh') || process.env.DSH_INSTALL_DIR || null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 显式指定的目录是否真的指向一份 DSH 安装(接受三种常见形状)。 */
|
|
93
|
+
function looksLikeDshInstall(dir) {
|
|
94
|
+
if (!existsSync(dir)) return false
|
|
95
|
+
if (existsSync(join(dir, 'node_modules', '@deepseek-ai', 'dsh'))) return true
|
|
96
|
+
const manifest = join(dir, 'package.json')
|
|
97
|
+
if (!existsSync(manifest)) return false
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).name === '@deepseek-ai/dsh'
|
|
100
|
+
} catch {
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveFrom(anchor, spec) {
|
|
106
|
+
try {
|
|
107
|
+
return createRequire(join(anchor, 'noop.js')).resolve(spec)
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 返回宿主子包的解析锚点列表:DSH 自身与其嵌套的 @deepseek-ai 依赖目录。 */
|
|
114
|
+
function hostAnchors() {
|
|
115
|
+
const dshPkg = anchors().map((a) => resolveFrom(a, '@deepseek-ai/dsh/package.json')).find(Boolean)
|
|
116
|
+
const list = [...anchors()]
|
|
117
|
+
if (dshPkg) {
|
|
118
|
+
const dshDir = dirname(dshPkg)
|
|
119
|
+
list.unshift(join(dshDir, 'node_modules', '@deepseek-ai', 'dsh'), join(dshDir, 'node_modules'), dshDir)
|
|
120
|
+
}
|
|
121
|
+
return list
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 列出所有能解析出宿主子包的**不同** DSH 副本。
|
|
126
|
+
* 多于一份时必须打印出来:报告要说清验证的是哪一份实现,否则结论无法复核。
|
|
127
|
+
*/
|
|
128
|
+
function hostCandidates() {
|
|
129
|
+
const seen = new Map()
|
|
130
|
+
for (const anchor of hostAnchors()) {
|
|
131
|
+
const hit = resolveFrom(anchor, '@deepseek-ai/dsh-system-prompt')
|
|
132
|
+
if (!hit) continue
|
|
133
|
+
const entry = realDshEntry(hit)
|
|
134
|
+
if (!seen.has(entry)) seen.set(entry, { entry, anchor })
|
|
135
|
+
}
|
|
136
|
+
return [...seen.values()]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 从 dsh-system-prompt 的入口回推到所属 DSH 安装根(pnpm store 里的副本也算)。 */
|
|
140
|
+
function realDshEntry(spEntry) {
|
|
141
|
+
const marker = `${sep}@deepseek-ai${sep}dsh-system-prompt${sep}`
|
|
142
|
+
const at = spEntry.indexOf(marker)
|
|
143
|
+
return at === -1 ? spEntry : spEntry.slice(0, at)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ==================== 极简断言台 ====================
|
|
147
|
+
|
|
148
|
+
let passed = 0
|
|
149
|
+
const failures = []
|
|
150
|
+
|
|
151
|
+
function record(name, ok, detail) {
|
|
152
|
+
if (ok) {
|
|
153
|
+
passed += 1
|
|
154
|
+
console.log(` ✓ ${name}`)
|
|
155
|
+
} else {
|
|
156
|
+
failures.push(name)
|
|
157
|
+
console.log(` ✗ ${name}${detail ? ` —— ${detail}` : ''}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
162
|
+
|
|
163
|
+
// ==================== A:真实 SystemPrompt 服务 ====================
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 用真实宿主的 `SystemPrompt` 跑一遍「section 文本随配置变化」。
|
|
167
|
+
* @param opts.staticText - 判断力对照:把 text 退化为静态字符串(应当让可变性用例失败)
|
|
168
|
+
* @returns 用例结果数组(不直接断言,便于基线 / 对照两种跑法复用)
|
|
169
|
+
*/
|
|
170
|
+
async function partA(SystemPrompt, renderPrompt, Context, opts = {}) {
|
|
171
|
+
const results = []
|
|
172
|
+
const push = (name, ok, detail) => results.push({ name, ok, detail })
|
|
173
|
+
|
|
174
|
+
const root = new Context()
|
|
175
|
+
const service = new SystemPrompt(root, {
|
|
176
|
+
includeHarnessIdentity: true,
|
|
177
|
+
includeRuntimeContext: true,
|
|
178
|
+
personaPrefix: '',
|
|
179
|
+
personaSuffix: '',
|
|
180
|
+
toolOrder: undefined
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
let backing = 'AAA'
|
|
184
|
+
// interpolate: false —— 与 dsh-soul 的注册完全一致(R1)
|
|
185
|
+
const text = opts.staticText ? 'AAA' : () => backing
|
|
186
|
+
service.section({ name: 'soul:persona', order: 0, interpolate: false, text })
|
|
187
|
+
|
|
188
|
+
const read = async () => {
|
|
189
|
+
const assembly = await service.assemble({})
|
|
190
|
+
return assembly.sections.find((s) => s.name === 'soul:persona')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const first = await read()
|
|
194
|
+
push('A1 注册一次即可装配出 section', first?.text === 'AAA', `实际 ${JSON.stringify(first?.text)}`)
|
|
195
|
+
|
|
196
|
+
backing = 'BBB'
|
|
197
|
+
const second = await read()
|
|
198
|
+
push(
|
|
199
|
+
'A2 改配置后**再次装配**即读到新文本(无需重新注册 section)',
|
|
200
|
+
second?.text === 'BBB',
|
|
201
|
+
`实际 ${JSON.stringify(second?.text)}`
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
backing = 'AAA'
|
|
205
|
+
const third = await read()
|
|
206
|
+
push(
|
|
207
|
+
'A3 改回后读到旧文本,且与上一次装配**不同**(双向可变 ⇒ 宿主未缓存)',
|
|
208
|
+
third?.text === 'AAA' && second?.text !== third?.text,
|
|
209
|
+
`second=${JSON.stringify(second?.text)} third=${JSON.stringify(third?.text)}`
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
push('A4 interpolate: false 被原样传递到装配结果', third?.interpolate === false, `实际 ${JSON.stringify(third?.interpolate)}`)
|
|
213
|
+
|
|
214
|
+
// R1 的等价断言:关掉插值后 {{...}} 按字面保留,renderPrompt 不抛错
|
|
215
|
+
backing = '模板 {{cwd}} 与 {{ item.name }} 都是字面量'
|
|
216
|
+
const literal = await read()
|
|
217
|
+
let renderOk = true
|
|
218
|
+
let renderErr = ''
|
|
219
|
+
let rendered = ''
|
|
220
|
+
try {
|
|
221
|
+
rendered = renderPrompt(await service.assemble({}))
|
|
222
|
+
} catch (err) {
|
|
223
|
+
renderOk = false
|
|
224
|
+
renderErr = String((err && err.message) || err)
|
|
225
|
+
}
|
|
226
|
+
push('A5 关插值后 {{...}} 按字面渲染,renderPrompt 不抛错', renderOk, renderErr)
|
|
227
|
+
push('A6 新配置确实进入渲染结果', rendered.includes('{{cwd}}'), JSON.stringify(rendered.slice(0, 120)))
|
|
228
|
+
|
|
229
|
+
return results
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ==================== B:切片 SystemPromptProjection ====================
|
|
233
|
+
|
|
234
|
+
/** 从宿主源码里原样取出 textOf + SystemPromptProjection。取不到返回 null(跳过,不算失败)。 */
|
|
235
|
+
function sliceProjection(source) {
|
|
236
|
+
const textOf = source.match(/function textOf\(message\) \{[\s\S]*?\n\}/)
|
|
237
|
+
const klass = source.match(/var SystemPromptProjection = class \{[\s\S]*?\n\};/)
|
|
238
|
+
if (!textOf || !klass) return null
|
|
239
|
+
return `${textOf[0]}\n${klass[0]}\nreturn SystemPromptProjection`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function makeSession(entries) {
|
|
243
|
+
const events = new Map(
|
|
244
|
+
entries.map(([seq, text]) => [
|
|
245
|
+
seq,
|
|
246
|
+
{ type: 'system/message', data: { message: { content: text === '' ? [] : [{ type: 'text', text }] } } }
|
|
247
|
+
])
|
|
248
|
+
)
|
|
249
|
+
return { surface: { nodes: entries.map(([seq]) => seq) }, eventAt: (seq) => events.get(seq) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function partB(build, opts = {}) {
|
|
253
|
+
const results = []
|
|
254
|
+
const push = (name, ok, detail) => results.push({ name, ok, detail })
|
|
255
|
+
|
|
256
|
+
// createSystemMessage 与断言无关:只用来承载渲染后的文本
|
|
257
|
+
const compile = (src) =>
|
|
258
|
+
new Function('createSystemMessage', src)((text) => ({
|
|
259
|
+
content: text === '' ? [] : [{ type: 'text', text }]
|
|
260
|
+
}))
|
|
261
|
+
|
|
262
|
+
let projection
|
|
263
|
+
try {
|
|
264
|
+
projection = compile(build())
|
|
265
|
+
} catch (err) {
|
|
266
|
+
push('B0 切片可用', false, String((err && err.message) || err))
|
|
267
|
+
return results
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const fresh = new projection(makeSession([]))
|
|
271
|
+
const firstCommits = fresh.project('AAA', { inHistory: true, startsSeries: false })
|
|
272
|
+
push('B1 首次装配提交一条 system 消息(即使文本非空)', firstCommits.length === 1, `实际 ${firstCommits.length}`)
|
|
273
|
+
|
|
274
|
+
const session = makeSession([[1, 'AAA']])
|
|
275
|
+
const proj = new projection(session)
|
|
276
|
+
const changed = proj.project('BBB', { inHistory: true, startsSeries: false })
|
|
277
|
+
push('B2 文本变化 → 提交新的 system 快照', changed.length === 1, `实际 ${changed.length}`)
|
|
278
|
+
push(
|
|
279
|
+
'B3 新增快照走 surfaceOp=append(不覆盖历史)',
|
|
280
|
+
changed[0]?.intent?.surfaceOp === 'append',
|
|
281
|
+
JSON.stringify(changed[0]?.intent)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
const same = proj.project('AAA', { inHistory: true, startsSeries: false })
|
|
285
|
+
push(
|
|
286
|
+
'B4 文本未变化 → 不提交(宿主按文本精确比较,不会堆消息)',
|
|
287
|
+
same.length === 0,
|
|
288
|
+
`实际 ${same.length}`
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const cleared = new projection(makeSession([[1, 'AAA'], [2, 'BBB']])).project('', {
|
|
292
|
+
inHistory: true,
|
|
293
|
+
startsSeries: false
|
|
294
|
+
})
|
|
295
|
+
push('B5 提示词清空(配置停用)→ 归一化:清掉后续节点并清空 head', cleared.length === 2, `实际 ${cleared.length}`)
|
|
296
|
+
|
|
297
|
+
const notHistory = new projection(makeSession([[1, 'AAA']])).project('AAA', {
|
|
298
|
+
inHistory: false,
|
|
299
|
+
startsSeries: false
|
|
300
|
+
})
|
|
301
|
+
push(
|
|
302
|
+
'B6 不在历史中但文本相同 → 仍不提交(避免无谓重写 head)',
|
|
303
|
+
notHistory.length === 0,
|
|
304
|
+
`实际 ${notHistory.length}`
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return results
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ==================== 主流程 ====================
|
|
311
|
+
|
|
312
|
+
async function main() {
|
|
313
|
+
console.log('dsh-soul — 宿主实现层等价性回归(改配置 → 下一轮生效)')
|
|
314
|
+
console.log('')
|
|
315
|
+
|
|
316
|
+
// 显式指定的目录必须真的是一份 DSH 安装:若悄悄退回自动定位,用户以为验证了 A、
|
|
317
|
+
// 实际验证了 B —— 这正是本脚本最不能出的错。
|
|
318
|
+
const explicit = explicitDshDir()
|
|
319
|
+
if (explicit) {
|
|
320
|
+
const dir = resolve(explicit)
|
|
321
|
+
if (!looksLikeDshInstall(dir)) {
|
|
322
|
+
console.log(`✗ --dsh 指定的目录不是一份 DSH 安装:${dir}`)
|
|
323
|
+
console.log(' 该目录下既没有 node_modules/@deepseek-ai/dsh,其 package.json 的 name 也不是 @deepseek-ai/dsh。')
|
|
324
|
+
console.log(' 本脚本不会退回自动定位(否则验证的会是另一份宿主)。')
|
|
325
|
+
process.exit(1)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const ha = hostAnchors()
|
|
330
|
+
const spEntry = ha.map((a) => resolveFrom(a, '@deepseek-ai/dsh-system-prompt')).find(Boolean)
|
|
331
|
+
const cordisEntry = ha.map((a) => resolveFrom(a, '@deepseek-ai/cordis')).find(Boolean)
|
|
332
|
+
// 包的 exports "." 即 lib/index.js,直接用解析到的主入口当源码读
|
|
333
|
+
const loopEntry = ha.map((a) => resolveFrom(a, '@deepseek-ai/dsh-agent-loop')).find(Boolean)
|
|
334
|
+
|
|
335
|
+
if (!spEntry || !cordisEntry) {
|
|
336
|
+
skipExit(
|
|
337
|
+
'verify-live-prompt(宿主等价性回归)',
|
|
338
|
+
EXPECTED_ASSERTIONS,
|
|
339
|
+
'未定位到 DSH 的 @deepseek-ai/dsh-system-prompt / cordis',
|
|
340
|
+
'--dsh <目录> 指定 DSH 安装目录(含 node_modules/@deepseek-ai/dsh)'
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 本机常常装着不止一份 DSH。报告必须说明验证的是哪一份,否则结论没法复核。
|
|
345
|
+
const candidates = hostCandidates()
|
|
346
|
+
if (candidates.length > 1) {
|
|
347
|
+
console.log(`注意:本机解析到 ${candidates.length} 份 DSH 副本,本脚本验证的是第一份(其余仅供对照):`)
|
|
348
|
+
for (const [i, c] of candidates.entries()) console.log(` ${i === 0 ? '→' : ' '} ${c.entry}`)
|
|
349
|
+
console.log('')
|
|
350
|
+
}
|
|
351
|
+
console.log(`宿主模块:${spEntry}`)
|
|
352
|
+
console.log('')
|
|
353
|
+
|
|
354
|
+
const sp = await import(pathToFileURL(spEntry).href)
|
|
355
|
+
const cordis = await import(pathToFileURL(cordisEntry).href)
|
|
356
|
+
const Context = cordis.Context || cordis.default
|
|
357
|
+
|
|
358
|
+
console.log('A 真实 SystemPrompt + 真实 Cordis Context')
|
|
359
|
+
const baseA = await partA(sp.SystemPrompt, sp.renderPrompt, Context)
|
|
360
|
+
for (const r of baseA) record(r.name, r.ok, r.detail)
|
|
361
|
+
|
|
362
|
+
// 判断力对照:把函数式 text 退化为静态字符串,可变性用例必须失败
|
|
363
|
+
const sabA = await partA(sp.SystemPrompt, sp.renderPrompt, Context, { staticText: true })
|
|
364
|
+
const sticky = sabA.filter((r) => !r.ok).map((r) => r.name)
|
|
365
|
+
record(
|
|
366
|
+
'A 判断力对照:text 退化为静态字符串后,A2 / A3 必须失败',
|
|
367
|
+
sticky.some((n) => n.startsWith('A2')) && sticky.some((n) => n.startsWith('A3')),
|
|
368
|
+
`实际失败项 ${JSON.stringify(sticky)}`
|
|
369
|
+
)
|
|
370
|
+
record(
|
|
371
|
+
'A 判断力对照:A1 / A4 属结构性事实,不应随之失败',
|
|
372
|
+
sabA.filter((r) => r.name.startsWith('A1') || r.name.startsWith('A4')).every((r) => r.ok),
|
|
373
|
+
'对照跑不该让结构断言失败'
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
console.log('')
|
|
377
|
+
console.log('B 切片 SystemPromptProjection(dsh-agent-loop)')
|
|
378
|
+
let baseB = null
|
|
379
|
+
if (!loopEntry) {
|
|
380
|
+
console.log(' ⚠ 未定位到 @deepseek-ai/dsh-agent-loop,B 层跳过 —— 本次运行将因断言数不足被判为不可信')
|
|
381
|
+
} else {
|
|
382
|
+
const loopSource = readFileSync(loopEntry, 'utf8')
|
|
383
|
+
const src = sliceProjection(loopSource)
|
|
384
|
+
if (!src) {
|
|
385
|
+
console.log(' ⚠ 未能在宿主源码中定位 SystemPromptProjection(宿主实现已变),B 层跳过 —— 本次运行将因断言数不足被判为不可信')
|
|
386
|
+
} else {
|
|
387
|
+
baseB = partB(() => src)
|
|
388
|
+
for (const r of baseB) record(r.name, r.ok, r.detail)
|
|
389
|
+
|
|
390
|
+
// 判断力对照:删掉「文本相等就不提交」这一句,B4 必须失败
|
|
391
|
+
const broken = src.replace(/if \(latest\.text === rendered\) return \[\];\n/, '')
|
|
392
|
+
const sabB = partB(() => broken)
|
|
393
|
+
const sameness = sabB.find((r) => r.name.startsWith('B4'))
|
|
394
|
+
record(
|
|
395
|
+
'B 判断力对照:删掉「文本相等则不提交」后,B4 必须失败',
|
|
396
|
+
sameness !== undefined && sameness.ok === false,
|
|
397
|
+
`实际 ${JSON.stringify(sameness)}`
|
|
398
|
+
)
|
|
399
|
+
record(
|
|
400
|
+
'B 判断力对照:其余投影用例不受影响(改坏的是比较而非提交路径)',
|
|
401
|
+
sabB.filter((r) => !r.name.startsWith('B4')).every((r) => r.ok),
|
|
402
|
+
'对照跑不应波及无关用例'
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
console.log('')
|
|
408
|
+
console.log('C 源文本契约(宿主每条 step 都重新装配)')
|
|
409
|
+
const spSource = readFileSync(spEntry, 'utf8')
|
|
410
|
+
record(
|
|
411
|
+
'C1 assemble 对函数式 text 直接调用、无缓存',
|
|
412
|
+
/typeof section\.text === "function" \? section\.text\(context\) : section\.text/.test(spSource),
|
|
413
|
+
'宿主可能已改为缓存 section 文本 —— 若如此,这条通道会静默失效(此时只剩活动会话注入兜底)'
|
|
414
|
+
)
|
|
415
|
+
if (loopEntry) {
|
|
416
|
+
const loopSource = readFileSync(loopEntry, 'utf8')
|
|
417
|
+
const preStep = loopSource.match(/async preStep\(target, position\) \{[\s\S]*?\n\t\}/)
|
|
418
|
+
record('C2 能在宿主源码中定位 preStep', Boolean(preStep))
|
|
419
|
+
record(
|
|
420
|
+
'C3 preStep 每个 step 都调用 systemPrompt.assemble',
|
|
421
|
+
Boolean(preStep) && /systemPrompt\.assemble\(/.test(preStep[0]),
|
|
422
|
+
'preStep 不再装配系统提示词 ⇒ section 不会每步重新求值'
|
|
423
|
+
)
|
|
424
|
+
record(
|
|
425
|
+
'C4 preStep 在组装后调用 renderPrompt / project 链路(文本变化会被提交)',
|
|
426
|
+
Boolean(preStep) && /renderContextSections|renderPrompt/.test(loopSource.slice(loopSource.indexOf(preStep[0]) + preStep[0].length)),
|
|
427
|
+
'preStep 之后未见渲染/投影链路'
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
console.log('')
|
|
432
|
+
if (failures.length === 0) {
|
|
433
|
+
if (!assertCount('verify-live-prompt', passed, EXPECTED_ASSERTIONS)) process.exit(1)
|
|
434
|
+
console.log(`全部通过:${passed} 项检查`)
|
|
435
|
+
process.exit(0)
|
|
436
|
+
}
|
|
437
|
+
console.log(`失败 ${failures.length} 项:`)
|
|
438
|
+
for (const name of failures) console.log(` - ${name}`)
|
|
439
|
+
process.exit(1)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
main().catch((error) => {
|
|
443
|
+
console.error('自检脚本异常:', error)
|
|
444
|
+
process.exit(1)
|
|
445
|
+
})
|
|
@@ -52,9 +52,14 @@ import os from 'node:os'
|
|
|
52
52
|
import path from 'node:path'
|
|
53
53
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
54
54
|
import { spawnSync } from 'node:child_process'
|
|
55
|
+
import { assertCount, skipExit } from './lib/skip-report.mjs'
|
|
55
56
|
|
|
56
57
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
57
58
|
const ROOT = path.resolve(__dirname, '..')
|
|
59
|
+
|
|
60
|
+
// 本脚本要跑多少项断言(含 6 项判断力自检)。跳过的提示与 --strict 依赖它,
|
|
61
|
+
// 由 report() 在真正跑完时校验。
|
|
62
|
+
const EXPECTED_ASSERTIONS = 16
|
|
58
63
|
const CLIENT_FILE = path.join(ROOT, 'client', 'index.mjs')
|
|
59
64
|
const ICON_FILE = path.join(ROOT, 'assets', 'icon.svg')
|
|
60
65
|
|
|
@@ -91,7 +96,23 @@ function findBrowser(explicit) {
|
|
|
91
96
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
|
92
97
|
)
|
|
93
98
|
} else {
|
|
94
|
-
|
|
99
|
+
// Linux 必须用**绝对路径**:`fs.existsSync('google-chrome')` 只按当前工作目录解析
|
|
100
|
+
// 相对路径,**不做 PATH 查找** —— 实测恒为 false,于是 CI(ubuntu runner 自带
|
|
101
|
+
// Chrome)上这 16 项永远走「找不到浏览器」的跳过分支,而 ci.yml 却声称「真跑」。
|
|
102
|
+
// 这里与 verify-trail 的 Linux 分支保持一致。
|
|
103
|
+
candidates.push(
|
|
104
|
+
'/usr/bin/google-chrome',
|
|
105
|
+
'/usr/bin/chromium',
|
|
106
|
+
'/usr/bin/chromium-browser',
|
|
107
|
+
'/usr/bin/microsoft-edge'
|
|
108
|
+
)
|
|
109
|
+
// 兜底:PATH 里逐个目录拼绝对路径(这才是 existsSync 能判定的形态)
|
|
110
|
+
const pathDirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
|
|
111
|
+
for (const dir of pathDirs) {
|
|
112
|
+
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']) {
|
|
113
|
+
candidates.push(path.join(dir, name))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
95
116
|
}
|
|
96
117
|
for (const candidate of candidates) {
|
|
97
118
|
if (!candidate) continue
|
|
@@ -387,6 +408,7 @@ function report(result) {
|
|
|
387
408
|
for (const item of result.items) {
|
|
388
409
|
if (!item.ok) console.log(` ✗ ${item.name}${item.detail ? ' — ' + item.detail : ''}`)
|
|
389
410
|
}
|
|
411
|
+
if (!assertCount('verify-nav-icon', result.total, EXPECTED_ASSERTIONS)) return 1
|
|
390
412
|
if (result.failed === 0) {
|
|
391
413
|
console.log(`\n全部通过:${result.total} 项检查`)
|
|
392
414
|
return 0
|
|
@@ -415,9 +437,12 @@ function main() {
|
|
|
415
437
|
|
|
416
438
|
const browser = findBrowser(opts.chrome)
|
|
417
439
|
if (!browser) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
440
|
+
skipExit(
|
|
441
|
+
'verify-nav-icon(设置导航图标回归)',
|
|
442
|
+
EXPECTED_ASSERTIONS,
|
|
443
|
+
'未找到 Chrome / Edge',
|
|
444
|
+
'--chrome <可执行文件> 指定浏览器;或两段式:--emit <页面> → 手动 --dump-dom → --dump <文件>'
|
|
445
|
+
)
|
|
421
446
|
}
|
|
422
447
|
|
|
423
448
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-soul-nav-icon-'))
|
|
@@ -437,9 +462,12 @@ function main() {
|
|
|
437
462
|
try {
|
|
438
463
|
if (result.error || result.status !== 0 || !result.stdout) {
|
|
439
464
|
const reason = result.error ? result.error.code || result.error.message : `退出码 ${result.status}`
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
465
|
+
skipExit(
|
|
466
|
+
'verify-nav-icon(设置导航图标回归)',
|
|
467
|
+
EXPECTED_ASSERTIONS,
|
|
468
|
+
`无头浏览器未能运行(${reason})`,
|
|
469
|
+
'两段式:--emit <页面> → 手动 --dump-dom → --dump <文件>'
|
|
470
|
+
)
|
|
443
471
|
}
|
|
444
472
|
return report(parseDump(result.stdout))
|
|
445
473
|
} finally {
|