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,627 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/verify-e2e-prompt.mjs — 插件侧端到端回归:保存配置 → 两条通道都拿到新文本
|
|
4
|
+
*
|
|
5
|
+
* 用**真实的 index.mjs**(不是抽取片段)+ 假 ctx 在纯 Node 里跑通整条链路:
|
|
6
|
+
*
|
|
7
|
+
* POST /api/soul/config → 写队列 → 原子落盘 → configCache → section 的 text provider
|
|
8
|
+
* ↘ refreshPromptAndInject → 活动会话注入
|
|
9
|
+
*
|
|
10
|
+
* 「改配置 → 下一轮生效」由两条通道共同保证(0.7.1 的实测结论):
|
|
11
|
+
* 1. system prompt section:宿主每个 step 都重新求值 provider(E4 / E8 证明);
|
|
12
|
+
* 2. 活动会话注入:保存后主动把最新快照推给所有活动 agent(E13 / E16 证明)。
|
|
13
|
+
* 0.7.1 曾尝试只保留通道 1 并删除注入,实测出现「会话进行中改配置不生效」,
|
|
14
|
+
* 因此注入已恢复。本脚本把两条通道都钉住 —— 少了任何一条都会有用例失败。
|
|
15
|
+
*
|
|
16
|
+
* 另有一组用例钉住「人设预设与『关于你』的边界」:预设是 **Agent 的人格存档**,
|
|
17
|
+
* 「关于你」(昵称 / 职业 / 介绍)是**使用者的身份信息**,两者是两码事 ——
|
|
18
|
+
* 所以应用**任何**预设(内置 E25 或自建 E27)都不得改动这三个字段,同时它声明过的
|
|
19
|
+
* 人格维度(含输出语言 E28)必须真的写回。E29 / E30 另起一份带着「v0.7.1 之前那套
|
|
20
|
+
* 完整快照」的旧配置,验证历史残留既被迁移剥离、也不会在应用时写回。
|
|
21
|
+
* 语义来源见 lib/personas.mjs 的四条约定。
|
|
22
|
+
*
|
|
23
|
+
* index.mjs 有静态外部依赖 `@deepseek-ai/dsh-llm`(注入用的 `createUserMessage`),
|
|
24
|
+
* 而插件包内不含该依赖,因此挂载前先在临时目录复制一份并生成形状兼容的桩。
|
|
25
|
+
*
|
|
26
|
+
* 用法:node scripts/verify-e2e-prompt.mjs
|
|
27
|
+
* 退出码:0 = 通过;1 = 存在失败项。
|
|
28
|
+
*/
|
|
29
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { dirname, join, resolve } from 'node:path'
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
33
|
+
import { assertCount } from './lib/skip-report.mjs'
|
|
34
|
+
// 内置人设的期望值直接取代码里的同一份数据(它的数据契约另有 verify-config 钉住),
|
|
35
|
+
// 免得 e2e 里再抄一份「极简主义者 = efficient / less」这类会悄悄漂移的常量。
|
|
36
|
+
// 「关于你」的字段名单同理取自配置层唯一来源(PROFILE_FIELDS)。
|
|
37
|
+
import { BUILTIN_PERSONAS } from '../lib/personas.mjs'
|
|
38
|
+
import { PROFILE_FIELDS } from '../lib/config.mjs'
|
|
39
|
+
|
|
40
|
+
// 基线运行应跑出的断言数:E 场景 24 项 + F 损坏场景 8 项 + 2 项判断力对照。
|
|
41
|
+
// 跑完时校验,防止「脚本加/删用例」与文档口径悄悄脱节。
|
|
42
|
+
const EXPECTED_ASSERTIONS = 38
|
|
43
|
+
|
|
44
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
const PKG_DIR = resolve(HERE, '..')
|
|
46
|
+
|
|
47
|
+
let passed = 0
|
|
48
|
+
const failures = []
|
|
49
|
+
function record(name, ok, detail) {
|
|
50
|
+
if (ok) {
|
|
51
|
+
passed += 1
|
|
52
|
+
console.log(` ✓ ${name}`)
|
|
53
|
+
} else {
|
|
54
|
+
failures.push(name)
|
|
55
|
+
console.log(` ✗ ${name}${detail ? ` —— ${detail}` : ''}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ==================== 假宿主 ====================
|
|
60
|
+
|
|
61
|
+
/** 记录一切被访问的服务名与每次会话注入,供 E13 / E16 断言两条通道都健在。 */
|
|
62
|
+
function makeCtx() {
|
|
63
|
+
const state = {
|
|
64
|
+
sections: [], // 每次 systemPrompt.section() 的注册
|
|
65
|
+
disposals: 0,
|
|
66
|
+
routes: new Map(), // path -> handler
|
|
67
|
+
services: [], // ctx.get / ctx.inject 索要过的服务名
|
|
68
|
+
providers: [], // 已注册的服务(ctx.provide)
|
|
69
|
+
injected: [] // 每个活动 agent 收到的注入消息
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 活动 agent 桩:宿主通过 agents.list() 暴露,注入走 agent.inject()
|
|
73
|
+
const agents = {
|
|
74
|
+
list() {
|
|
75
|
+
return [{ id: 'fake-agent', inject(message) { state.injected.push(message) } }]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const systemPrompt = {
|
|
80
|
+
section(options) {
|
|
81
|
+
state.sections.push(options)
|
|
82
|
+
let live = true
|
|
83
|
+
return () => {
|
|
84
|
+
if (live) state.disposals += 1
|
|
85
|
+
live = false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const webServer = {
|
|
91
|
+
register({ path, handler }) {
|
|
92
|
+
state.routes.set(path, handler)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const host = {
|
|
97
|
+
systemPrompt,
|
|
98
|
+
webServer,
|
|
99
|
+
agents,
|
|
100
|
+
logger: { info() {}, warn() {}, error() {} }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Cordis 的语义:`ctx.inject(['x'], cb)` 交给回调的 ctx 上直接挂着 `x`
|
|
104
|
+
const ctx = {
|
|
105
|
+
state,
|
|
106
|
+
systemPrompt,
|
|
107
|
+
webServer,
|
|
108
|
+
logger: host.logger,
|
|
109
|
+
get(name) {
|
|
110
|
+
state.services.push(`get:${name}`)
|
|
111
|
+
return host[name]
|
|
112
|
+
},
|
|
113
|
+
inject(names, callback) {
|
|
114
|
+
const list = Array.isArray(names) ? names : [names]
|
|
115
|
+
for (const n of list) state.services.push(`inject:${n}`)
|
|
116
|
+
// 只提供 systemPrompt 与 webServer:commands / tools 等回调不执行
|
|
117
|
+
const available = list.filter((n) => n === 'systemPrompt' || n === 'webServer')
|
|
118
|
+
if (available.length !== list.length || list.length === 0) return
|
|
119
|
+
callback(ctx)
|
|
120
|
+
},
|
|
121
|
+
provide(name, value) {
|
|
122
|
+
state.providers.push(name)
|
|
123
|
+
host[name] = value
|
|
124
|
+
},
|
|
125
|
+
effect(callback) {
|
|
126
|
+
return callback()
|
|
127
|
+
},
|
|
128
|
+
on() {},
|
|
129
|
+
emit() {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return ctx
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 直接调用捕获到的 HTTP 处理器,避免起真实服务器。 */
|
|
136
|
+
async function callRoute(ctx, path, body, method = 'POST') {
|
|
137
|
+
const handler = ctx.state.routes.get(path)
|
|
138
|
+
if (!handler) throw new Error(`未注册路由 ${path}`)
|
|
139
|
+
const payload = Buffer.from(JSON.stringify(body ?? {}))
|
|
140
|
+
const req = {
|
|
141
|
+
method,
|
|
142
|
+
async *[Symbol.asyncIterator]() {
|
|
143
|
+
yield payload
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const captured = { status: 0, body: null }
|
|
147
|
+
const res = {
|
|
148
|
+
writeHead(status) {
|
|
149
|
+
captured.status = status
|
|
150
|
+
},
|
|
151
|
+
end(text) {
|
|
152
|
+
try {
|
|
153
|
+
captured.body = JSON.parse(String(text))
|
|
154
|
+
} catch {
|
|
155
|
+
captured.body = String(text)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await handler(req, res)
|
|
160
|
+
return captured
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 当前生效的 section 文本:始终读**最后一次**注册的 provider(模拟宿主每步求值)。 */
|
|
164
|
+
function currentPrompt(ctx) {
|
|
165
|
+
const last = ctx.state.sections[ctx.state.sections.length - 1]
|
|
166
|
+
if (!last) return '<未注册>'
|
|
167
|
+
return last.text()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ==================== 场景 ====================
|
|
171
|
+
|
|
172
|
+
async function scenario(entry) {
|
|
173
|
+
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-soul-e2e-'))
|
|
174
|
+
const previous = process.env.DSH_HOME
|
|
175
|
+
process.env.DSH_HOME = dshHome
|
|
176
|
+
|
|
177
|
+
const results = []
|
|
178
|
+
const push = (name, ok, detail) => results.push({ name, ok, detail })
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// 预置一份「v0.7.1 之前保存的」自建预设:那时快照是完整配置,带着「关于你」。
|
|
182
|
+
// 必须在插件挂载前落盘 —— loadConfig 会缓存首次读取的结果,而这正是被测的迁移入口。
|
|
183
|
+
const LEGACY_PERSONA = {
|
|
184
|
+
nickname: '老昵称',
|
|
185
|
+
occupation: '老职业',
|
|
186
|
+
bio: '老简介',
|
|
187
|
+
style: 'roast',
|
|
188
|
+
headingLists: 'less',
|
|
189
|
+
updatedAt: '2025-01-01T00:00:00.000Z'
|
|
190
|
+
}
|
|
191
|
+
const LEGACY_NAME = '历史预设'
|
|
192
|
+
// 同批预置两个「取值非法」的预设(必须在挂载前落盘:loadConfig 会缓存首次读取)。
|
|
193
|
+
// 'friendly' 是 v0.1.x 的合法 style 名,LEGACY_STYLE_MAP 只迁移**活动配置**、
|
|
194
|
+
// 不迁移预设库内的值 —— 所以这是真实升级路径会遇到的数据,不是人为构造。
|
|
195
|
+
const PARTIAL_NAME = '部分非法预设'
|
|
196
|
+
const ALL_INVALID_NAME = '全部非法预设'
|
|
197
|
+
writeFileSync(
|
|
198
|
+
join(dshHome, 'soul-config.json'),
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
personas: {
|
|
201
|
+
[LEGACY_NAME]: LEGACY_PERSONA,
|
|
202
|
+
[PARTIAL_NAME]: { style: 'friendly', emoji: 'default', customInstructions: 'P1-部分非法' },
|
|
203
|
+
[ALL_INVALID_NAME]: { style: 'bogus', emoji: 'lots' }
|
|
204
|
+
}
|
|
205
|
+
}, null, 2)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const mod = await import(pathToFileURL(entry).href + `?t=${Date.now()}`)
|
|
209
|
+
const ctx = makeCtx()
|
|
210
|
+
await mod.apply(ctx)
|
|
211
|
+
|
|
212
|
+
push('E1 apply 完成并注册了一份 soul:persona section', ctx.state.sections.length === 1, `实际 ${ctx.state.sections.length}`)
|
|
213
|
+
push(
|
|
214
|
+
'E2 section 带 interpolate: false(R1 契约)',
|
|
215
|
+
ctx.state.sections[0]?.interpolate === false,
|
|
216
|
+
JSON.stringify(ctx.state.sections[0]?.interpolate)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const before = currentPrompt(ctx)
|
|
220
|
+
|
|
221
|
+
const saved1 = await callRoute(ctx, '/api/soul/config', { customInstructions: 'E2E-甲' })
|
|
222
|
+
const after1 = currentPrompt(ctx)
|
|
223
|
+
push('E3 保存返回 200 ok', saved1.status === 200 && saved1.body?.ok === true, JSON.stringify(saved1.body))
|
|
224
|
+
push('E4 保存后 provider 立即读到新文本', after1.includes('E2E-甲'), JSON.stringify(after1.slice(0, 80)))
|
|
225
|
+
push('E5 保存前确实不含该文本(变化来自保存而非初始值)', !before.includes('E2E-甲'), JSON.stringify(before.slice(0, 80)))
|
|
226
|
+
push('E6 配置真的落盘(原子写路径生效)', existsSync(join(dshHome, 'soul-config.json')))
|
|
227
|
+
|
|
228
|
+
const saved2 = await callRoute(ctx, '/api/soul/config', { customInstructions: 'E2E-乙' })
|
|
229
|
+
const after2 = currentPrompt(ctx)
|
|
230
|
+
push('E7 二次保存同样 200', saved2.status === 200)
|
|
231
|
+
push('E8 同一进程内再次改配置 → provider 读到新值(无需重启、无需重新注册)', after2.includes('E2E-乙'), JSON.stringify(after2.slice(0, 80)))
|
|
232
|
+
push('E9 旧值已被替换(不是叠加)', !after2.includes('E2E-甲'), JSON.stringify(after2.slice(0, 80)))
|
|
233
|
+
|
|
234
|
+
// 纯外观字段不得触发提示词刷新,也不得注入会话
|
|
235
|
+
const sectionCountBefore = ctx.state.sections.length
|
|
236
|
+
const injectedBefore = ctx.state.injected.length
|
|
237
|
+
await callRoute(ctx, '/api/soul/config', { trailColor: '#123456' })
|
|
238
|
+
push(
|
|
239
|
+
'E10 纯外观字段(光轨颜色)保存后不重新注册 section(promptChanged 门控)',
|
|
240
|
+
ctx.state.sections.length === sectionCountBefore,
|
|
241
|
+
`${sectionCountBefore} → ${ctx.state.sections.length}`
|
|
242
|
+
)
|
|
243
|
+
push(
|
|
244
|
+
'E10b 纯外观字段保存后不向会话注入(promptChanged 门控)',
|
|
245
|
+
ctx.state.injected.length === injectedBefore,
|
|
246
|
+
`${injectedBefore} → ${ctx.state.injected.length}`
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
// 校验失败不得改动生效提示词
|
|
250
|
+
const bad = await callRoute(ctx, '/api/soul/config', { style: '不存在的风格' })
|
|
251
|
+
push('E11 非法枚举被拒绝(400)', bad.status === 400, JSON.stringify(bad.body))
|
|
252
|
+
push('E12 被拒绝的保存不影响生效提示词', currentPrompt(ctx) === after2, '生效文本发生了变化')
|
|
253
|
+
|
|
254
|
+
// 通道 2:配置变化必须主动推给活动会话(0.7.1 恢复注入后的核心契约)
|
|
255
|
+
const lastInjected = ctx.state.injected[ctx.state.injected.length - 1]
|
|
256
|
+
push(
|
|
257
|
+
'E13 保存配置后向活动 agent 注入了最新快照(含刚保存的文本)',
|
|
258
|
+
ctx.state.injected.length > 0 && JSON.stringify(lastInjected).includes('E2E-乙'),
|
|
259
|
+
`注入 ${ctx.state.injected.length} 次`
|
|
260
|
+
)
|
|
261
|
+
push(
|
|
262
|
+
'E16 注入 source 带生产者自持 kind(会话格式 v4 准入,issue #1)',
|
|
263
|
+
ctx.state.injected.length > 0 &&
|
|
264
|
+
ctx.state.injected.every(
|
|
265
|
+
(m) =>
|
|
266
|
+
typeof m?.source?.kind === 'string' &&
|
|
267
|
+
m.source.kind.length > 0 &&
|
|
268
|
+
m.source.kind !== 'plugin' &&
|
|
269
|
+
m.source.kind.startsWith('plugin:')
|
|
270
|
+
),
|
|
271
|
+
JSON.stringify(ctx.state.injected[0]?.source?.kind)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
// 草稿预览是只读旁路
|
|
275
|
+
const preview = await callRoute(ctx, '/api/soul/prompt/preview', { customInstructions: '草稿-丙' })
|
|
276
|
+
push(
|
|
277
|
+
'E14 草稿预览按草稿编译并回报非法字段',
|
|
278
|
+
preview.status === 200 &&
|
|
279
|
+
preview.body?.ok === true &&
|
|
280
|
+
typeof preview.body?.prompt === 'string' &&
|
|
281
|
+
preview.body.prompt.includes('草稿-丙') &&
|
|
282
|
+
Array.isArray(preview.body?.invalid),
|
|
283
|
+
JSON.stringify(preview.body).slice(0, 120)
|
|
284
|
+
)
|
|
285
|
+
push(
|
|
286
|
+
'E15 草稿预览不改变生效提示词',
|
|
287
|
+
currentPrompt(ctx) === after2 && !currentPrompt(ctx).includes('草稿-丙'),
|
|
288
|
+
JSON.stringify(currentPrompt(ctx).slice(0, 80))
|
|
289
|
+
)
|
|
290
|
+
push(
|
|
291
|
+
'E17 草稿预览不注入会话、不重新注册 section(只读旁路)',
|
|
292
|
+
ctx.state.injected.length === injectedBefore && ctx.state.sections.length === sectionCountBefore,
|
|
293
|
+
`注入 ${ctx.state.injected.length}(基准 ${injectedBefore})/ section 注册 ${ctx.state.sections.length}(基准 ${sectionCountBefore})`
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
// ── 人设预设与「关于你」的边界 ──────────────────────────────────────────────
|
|
297
|
+
// 预设是 Agent 的人格存档,「关于你」是使用者的身份信息 —— 切换预设只换说话方式,
|
|
298
|
+
// 不该动你的资料。用真实 index.mjs 验证这条对**任何**预设都成立:内置(E25)与
|
|
299
|
+
// 自建(E27)都不动昵称 / 职业 / 介绍;同时它声明过的人格维度必须真的写回 ——
|
|
300
|
+
// 含输出语言(E28)。E26 是 E25 的防空过护栏:若 apply 整个失败、配置没变,
|
|
301
|
+
// E25 会「因为没改所以通过」。
|
|
302
|
+
const MY_PERSONAL = { nickname: 'E2E昵称', occupation: 'E2E职业', bio: 'E2E简介', language: 'en' }
|
|
303
|
+
await callRoute(ctx, '/api/soul/config', MY_PERSONAL)
|
|
304
|
+
|
|
305
|
+
const minimal = BUILTIN_PERSONAS['极简主义者']
|
|
306
|
+
const useBuiltin = await callRoute(ctx, '/api/soul/personas/use', { name: '极简主义者' })
|
|
307
|
+
const afterBuiltin = useBuiltin.body?.config || {}
|
|
308
|
+
push(
|
|
309
|
+
'E25 应用内置预设不改动「关于你」(只覆盖它声明过的字段)',
|
|
310
|
+
PROFILE_FIELDS.every((key) => afterBuiltin[key] === MY_PERSONAL[key]),
|
|
311
|
+
JSON.stringify({
|
|
312
|
+
nickname: afterBuiltin.nickname,
|
|
313
|
+
occupation: afterBuiltin.occupation,
|
|
314
|
+
bio: afterBuiltin.bio
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
push(
|
|
318
|
+
'E26 内置预设声明过的维度确实写回了活动配置(E25 不是空过)',
|
|
319
|
+
afterBuiltin.style === minimal.style &&
|
|
320
|
+
afterBuiltin.headingLists === minimal.headingLists &&
|
|
321
|
+
afterBuiltin.emoji === minimal.emoji,
|
|
322
|
+
JSON.stringify({ style: afterBuiltin.style, headingLists: afterBuiltin.headingLists, emoji: afterBuiltin.emoji })
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
// 自建预设:先保存,再把「关于你」与一个已声明维度一起改掉,然后应用 ——
|
|
326
|
+
// 只有后者该被回滚。
|
|
327
|
+
await callRoute(ctx, '/api/soul/personas/save', { name: 'E2E自建' })
|
|
328
|
+
await callRoute(ctx, '/api/soul/config', { nickname: '改过的昵称', style: 'humorous', language: 'zh' })
|
|
329
|
+
const useMine = await callRoute(ctx, '/api/soul/personas/use', { name: 'E2E自建' })
|
|
330
|
+
const afterMine = useMine.body?.config || {}
|
|
331
|
+
push(
|
|
332
|
+
'E27 应用自建预设同样不动「关于你」(与内置一致,不再是完整配置快照)',
|
|
333
|
+
afterMine.nickname === '改过的昵称' &&
|
|
334
|
+
afterMine.occupation === MY_PERSONAL.occupation &&
|
|
335
|
+
afterMine.bio === MY_PERSONAL.bio,
|
|
336
|
+
JSON.stringify({ nickname: afterMine.nickname, occupation: afterMine.occupation, bio: afterMine.bio })
|
|
337
|
+
)
|
|
338
|
+
push(
|
|
339
|
+
'E28 自建预设会还原它声明过的人格维度,含输出语言(语言属于预设范围)',
|
|
340
|
+
afterMine.style === minimal.style && afterMine.language === MY_PERSONAL.language,
|
|
341
|
+
JSON.stringify({ style: afterMine.style, language: afterMine.language })
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
// 历史数据:v0.7.1 之前的自建预设是「完整配置快照」,磁盘上残留着「关于你」——
|
|
345
|
+
// 这份预设是在插件挂载前就写进 soul-config.json 的(见本函数开头的预置)。
|
|
346
|
+
const readBack = await callRoute(ctx, '/api/soul/config', null, 'GET')
|
|
347
|
+
const legacy = readBack.body?.config?.personas?.[LEGACY_NAME] || {}
|
|
348
|
+
const legacyDroppedFromDisk = !readFileSync(join(dshHome, 'soul-config.json'), 'utf8').includes('老昵称')
|
|
349
|
+
push(
|
|
350
|
+
'E29 历史预设里残留的「关于你」在读取时被剥离,并随下一次写入落盘',
|
|
351
|
+
PROFILE_FIELDS.every((key) => !(key in legacy)) &&
|
|
352
|
+
legacy.style === LEGACY_PERSONA.style &&
|
|
353
|
+
legacy.headingLists === LEGACY_PERSONA.headingLists &&
|
|
354
|
+
legacy.updatedAt === LEGACY_PERSONA.updatedAt &&
|
|
355
|
+
legacyDroppedFromDisk,
|
|
356
|
+
`${JSON.stringify(legacy)} 落盘已清理=${legacyDroppedFromDisk}`
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const useLegacy = await callRoute(ctx, '/api/soul/personas/use', { name: LEGACY_NAME })
|
|
360
|
+
const afterLegacy = useLegacy.body?.config || {}
|
|
361
|
+
push(
|
|
362
|
+
'E30 应用历史预设同样不动「关于你」(迁移 + 应用白名单双重防线)',
|
|
363
|
+
afterLegacy.nickname === '改过的昵称' && afterLegacy.style === LEGACY_PERSONA.style,
|
|
364
|
+
JSON.stringify({ nickname: afterLegacy.nickname, style: afterLegacy.style })
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
// ── 预设含非法取值时不得静默部分应用 ────────────────────────────────────────
|
|
368
|
+
// normalizePersonas 只按字段名过滤、**不校验取值**,所以磁盘上的历史值(v0.1.x 的
|
|
369
|
+
// 合法 style 'friendly')或手改的异常值会走到应用路径被 sanitizeConfig 拒绝。
|
|
370
|
+
// 此前两条应用路径都只取 patch、把 errors 丢掉 ⇒ 预设被「部分应用」却返回
|
|
371
|
+
// ok:true,用户以为整套生效(实测 style 与 emoji 被静默丢弃)。
|
|
372
|
+
// 现在:合法部分照常应用,被丢弃的字段必须出现在 invalid 里回报。
|
|
373
|
+
const usePartial = await callRoute(ctx, '/api/soul/personas/use', { name: PARTIAL_NAME })
|
|
374
|
+
push(
|
|
375
|
+
'E31 应用含非法取值的预设时,被丢弃的字段必须在 invalid 里回报(不再静默部分应用)',
|
|
376
|
+
usePartial.status === 200 &&
|
|
377
|
+
Array.isArray(usePartial.body?.invalid) &&
|
|
378
|
+
usePartial.body.invalid.includes('style') &&
|
|
379
|
+
// 合法部分确实应用了:不能因为报错就整体不回写
|
|
380
|
+
usePartial.body?.config?.customInstructions === 'P1-部分非法',
|
|
381
|
+
JSON.stringify({ status: usePartial.status, invalid: usePartial.body?.invalid, changed: usePartial.body?.changed })
|
|
382
|
+
)
|
|
383
|
+
const useAllBad = await callRoute(ctx, '/api/soul/personas/use', { name: ALL_INVALID_NAME })
|
|
384
|
+
push(
|
|
385
|
+
'E32 预设无任何合法字段时按失败处理(400),不得返回 ok:true 假装应用成功',
|
|
386
|
+
useAllBad.status === 400 && useAllBad.body?.ok === false,
|
|
387
|
+
JSON.stringify({ status: useAllBad.status, body: useAllBad.body })
|
|
388
|
+
)
|
|
389
|
+
const useClean = await callRoute(ctx, '/api/soul/personas/use', { name: LEGACY_NAME })
|
|
390
|
+
push(
|
|
391
|
+
'E33 正常预设不受影响:invalid 为空数组(防空过 —— 若判定写反,E31 会通过而这条必炸)',
|
|
392
|
+
useClean.status === 200 && Array.isArray(useClean.body?.invalid) && useClean.body.invalid.length === 0,
|
|
393
|
+
JSON.stringify({ status: useClean.status, invalid: useClean.body?.invalid })
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
// 只读端点也必须判方法:宿主只做路径匹配,method 判定是 handler 的责任。
|
|
397
|
+
// /api/soul/prompt 此前是唯一漏判的端点 —— 实测 POST 也能拿到 200。
|
|
398
|
+
const promptWrongMethod = await callRoute(ctx, '/api/soul/prompt', null, 'POST')
|
|
399
|
+
push(
|
|
400
|
+
'E34 只读端点拒绝非 GET(POST /api/soul/prompt 必须 405,而非 200)',
|
|
401
|
+
promptWrongMethod.status === 405,
|
|
402
|
+
`status=${promptWrongMethod.status}`
|
|
403
|
+
)
|
|
404
|
+
} finally {
|
|
405
|
+
if (previous === undefined) delete process.env.DSH_HOME
|
|
406
|
+
else process.env.DSH_HOME = previous
|
|
407
|
+
rmSync(dshHome, { recursive: true, force: true })
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return results
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ==================== 配置损坏场景(F1 逃生口 / F2 可观测性)====================
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 预置一份**截断的** soul-config.json,再启动插件 —— 复现用户真实踩到的路径:
|
|
417
|
+
* 文件被手改坏后重启,「人设像是被重置了」,而且保存 / 命令 / 工具 / 服务四条写路径
|
|
418
|
+
* 全部被拒(这是刻意的:避免以默认值为底把用户配置覆盖掉),此时**重置是唯一出路**。
|
|
419
|
+
*
|
|
420
|
+
* 单独一个场景而不是塞进 scenario():损坏状态下前面那些「保存后立即生效」的用例
|
|
421
|
+
* 必然全灭(它们本来就要被拒绝),混在一起会互相污染。
|
|
422
|
+
*/
|
|
423
|
+
async function corruptConfigScenario(entry) {
|
|
424
|
+
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-soul-e2e-corrupt-'))
|
|
425
|
+
const previous = process.env.DSH_HOME
|
|
426
|
+
process.env.DSH_HOME = dshHome
|
|
427
|
+
|
|
428
|
+
const results = []
|
|
429
|
+
const push = (name, ok, detail) => results.push({ name, ok, detail })
|
|
430
|
+
const configFile = join(dshHome, 'soul-config.json')
|
|
431
|
+
const damaged = '{"enabled": true, "nickname": "小明"'
|
|
432
|
+
const backupFile = `${configFile}.corrupt`
|
|
433
|
+
const parseable = () => {
|
|
434
|
+
try {
|
|
435
|
+
JSON.parse(readFileSync(configFile, 'utf8'))
|
|
436
|
+
return true
|
|
437
|
+
} catch {
|
|
438
|
+
return false
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
writeFileSync(configFile, damaged, 'utf8')
|
|
444
|
+
|
|
445
|
+
const mod = await import(pathToFileURL(entry).href + `?t=${Date.now()}`)
|
|
446
|
+
const ctx = makeCtx()
|
|
447
|
+
await mod.apply(ctx)
|
|
448
|
+
|
|
449
|
+
const read1 = await callRoute(ctx, '/api/soul/config', null, 'GET')
|
|
450
|
+
push(
|
|
451
|
+
'E18 损坏的配置被如实上报(configError),而不是静默回退默认值',
|
|
452
|
+
read1.status === 200 && typeof read1.body?.configError === 'string' && read1.body.configError.length > 0,
|
|
453
|
+
JSON.stringify(read1.body?.configError)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
const refused = await callRoute(ctx, '/api/soul/config', { nickname: '不该写进去' })
|
|
457
|
+
push(
|
|
458
|
+
'E19 损坏状态下普通保存被拒绝(防止以默认值为底覆盖用户配置)',
|
|
459
|
+
refused.status === 500 && /拒绝/.test(String(refused.body?.error)),
|
|
460
|
+
JSON.stringify(refused.body).slice(0, 140)
|
|
461
|
+
)
|
|
462
|
+
push('E19b 被拒绝的保存没有改动磁盘上的原文件一个字节', readFileSync(configFile, 'utf8') === damaged)
|
|
463
|
+
|
|
464
|
+
const reset = await callRoute(ctx, '/api/soul/config/reset')
|
|
465
|
+
push(
|
|
466
|
+
'E20 重置仍可用(逃生口),并回报备份路径',
|
|
467
|
+
reset.status === 200 && typeof reset.body?.backupPath === 'string' && reset.body.backupPath.length > 0,
|
|
468
|
+
JSON.stringify(reset.body).slice(0, 160)
|
|
469
|
+
)
|
|
470
|
+
push(
|
|
471
|
+
'E21 损坏内容被备份到 .corrupt(逐字节一致),原文件已重新可解析',
|
|
472
|
+
existsSync(backupFile) && readFileSync(backupFile, 'utf8') === damaged && parseable(),
|
|
473
|
+
`备份存在=${existsSync(backupFile)} 可解析=${parseable()}`
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
const read2 = await callRoute(ctx, '/api/soul/config', null, 'GET')
|
|
477
|
+
push(
|
|
478
|
+
'E22 重置后不再上报 configError(修好/重置后不必重启 DSH)',
|
|
479
|
+
read2.status === 200 && read2.body?.configError === null,
|
|
480
|
+
JSON.stringify(read2.body?.configError)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
const save2 = await callRoute(ctx, '/api/soul/config', { nickname: '重置后' })
|
|
484
|
+
push(
|
|
485
|
+
'E23 重置后保存恢复正常',
|
|
486
|
+
save2.status === 200 && save2.body?.config?.nickname === '重置后',
|
|
487
|
+
JSON.stringify(save2.body).slice(0, 140)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
const status = await callRoute(ctx, '/api/soul/status', null, 'GET')
|
|
491
|
+
push(
|
|
492
|
+
'E24 诊断端点报告两条通道与版本号',
|
|
493
|
+
status.status === 200 &&
|
|
494
|
+
status.body?.channels?.section?.registered === true &&
|
|
495
|
+
typeof status.body?.channels?.injection?.delivered === 'number' &&
|
|
496
|
+
typeof status.body?.version === 'string' && status.body.version.length > 0,
|
|
497
|
+
JSON.stringify(status.body).slice(0, 220)
|
|
498
|
+
)
|
|
499
|
+
} finally {
|
|
500
|
+
if (previous === undefined) delete process.env.DSH_HOME
|
|
501
|
+
else process.env.DSH_HOME = previous
|
|
502
|
+
rmSync(dshHome, { recursive: true, force: true })
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return results
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ==================== 挂载沙箱 ====================
|
|
509
|
+
/**
|
|
510
|
+
* 把插件复制到临时目录,并生成静态依赖 `@deepseek-ai/dsh-llm` 的形状兼容桩。
|
|
511
|
+
*
|
|
512
|
+
* 真实宿主由 DSH 自身提供该包(peer),插件包内不含 —— 离线挂载要么指向已装
|
|
513
|
+
* DSH 的 node_modules,要么造桩。造桩更稳:不依赖本机是否装了 DSH,也不会
|
|
514
|
+
* 因宿主版本变化而改变被测行为(注入构造的形状由 E16 单独钉住)。
|
|
515
|
+
*
|
|
516
|
+
* @param pkgDir - 插件包目录(含 index.mjs 与 lib/)
|
|
517
|
+
* @param label - 临时目录后缀,便于排查
|
|
518
|
+
* @returns { dir, entry } —— dir 需由调用方清理
|
|
519
|
+
*/
|
|
520
|
+
function prepareSandbox(pkgDir, label) {
|
|
521
|
+
const dir = mkdtempSync(join(tmpdir(), `dsh-soul-e2e-${label}-`))
|
|
522
|
+
cpSync(join(pkgDir, 'index.mjs'), join(dir, 'index.mjs'))
|
|
523
|
+
cpSync(join(pkgDir, 'lib'), join(dir, 'lib'), { recursive: true })
|
|
524
|
+
// package.json 也要带上:诊断端点用 createRequire 从包根读版本号(唯一来源)
|
|
525
|
+
cpSync(join(pkgDir, 'package.json'), join(dir, 'package.json'))
|
|
526
|
+
|
|
527
|
+
const llmDir = join(dir, 'node_modules', '@deepseek-ai', 'dsh-llm')
|
|
528
|
+
mkdirSync(llmDir, { recursive: true })
|
|
529
|
+
writeFileSync(
|
|
530
|
+
join(llmDir, 'package.json'),
|
|
531
|
+
JSON.stringify({ name: '@deepseek-ai/dsh-llm', version: '0.0.0', type: 'module', main: 'index.js' }, null, 2)
|
|
532
|
+
)
|
|
533
|
+
writeFileSync(
|
|
534
|
+
join(llmDir, 'index.js'),
|
|
535
|
+
[
|
|
536
|
+
'// 形状兼容桩:真实宿主由 DSH 提供该包,这里只满足插件对 createUserMessage 的使用',
|
|
537
|
+
'export function createUserMessage(input) {',
|
|
538
|
+
' return { role: "user", content: input && input.content, source: input && input.source }',
|
|
539
|
+
'}'
|
|
540
|
+
].join('\n')
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return { dir, entry: join(dir, 'index.mjs') }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ==================== 主流程 ====================
|
|
547
|
+
|
|
548
|
+
async function main() {
|
|
549
|
+
console.log('dsh-soul — 端到端回归:保存配置 → section 与活动会话都拿到新文本')
|
|
550
|
+
console.log('')
|
|
551
|
+
|
|
552
|
+
console.log('E 真实 index.mjs + 假宿主')
|
|
553
|
+
const baseSandbox = prepareSandbox(PKG_DIR, 'base')
|
|
554
|
+
let base = []
|
|
555
|
+
try {
|
|
556
|
+
base = await scenario(baseSandbox.entry)
|
|
557
|
+
} finally {
|
|
558
|
+
rmSync(baseSandbox.dir, { recursive: true, force: true })
|
|
559
|
+
}
|
|
560
|
+
for (const r of base) record(r.name, r.ok, r.detail)
|
|
561
|
+
|
|
562
|
+
// 配置损坏场景(F1 逃生口 / F2 可观测性):另起一份沙箱,预置截断的配置文件
|
|
563
|
+
console.log('')
|
|
564
|
+
console.log('F 配置损坏:重置逃生口 + 诊断端点')
|
|
565
|
+
const corruptSandbox = prepareSandbox(PKG_DIR, 'corrupt')
|
|
566
|
+
try {
|
|
567
|
+
for (const r of await corruptConfigScenario(corruptSandbox.entry)) record(r.name, r.ok, r.detail)
|
|
568
|
+
} finally {
|
|
569
|
+
rmSync(corruptSandbox.dir, { recursive: true, force: true })
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 判断力对照:把 provider 读的配置源换成 DEFAULT_CONFIG(模拟「配置改了但读不到」),
|
|
573
|
+
// E4 / E8 必须失败 —— 否则这些断言证明不了「新配置真的到了 provider」。
|
|
574
|
+
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-soul-sabotage-'))
|
|
575
|
+
let sabotage = []
|
|
576
|
+
try {
|
|
577
|
+
const patched = join(sandbox, 'pkg')
|
|
578
|
+
cpSync(PKG_DIR, patched, {
|
|
579
|
+
recursive: true,
|
|
580
|
+
filter: (src) => !src.includes('node_modules') && !src.includes('.git')
|
|
581
|
+
})
|
|
582
|
+
const target = join(patched, 'index.mjs')
|
|
583
|
+
const source = readFileSync(target, 'utf8')
|
|
584
|
+
// marker 取「provider 读的那个配置源」这一表达式本身,而不是它所在的整行语句 ——
|
|
585
|
+
// 后者会随 section 注册处的写法调整而失效(曾经就这样报废过一次对照)。
|
|
586
|
+
const marker = 'compilePrompt(configCache || DEFAULT_CONFIG)'
|
|
587
|
+
if (!source.includes(marker)) {
|
|
588
|
+
record('E 判断力对照:可在副本中改坏 provider 的配置源', false, '找不到 marker')
|
|
589
|
+
} else {
|
|
590
|
+
writeFileSync(target, source.replace(marker, 'compilePrompt(DEFAULT_CONFIG)'))
|
|
591
|
+
const sabotageSandbox = prepareSandbox(patched, 'sabotage')
|
|
592
|
+
try {
|
|
593
|
+
sabotage = await scenario(sabotageSandbox.entry)
|
|
594
|
+
} finally {
|
|
595
|
+
rmSync(sabotageSandbox.dir, { recursive: true, force: true })
|
|
596
|
+
}
|
|
597
|
+
const killed = sabotage.filter((r) => !r.ok).map((r) => r.name)
|
|
598
|
+
record(
|
|
599
|
+
'E 判断力对照:provider 读不到新配置时,E4 / E8 必须失败',
|
|
600
|
+
killed.some((n) => n.startsWith('E4')) && killed.some((n) => n.startsWith('E8')),
|
|
601
|
+
`实际失败项 ${JSON.stringify(killed)}`
|
|
602
|
+
)
|
|
603
|
+
record(
|
|
604
|
+
'E 判断力对照:E1 / E16 等结构断言不受影响',
|
|
605
|
+
sabotage.filter((r) => r.name.startsWith('E1 ') || r.name.startsWith('E16')).every((r) => r.ok),
|
|
606
|
+
'对照跑不应波及结构断言'
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
rmSync(sandbox, { recursive: true, force: true })
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
console.log('')
|
|
614
|
+
if (failures.length === 0) {
|
|
615
|
+
if (!assertCount('verify-e2e-prompt', passed, EXPECTED_ASSERTIONS)) process.exit(1)
|
|
616
|
+
console.log(`全部通过:${passed} 项检查`)
|
|
617
|
+
process.exit(0)
|
|
618
|
+
}
|
|
619
|
+
console.log(`失败 ${failures.length} 项:`)
|
|
620
|
+
for (const name of failures) console.log(` - ${name}`)
|
|
621
|
+
process.exit(1)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
main().catch((error) => {
|
|
625
|
+
console.error('自检脚本异常:', error)
|
|
626
|
+
process.exit(1)
|
|
627
|
+
})
|