@mzzsfy/dsh-context-manager 0.1.0

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.
@@ -0,0 +1,790 @@
1
+ // 路由层 BDD(mock ctx 强制 cordis 注入语义):宿主上下文上任何未声明 inject 的
2
+ // 服务属性访问一律抛 cannot get property ... without inject,与 cordis reflect 行为
3
+ // 一致,防止 inject 声明缺失回归。peer 依赖未安装的仓库环境(禁止 install)下整文件跳过。
4
+
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { EventEmitter } from 'node:events'
8
+ import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
9
+ import { mkdtempSync, rmSync } from 'node:fs'
10
+ import { createHash } from 'node:crypto'
11
+ import { tmpdir } from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ // 仅 peer 依赖缺失允许整文件跳过;其余加载失败(src 自身损坏)直接失败,禁止静默 skip
15
+ const indexModule = await import('../src/index.js').catch((error) => {
16
+ const missingPeer = error && error.code === 'ERR_MODULE_NOT_FOUND'
17
+ && error.message.includes("'@deepseek-ai/")
18
+ if (missingPeer) return error
19
+ throw error
20
+ })
21
+ const apply = indexModule.apply
22
+ const declaredInject = Array.isArray(indexModule.inject) ? indexModule.inject : []
23
+ const MESSAGES = indexModule.MESSAGES
24
+ const dependencyReady = typeof apply === 'function'
25
+ const skipMissingDeps = { skip: dependencyReady ? false : 'peer 依赖未安装,路由层测试跳过' }
26
+ const {
27
+ HISTORY_SCOPES,
28
+ HISTORY_INPUT_LIMIT,
29
+ HISTORY_INPUT_MAX_CHARS,
30
+ HISTORY_PROMPTS_MAX,
31
+ HISTORY_SESSION_SCAN_LIMIT,
32
+ HISTORY_STARTUP_SCAN_LIMIT,
33
+ } = await import('../src/core.mjs')
34
+ const { ensureCacheDir, writeWorkspaceCache } = await import('../src/history-cache.mjs')
35
+
36
+ // 插件激活即跑历史缓存启动对齐:所有测试统一隔离缓存目录,
37
+ // 防止夹具工作区经默认路径(~/.dsh/historyPrompt)泄漏进真实用户目录
38
+ const sharedCacheDir = mkdtempSync(path.join(tmpdir(), 'cx-hist-shared-'))
39
+ process.env.DSH_HISTORY_CACHE_DIR = sharedCacheDir
40
+ // 各 makeCtx 的 effect disposer 统一卸载:启动对齐定时器不可清理则套件尾部
41
+ // 固定多等 30s(事件循环被 timer 拖住)
42
+ const effectDisposers = []
43
+ test.after(() => {
44
+ for (const disposer of effectDisposers) {
45
+ try { disposer() } catch { /* 逐个卸载,单个失败不阻断其余清理 */ }
46
+ }
47
+ rmSync(sharedCacheDir, { recursive: true, force: true })
48
+ })
49
+
50
+ function makeCtx({
51
+ headers,
52
+ agents,
53
+ sessionPersistence,
54
+ settingsValue,
55
+ settingsGetError,
56
+ readSessions,
57
+ }) {
58
+ const routes = []
59
+ const pendingInjects = []
60
+ const settingsState = { value: settingsValue }
61
+ const settingsService = {
62
+ // settingsGetError 注入读取期故障:验证 respondError 的系统级错误收敛分支
63
+ get: () => {
64
+ if (settingsGetError) throw settingsGetError
65
+ return settingsState.value
66
+ },
67
+ register: () => ({ resolved: undefined }),
68
+ // 宿主 update 为异步串行:合并进微任务队列后生效,读旧值发生在 flush 前
69
+ update: (ns, patch) => new Promise((resolve) => queueMicrotask(() => {
70
+ settingsState.value = { ...(settingsState.value || {}), ...patch }
71
+ resolve()
72
+ })),
73
+ }
74
+ // readSession 桩:sessionId → 事件数组(工厂形态则调用后抛错);reads 记录实际读取次数供缓存断言
75
+ const readCounts = new Map()
76
+ const sessionQuery = {
77
+ listSessions: async () => headers.map((header) => ({ header })),
78
+ readSession: async (sessionId) => {
79
+ readCounts.set(sessionId, (readCounts.get(sessionId) || 0) + 1)
80
+ const events = readSessions ? readSessions[sessionId] : undefined
81
+ if (typeof events === 'function') throw events()
82
+ if (events === undefined) throw new Error('桩未配置该会话产物')
83
+ return { session: headers.find((header) => header.id === sessionId), events }
84
+ },
85
+ }
86
+ const services = {
87
+ webServer: { register: (route) => routes.push(route) },
88
+ sessionQuery,
89
+ }
90
+ const base = {
91
+ // disposer 收集进全局清单,test.after 统一执行:启动对齐的 30s 延迟定时器
92
+ // 不清理则套件尾部固定多等 30s(无 force-exit 时事件循环被 timer 拖住)
93
+ effect: (fn) => {
94
+ const disposer = fn()
95
+ if (typeof disposer === 'function') effectDisposers.push(disposer)
96
+ },
97
+ inject: (_deps, fn) => { pendingInjects.push(fn) },
98
+ get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
99
+ logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
100
+ }
101
+ const ctx = new Proxy(base, {
102
+ get(target, prop) {
103
+ if (typeof prop === 'symbol') return undefined
104
+ if (prop in target) return target[prop]
105
+ if (!declaredInject.includes(prop)) {
106
+ throw new Error(`cannot get property "${String(prop)}" without inject`)
107
+ }
108
+ return services[prop]
109
+ },
110
+ })
111
+ apply(ctx, undefined)
112
+ return {
113
+ handlers: new Map(routes.map((route) => [route.path, route.handler])),
114
+ logger: base.logger,
115
+ readCounts,
116
+ // 模拟宿主 settings 服务激活:触发 inject 回调(注册)
117
+ activateSettings: () => {
118
+ while (pendingInjects.length > 0) pendingInjects.shift()({ settings: settingsService })
119
+ },
120
+ }
121
+ }
122
+
123
+ function response() {
124
+ const state = { status: undefined, body: undefined }
125
+ state.writeHead = (status) => { state.status = status }
126
+ state.end = (text) => { state.body = JSON.parse(text) }
127
+ return state
128
+ }
129
+
130
+ // ── 历史输入路由(GET /api/context/inputs;工作区持久缓存)──
131
+
132
+ function getRequest(query) {
133
+ const req = new EventEmitter()
134
+ req.method = 'GET'
135
+ req.url = '/api/context/inputs' + query
136
+ return req
137
+ }
138
+
139
+ function getRequest2(url, method) {
140
+ const req = new EventEmitter()
141
+ req.method = method
142
+ req.url = url
143
+ return req
144
+ }
145
+
146
+ function request(sessionId, method = 'POST') {
147
+ const req = new EventEmitter()
148
+ req.method = method
149
+ if (method !== 'GET') {
150
+ process.nextTick(() => {
151
+ req.emit('data', Buffer.from(JSON.stringify({ sessionId })))
152
+ req.emit('end')
153
+ })
154
+ }
155
+ return req
156
+ }
157
+
158
+ function userMessageEvent(text, at) {
159
+ return {
160
+ type: 'user/message',
161
+ seq: at,
162
+ time: at,
163
+ data: { id: 'm' + at, role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text }] },
164
+ }
165
+ }
166
+
167
+ function makeHistoryCtx() {
168
+ return makeCtx({
169
+ headers: [
170
+ { id: 's1', cwd: 'C:\\x', createdAt: 0 },
171
+ { id: 's2', cwd: 'C:\\x', createdAt: 0 },
172
+ { id: 's3', cwd: 'C:\\other', createdAt: 0 },
173
+ ],
174
+ agents: new Map(),
175
+ readSessions: {
176
+ s1: [userMessageEvent('更早的输入', 100), userMessageEvent('最新输入', 900)],
177
+ s2: [userMessageEvent('第二条', 500)],
178
+ s3: [userMessageEvent('别的工作区', 999)],
179
+ },
180
+ })
181
+ }
182
+
183
+ // 后台对齐是异步的:轮询等待响应满足条件(默认 aligned 翻真,可断言数据就绪)
184
+ async function requestUntil(handlers, query, predicate, maxMs = 3000) {
185
+ for (let waited = 0; waited <= maxMs; waited += 100) {
186
+ const res = response()
187
+ await handlers.get('/api/context/inputs')(getRequest(query), res)
188
+ if (predicate(res.body)) return res
189
+ await new Promise((resolve) => setTimeout(resolve, 100))
190
+ }
191
+ throw new Error('条件未满足: ' + query)
192
+ }
193
+
194
+ function requestUntilAligned(handlers, query) {
195
+ return requestUntil(handlers, query, (body) => body.aligned)
196
+ }
197
+
198
+ // POST 夹具:请求体走 readBody 的 data/end 事件流,handler 按路径查找
199
+ async function postJson(handlers, routePath, body) {
200
+ const req = new EventEmitter()
201
+ req.method = 'POST'
202
+ req.url = routePath
203
+ const res = response()
204
+ const done = handlers.get(routePath)(req, res)
205
+ req.emit('data', Buffer.from(JSON.stringify(body), 'utf8'))
206
+ req.emit('end')
207
+ await done
208
+ return res
209
+ }
210
+
211
+ // POST 原始体夹具:发非法 JSON 用
212
+ async function postRaw(handlers, routePath, raw) {
213
+ const req = new EventEmitter()
214
+ req.method = 'POST'
215
+ req.url = routePath
216
+ const res = response()
217
+ const done = handlers.get(routePath)(req, res)
218
+ req.emit('data', Buffer.from(raw, 'utf8'))
219
+ req.emit('end')
220
+ await done
221
+ return res
222
+ }
223
+
224
+ // 每个测试独占临时缓存目录:插件 factory 在 makeCtx 时读取 env 解析缓存目录。
225
+ // 后台对齐 fire-and-forget,测试结束仍在写目录——清理重试躲开 Windows 文件占用
226
+ async function withHistoryCacheDir(run) {
227
+ const cacheDir = await mkdtemp(path.join(tmpdir(), 'cx-hist-'))
228
+ process.env.DSH_HISTORY_CACHE_DIR = cacheDir
229
+ try {
230
+ return await run(cacheDir)
231
+ } finally {
232
+ delete process.env.DSH_HISTORY_CACHE_DIR
233
+ for (let attempt = 0; attempt < 6; attempt += 1) {
234
+ try {
235
+ await rm(cacheDir, { recursive: true, force: true })
236
+ break
237
+ } catch {
238
+ await new Promise((resolve) => setTimeout(resolve, 150))
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ function workspaceCacheFile(dir, cwd) {
245
+ return path.join(dir, 'x-' + createHash('sha1').update(cwd).digest('hex').slice(0, 8) + '.json')
246
+ }
247
+
248
+ test('inject 声明覆盖路由层触及的全部宿主服务(cordis 未声明即抛 cannot get property)', skipMissingDeps, () => {
249
+ for (const service of ['webServer', 'agents', 'sessionQuery', 'sessionPersistence']) {
250
+ assert.ok(declaredInject.includes(service), `inject 缺少 "${service}"`)
251
+ }
252
+ })
253
+
254
+ // ── 历史输入路由:范围/对齐/缓存 ──
255
+
256
+ test('历史输入路由:首次 aligned=false 触发后台对齐,对齐后三范围正确且 session 按 sid 过滤', skipMissingDeps, async () => {
257
+ await withHistoryCacheDir(async () => {
258
+ const { handlers } = makeHistoryCtx()
259
+ const first = response()
260
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=workspace'), first)
261
+ assert.equal(first.status, 200)
262
+ assert.deepEqual(first.body.inputs, [])
263
+ assert.equal(first.body.aligned, false)
264
+ const workspaceRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
265
+ assert.deepEqual(workspaceRes.body.inputs.map((item) => item.text), ['最新输入', '第二条', '更早的输入'])
266
+ const sessionRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=session')
267
+ assert.deepEqual(sessionRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
268
+ const otherSessionRes = await requestUntilAligned(handlers, '?sessionId=s2&scope=session')
269
+ assert.deepEqual(otherSessionRes.body.inputs.map((item) => item.text), ['第二条'])
270
+ // global 合并全部工作区缓存:先触发 C:\other 对齐(模拟其已就绪),再等 global 汇聚
271
+ await requestUntilAligned(handlers, '?sessionId=s3&scope=workspace')
272
+ const globalRes = await requestUntil(handlers, '?sessionId=s1&scope=global',
273
+ (body) => body.aligned && body.inputs.some((item) => item.text === '别的工作区'))
274
+ assert.deepEqual(globalRes.body.inputs.map((item) => item.text), ['别的工作区', '最新输入', '第二条', '更早的输入'])
275
+ })
276
+ })
277
+
278
+ test('历史输入路由:对齐与既有缓存合并——旧条目保留、新输入追加、sid 溯源不变', skipMissingDeps, async () => {
279
+ await withHistoryCacheDir(async (cacheDir) => {
280
+ await ensureCacheDir(cacheDir)
281
+ await writeWorkspaceCache(cacheDir, 'C:\\x', {
282
+ entries: [
283
+ { text: '更早的输入', at: 100, sid: 's1' },
284
+ { text: '已消失会话的输入', at: 50, sid: 'gone' },
285
+ ],
286
+ extracts: {},
287
+ })
288
+ const { handlers } = makeHistoryCtx()
289
+ const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
290
+ (body) => body.aligned && body.inputs.some((item) => item.text === '最新输入'))
291
+ assert.deepEqual(res.body.inputs.map((item) => item.text), ['最新输入', '第二条', '更早的输入', '已消失会话的输入'])
292
+ const sessionRes = await requestUntil(handlers, '?sessionId=s1&scope=session',
293
+ (body) => body.aligned && body.inputs.some((item) => item.text === '最新输入'))
294
+ assert.deepEqual(sessionRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
295
+ })
296
+ })
297
+
298
+ test('历史输入路由:scope 缺省回退 session;未知会话/非 GET/缺 sessionId 拒绝', skipMissingDeps, async () => {
299
+ await withHistoryCacheDir(async () => {
300
+ const { handlers } = makeHistoryCtx()
301
+ const defaultRes = await requestUntilAligned(handlers, '?sessionId=s1')
302
+ assert.deepEqual(defaultRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
303
+ const invalidRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=bogus')
304
+ assert.deepEqual(invalidRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
305
+ const unknown = response()
306
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=ghost'), unknown)
307
+ assert.equal(unknown.status, 400)
308
+ assert.equal(unknown.body.error, '会话不存在')
309
+ const wrongMethod = response()
310
+ await handlers.get('/api/context/inputs')(request('s1'), wrongMethod)
311
+ assert.equal(wrongMethod.status, 405)
312
+ const missing = response()
313
+ await handlers.get('/api/context/inputs')(getRequest(''), missing)
314
+ assert.equal(missing.status, 400)
315
+ })
316
+ })
317
+
318
+ test('历史输入路由:损坏会话对齐跳过不中断;无 cwd 会话 workspace/session 返回空且 aligned', skipMissingDeps, async () => {
319
+ await withHistoryCacheDir(async () => {
320
+ const { handlers, logger } = makeCtx({
321
+ headers: [
322
+ { id: 's1', cwd: 'C:\\x', createdAt: 0 },
323
+ { id: 'broken', cwd: 'C:\\x', createdAt: 0 },
324
+ { id: 'nocwd', createdAt: 0 },
325
+ ],
326
+ agents: new Map(),
327
+ readSessions: {
328
+ s1: [userMessageEvent('存活输入', 100)],
329
+ broken: () => { throw new Error('产物损坏') },
330
+ },
331
+ })
332
+ const res = await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
333
+ assert.deepEqual(res.body.inputs.map((item) => item.text), ['存活输入'])
334
+ assert.ok(logger.warns.some((line) => line.includes('broken')), '对齐失败未进服务端日志')
335
+ const noCwdWorkspace = response()
336
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=nocwd&scope=workspace'), noCwdWorkspace)
337
+ assert.deepEqual(noCwdWorkspace.body, { inputs: [], aligned: true })
338
+ const noCwdSession = response()
339
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=nocwd&scope=session'), noCwdSession)
340
+ assert.deepEqual(noCwdSession.body, { inputs: [], aligned: true })
341
+ })
342
+ })
343
+
344
+ test('历史输入路由:运行中会话参与对齐(实时追加),缓存上限裁剪保留最新', skipMissingDeps, async () => {
345
+ await withHistoryCacheDir(async () => {
346
+ const agents = new Map([['running1', { status: 'running' }]])
347
+ const manyEvents = Array.from({ length: HISTORY_INPUT_LIMIT + 50 }, (_, i) => userMessageEvent('运行输入' + i, 1000 + i))
348
+ const { handlers, readCounts } = makeCtx({
349
+ headers: [
350
+ { id: 's1', cwd: 'C:\\x', createdAt: 0 },
351
+ { id: 'running1', cwd: 'C:\\x', createdAt: 0 },
352
+ ],
353
+ agents,
354
+ readSessions: {
355
+ s1: [userMessageEvent('历史输入', 1000 + manyEvents.length + 10)],
356
+ running1: manyEvents,
357
+ },
358
+ })
359
+ const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
360
+ (body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT + 1)
361
+ const texts = res.body.inputs.map((item) => item.text)
362
+ // 运行中会话输入量远超上限:limit 之外仅追加该会话去重后最早一条(s1 首条已在 limit 内)
363
+ assert.equal(texts.length, HISTORY_INPUT_LIMIT + 1)
364
+ assert.ok(texts.includes('历史输入'), '最新既有输入保留')
365
+ assert.ok(texts.includes('运行输入' + (HISTORY_INPUT_LIMIT + 49)), '次新运行输入保留')
366
+ assert.ok(texts.includes('运行输入0'), '运行中会话最早输入受首条保护')
367
+ assert.ok(!texts.includes('运行输入1'), '首条之外的旧输入仍被上限裁剪')
368
+ assert.ok(readCounts.get('running1') >= 1, '运行中会话被对齐解压')
369
+ const sessionRes = await requestUntilAligned(handlers, '?sessionId=running1&scope=session')
370
+ assert.ok(sessionRes.body.inputs.length > 0, 'session 范围可取运行中会话自身输入')
371
+ })
372
+ })
373
+
374
+ test('历史输入路由:再次请求读缓存不再解压产物(对齐节流内)', skipMissingDeps, async () => {
375
+ await withHistoryCacheDir(async () => {
376
+ const { handlers, readCounts } = makeCtx({
377
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
378
+ agents: new Map(),
379
+ readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
380
+ })
381
+ await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
382
+ const readsAfterAlign = readCounts.get('s1')
383
+ const second = response()
384
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=workspace'), second)
385
+ assert.deepEqual(second.body.inputs.map((item) => item.text), ['工作区输入'])
386
+ assert.equal(second.body.aligned, true)
387
+ assert.equal(readCounts.get('s1'), readsAfterAlign, '读路径不得解压产物')
388
+ })
389
+ })
390
+
391
+ test('历史输入路由:会话产物变化即席聚焦对齐,单次请求内拿到新输入(首条<3s)', skipMissingDeps, async () => {
392
+ await withHistoryCacheDir(async () => {
393
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-focus-'))
394
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
395
+ await writeFile(artifactPath, 'log-v1')
396
+ try {
397
+ const shared = {
398
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
399
+ agents: new Map(),
400
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
401
+ }
402
+ const first = makeCtx({ ...shared, readSessions: { s1: [userMessageEvent('旧输入', 100)] } })
403
+ await requestUntilAligned(first.handlers, '?sessionId=s1&scope=session')
404
+ // 产物增长(会话有新输入):指纹失效——路由就地等待聚焦对齐,
405
+ // 测试桩的 readSession 立即完成,单次请求内应直接拿到新输入且 aligned
406
+ await writeFile(artifactPath, 'log-v1-much-longer-appended')
407
+ const second = makeCtx({ ...shared, readSessions: { s1: [userMessageEvent('旧输入', 100), userMessageEvent('新输入XYZ', 200)] } })
408
+ const res = response()
409
+ await second.handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=session'), res)
410
+ assert.equal(res.body.aligned, true, '焦点对齐快于等待时限,首请求即就绪')
411
+ assert.deepEqual(res.body.inputs.map((item) => item.text), ['新输入XYZ', '旧输入'])
412
+ } finally {
413
+ await rm(dir, { recursive: true, force: true })
414
+ }
415
+ })
416
+ })
417
+
418
+ test('历史输入路由:聚焦对齐只更新目标会话,不抹其他会话的持久 extracts', skipMissingDeps, async () => {
419
+ await withHistoryCacheDir(async (cacheDir) => {
420
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-prune-'))
421
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
422
+ await writeFile(artifactPath, 'log-v1')
423
+ try {
424
+ await ensureCacheDir(cacheDir)
425
+ await writeWorkspaceCache(cacheDir, 'C:\\x', {
426
+ entries: [{ text: '更早的输入', at: 100, sid: 's1' }],
427
+ extracts: { s2: { mtimeMs: 1, size: 2, entries: [{ text: '邻居会话输入', at: 50 }] } },
428
+ })
429
+ const { handlers } = makeCtx({
430
+ headers: [
431
+ { id: 's1', cwd: 'C:\\x', createdAt: 0 },
432
+ { id: 's2', cwd: 'C:\\x', createdAt: 0 },
433
+ ],
434
+ agents: new Map(),
435
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
436
+ readSessions: { s1: [userMessageEvent('更早的输入', 100), userMessageEvent('聚焦新输入', 200)] },
437
+ })
438
+ await requestUntil(handlers, '?sessionId=s1&scope=session',
439
+ (body) => body.aligned && body.inputs.some((item) => item.text === '聚焦新输入'))
440
+ const cached = JSON.parse(await readFile(workspaceCacheFile(cacheDir, 'C:\\x'), 'utf8'))
441
+ assert.ok(cached.extracts.s2, '邻居会话 extracts 不得被聚焦对齐抹掉')
442
+ assert.ok(cached.extracts.s1, '目标会话 extracts 已更新')
443
+ } finally {
444
+ await rm(dir, { recursive: true, force: true })
445
+ }
446
+ })
447
+ })
448
+
449
+ test('历史输入路由:产物未变的会话跨对齐零解压(磁盘 extracts 指纹命中)', skipMissingDeps, async () => {
450
+ await withHistoryCacheDir(async () => {
451
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-art-'))
452
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
453
+ await writeFile(artifactPath, 'log-bytes')
454
+ try {
455
+ const { handlers, readCounts } = makeCtx({
456
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
457
+ agents: new Map(),
458
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
459
+ readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
460
+ })
461
+ await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
462
+ const readsAfterFirstAlign = readCounts.get('s1')
463
+ assert.ok(readsAfterFirstAlign >= 1, '首次对齐必然解压')
464
+ // 节流壳 30s 挡住同工作区二次触发:直接以新 makeCtx(新节流 Map)模拟"重启后再次对齐"
465
+ const { handlers: handlers2, readCounts: readCounts2 } = makeCtx({
466
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
467
+ agents: new Map(),
468
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
469
+ readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
470
+ })
471
+ // 同一缓存目录(env 未变,withHistoryCacheDir 复用):指纹命中 → 零解压
472
+ const second = response()
473
+ await handlers2.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=workspace'), second)
474
+ assert.equal(second.body.aligned, true)
475
+ assert.deepEqual(second.body.inputs.map((item) => item.text), ['工作区输入'])
476
+ // 触发一次对齐(绕过节流:新 ctx 节流 Map 为空,请求即触发)
477
+ await new Promise((resolve) => setTimeout(resolve, 300))
478
+ assert.equal(readCounts2.get('s1'), undefined, '产物未变,重启后对齐零解压')
479
+ } finally {
480
+ await rm(dir, { recursive: true, force: true })
481
+ }
482
+ })
483
+ })
484
+
485
+ // ── 历史输入路由:窗口与 subagent 排除 ──
486
+
487
+ test('历史输入路由:启动对齐只回溯最近 STARTUP_SCAN 个会话,老会话不解压', skipMissingDeps, async () => {
488
+ await withHistoryCacheDir(async () => {
489
+ const total = HISTORY_STARTUP_SCAN_LIMIT + 20
490
+ // 宿主 listSessions 为最新在前:数组首元素即最新会话
491
+ const headers = Array.from({ length: total }, (_, i) => ({ id: 's' + (total - 1 - i), cwd: 'C:\\x', createdAt: 0 }))
492
+ const readSessions = {}
493
+ for (let i = 0; i < total; i++) readSessions['s' + i] = [userMessageEvent('输入' + i, 1000 + i)]
494
+ const { handlers, readCounts } = makeCtx({ headers, agents: new Map(), readSessions })
495
+ // 触发对齐(请求工作区),等队列排空后窗口外老会话(s0..s19)不得被解压
496
+ await requestUntil(handlers, '?sessionId=s' + (total - 1) + '&scope=workspace',
497
+ (body) => body.aligned && body.inputs.length > 0)
498
+ assert.equal(readCounts.get('s0'), undefined, '窗口外最老会话不被启动对齐解压')
499
+ assert.ok(readCounts.get('s' + (total - 1)) >= 1, '窗口内最新会话被解压')
500
+ })
501
+ })
502
+
503
+ test('历史输入路由:窗口排除 subagent 会话——不解压、条目不入浮层', skipMissingDeps, async () => {
504
+ await withHistoryCacheDir(async () => {
505
+ // subagent 会话在宿主列表中更新更近(排最前):若不排除将挤占窗口并收录其输入
506
+ const headers = [
507
+ { id: 'sub1', cwd: 'C:\\x', createdAt: 0, origin: 'subagent' },
508
+ { id: 's1', cwd: 'C:\\x', createdAt: 0 },
509
+ ]
510
+ const { handlers, readCounts } = makeCtx({
511
+ headers,
512
+ agents: new Map(),
513
+ readSessions: {
514
+ sub1: [userMessageEvent('子代理任务提示', 100)],
515
+ s1: [userMessageEvent('人类输入', 200)],
516
+ },
517
+ })
518
+ const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
519
+ (body) => body.aligned && body.inputs.some((item) => item.text === '人类输入'))
520
+ assert.equal(res.body.inputs.some((item) => item.text === '子代理任务提示'), false, 'subagent 输入不入浮层')
521
+ assert.equal(readCounts.get('sub1'), undefined, 'subagent 产物不被解压')
522
+ })
523
+ })
524
+
525
+ test('历史输入路由:窗口放宽后最近 SCAN 个主会话全部参与对齐(超过旧 20 窗口)', skipMissingDeps, async () => {
526
+ await withHistoryCacheDir(async () => {
527
+ const total = HISTORY_SESSION_SCAN_LIMIT + 10
528
+ const headers = Array.from({ length: total }, (_, i) => ({ id: 's' + (total - 1 - i), cwd: 'C:\\x', createdAt: 0 }))
529
+ const readSessions = {}
530
+ for (let i = 0; i < total; i++) readSessions['s' + i] = [userMessageEvent('输入' + i, 1000 + i)]
531
+ const { handlers, readCounts } = makeCtx({ headers, agents: new Map(), readSessions })
532
+ // 第 SCAN 个会话(旧窗口外)的输入必须可入浮层
533
+ const boundary = 's' + (total - HISTORY_SESSION_SCAN_LIMIT)
534
+ await requestUntil(handlers, '?sessionId=' + boundary + '&scope=workspace',
535
+ (body) => body.aligned && body.inputs.some((item) => item.text === '输入' + (total - HISTORY_SESSION_SCAN_LIMIT)))
536
+ assert.ok(readCounts.get(boundary) >= 1, '窗口边界会话被解压')
537
+ assert.equal(readCounts.get('s0'), undefined, '窗口外最老会话不解压')
538
+ })
539
+ })
540
+
541
+ test('历史输入路由:既往误收录的 subagent 条目在对齐时自愈清除', skipMissingDeps, async () => {
542
+ await withHistoryCacheDir(async (cacheDir) => {
543
+ // 预置被旧版本污染的缓存:subagent 条目已在 entries 中
544
+ const cacheFile = workspaceCacheFile(cacheDir, 'C:\\x')
545
+ await writeFile(cacheFile, JSON.stringify({
546
+ cwd: 'C:\\x',
547
+ entries: [
548
+ { text: '子代理任务提示', at: 100, sid: 'sub1' },
549
+ { text: '人类输入', at: 200, sid: 's1' },
550
+ ],
551
+ extracts: {},
552
+ }), 'utf8')
553
+ const headers = [
554
+ { id: 'sub1', cwd: 'C:\\x', createdAt: 0, origin: 'subagent' },
555
+ { id: 's1', cwd: 'C:\\x', createdAt: 0 },
556
+ ]
557
+ const { handlers } = makeCtx({
558
+ headers,
559
+ agents: new Map(),
560
+ readSessions: { s1: [userMessageEvent('人类输入', 200)] },
561
+ })
562
+ // 对齐完成前请求直接返回预置缓存(aligned 且含目标文本):必须等到污染条目消失
563
+ const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
564
+ (body) => body.aligned && body.inputs.some((item) => item.text === '人类输入')
565
+ && !body.inputs.some((item) => item.text === '子代理任务提示'))
566
+ assert.deepEqual(res.body.inputs.map((item) => item.text), ['人类输入'])
567
+ })
568
+ })
569
+
570
+ test('历史输入路由:subagent 排最前不挤占窗口——第 SCAN 个主会话仍被解压', skipMissingDeps, async () => {
571
+ await withHistoryCacheDir(async () => {
572
+ // 锁定 filter 先于 slice:若先 slice 再 filter,sub1 占一槽后 s1(第 SCAN 个主会话)落窗外
573
+ const headers = [
574
+ { id: 'sub1', cwd: 'C:\\x', createdAt: 0, origin: 'subagent' },
575
+ ...Array.from({ length: HISTORY_SESSION_SCAN_LIMIT }, (_, i) => ({ id: 's' + (HISTORY_SESSION_SCAN_LIMIT - i), cwd: 'C:\\x', createdAt: 0 })),
576
+ ]
577
+ const readSessions = {}
578
+ for (let i = 1; i <= HISTORY_SESSION_SCAN_LIMIT; i++) readSessions['s' + i] = [userMessageEvent('输入' + i, 1000 + i)]
579
+ const { handlers, readCounts } = makeCtx({ headers, agents: new Map(), readSessions })
580
+ await requestUntil(handlers, '?sessionId=s1&scope=workspace',
581
+ (body) => body.aligned && body.inputs.some((item) => item.text === '输入1'))
582
+ assert.ok(readCounts.get('s1') >= 1, '第 SCAN 个主会话在窗内被解压')
583
+ assert.equal(readCounts.get('sub1'), undefined, 'subagent 仍不被解压')
584
+ })
585
+ })
586
+
587
+ // ── 常用提示词 ──
588
+
589
+ test('常用提示词:toggle 收藏/取消,prompts 范围可见,与工作区缓存互不污染', skipMissingDeps, async () => {
590
+ await withHistoryCacheDir(async () => {
591
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-prompts-'))
592
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
593
+ try {
594
+ await writeFile(artifactPath, 'log-v1')
595
+ const { handlers } = makeCtx({
596
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
597
+ agents: new Map(),
598
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
599
+ readSessions: { s1: [userMessageEvent('历史输入A', 100)] },
600
+ })
601
+ // 初始为空
602
+ const empty = response()
603
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=prompts'), empty)
604
+ assert.deepEqual(empty.body.inputs, [])
605
+ // 收藏 → prompts 可见;工作区范围不混入
606
+ await postJson(handlers, '/api/context/prompts/toggle', { text: '常用句子X' })
607
+ const listed = response()
608
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=prompts'), listed)
609
+ assert.deepEqual(listed.body.inputs.map((item) => item.text), ['常用句子X'])
610
+ const workspace = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
611
+ (body) => body.aligned && body.inputs.some((item) => item.text === '历史输入A'))
612
+ assert.equal(workspace.body.inputs.some((item) => item.text === '常用句子X'), false, '收藏不混入工作区历史')
613
+ // 再 toggle = 取消
614
+ await postJson(handlers, '/api/context/prompts/toggle', { text: '常用句子X' })
615
+ const cleared = response()
616
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=prompts'), cleared)
617
+ assert.deepEqual(cleared.body.inputs, [])
618
+ } finally {
619
+ await rm(dir, { recursive: true, force: true })
620
+ }
621
+ })
622
+ })
623
+
624
+ test('常用提示词:超长截断、上限裁剪、空 text 拒绝', skipMissingDeps, async () => {
625
+ await withHistoryCacheDir(async () => {
626
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-prompts-cap-'))
627
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
628
+ try {
629
+ await writeFile(artifactPath, 'log-v1')
630
+ const { handlers } = makeCtx({
631
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
632
+ agents: new Map(),
633
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
634
+ readSessions: { s1: [userMessageEvent('占位', 100)] },
635
+ })
636
+ await requestUntilAligned(handlers, '?sessionId=s1&scope=prompts')
637
+ // 空白文本拒绝
638
+ const blank = await postJson(handlers, '/api/context/prompts/toggle', { text: ' ' })
639
+ assert.equal(blank.status, 400)
640
+ // 截断:HISTORY_INPUT_MAX_CHARS 以上裁到上限
641
+ const long = '长'.repeat(HISTORY_INPUT_MAX_CHARS + 10)
642
+ await postJson(handlers, '/api/context/prompts/toggle', { text: long })
643
+ const listed = response()
644
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=prompts'), listed)
645
+ assert.equal(listed.body.inputs[0].text.length, HISTORY_INPUT_MAX_CHARS)
646
+ await postJson(handlers, '/api/context/prompts/toggle', { text: '长'.repeat(HISTORY_INPUT_MAX_CHARS) })
647
+ // 上限:超过 HISTORY_PROMPTS_MAX 后最旧被裁
648
+ for (let i = 0; i < HISTORY_PROMPTS_MAX + 1; i++) {
649
+ await postJson(handlers, '/api/context/prompts/toggle', { text: '条目' + i })
650
+ }
651
+ const capped = response()
652
+ await handlers.get('/api/context/inputs')(getRequest('?sessionId=s1&scope=prompts'), capped)
653
+ assert.equal(capped.body.inputs.length, HISTORY_PROMPTS_MAX)
654
+ assert.equal(capped.body.inputs.some((item) => item.text === '条目0'), false, '最旧条目被裁掉')
655
+ assert.equal(capped.body.inputs[0].text, '条目' + HISTORY_PROMPTS_MAX, '最新条目在顶部')
656
+ } finally {
657
+ await rm(dir, { recursive: true, force: true })
658
+ }
659
+ })
660
+ })
661
+
662
+ // ── 路由层横切守卫:方法白名单 / JSON 体 / 系统级错误收敛 / 非字符串 text ──
663
+
664
+ test('横切守卫:5 条路由方法白名单,白名单外一律 405', skipMissingDeps, async () => {
665
+ await withHistoryCacheDir(async () => {
666
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
667
+ const getOnly = '/api/context/inputs'
668
+ const postOnly = '/api/context/prompts/toggle'
669
+ const dualMethods = ['/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled']
670
+ // GET-only 路由:POST 拒绝
671
+ const inputsPost = response()
672
+ await handlers.get(getOnly)(request('s1'), inputsPost)
673
+ assert.equal(inputsPost.status, 405)
674
+ // POST-only 路由:GET 拒绝
675
+ const toggleGet = response()
676
+ await handlers.get(postOnly)(getRequest2(postOnly, 'GET'), toggleGet)
677
+ assert.equal(toggleGet.status, 405)
678
+ // 双方法路由(GET+POST):白名单外方法拒绝
679
+ for (const routePath of dualMethods) {
680
+ const res = response()
681
+ await handlers.get(routePath)(getRequest2(routePath, 'PUT'), res)
682
+ assert.equal(res.status, 405, routePath + ' 应拒绝 PUT')
683
+ }
684
+ })
685
+ })
686
+
687
+ test('横切守卫:4 条 POST 路由的非法 JSON 请求体统一 400(badJsonBody)', skipMissingDeps, async () => {
688
+ await withHistoryCacheDir(async () => {
689
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
690
+ const postPaths = ['/api/context/prompts/toggle', '/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled']
691
+ for (const routePath of postPaths) {
692
+ const res = await postRaw(handlers, routePath, '{oops')
693
+ assert.equal(res.status, 400, routePath + ' 非法 JSON 应回 400')
694
+ assert.equal(res.body.error, MESSAGES.badJsonBody)
695
+ }
696
+ })
697
+ })
698
+
699
+ test('横切守卫:带错误码的系统级错误收敛为固定文案并进服务端日志', skipMissingDeps, async () => {
700
+ await withHistoryCacheDir(async () => {
701
+ const settingsGetError = new Error("EACCES: permission denied, open 'C:\\secrets\\settings.json'")
702
+ settingsGetError.code = 'EACCES'
703
+ const { handlers, logger } = makeCtx({
704
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
705
+ agents: new Map(),
706
+ settingsGetError,
707
+ })
708
+ const res = response()
709
+ await handlers.get('/api/context/history-enabled')(getRequest2('/api/context/history-enabled', 'GET'), res)
710
+ assert.equal(res.status, 400)
711
+ assert.equal(res.body.error, MESSAGES.systemError, '系统级错误文案必须收敛,不得内嵌绝对路径')
712
+ assert.ok(logger.warns.some((line) => line.includes('EACCES')), '原始错误应仅进服务端日志')
713
+ })
714
+ })
715
+
716
+ test('常用提示词:非字符串 text 一律拒绝;超长文本二次 toggle 幂等(按截断后文本查重)', skipMissingDeps, async () => {
717
+ await withHistoryCacheDir(async () => {
718
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
719
+ for (const body of [{ text: 123 }, { text: null }, {}]) {
720
+ const res = await postJson(handlers, '/api/context/prompts/toggle', body)
721
+ assert.equal(res.status, 400, JSON.stringify(body) + ' 应拒绝')
722
+ assert.equal(res.body.error, 'text 不能为空')
723
+ }
724
+ // 超长文本收藏后再次原样 toggle = 取消(查重基于截断后文本,直调不产生重复收藏)
725
+ const truncated = '长'.repeat(HISTORY_INPUT_MAX_CHARS)
726
+ const first = await postJson(handlers, '/api/context/prompts/toggle', { text: truncated + '超出部分' })
727
+ assert.equal(first.body.collected, true)
728
+ const second = await postJson(handlers, '/api/context/prompts/toggle', { text: truncated })
729
+ assert.equal(second.body.collected, false, '原样超长文本 toggle 应命中截断后文本并取消')
730
+ })
731
+ })
732
+
733
+ test('常用提示词:client 与 core 的 HISTORY_SCOPES 镜像同序同值,默认落点为当前会话', { skip: process.env.TEST_SKIP_PARITY }, async () => {
734
+ const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
735
+ const declared = clientSource.match(/const HISTORY_SCOPES = (\[[^\]]*\])/)
736
+ assert.ok(declared, 'client.js 必须内联声明 HISTORY_SCOPES')
737
+ assert.deepEqual(JSON.parse(declared[1].replace(/'/g, '"')), HISTORY_SCOPES)
738
+ const labels = clientSource.match(/const HISTORY_SCOPE_LABELS = (\[[^\]]*\])/)
739
+ assert.ok(labels, 'client.js 必须内联声明 HISTORY_SCOPE_LABELS')
740
+ assert.equal(JSON.parse(labels[1].replace(/'/g, '"')).length, HISTORY_SCOPES.length, '标签与范围一一对应')
741
+ assert.equal(HISTORY_SCOPES.indexOf('session'), 1, '浮层默认落点为当前会话(索引 1)')
742
+ })
743
+
744
+ // ── 启停开关路由(GET/POST;settings 持久)──
745
+
746
+ test('历史浮层启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
747
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
748
+ const initial = response()
749
+ await handlers.get('/api/context/history-enabled')(getRequest2('/api/context/history-enabled', 'GET'), initial)
750
+ assert.equal(initial.body.enabled, true, '默认启用')
751
+ const off = await postJson(handlers, '/api/context/history-enabled', { enabled: false })
752
+ assert.equal(off.body.enabled, false)
753
+ // settings 持久:重读反映关闭态;POST 回 true 恢复
754
+ const reread = response()
755
+ await handlers.get('/api/context/history-enabled')(getRequest2('/api/context/history-enabled', 'GET'), reread)
756
+ assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
757
+ await postJson(handlers, '/api/context/history-enabled', { enabled: true })
758
+ const restored = response()
759
+ await handlers.get('/api/context/history-enabled')(getRequest2('/api/context/history-enabled', 'GET'), restored)
760
+ assert.equal(restored.body.enabled, true)
761
+ })
762
+
763
+ test('插话撤回启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
764
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
765
+ const off = await postJson(handlers, '/api/context/steer-recall-enabled', { enabled: false })
766
+ assert.equal(off.body.enabled, false)
767
+ const reread = response()
768
+ await handlers.get('/api/context/steer-recall-enabled')(getRequest2('/api/context/steer-recall-enabled', 'GET'), reread)
769
+ assert.equal(reread.body.enabled, false)
770
+ await postJson(handlers, '/api/context/steer-recall-enabled', { enabled: true })
771
+ const restored = response()
772
+ await handlers.get('/api/context/steer-recall-enabled')(getRequest2('/api/context/steer-recall-enabled', 'GET'), restored)
773
+ assert.equal(restored.body.enabled, true)
774
+ })
775
+
776
+ test('fork 启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
777
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
778
+ const initial = response()
779
+ await handlers.get('/api/context/fork-enabled')(getRequest2('/api/context/fork-enabled', 'GET'), initial)
780
+ assert.equal(initial.body.enabled, true, '默认启用')
781
+ const off = await postJson(handlers, '/api/context/fork-enabled', { enabled: false })
782
+ assert.equal(off.body.enabled, false)
783
+ const reread = response()
784
+ await handlers.get('/api/context/fork-enabled')(getRequest2('/api/context/fork-enabled', 'GET'), reread)
785
+ assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
786
+ await postJson(handlers, '/api/context/fork-enabled', { enabled: true })
787
+ const restored = response()
788
+ await handlers.get('/api/context/fork-enabled')(getRequest2('/api/context/fork-enabled', 'GET'), restored)
789
+ assert.equal(restored.body.enabled, true)
790
+ })