@mzzsfy/dsh-session-manager 0.3.1
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/README.md +82 -0
- package/cordis.patch.yml +12 -0
- package/package.json +51 -0
- package/src/client.js +835 -0
- package/src/core.mjs +190 -0
- package/src/history-cache.mjs +94 -0
- package/src/index.js +1069 -0
- package/src/trash.mjs +49 -0
- package/test/client-id.test.mjs +16 -0
- package/test/client-toast.test.mjs +89 -0
- package/test/core.test.mjs +307 -0
- package/test/index.test.mjs +1851 -0
- package/test/parity.test.mjs +160 -0
- package/test/trash.test.mjs +73 -0
|
@@ -0,0 +1,1851 @@
|
|
|
1
|
+
// 路由层 BDD(mock ctx 强制 cordis 注入语义):宿主上下文上任何未声明 inject 的
|
|
2
|
+
// 服务属性访问一律抛 cannot get property ... without inject,与 cordis reflect 行为
|
|
3
|
+
// 一致,防止 inject 声明缺失回归。peer 依赖未安装的仓库环境(禁止 install)下整文件跳过。
|
|
4
|
+
|
|
5
|
+
import test, { mock } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { EventEmitter } from 'node:events'
|
|
8
|
+
import { mkdtemp, writeFile, utimes, mkdir, rm, readFile } from 'node:fs/promises'
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
// 仅 peer 依赖缺失允许整文件跳过;其余加载失败(src 自身损坏)直接失败,禁止静默 skip
|
|
14
|
+
const indexModule = await import('../src/index.js').catch((error) => {
|
|
15
|
+
const missingPeer = error && error.code === 'ERR_MODULE_NOT_FOUND'
|
|
16
|
+
&& (error.message.includes("'@deepseek-ai/") || error.message.includes("'zod"))
|
|
17
|
+
if (missingPeer) return error
|
|
18
|
+
throw error
|
|
19
|
+
})
|
|
20
|
+
const apply = indexModule.apply
|
|
21
|
+
const declaredInject = Array.isArray(indexModule.inject) ? indexModule.inject : []
|
|
22
|
+
const dependencyReady = typeof apply === 'function'
|
|
23
|
+
const skipMissingDeps = { skip: dependencyReady ? false : 'peer 依赖未安装,路由层测试跳过' }
|
|
24
|
+
const MESSAGES = indexModule.MESSAGES
|
|
25
|
+
const { DELETE_MESSAGES, HISTORY_INPUT_LIMIT, HISTORY_STARTUP_SCAN_LIMIT, HISTORY_INPUT_MAX_CHARS, HISTORY_PROMPTS_MAX, HISTORY_SCOPES } = await import('../src/core.mjs').catch(() => ({ DELETE_MESSAGES: {} }))
|
|
26
|
+
const { ensureCacheDir, writeWorkspaceCache } = await import('../src/history-cache.mjs')
|
|
27
|
+
|
|
28
|
+
// 插件激活即跑历史缓存启动对齐:所有测试统一隔离缓存目录,
|
|
29
|
+
// 防止夹具工作区经默认路径(~/.dsh/historyPrompt)泄漏进真实用户目录
|
|
30
|
+
const sharedCacheDir = mkdtempSync(path.join(tmpdir(), 'sm-hist-shared-'))
|
|
31
|
+
process.env.DSH_HISTORY_CACHE_DIR = sharedCacheDir
|
|
32
|
+
test.after(() => { rmSync(sharedCacheDir, { recursive: true, force: true }) })
|
|
33
|
+
// 台账/重挂载夹具路径仅作数据,不落盘;形态与实现一致(会话目录)
|
|
34
|
+
const LEDGER_FIXTURE_PATH = 'C:\\store\\s1'
|
|
35
|
+
|
|
36
|
+
function makeRegistry(initialIds) {
|
|
37
|
+
// 真实注册表契约:archivedSessionIds 是进程内快照的 getter,官方读路径全部经快照;
|
|
38
|
+
// archiveSession 归档即入集合(官方幂等判定依据),桩保持同语义
|
|
39
|
+
const registry = {
|
|
40
|
+
state: { initialized: true, workspaceIds: [], archivedSessionIds: initialIds },
|
|
41
|
+
list: () => [],
|
|
42
|
+
archiveCalls: [],
|
|
43
|
+
archiveSession: async (id) => {
|
|
44
|
+
const sid = String(id)
|
|
45
|
+
registry.archiveCalls.push(sid)
|
|
46
|
+
registry.state.archivedSessionIds = [...registry.state.archivedSessionIds, sid]
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
Object.defineProperty(registry, 'archivedSessionIds', {
|
|
50
|
+
get: () => registry.state.archivedSessionIds,
|
|
51
|
+
})
|
|
52
|
+
return registry
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// workspace 域句柄:null 表示域未打开(get 返回 undefined)
|
|
56
|
+
function makeDomain(initialIds) {
|
|
57
|
+
const domain = {
|
|
58
|
+
state: { archivedSessionIds: initialIds },
|
|
59
|
+
writes: 0,
|
|
60
|
+
global: {
|
|
61
|
+
get: () => domain.state,
|
|
62
|
+
set: async (next) => {
|
|
63
|
+
domain.state = { ...next }
|
|
64
|
+
domain.writes += 1
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
return domain
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 台账域句柄;openRejected 模拟台账域打开失败
|
|
72
|
+
function makeLedgerDomain(entries) {
|
|
73
|
+
const domain = {
|
|
74
|
+
state: { deleted: entries },
|
|
75
|
+
writes: 0,
|
|
76
|
+
global: {
|
|
77
|
+
get: () => domain.state,
|
|
78
|
+
set: async (next) => {
|
|
79
|
+
domain.state = { ...next }
|
|
80
|
+
domain.writes += 1
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return domain
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// workspace 注册表实体桩:detach/attach 均为官方实体契约,供删除与重挂载断言
|
|
88
|
+
function makeWorkspace(path, sessionIds, options = {}) {
|
|
89
|
+
const workspace = {
|
|
90
|
+
path,
|
|
91
|
+
sessionIds,
|
|
92
|
+
attachCalls: [],
|
|
93
|
+
attachSession: async (id) => {
|
|
94
|
+
if (options.attachError) throw options.attachError
|
|
95
|
+
workspace.attachCalls.push(String(id))
|
|
96
|
+
workspace.sessionIds = [String(id), ...workspace.sessionIds.filter((existing) => existing !== id)]
|
|
97
|
+
},
|
|
98
|
+
detachSession: async (id) => {
|
|
99
|
+
if (options.detachError) throw options.detachError
|
|
100
|
+
workspace.sessionIds = workspace.sessionIds.filter((existing) => String(existing) !== String(id))
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
return workspace
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function makeCtx({
|
|
107
|
+
archivedIds,
|
|
108
|
+
headers,
|
|
109
|
+
agents,
|
|
110
|
+
domain,
|
|
111
|
+
sessionPersistence,
|
|
112
|
+
ledger,
|
|
113
|
+
openRejected,
|
|
114
|
+
workspaces,
|
|
115
|
+
settingsValue,
|
|
116
|
+
timerAvailable,
|
|
117
|
+
readSessions,
|
|
118
|
+
}) {
|
|
119
|
+
const routes = []
|
|
120
|
+
const eventHandlers = {}
|
|
121
|
+
const pendingInjects = []
|
|
122
|
+
const ledgerDomain = ledger === undefined ? makeLedgerDomain([]) : ledger
|
|
123
|
+
const workspaceList = workspaces || []
|
|
124
|
+
const settingsState = { value: settingsValue }
|
|
125
|
+
const settingsService = {
|
|
126
|
+
get: () => settingsState.value,
|
|
127
|
+
register: () => ({ resolved: undefined }),
|
|
128
|
+
update: (ns, patch) => { settingsState.value = { ...(settingsState.value || {}), ...patch } },
|
|
129
|
+
}
|
|
130
|
+
// timer 服务桩:模拟宿主 timer 激活后的 interval;unref 保证测试进程可自然退出
|
|
131
|
+
const intervalStub = (fn, ms) => {
|
|
132
|
+
const timer = setInterval(fn, ms)
|
|
133
|
+
timer.unref()
|
|
134
|
+
return () => clearInterval(timer)
|
|
135
|
+
}
|
|
136
|
+
// readSession 桩:sessionId → 事件数组(工厂形态则调用后抛错);reads 记录实际读取次数供缓存断言
|
|
137
|
+
const readCounts = new Map()
|
|
138
|
+
const sessionQuery = {
|
|
139
|
+
listSessions: async () => headers.map((header) => ({ header })),
|
|
140
|
+
readSession: async (sessionId) => {
|
|
141
|
+
readCounts.set(sessionId, (readCounts.get(sessionId) || 0) + 1)
|
|
142
|
+
const events = readSessions ? readSessions[sessionId] : undefined
|
|
143
|
+
if (typeof events === 'function') throw events()
|
|
144
|
+
if (events === undefined) throw new Error('桩未配置该会话产物')
|
|
145
|
+
return { session: headers.find((header) => header.id === sessionId), events }
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
const services = {
|
|
149
|
+
webServer: { register: (route) => routes.push(route) },
|
|
150
|
+
workspaceRegistry: Object.assign(makeRegistry(archivedIds), { list: () => workspaceList }),
|
|
151
|
+
sessionQuery,
|
|
152
|
+
storageDomain: {
|
|
153
|
+
get: (name) => name === 'workspace' ? (domain || undefined) : undefined,
|
|
154
|
+
open: openRejected ? () => Promise.reject(openRejected) : async () => ledgerDomain,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
const base = {
|
|
158
|
+
effect: (fn) => fn(),
|
|
159
|
+
inject: (_deps, fn) => { pendingInjects.push(fn) },
|
|
160
|
+
get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
|
|
161
|
+
on: (event, handler) => { eventHandlers[event] = handler },
|
|
162
|
+
// 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
|
|
163
|
+
logger: { warns: [], warn(message) { this.warns.push(message) }, info() {} },
|
|
164
|
+
}
|
|
165
|
+
const ctx = new Proxy(base, {
|
|
166
|
+
get(target, prop) {
|
|
167
|
+
if (typeof prop === 'symbol') return undefined
|
|
168
|
+
if (prop in target) return target[prop]
|
|
169
|
+
if (!declaredInject.includes(prop)) {
|
|
170
|
+
throw new Error(`cannot get property "${String(prop)}" without inject`)
|
|
171
|
+
}
|
|
172
|
+
return services[prop]
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
apply(ctx, undefined)
|
|
176
|
+
return {
|
|
177
|
+
handlers: new Map(routes.map((route) => [route.path, route.handler])),
|
|
178
|
+
eventHandlers,
|
|
179
|
+
domain,
|
|
180
|
+
ledger: ledgerDomain,
|
|
181
|
+
registry: services.workspaceRegistry,
|
|
182
|
+
logger: base.logger,
|
|
183
|
+
readCounts,
|
|
184
|
+
// 模拟宿主 settings 与 timer 服务激活:触发 inject 回调(注册 + 启动补扫 + 周期武装)
|
|
185
|
+
activateSettings: () => {
|
|
186
|
+
const injected = { settings: settingsService, interval: timerAvailable === false ? undefined : intervalStub }
|
|
187
|
+
while (pendingInjects.length > 0) pendingInjects.shift()(injected)
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function waitFor(predicate) {
|
|
193
|
+
// performance.now 不受 mock.timers 的 Date 接管影响,周期评估测试中仍可真实计时
|
|
194
|
+
const deadline = performance.now() + 2000
|
|
195
|
+
while (performance.now() < deadline) {
|
|
196
|
+
if (predicate()) return
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
198
|
+
}
|
|
199
|
+
assert.ok(predicate(), 'waitFor 超时')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function request(sessionId, method = 'POST') {
|
|
203
|
+
const req = new EventEmitter()
|
|
204
|
+
req.method = method
|
|
205
|
+
if (method !== 'GET') {
|
|
206
|
+
process.nextTick(() => {
|
|
207
|
+
req.emit('data', Buffer.from(JSON.stringify({ sessionId })))
|
|
208
|
+
req.emit('end')
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
return req
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function response() {
|
|
215
|
+
const state = { status: undefined, body: undefined }
|
|
216
|
+
state.writeHead = (status) => { state.status = status }
|
|
217
|
+
state.end = (text) => { state.body = JSON.parse(text) }
|
|
218
|
+
return state
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const HEADER = { id: 's1', cwd: 'C:\\x', createdAt: 0 }
|
|
222
|
+
|
|
223
|
+
test('inject 声明覆盖路由层触及的全部宿主服务(cordis 未声明即抛 cannot get property)', skipMissingDeps, () => {
|
|
224
|
+
for (const service of ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']) {
|
|
225
|
+
assert.ok(declaredInject.includes(service), `inject 缺少 "${service}"`)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('请求体非法 JSON:协议错误与业务错误分通道,不透出引擎 SyntaxError', skipMissingDeps, async () => {
|
|
230
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
|
|
231
|
+
const req = new EventEmitter()
|
|
232
|
+
req.method = 'POST'
|
|
233
|
+
process.nextTick(() => {
|
|
234
|
+
req.emit('data', Buffer.from('{not json'))
|
|
235
|
+
req.emit('end')
|
|
236
|
+
})
|
|
237
|
+
const res = response()
|
|
238
|
+
await handlers.get('/api/session-manager/unarchive')(req, res)
|
|
239
|
+
assert.equal(res.status, 400)
|
|
240
|
+
assert.equal(res.body.error, '请求体不是合法 JSON')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('自动归档评估门闩:评估进行中的新触发被丢弃不排队', skipMissingDeps, async () => {
|
|
244
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-gate-'))
|
|
245
|
+
try {
|
|
246
|
+
const alivePath = path.join(dir, 'alive.jsonl')
|
|
247
|
+
await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
|
|
248
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
249
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
250
|
+
await utimes(alivePath, stale / 1000, stale / 1000)
|
|
251
|
+
const cwd = 'C:\\x'
|
|
252
|
+
const headers = [{ id: 's1', cwd, createdAt: stale }]
|
|
253
|
+
const { eventHandlers, registry } = makeCtx({
|
|
254
|
+
archivedIds: [],
|
|
255
|
+
headers,
|
|
256
|
+
agents: new Map(),
|
|
257
|
+
sessionPersistence: { locate: (header) => ({ path: alivePath }) },
|
|
258
|
+
})
|
|
259
|
+
eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
|
|
260
|
+
// 首轮启动后立即补新候选并连发两触发:首轮候选列表已捕获(s1),门闩正常时
|
|
261
|
+
// trigger2/3 被丢弃,s2 永不评估;门闩失效则 s2 被归档,断言可见
|
|
262
|
+
headers.push({ id: 's2', cwd, createdAt: stale })
|
|
263
|
+
eventHandlers['session/created']({ header: { id: 'trigger2', cwd } })
|
|
264
|
+
eventHandlers['session/created']({ header: { id: 'trigger3', cwd } })
|
|
265
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
266
|
+
// 给在途与迟到轮留完成窗口后断言:仅首轮产出,s2 未被任何一轮归档
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
268
|
+
assert.deepEqual(registry.archiveCalls, ['s1'], '门闩应丢弃在途触发,新候选 s2 不被评估')
|
|
269
|
+
} finally {
|
|
270
|
+
await rm(dir, { recursive: true, force: true })
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('并发删除:台账串行化不丢条目', skipMissingDeps, async () => {
|
|
275
|
+
const artifactA = await makeLocatedArtifact('a')
|
|
276
|
+
const artifactB = await makeLocatedArtifact('b')
|
|
277
|
+
try {
|
|
278
|
+
const headers = [
|
|
279
|
+
{ id: 'a', cwd: 'C:\\x', createdAt: 0 },
|
|
280
|
+
{ id: 'b', cwd: 'C:\\x', createdAt: 0 },
|
|
281
|
+
]
|
|
282
|
+
const workspace = makeWorkspace('C:\\x', ['a', 'b'])
|
|
283
|
+
const { handlers, ledger } = makeCtx({
|
|
284
|
+
archivedIds: ['a', 'b'],
|
|
285
|
+
headers,
|
|
286
|
+
agents: new Map([['a', { status: 'idle' }], ['b', { status: 'idle' }]]),
|
|
287
|
+
domain: makeDomain(['a', 'b']),
|
|
288
|
+
sessionPersistence: { locate: (header) => header.id === 'a' ? { path: artifactA.locatedPath } : { path: artifactB.locatedPath } },
|
|
289
|
+
workspaces: [workspace],
|
|
290
|
+
})
|
|
291
|
+
const resA = response()
|
|
292
|
+
const resB = response()
|
|
293
|
+
await Promise.all([
|
|
294
|
+
withTrashStub(async () => {}, async () => {
|
|
295
|
+
await handlers.get('/api/session-manager/delete')(request('a'), resA)
|
|
296
|
+
}),
|
|
297
|
+
withTrashStub(async () => {}, async () => {
|
|
298
|
+
await handlers.get('/api/session-manager/delete')(request('b'), resB)
|
|
299
|
+
}),
|
|
300
|
+
])
|
|
301
|
+
assert.equal(resA.status, 200)
|
|
302
|
+
assert.equal(resB.status, 200)
|
|
303
|
+
// 串行化后两条台账都在;丢失更新会让后写者只剩自己的条目
|
|
304
|
+
assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId).sort(), ['a', 'b'])
|
|
305
|
+
} finally {
|
|
306
|
+
await artifactA.cleanup()
|
|
307
|
+
await artifactB.cleanup()
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('同 id 并发删除:第二个请求收到 in-flight 提示', skipMissingDeps, async () => {
|
|
312
|
+
const artifact = await makeLocatedArtifact()
|
|
313
|
+
try {
|
|
314
|
+
let releaseTrash
|
|
315
|
+
const trashGate = new Promise((resolve) => { releaseTrash = resolve })
|
|
316
|
+
const workspace = makeWorkspace('C:\\x', ['s1'])
|
|
317
|
+
const { handlers } = makeCtx({
|
|
318
|
+
archivedIds: ['s1'],
|
|
319
|
+
headers: [HEADER],
|
|
320
|
+
agents: IDLE_S1,
|
|
321
|
+
domain: makeDomain(['s1']),
|
|
322
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
323
|
+
workspaces: [workspace],
|
|
324
|
+
})
|
|
325
|
+
const first = response()
|
|
326
|
+
const firstDone = withTrashStub(async () => { await trashGate }, async () => {
|
|
327
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), first)
|
|
328
|
+
})
|
|
329
|
+
// 等 trash 进入挂起后再发第二发
|
|
330
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
331
|
+
const second = response()
|
|
332
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), second)
|
|
333
|
+
assert.equal(second.status, 400)
|
|
334
|
+
assert.equal(second.body.error, '该会话正在删除中,请稍后重试')
|
|
335
|
+
releaseTrash()
|
|
336
|
+
await firstDone
|
|
337
|
+
} finally {
|
|
338
|
+
await artifact.cleanup()
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('多工作区 detach:首个失败不终止后续,全部失败进日志', skipMissingDeps, async () => {
|
|
343
|
+
const artifact = await makeLocatedArtifact()
|
|
344
|
+
try {
|
|
345
|
+
const broken = makeWorkspace('C:\\one', ['s1'], { detachError: new Error('one boom') })
|
|
346
|
+
const healthy = makeWorkspace('C:\\two', ['s1'])
|
|
347
|
+
const { handlers } = makeCtx({
|
|
348
|
+
archivedIds: ['s1'],
|
|
349
|
+
headers: [HEADER],
|
|
350
|
+
agents: IDLE_S1,
|
|
351
|
+
domain: makeDomain(['s1']),
|
|
352
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
353
|
+
workspaces: [broken, healthy],
|
|
354
|
+
})
|
|
355
|
+
const res = response()
|
|
356
|
+
await withTrashStub(async () => {}, async () => {
|
|
357
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
358
|
+
})
|
|
359
|
+
assert.equal(res.status, 200)
|
|
360
|
+
assert.equal(res.body.partial, true)
|
|
361
|
+
assert.equal(res.body.message, '已移入回收站,但移除列表记录失败')
|
|
362
|
+
// 健康工作区仍被解除关联
|
|
363
|
+
assert.deepEqual(healthy.sessionIds, [])
|
|
364
|
+
} finally {
|
|
365
|
+
await artifact.cleanup()
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('取消归档:域领先快照时反向合并清理', skipMissingDeps, async () => {
|
|
370
|
+
const domain = makeDomain(['s1'])
|
|
371
|
+
const { handlers, registry } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map(), domain })
|
|
372
|
+
const res = response()
|
|
373
|
+
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
374
|
+
assert.equal(res.status, 200)
|
|
375
|
+
assert.deepEqual(res.body, { ok: true })
|
|
376
|
+
// durable 有而快照无:合并后仍完成移除,防漏清理
|
|
377
|
+
assert.deepEqual(domain.state.archivedSessionIds, [])
|
|
378
|
+
assert.equal(domain.writes, 1)
|
|
379
|
+
assert.deepEqual(registry.state.archivedSessionIds, [])
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissingDeps, async () => {
|
|
383
|
+
const artifact = await makeLocatedArtifact()
|
|
384
|
+
try {
|
|
385
|
+
const before = Date.now()
|
|
386
|
+
const { handlers, ledger } = makeCtx({
|
|
387
|
+
archivedIds: ['s1'],
|
|
388
|
+
headers: [HEADER],
|
|
389
|
+
agents: IDLE_S1,
|
|
390
|
+
domain: makeDomain(['s1']),
|
|
391
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
392
|
+
})
|
|
393
|
+
const res = response()
|
|
394
|
+
await withTrashStub(async () => {}, async () => {
|
|
395
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
396
|
+
})
|
|
397
|
+
assert.equal(res.status, 200)
|
|
398
|
+
const deletedAt = ledger.state.deleted[0].deletedAt
|
|
399
|
+
assert.ok(typeof deletedAt === 'number' && deletedAt >= before && deletedAt <= Date.now() + 1000)
|
|
400
|
+
} finally {
|
|
401
|
+
await artifact.cleanup()
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('取消归档路由:从域集合移除目标 id 并写回', skipMissingDeps, async () => {
|
|
406
|
+
const domain = makeDomain(['s1', 's2'])
|
|
407
|
+
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
|
|
408
|
+
const res = response()
|
|
409
|
+
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
410
|
+
assert.equal(res.status, 200)
|
|
411
|
+
assert.deepEqual(res.body, { ok: true })
|
|
412
|
+
assert.deepEqual(domain.state.archivedSessionIds, ['s2'])
|
|
413
|
+
assert.equal(domain.writes, 1)
|
|
414
|
+
// 复活守卫:注册表进程内快照必须同步移除,否则官方全量写回与 list 基线会恢复该 id
|
|
415
|
+
assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
|
|
416
|
+
assert.deepEqual(registry.archivedSessionIds, ['s2'])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('取消归档路由:workspace 域未打开时拒绝', skipMissingDeps, async () => {
|
|
420
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain: null })
|
|
421
|
+
const res = response()
|
|
422
|
+
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
423
|
+
assert.equal(res.status, 400)
|
|
424
|
+
assert.equal(res.body.error, 'workspace 域未打开')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('取消归档路由:未归档 id 幂等跳过,不产生写回', skipMissingDeps, async () => {
|
|
428
|
+
const domain = makeDomain(['s1'])
|
|
429
|
+
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
|
|
430
|
+
const res = response()
|
|
431
|
+
await handlers.get('/api/session-manager/unarchive')(request('s9'), res)
|
|
432
|
+
assert.equal(res.status, 200)
|
|
433
|
+
assert.deepEqual(res.body, { ok: true })
|
|
434
|
+
assert.deepEqual(domain.state.archivedSessionIds, ['s1'])
|
|
435
|
+
assert.equal(domain.writes, 0)
|
|
436
|
+
assert.deepEqual(registry.state.archivedSessionIds, ['s1'])
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('取消归档路由:快照领先域时补全清理(重试路径)', skipMissingDeps, async () => {
|
|
440
|
+
const domain = makeDomain([])
|
|
441
|
+
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
|
|
442
|
+
const res = response()
|
|
443
|
+
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
444
|
+
assert.equal(res.status, 200)
|
|
445
|
+
assert.deepEqual(res.body, { ok: true })
|
|
446
|
+
assert.deepEqual(domain.state.archivedSessionIds, [])
|
|
447
|
+
assert.equal(domain.writes, 1)
|
|
448
|
+
assert.deepEqual(registry.state.archivedSessionIds, [])
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('info 路由:会话不存在拒绝', skipMissingDeps, async () => {
|
|
452
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
|
|
453
|
+
const res = response()
|
|
454
|
+
await handlers.get('/api/session-manager/info')(request('s1'), res)
|
|
455
|
+
assert.equal(res.status, 400)
|
|
456
|
+
assert.equal(res.body.error, '会话不存在')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
test('info 路由:locate 缺失按不支持返回', skipMissingDeps, async () => {
|
|
460
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map() })
|
|
461
|
+
const res = response()
|
|
462
|
+
await handlers.get('/api/session-manager/info')(request('s1'), res)
|
|
463
|
+
assert.equal(res.status, 200)
|
|
464
|
+
assert.deepEqual(res.body, { supported: false })
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test('info 路由:产物已缺失按 missing 返回,不裸抛', skipMissingDeps, async () => {
|
|
468
|
+
const { handlers } = makeCtx({
|
|
469
|
+
archivedIds: [],
|
|
470
|
+
headers: [HEADER],
|
|
471
|
+
agents: new Map(),
|
|
472
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: 'C:\\gone\\s1\\session.jsonl.zstd' } : undefined },
|
|
473
|
+
})
|
|
474
|
+
const res = response()
|
|
475
|
+
await handlers.get('/api/session-manager/info')(request('s1'), res)
|
|
476
|
+
assert.equal(res.status, 200)
|
|
477
|
+
assert.deepEqual(res.body, { supported: true, sizeBytes: 0, missing: true })
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('删除路由:运行中会话被守卫拒绝,不触发 locate 与 trash', skipMissingDeps, async () => {
|
|
481
|
+
const { handlers } = makeCtx({
|
|
482
|
+
archivedIds: ['s1'],
|
|
483
|
+
headers: [HEADER],
|
|
484
|
+
agents: new Map([['s1', { status: 'running' }]]),
|
|
485
|
+
})
|
|
486
|
+
const res = response()
|
|
487
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
488
|
+
assert.equal(res.status, 400)
|
|
489
|
+
assert.equal(res.body.error, '运行中的会话不可删除')
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('删除路由:非运行中的已归档会话因 locate 缺失拒绝(守卫未误拦)', skipMissingDeps, async () => {
|
|
493
|
+
const { handlers } = makeCtx({
|
|
494
|
+
archivedIds: ['s1'],
|
|
495
|
+
headers: [HEADER],
|
|
496
|
+
agents: new Map([['s1', { status: 'idle' }]]),
|
|
497
|
+
})
|
|
498
|
+
const res = response()
|
|
499
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
500
|
+
assert.equal(res.status, 400)
|
|
501
|
+
assert.equal(res.body.error, '当前存储后端不支持按会话删除')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('删除路由:未归档会话拒绝(运行守卫优先于资格)', skipMissingDeps, async () => {
|
|
505
|
+
const { handlers } = makeCtx({
|
|
506
|
+
archivedIds: [],
|
|
507
|
+
headers: [HEADER],
|
|
508
|
+
agents: new Map([['s1', { status: 'running' }]]),
|
|
509
|
+
})
|
|
510
|
+
const res = response()
|
|
511
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
512
|
+
assert.equal(res.status, 400)
|
|
513
|
+
assert.equal(res.body.error, '运行中的会话不可删除')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('删除路由:未归档且空闲的会话拒绝', skipMissingDeps, async () => {
|
|
517
|
+
const artifact = await makeLocatedArtifact()
|
|
518
|
+
try {
|
|
519
|
+
const { handlers } = makeCtx({
|
|
520
|
+
archivedIds: [],
|
|
521
|
+
headers: [HEADER],
|
|
522
|
+
agents: new Map([['s1', { status: 'idle' }]]),
|
|
523
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
524
|
+
})
|
|
525
|
+
const res = response()
|
|
526
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
527
|
+
assert.equal(res.status, 400)
|
|
528
|
+
assert.equal(res.body.error, '仅已归档会话可删除')
|
|
529
|
+
} finally {
|
|
530
|
+
await artifact.cleanup()
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('自动归档评估:产物不可读的会话不参与归档(防删除后复活)', skipMissingDeps, async () => {
|
|
535
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-'))
|
|
536
|
+
const alivePath = path.join(dir, 'alive.jsonl')
|
|
537
|
+
await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
|
|
538
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
539
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
540
|
+
await utimes(alivePath, stale / 1000, stale / 1000)
|
|
541
|
+
const cwd = 'C:\\x'
|
|
542
|
+
const headers = [
|
|
543
|
+
{ id: 's1', cwd, createdAt: stale },
|
|
544
|
+
{ id: 's2', cwd, createdAt: stale },
|
|
545
|
+
]
|
|
546
|
+
const sessionPersistence = {
|
|
547
|
+
locate: (header) => header.id === 's1' ? { path: alivePath } : { path: path.join(dir, 'gone.jsonl') },
|
|
548
|
+
}
|
|
549
|
+
const { eventHandlers, registry } = makeCtx({
|
|
550
|
+
archivedIds: [],
|
|
551
|
+
headers,
|
|
552
|
+
agents: new Map(),
|
|
553
|
+
sessionPersistence,
|
|
554
|
+
})
|
|
555
|
+
eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
|
|
556
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
557
|
+
// s2 产物缺失必须被守卫跳过:否则已删除会话会被重新归档而在面板复活
|
|
558
|
+
assert.deepEqual(registry.archiveCalls, ['s1'])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档', skipMissingDeps, async () => {
|
|
562
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-nolocate-'))
|
|
563
|
+
try {
|
|
564
|
+
const alivePath = path.join(dir, 'alive.jsonl')
|
|
565
|
+
await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
|
|
566
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
567
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
568
|
+
await utimes(alivePath, stale / 1000, stale / 1000)
|
|
569
|
+
const cwd = 'C:\\x'
|
|
570
|
+
const headers = [
|
|
571
|
+
{ id: 's1', cwd, createdAt: stale },
|
|
572
|
+
{ id: 's2', cwd, createdAt: stale },
|
|
573
|
+
]
|
|
574
|
+
const { eventHandlers, registry } = makeCtx({
|
|
575
|
+
archivedIds: [],
|
|
576
|
+
headers,
|
|
577
|
+
agents: new Map(),
|
|
578
|
+
// s2 locate 返回 undefined(抽象后端契约允许):仅凭 createdAt 判活跃会绕过
|
|
579
|
+
// mtime 保护,与 delete 的 unsupportedBackend 拒绝对齐
|
|
580
|
+
sessionPersistence: {
|
|
581
|
+
locate: (header) => header.id === 's1' ? { path: alivePath } : undefined,
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
|
|
585
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
586
|
+
assert.deepEqual(registry.archiveCalls, ['s1'])
|
|
587
|
+
} finally {
|
|
588
|
+
await rm(dir, { recursive: true, force: true })
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('启动评估:settings 就绪后全量归档超期会话,不限于单工作区', skipMissingDeps, async () => {
|
|
593
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-startup-'))
|
|
594
|
+
try {
|
|
595
|
+
const artifactA = path.join(dir, 'a.jsonl')
|
|
596
|
+
const artifactB = path.join(dir, 'b.jsonl')
|
|
597
|
+
await writeFile(artifactA, '{"header":1}\n{"event":0}\n')
|
|
598
|
+
await writeFile(artifactB, '{"header":1}\n{"event":0}\n')
|
|
599
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
600
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
601
|
+
await utimes(artifactA, stale / 1000, stale / 1000)
|
|
602
|
+
await utimes(artifactB, stale / 1000, stale / 1000)
|
|
603
|
+
const headers = [
|
|
604
|
+
{ id: 'a', cwd: 'C:\\x', createdAt: stale },
|
|
605
|
+
{ id: 'b', cwd: 'C:\\y', createdAt: stale },
|
|
606
|
+
]
|
|
607
|
+
const { activateSettings, registry, handlers } = makeCtx({
|
|
608
|
+
archivedIds: [],
|
|
609
|
+
headers,
|
|
610
|
+
agents: new Map(),
|
|
611
|
+
sessionPersistence: {
|
|
612
|
+
locate: (header) => ({ path: header.id === 'a' ? artifactA : artifactB }),
|
|
613
|
+
},
|
|
614
|
+
})
|
|
615
|
+
activateSettings()
|
|
616
|
+
await waitFor(() => registry.archiveCalls.length >= 2)
|
|
617
|
+
assert.deepEqual([...registry.archiveCalls].sort(), ['a', 'b'])
|
|
618
|
+
const status = response()
|
|
619
|
+
await handlers.get('/api/session-manager/status')(request('x', 'GET'), status)
|
|
620
|
+
assert.equal(status.status, 200)
|
|
621
|
+
assert.equal(status.body.periodic.running, true)
|
|
622
|
+
} finally {
|
|
623
|
+
await rm(dir, { recursive: true, force: true })
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('启动评估:autoArchiveDays=0 时不评估', skipMissingDeps, async () => {
|
|
628
|
+
const { activateSettings, registry } = makeCtx({
|
|
629
|
+
archivedIds: [],
|
|
630
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
631
|
+
agents: new Map(),
|
|
632
|
+
settingsValue: { autoArchiveDays: 0 },
|
|
633
|
+
})
|
|
634
|
+
activateSettings()
|
|
635
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
636
|
+
assert.deepEqual(registry.archiveCalls, [])
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('周期评估:每日定时补扫首轮不可评估的会话', skipMissingDeps, async () => {
|
|
640
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-period-'))
|
|
641
|
+
// 夹具 mtime 用真实时钟落盘,之后接管 Date 与 setInterval:到期判断(nextDueAt)
|
|
642
|
+
// 与定时器触发共用同一虚拟时钟,tick 推进 24h 即触发周期轮
|
|
643
|
+
const artifactA = path.join(dir, 'a.jsonl')
|
|
644
|
+
const artifactB = path.join(dir, 'b.jsonl')
|
|
645
|
+
await writeFile(artifactA, '{"header":1}\n{"event":0}\n')
|
|
646
|
+
await writeFile(artifactB, '{"header":1}\n{"event":0}\n')
|
|
647
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
648
|
+
const realNow = Date.now()
|
|
649
|
+
const stale = realNow - 30 * DAY_MS
|
|
650
|
+
await utimes(artifactA, stale / 1000, stale / 1000)
|
|
651
|
+
await utimes(artifactB, stale / 1000, stale / 1000)
|
|
652
|
+
// 显式传 now:mock.timers 接管 Date 后从 epoch 0 起算,不传则夹具时间戳全成「未来」
|
|
653
|
+
mock.timers.enable({ apis: ['setInterval', 'Date'], now: realNow })
|
|
654
|
+
try {
|
|
655
|
+
// 首轮 b 产物不可读(守卫跳过),周期轮起可读:b 只能经周期评估归档
|
|
656
|
+
let bReadable = false
|
|
657
|
+
const { activateSettings, registry } = makeCtx({
|
|
658
|
+
archivedIds: [],
|
|
659
|
+
headers: [
|
|
660
|
+
{ id: 'a', cwd: 'C:\\x', createdAt: stale },
|
|
661
|
+
{ id: 'b', cwd: 'C:\\x', createdAt: stale },
|
|
662
|
+
],
|
|
663
|
+
agents: new Map(),
|
|
664
|
+
sessionPersistence: {
|
|
665
|
+
locate: (header) => (header.id === 'b' && !bReadable)
|
|
666
|
+
? { path: path.join(dir, 'gone.jsonl') }
|
|
667
|
+
: { path: header.id === 'a' ? artifactA : artifactB },
|
|
668
|
+
},
|
|
669
|
+
})
|
|
670
|
+
activateSettings()
|
|
671
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
672
|
+
assert.deepEqual(registry.archiveCalls, ['a'])
|
|
673
|
+
bReadable = true
|
|
674
|
+
mock.timers.tick(DAY_MS)
|
|
675
|
+
await waitFor(() => registry.archiveCalls.includes('b'))
|
|
676
|
+
// 桩回写 archivedSessionIds(真实注册表契约):已归档 a 不被周期轮重复选中
|
|
677
|
+
assert.deepEqual(registry.archiveCalls, ['a', 'b'])
|
|
678
|
+
} finally {
|
|
679
|
+
mock.timers.reset()
|
|
680
|
+
await rm(dir, { recursive: true, force: true })
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('周期评估:autoArchiveDays=0 时不评估', skipMissingDeps, async () => {
|
|
685
|
+
mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
|
|
686
|
+
try {
|
|
687
|
+
const { activateSettings, registry } = makeCtx({
|
|
688
|
+
archivedIds: [],
|
|
689
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
690
|
+
agents: new Map(),
|
|
691
|
+
settingsValue: { autoArchiveDays: 0 },
|
|
692
|
+
})
|
|
693
|
+
activateSettings()
|
|
694
|
+
mock.timers.tick(24 * 60 * 60 * 1000)
|
|
695
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
696
|
+
assert.deepEqual(registry.archiveCalls, [])
|
|
697
|
+
} finally {
|
|
698
|
+
mock.timers.reset()
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
test('周期评估:间隔设置运行时变更经 tick 对账(0 重启用 / 关闭即时暂停)', skipMissingDeps, async () => {
|
|
703
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-rearm-'))
|
|
704
|
+
// settings 桩返回同一对象引用:测试中改字段即模拟用户运行时改设置
|
|
705
|
+
const settingsValue = { autoArchiveIntervalHours: 0 }
|
|
706
|
+
mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
|
|
707
|
+
try {
|
|
708
|
+
const artifacts = {}
|
|
709
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
710
|
+
const TICK_MS = 60 * 1000
|
|
711
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
712
|
+
const readable = { s1: true, s2: false, s3: false }
|
|
713
|
+
for (const id of ['s1', 's2', 's3']) {
|
|
714
|
+
const artifact = path.join(dir, id + '.jsonl')
|
|
715
|
+
await writeFile(artifact, '{"header":1}\n{"event":0}\n')
|
|
716
|
+
await utimes(artifact, stale / 1000, stale / 1000)
|
|
717
|
+
artifacts[id] = artifact
|
|
718
|
+
}
|
|
719
|
+
const headers = [
|
|
720
|
+
{ id: 's1', cwd: 'C:\\x', createdAt: stale },
|
|
721
|
+
{ id: 's2', cwd: 'C:\\x', createdAt: stale },
|
|
722
|
+
{ id: 's3', cwd: 'C:\\x', createdAt: stale },
|
|
723
|
+
]
|
|
724
|
+
const { activateSettings, registry } = makeCtx({
|
|
725
|
+
archivedIds: [],
|
|
726
|
+
headers,
|
|
727
|
+
agents: new Map(),
|
|
728
|
+
settingsValue,
|
|
729
|
+
sessionPersistence: {
|
|
730
|
+
locate: (header) => ({ path: readable[header.id] ? artifacts[header.id] : path.join(dir, 'gone-' + header.id) }),
|
|
731
|
+
},
|
|
732
|
+
})
|
|
733
|
+
activateSettings()
|
|
734
|
+
// 启动补扫只看 autoArchiveDays(默认 7),intervalHours=0 不影响首轮
|
|
735
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
736
|
+
assert.deepEqual(registry.archiveCalls, ['s1'])
|
|
737
|
+
// intervalHours=0 期间周期轮保持关闭:到期也不评估
|
|
738
|
+
readable.s2 = true
|
|
739
|
+
mock.timers.tick(DAY_MS)
|
|
740
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
741
|
+
assert.deepEqual(registry.archiveCalls, ['s1'], '关闭状态下到期不得评估')
|
|
742
|
+
// 0 重启用:缺失 due 经 tick 对账补排,最迟下个 tick 恢复周期轮
|
|
743
|
+
settingsValue.autoArchiveIntervalHours = 24
|
|
744
|
+
mock.timers.tick(TICK_MS)
|
|
745
|
+
await waitFor(() => registry.archiveCalls.includes('s2'))
|
|
746
|
+
assert.deepEqual(registry.archiveCalls, ['s1', 's2'])
|
|
747
|
+
// 再次关闭:即时暂停,s3 不被评估
|
|
748
|
+
readable.s3 = true
|
|
749
|
+
settingsValue.autoArchiveIntervalHours = 0
|
|
750
|
+
mock.timers.tick(DAY_MS)
|
|
751
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
752
|
+
assert.deepEqual(registry.archiveCalls, ['s1', 's2'], '再次关闭后到期不得评估')
|
|
753
|
+
// 第三次重启用:对账恢复,归档 s3
|
|
754
|
+
settingsValue.autoArchiveIntervalHours = 24
|
|
755
|
+
mock.timers.tick(TICK_MS)
|
|
756
|
+
await waitFor(() => registry.archiveCalls.includes('s3'))
|
|
757
|
+
assert.deepEqual(registry.archiveCalls, ['s1', 's2', 's3'])
|
|
758
|
+
} finally {
|
|
759
|
+
mock.timers.reset()
|
|
760
|
+
await rm(dir, { recursive: true, force: true })
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
test('周期评估:缩短间隔经 earliest 钳制前移,不等满旧周期', skipMissingDeps, async () => {
|
|
765
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-shorten-'))
|
|
766
|
+
const TICK_MS = 60 * 1000
|
|
767
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
768
|
+
mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
|
|
769
|
+
try {
|
|
770
|
+
const readable = { a: true, b: false }
|
|
771
|
+
const artifacts = {}
|
|
772
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
773
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
774
|
+
for (const id of ['a', 'b']) {
|
|
775
|
+
const artifact = path.join(dir, id + '.jsonl')
|
|
776
|
+
await writeFile(artifact, '{"header":1}\n{"event":0}\n')
|
|
777
|
+
await utimes(artifact, stale / 1000, stale / 1000)
|
|
778
|
+
artifacts[id] = artifact
|
|
779
|
+
}
|
|
780
|
+
const settingsValue = { autoArchiveIntervalHours: 24 }
|
|
781
|
+
const { activateSettings, registry } = makeCtx({
|
|
782
|
+
archivedIds: [],
|
|
783
|
+
headers: [
|
|
784
|
+
{ id: 'a', cwd: 'C:\\x', createdAt: stale },
|
|
785
|
+
{ id: 'b', cwd: 'C:\\x', createdAt: stale },
|
|
786
|
+
],
|
|
787
|
+
agents: new Map(),
|
|
788
|
+
settingsValue,
|
|
789
|
+
sessionPersistence: {
|
|
790
|
+
locate: (header) => readable[header.id] ? { path: artifacts[header.id] } : { path: path.join(dir, 'gone-' + header.id) },
|
|
791
|
+
},
|
|
792
|
+
})
|
|
793
|
+
activateSettings()
|
|
794
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
795
|
+
assert.deepEqual(registry.archiveCalls, ['a'])
|
|
796
|
+
const afterStartup = registry.archiveCalls.length
|
|
797
|
+
// T+24h 到期轮(a 已归档无产出);紧接缩短为 1h:earliest 钳制应把 due 前移到
|
|
798
|
+
// 缩短时刻 + 1h,而非等满旧周期 T+48h;b 在缩短后变可读,由前移后的轮归档
|
|
799
|
+
mock.timers.tick(DAY_MS)
|
|
800
|
+
settingsValue.autoArchiveIntervalHours = 1
|
|
801
|
+
readable.b = true
|
|
802
|
+
mock.timers.tick(DAY_MS + TICK_MS)
|
|
803
|
+
await waitFor(() => registry.archiveCalls.length > afterStartup)
|
|
804
|
+
assert.deepEqual(registry.archiveCalls, ['a', 'b'])
|
|
805
|
+
} finally {
|
|
806
|
+
mock.timers.reset()
|
|
807
|
+
await rm(dir, { recursive: true, force: true })
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
test('定时服务缺失:周期评估降级,状态接口提示且不影响启动补扫', skipMissingDeps, async () => {
|
|
812
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-degrade-'))
|
|
813
|
+
try {
|
|
814
|
+
const artifact = path.join(dir, 'a.jsonl')
|
|
815
|
+
await writeFile(artifact, '{"header":1}\n{"event":0}\n')
|
|
816
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
817
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
818
|
+
await utimes(artifact, stale / 1000, stale / 1000)
|
|
819
|
+
const { activateSettings, handlers, registry } = makeCtx({
|
|
820
|
+
archivedIds: [],
|
|
821
|
+
headers: [{ id: 'a', cwd: 'C:\\x', createdAt: stale }],
|
|
822
|
+
agents: new Map(),
|
|
823
|
+
sessionPersistence: { locate: () => ({ path: artifact }) },
|
|
824
|
+
timerAvailable: false,
|
|
825
|
+
})
|
|
826
|
+
activateSettings()
|
|
827
|
+
// 启动补扫照常工作:软依赖缺失只影响周期轮
|
|
828
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
829
|
+
assert.deepEqual(registry.archiveCalls, ['a'])
|
|
830
|
+
const status = response()
|
|
831
|
+
await handlers.get('/api/session-manager/status')(request('x', 'GET'), status)
|
|
832
|
+
assert.equal(status.status, 200)
|
|
833
|
+
assert.equal(status.body.periodic.running, false)
|
|
834
|
+
assert.notEqual(status.body.periodic.reason, '')
|
|
835
|
+
} finally {
|
|
836
|
+
await rm(dir, { recursive: true, force: true })
|
|
837
|
+
}
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
test('周期评估:到期 tick 恰逢门闩占用时保持到期态,下个 tick 重试而非推后一个周期', skipMissingDeps, async () => {
|
|
841
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-gate-tick-'))
|
|
842
|
+
const TICK_MS = 60 * 1000
|
|
843
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
844
|
+
mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
|
|
845
|
+
try {
|
|
846
|
+
const readable = { a: true, b: false, c: false }
|
|
847
|
+
const artifacts = {}
|
|
848
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
849
|
+
for (const id of ['a', 'b', 'c']) {
|
|
850
|
+
const artifact = path.join(dir, id + '.jsonl')
|
|
851
|
+
await writeFile(artifact, '{"header":1}\n{"event":0}\n')
|
|
852
|
+
await utimes(artifact, stale / 1000, stale / 1000)
|
|
853
|
+
artifacts[id] = artifact
|
|
854
|
+
}
|
|
855
|
+
const headers = [
|
|
856
|
+
{ id: 'a', cwd: 'C:\\x', createdAt: stale },
|
|
857
|
+
{ id: 'b', cwd: 'C:\\x', createdAt: stale },
|
|
858
|
+
{ id: 'c', cwd: 'C:\\x', createdAt: stale },
|
|
859
|
+
]
|
|
860
|
+
const { activateSettings, eventHandlers, registry } = makeCtx({
|
|
861
|
+
archivedIds: [],
|
|
862
|
+
headers,
|
|
863
|
+
agents: new Map(),
|
|
864
|
+
sessionPersistence: {
|
|
865
|
+
locate: (header) => readable[header.id] ? { path: artifacts[header.id] } : { path: path.join(dir, 'gone-' + header.id) },
|
|
866
|
+
},
|
|
867
|
+
})
|
|
868
|
+
activateSettings()
|
|
869
|
+
// 启动补扫:a 归档(桩回写 archived),b/c 不可读留候选
|
|
870
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
871
|
+
assert.deepEqual(registry.archiveCalls, ['a'])
|
|
872
|
+
// 挂起归档执行器,再触发一轮评估:门闩被占
|
|
873
|
+
let releaseArchive
|
|
874
|
+
const held = new Promise((resolve) => { releaseArchive = resolve })
|
|
875
|
+
const originalArchive = registry.archiveSession
|
|
876
|
+
let archiveArrived = false
|
|
877
|
+
registry.archiveSession = async (id) => {
|
|
878
|
+
archiveArrived = true
|
|
879
|
+
await held
|
|
880
|
+
await originalArchive(id)
|
|
881
|
+
}
|
|
882
|
+
readable.b = true
|
|
883
|
+
eventHandlers['session/created']({ header: headers[1] })
|
|
884
|
+
await waitFor(() => archiveArrived)
|
|
885
|
+
assert.deepEqual(registry.archiveCalls, ['a'], '挂起的 b 尚未计入归档调用')
|
|
886
|
+
// 到期 tick 恰逢门闩占用:被丢弃,不得把周期推后 24h(保持到期态)
|
|
887
|
+
mock.timers.tick(DAY_MS)
|
|
888
|
+
// 释放挂起评估并让出事件循环,确保门闩复位(evaluating=false)落定
|
|
889
|
+
releaseArchive()
|
|
890
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
891
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
892
|
+
// c 变可读,60s 后的下个 tick 即应重试并归档 c(若周期被推后 24h 此步超时)
|
|
893
|
+
readable.c = true
|
|
894
|
+
mock.timers.tick(TICK_MS)
|
|
895
|
+
await waitFor(() => registry.archiveCalls.includes('c'))
|
|
896
|
+
assert.deepEqual(registry.archiveCalls, ['a', 'b', 'c'])
|
|
897
|
+
} finally {
|
|
898
|
+
mock.timers.reset()
|
|
899
|
+
await rm(dir, { recursive: true, force: true })
|
|
900
|
+
}
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
// 执行器桩替:trash 为进程级唯一 OS 副作用出口,经 executor 注册表注入(README 已知测试缺口的基建扩展)
|
|
904
|
+
async function withTrashStub(stub, run) {
|
|
905
|
+
const original = indexModule.executor.trashPath
|
|
906
|
+
indexModule.executor.trashPath = stub
|
|
907
|
+
try {
|
|
908
|
+
await run()
|
|
909
|
+
} finally {
|
|
910
|
+
indexModule.executor.trashPath = original
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const IDLE_S1 = new Map([['s1', { status: 'idle' }]])
|
|
915
|
+
|
|
916
|
+
// locate 契约实证:返回会话目录下的日志文件;stat 为真实 fs,产物以临时文件承载
|
|
917
|
+
async function makeLocatedArtifact(sessionId = 's1') {
|
|
918
|
+
const root = await mkdtemp(path.join(tmpdir(), 'sm-del-'))
|
|
919
|
+
const locatedDir = path.join(root, sessionId)
|
|
920
|
+
const locatedPath = path.join(locatedDir, 'session.jsonl.zstd')
|
|
921
|
+
await mkdir(locatedDir, { recursive: true })
|
|
922
|
+
await writeFile(locatedPath, 'log-bytes')
|
|
923
|
+
return {
|
|
924
|
+
locatedDir,
|
|
925
|
+
locatedPath,
|
|
926
|
+
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMissingDeps, async () => {
|
|
931
|
+
const artifact = await makeLocatedArtifact()
|
|
932
|
+
try {
|
|
933
|
+
const domain = makeDomain(['s1'])
|
|
934
|
+
const workspace = makeWorkspace('C:\\x', ['s1'])
|
|
935
|
+
const { handlers, ledger } = makeCtx({
|
|
936
|
+
archivedIds: ['s1'],
|
|
937
|
+
headers: [HEADER],
|
|
938
|
+
agents: IDLE_S1,
|
|
939
|
+
domain,
|
|
940
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
941
|
+
workspaces: [workspace],
|
|
942
|
+
})
|
|
943
|
+
const trashed = []
|
|
944
|
+
const res = response()
|
|
945
|
+
await withTrashStub(async (p) => { trashed.push(p) }, async () => {
|
|
946
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
947
|
+
})
|
|
948
|
+
assert.equal(res.status, 200)
|
|
949
|
+
assert.deepEqual(res.body, { ok: true })
|
|
950
|
+
// locate 给的是日志文件,trash 必须上移到会话目录,否则残留空目录
|
|
951
|
+
assert.deepEqual(trashed, [artifact.locatedDir])
|
|
952
|
+
assert.equal(ledger.state.deleted.length, 1)
|
|
953
|
+
assert.equal(ledger.state.deleted[0].sessionId, 's1')
|
|
954
|
+
assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
|
|
955
|
+
assert.deepEqual(domain.state.archivedSessionIds, [])
|
|
956
|
+
assert.deepEqual(workspace.sessionIds, [])
|
|
957
|
+
} finally {
|
|
958
|
+
await artifact.cleanup()
|
|
959
|
+
}
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
test('删除成功:同 id 残留台账被替换,无重复条目', skipMissingDeps, async () => {
|
|
963
|
+
const artifact = await makeLocatedArtifact()
|
|
964
|
+
try {
|
|
965
|
+
const { handlers, ledger } = makeCtx({
|
|
966
|
+
archivedIds: ['s1'],
|
|
967
|
+
headers: [HEADER],
|
|
968
|
+
agents: IDLE_S1,
|
|
969
|
+
domain: makeDomain(['s1']),
|
|
970
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
971
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: 'old', deletedAt: 1 }]),
|
|
972
|
+
})
|
|
973
|
+
const res = response()
|
|
974
|
+
await withTrashStub(async () => {}, async () => {
|
|
975
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
976
|
+
})
|
|
977
|
+
assert.equal(res.status, 200)
|
|
978
|
+
assert.deepEqual(res.body, { ok: true })
|
|
979
|
+
assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s1'])
|
|
980
|
+
assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
|
|
981
|
+
} finally {
|
|
982
|
+
await artifact.cleanup()
|
|
983
|
+
}
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
test('删除:产物已缺失时跳过 trash 与台账,完成列表清理(同 id 重删场景)', skipMissingDeps, async () => {
|
|
987
|
+
const domain = makeDomain([])
|
|
988
|
+
const workspace = makeWorkspace('C:\\x', ['s1'])
|
|
989
|
+
const { handlers, ledger } = makeCtx({
|
|
990
|
+
// 幽灵会话已不在归档集合:残留清理不要求归档资格,但要求台账有记录
|
|
991
|
+
// (首删已记录,重删走幽灵收尾)
|
|
992
|
+
archivedIds: [],
|
|
993
|
+
headers: [HEADER],
|
|
994
|
+
agents: IDLE_S1,
|
|
995
|
+
domain,
|
|
996
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: 'C:\\gone\\s1\\session.jsonl.zstd' } : undefined },
|
|
997
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: 'C:\\gone\\s1', deletedAt: 1 }]),
|
|
998
|
+
workspaces: [workspace],
|
|
999
|
+
})
|
|
1000
|
+
const trashed = []
|
|
1001
|
+
const res = response()
|
|
1002
|
+
await withTrashStub(async (p) => { trashed.push(p) }, async () => {
|
|
1003
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1004
|
+
})
|
|
1005
|
+
assert.equal(res.status, 200)
|
|
1006
|
+
assert.deepEqual(res.body, { ok: true, message: MESSAGES.ghostCleanup })
|
|
1007
|
+
assert.deepEqual(trashed, [])
|
|
1008
|
+
assert.equal(ledger.writes, 0)
|
|
1009
|
+
// 残留清理仍解除工作区关联与归档集合(幂等)
|
|
1010
|
+
assert.deepEqual(workspace.sessionIds, [])
|
|
1011
|
+
assert.deepEqual(domain.state.archivedSessionIds, [])
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
test('删除:产物缺失且无归档记录无台账时拒绝(防新建会话未落盘被剥离)', skipMissingDeps, async () => {
|
|
1015
|
+
const workspace = makeWorkspace('C:\\x', ['s1'])
|
|
1016
|
+
const { handlers } = makeCtx({
|
|
1017
|
+
archivedIds: [],
|
|
1018
|
+
headers: [HEADER],
|
|
1019
|
+
agents: IDLE_S1,
|
|
1020
|
+
domain: makeDomain([]),
|
|
1021
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: 'C:\\gone\\s1\\session.jsonl.zstd' } : undefined },
|
|
1022
|
+
workspaces: [workspace],
|
|
1023
|
+
})
|
|
1024
|
+
const res = response()
|
|
1025
|
+
await withTrashStub(async () => {}, async () => {
|
|
1026
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1027
|
+
})
|
|
1028
|
+
assert.equal(res.status, 400)
|
|
1029
|
+
assert.equal(res.body.error, MESSAGES.notArchived)
|
|
1030
|
+
// 拒绝路径不动工作区关联
|
|
1031
|
+
assert.deepEqual(workspace.sessionIds, ['s1'])
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, async () => {
|
|
1035
|
+
const { handlers } = makeCtx({
|
|
1036
|
+
archivedIds: [],
|
|
1037
|
+
headers: [HEADER],
|
|
1038
|
+
agents: new Map([['s1', { status: 'running' }]]),
|
|
1039
|
+
domain: makeDomain([]),
|
|
1040
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: 'C:\\gone\\s1\\session.jsonl.zstd' } : undefined },
|
|
1041
|
+
})
|
|
1042
|
+
const res = response()
|
|
1043
|
+
await withTrashStub(async () => {}, async () => {
|
|
1044
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1045
|
+
})
|
|
1046
|
+
assert.equal(res.status, 400)
|
|
1047
|
+
assert.equal(res.body.error, MESSAGES.running)
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
test('删除:产物已缺失且清理半失败时聚合失败点', skipMissingDeps, async () => {
|
|
1051
|
+
const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
|
|
1052
|
+
const { handlers, logger } = makeCtx({
|
|
1053
|
+
archivedIds: [],
|
|
1054
|
+
headers: [HEADER],
|
|
1055
|
+
agents: IDLE_S1,
|
|
1056
|
+
domain: null,
|
|
1057
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: 'C:\\gone\\s1\\session.jsonl.zstd' } : undefined },
|
|
1058
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: 'C:\\gone\\s1', deletedAt: 1 }]),
|
|
1059
|
+
workspaces: [workspace],
|
|
1060
|
+
})
|
|
1061
|
+
const res = response()
|
|
1062
|
+
await withTrashStub(async () => {}, async () => {
|
|
1063
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1064
|
+
})
|
|
1065
|
+
assert.equal(res.status, 200)
|
|
1066
|
+
assert.equal(res.body.partial, true)
|
|
1067
|
+
assert.equal(res.body.message, '产物已不存在,但移除列表记录失败,且移除归档记录失败')
|
|
1068
|
+
// 幽灵清理半失败:原始错误进服务端日志(排障契约),响应文案保持摘要
|
|
1069
|
+
assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('detach boom')), '幽灵 detach 失败应落日志')
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
test('删除:回收窗口内会话恢复运行,响应警告形态且无 partial 键', skipMissingDeps, async () => {
|
|
1073
|
+
const artifact = await makeLocatedArtifact()
|
|
1074
|
+
try {
|
|
1075
|
+
const agents = new Map([['s1', { status: 'idle' }]])
|
|
1076
|
+
const { handlers } = makeCtx({
|
|
1077
|
+
archivedIds: ['s1'],
|
|
1078
|
+
headers: [HEADER],
|
|
1079
|
+
agents,
|
|
1080
|
+
domain: makeDomain(['s1']),
|
|
1081
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1082
|
+
})
|
|
1083
|
+
const res = response()
|
|
1084
|
+
// trash 桩内翻转运行态:模拟 OS 回收异步窗口内的执行期翻转
|
|
1085
|
+
await withTrashStub(async () => { agents.set('s1', { status: 'running' }) }, async () => {
|
|
1086
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1087
|
+
})
|
|
1088
|
+
assert.equal(res.status, 200)
|
|
1089
|
+
// 全成功警告态:{ok,message} 形态,无 partial 键
|
|
1090
|
+
assert.deepEqual(res.body, { ok: true, message: DELETE_MESSAGES.runningDuringTrash })
|
|
1091
|
+
} finally {
|
|
1092
|
+
await artifact.cleanup()
|
|
1093
|
+
}
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
test('归档评估:超期运行中会话零产物 IO(早退,core 过滤为第二道防线)', skipMissingDeps, async () => {
|
|
1097
|
+
const stale = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
1098
|
+
let locateCalls = 0
|
|
1099
|
+
const { activateSettings, registry } = makeCtx({
|
|
1100
|
+
archivedIds: [],
|
|
1101
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: stale }],
|
|
1102
|
+
agents: new Map([['s1', { status: 'running' }]]),
|
|
1103
|
+
sessionPersistence: {
|
|
1104
|
+
locate: (header) => {
|
|
1105
|
+
locateCalls += 1
|
|
1106
|
+
return { path: 'C:\\x\\s1\\session.jsonl.zstd' }
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
})
|
|
1110
|
+
// 历史启动对齐(运行中会话即时路径)也走 locate:等它跑完并清零计数,
|
|
1111
|
+
// 此后断言只针对归档评估的产物 IO
|
|
1112
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
1113
|
+
locateCalls = 0
|
|
1114
|
+
activateSettings()
|
|
1115
|
+
// 等评估轮跑完(正常会话路径必然触达 locate):running 会话不得产生 locate 调用
|
|
1116
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
1117
|
+
assert.equal(locateCalls, 0, '运行中会话应在产物 IO 前早退')
|
|
1118
|
+
assert.deepEqual(registry.archiveCalls, [])
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
test('删除:workspace 域未打开时归档清理失败,响应 partial 且台账已记录', skipMissingDeps, async () => {
|
|
1122
|
+
const artifact = await makeLocatedArtifact()
|
|
1123
|
+
try {
|
|
1124
|
+
const { handlers, ledger } = makeCtx({
|
|
1125
|
+
archivedIds: ['s1'],
|
|
1126
|
+
headers: [HEADER],
|
|
1127
|
+
agents: IDLE_S1,
|
|
1128
|
+
domain: null,
|
|
1129
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1130
|
+
})
|
|
1131
|
+
const res = response()
|
|
1132
|
+
await withTrashStub(async () => {}, async () => {
|
|
1133
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1134
|
+
})
|
|
1135
|
+
assert.equal(res.status, 200)
|
|
1136
|
+
assert.equal(res.body.partial, true)
|
|
1137
|
+
assert.equal(res.body.message, '已移入回收站,但移除归档记录失败')
|
|
1138
|
+
// detach 已完成、台账已记录:仅归档集合清理半失败
|
|
1139
|
+
assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s1'])
|
|
1140
|
+
} finally {
|
|
1141
|
+
await artifact.cleanup()
|
|
1142
|
+
}
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
test('删除:detach 与台账同时失败时,partial 消息聚合两个失败点', skipMissingDeps, async () => {
|
|
1146
|
+
const artifact = await makeLocatedArtifact()
|
|
1147
|
+
try {
|
|
1148
|
+
const brokenLedger = makeLedgerDomain([])
|
|
1149
|
+
brokenLedger.global.set = async () => { throw new Error('medium broken') }
|
|
1150
|
+
const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
|
|
1151
|
+
const { handlers, logger } = makeCtx({
|
|
1152
|
+
archivedIds: ['s1'],
|
|
1153
|
+
headers: [HEADER],
|
|
1154
|
+
agents: IDLE_S1,
|
|
1155
|
+
domain: makeDomain(['s1']),
|
|
1156
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1157
|
+
ledger: brokenLedger,
|
|
1158
|
+
workspaces: [workspace],
|
|
1159
|
+
})
|
|
1160
|
+
const res = response()
|
|
1161
|
+
await withTrashStub(async () => {}, async () => {
|
|
1162
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1163
|
+
})
|
|
1164
|
+
assert.equal(res.status, 200)
|
|
1165
|
+
assert.equal(res.body.partial, true)
|
|
1166
|
+
assert.equal(res.body.message, '已移入回收站,但移除列表记录失败,且重挂载记录失败')
|
|
1167
|
+
// 台账与 detach 的原始错误均进服务端日志
|
|
1168
|
+
assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('medium broken')), '台账失败应落日志')
|
|
1169
|
+
assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('detach boom')), 'detach 失败应落日志')
|
|
1170
|
+
} finally {
|
|
1171
|
+
await artifact.cleanup()
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
test('trash 失败:整体中止,台账不写', skipMissingDeps, async () => {
|
|
1176
|
+
const artifact = await makeLocatedArtifact()
|
|
1177
|
+
try {
|
|
1178
|
+
const { handlers, ledger } = makeCtx({
|
|
1179
|
+
archivedIds: ['s1'],
|
|
1180
|
+
headers: [HEADER],
|
|
1181
|
+
agents: IDLE_S1,
|
|
1182
|
+
domain: makeDomain(['s1']),
|
|
1183
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1184
|
+
})
|
|
1185
|
+
const res = response()
|
|
1186
|
+
await withTrashStub(async () => { throw new Error('no trash') }, async () => {
|
|
1187
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1188
|
+
})
|
|
1189
|
+
assert.equal(res.status, 400)
|
|
1190
|
+
assert.deepEqual(ledger.state.deleted, [])
|
|
1191
|
+
assert.equal(ledger.writes, 0)
|
|
1192
|
+
} finally {
|
|
1193
|
+
await artifact.cleanup()
|
|
1194
|
+
}
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
|
|
1198
|
+
const artifact = await makeLocatedArtifact()
|
|
1199
|
+
try {
|
|
1200
|
+
const domain = makeDomain(['s1'])
|
|
1201
|
+
const { handlers } = makeCtx({
|
|
1202
|
+
archivedIds: ['s1'],
|
|
1203
|
+
headers: [HEADER],
|
|
1204
|
+
agents: IDLE_S1,
|
|
1205
|
+
domain,
|
|
1206
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1207
|
+
openRejected: new Error('ledger down'),
|
|
1208
|
+
})
|
|
1209
|
+
const res = response()
|
|
1210
|
+
await withTrashStub(async () => {}, async () => {
|
|
1211
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1212
|
+
})
|
|
1213
|
+
assert.equal(res.status, 200)
|
|
1214
|
+
assert.equal(res.body.partial, true)
|
|
1215
|
+
assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
|
|
1216
|
+
// 台账失败不阻断删除主链路:归档清理照常完成
|
|
1217
|
+
assert.deepEqual(domain.state.archivedSessionIds, [])
|
|
1218
|
+
} finally {
|
|
1219
|
+
await artifact.cleanup()
|
|
1220
|
+
}
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
test('台账写入失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
|
|
1224
|
+
const artifact = await makeLocatedArtifact()
|
|
1225
|
+
try {
|
|
1226
|
+
const brokenLedger = makeLedgerDomain([])
|
|
1227
|
+
brokenLedger.global.set = async () => { throw new Error('medium broken') }
|
|
1228
|
+
const { handlers } = makeCtx({
|
|
1229
|
+
archivedIds: ['s1'],
|
|
1230
|
+
headers: [HEADER],
|
|
1231
|
+
agents: IDLE_S1,
|
|
1232
|
+
domain: makeDomain(['s1']),
|
|
1233
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1234
|
+
ledger: brokenLedger,
|
|
1235
|
+
})
|
|
1236
|
+
const res = response()
|
|
1237
|
+
await withTrashStub(async () => {}, async () => {
|
|
1238
|
+
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
1239
|
+
})
|
|
1240
|
+
assert.equal(res.status, 200)
|
|
1241
|
+
assert.equal(res.body.partial, true)
|
|
1242
|
+
assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
|
|
1243
|
+
} finally {
|
|
1244
|
+
await artifact.cleanup()
|
|
1245
|
+
}
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
test('已删除列表:GET 按存储序全量返回', skipMissingDeps, async () => {
|
|
1249
|
+
const entries = [
|
|
1250
|
+
{ sessionId: 's2', path: 'p2', deletedAt: 2 },
|
|
1251
|
+
{ sessionId: 's1', path: 'p1', deletedAt: 1 },
|
|
1252
|
+
]
|
|
1253
|
+
const { handlers } = makeCtx({
|
|
1254
|
+
archivedIds: [],
|
|
1255
|
+
headers: [],
|
|
1256
|
+
agents: new Map(),
|
|
1257
|
+
ledger: makeLedgerDomain(entries),
|
|
1258
|
+
})
|
|
1259
|
+
const res = response()
|
|
1260
|
+
await handlers.get('/api/session-manager/deleted')(request('x', 'GET'), res)
|
|
1261
|
+
assert.equal(res.status, 200)
|
|
1262
|
+
assert.deepEqual(res.body, { deleted: entries })
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
test('已删除列表:非 GET 拒绝', skipMissingDeps, async () => {
|
|
1266
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
|
|
1267
|
+
const res = response()
|
|
1268
|
+
await handlers.get('/api/session-manager/deleted')(request('s1'), res)
|
|
1269
|
+
assert.equal(res.status, 405)
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
test('已删除列表:台账域打开失败拒绝', skipMissingDeps, async () => {
|
|
1273
|
+
const { handlers } = makeCtx({
|
|
1274
|
+
archivedIds: [],
|
|
1275
|
+
headers: [],
|
|
1276
|
+
agents: new Map(),
|
|
1277
|
+
openRejected: new Error('ledger down'),
|
|
1278
|
+
})
|
|
1279
|
+
const res = response()
|
|
1280
|
+
await handlers.get('/api/session-manager/deleted')(request('x', 'GET'), res)
|
|
1281
|
+
assert.equal(res.status, 400)
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
test('重挂载:产物已还原时经官方 attachSession 挂回并清除台账', skipMissingDeps, async () => {
|
|
1285
|
+
const workspace = makeWorkspace('C:\\x', [])
|
|
1286
|
+
const { handlers, ledger } = makeCtx({
|
|
1287
|
+
archivedIds: [],
|
|
1288
|
+
headers: [HEADER],
|
|
1289
|
+
agents: new Map(),
|
|
1290
|
+
workspaces: [workspace],
|
|
1291
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
|
|
1292
|
+
})
|
|
1293
|
+
const res = response()
|
|
1294
|
+
await handlers.get('/api/session-manager/remount')(request('s1'), res)
|
|
1295
|
+
assert.equal(res.status, 200)
|
|
1296
|
+
assert.deepEqual(res.body, { ok: true })
|
|
1297
|
+
assert.deepEqual(workspace.attachCalls, ['s1'])
|
|
1298
|
+
assert.deepEqual(workspace.sessionIds, ['s1'])
|
|
1299
|
+
assert.deepEqual(ledger.state.deleted, [])
|
|
1300
|
+
assert.equal(ledger.writes, 1)
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
test('重挂载:产物未还原(持久层无 header)拒绝', skipMissingDeps, async () => {
|
|
1304
|
+
const { handlers, ledger } = makeCtx({
|
|
1305
|
+
archivedIds: [],
|
|
1306
|
+
headers: [],
|
|
1307
|
+
agents: new Map(),
|
|
1308
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
|
|
1309
|
+
})
|
|
1310
|
+
const res = response()
|
|
1311
|
+
await handlers.get('/api/session-manager/remount')(request('s1'), res)
|
|
1312
|
+
assert.equal(res.status, 400)
|
|
1313
|
+
assert.equal(res.body.error, '会话产物不在持久层,请先到系统回收站还原后重试')
|
|
1314
|
+
// 拒绝路径不动台账
|
|
1315
|
+
assert.equal(ledger.writes, 0)
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
test('重挂载:找不到所属工作区拒绝', skipMissingDeps, async () => {
|
|
1319
|
+
const { handlers } = makeCtx({
|
|
1320
|
+
archivedIds: [],
|
|
1321
|
+
headers: [HEADER],
|
|
1322
|
+
agents: new Map(),
|
|
1323
|
+
workspaces: [makeWorkspace('C:\\other', [])],
|
|
1324
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
|
|
1325
|
+
})
|
|
1326
|
+
const res = response()
|
|
1327
|
+
await handlers.get('/api/session-manager/remount')(request('s1'), res)
|
|
1328
|
+
assert.equal(res.status, 400)
|
|
1329
|
+
assert.equal(res.body.error, '未找到会话所属工作区,无法重新挂载')
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
test('重挂载:attachSession 失败时拒绝且台账保留', skipMissingDeps, async () => {
|
|
1333
|
+
const workspace = makeWorkspace('C:\\x', [], { attachError: new Error('cwd 消失') })
|
|
1334
|
+
const entries = [{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]
|
|
1335
|
+
const { handlers, ledger } = makeCtx({
|
|
1336
|
+
archivedIds: [],
|
|
1337
|
+
headers: [HEADER],
|
|
1338
|
+
agents: new Map(),
|
|
1339
|
+
workspaces: [workspace],
|
|
1340
|
+
ledger: makeLedgerDomain(entries),
|
|
1341
|
+
})
|
|
1342
|
+
const res = response()
|
|
1343
|
+
await handlers.get('/api/session-manager/remount')(request('s1'), res)
|
|
1344
|
+
assert.equal(res.status, 400)
|
|
1345
|
+
assert.match(res.body.error, /cwd 消失/)
|
|
1346
|
+
assert.deepEqual(ledger.state.deleted, entries)
|
|
1347
|
+
assert.equal(ledger.writes, 0)
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
test('重挂载:会话已在工作区时幂等收尾(残留条目重试无副作用)', skipMissingDeps, async () => {
|
|
1351
|
+
const workspace = makeWorkspace('C:\\x', ['s1'])
|
|
1352
|
+
const { handlers, ledger } = makeCtx({
|
|
1353
|
+
archivedIds: [],
|
|
1354
|
+
headers: [HEADER],
|
|
1355
|
+
agents: new Map(),
|
|
1356
|
+
workspaces: [workspace],
|
|
1357
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
|
|
1358
|
+
})
|
|
1359
|
+
const res = response()
|
|
1360
|
+
await handlers.get('/api/session-manager/remount')(request('s1'), res)
|
|
1361
|
+
assert.equal(res.status, 200)
|
|
1362
|
+
assert.deepEqual(res.body, { ok: true })
|
|
1363
|
+
// 官方契约:id 已在 sessionIds 中,挂载幂等无重复
|
|
1364
|
+
assert.deepEqual(workspace.sessionIds, ['s1'])
|
|
1365
|
+
assert.deepEqual(ledger.state.deleted, [])
|
|
1366
|
+
})
|
|
1367
|
+
|
|
1368
|
+
test('重挂载:挂载成功但台账清除失败时响应 partial', skipMissingDeps, async () => {
|
|
1369
|
+
const workspace = makeWorkspace('C:\\x', [])
|
|
1370
|
+
const brokenLedger = makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }])
|
|
1371
|
+
brokenLedger.global.set = async () => { throw new Error('medium broken') }
|
|
1372
|
+
const { handlers } = makeCtx({
|
|
1373
|
+
archivedIds: [],
|
|
1374
|
+
headers: [HEADER],
|
|
1375
|
+
agents: new Map(),
|
|
1376
|
+
workspaces: [workspace],
|
|
1377
|
+
ledger: brokenLedger,
|
|
1378
|
+
})
|
|
1379
|
+
const res = response()
|
|
1380
|
+
await handlers.get('/api/session-manager/remount')(request('s1'), res)
|
|
1381
|
+
assert.equal(res.status, 200)
|
|
1382
|
+
assert.equal(res.body.partial, true)
|
|
1383
|
+
assert.equal(res.body.message, '已重新挂载,但清除台账记录失败;可在「已删除」区移除记录收尾')
|
|
1384
|
+
assert.deepEqual(workspace.attachCalls, ['s1'])
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
test('移除记录:命中删除,未命中幂等无写', skipMissingDeps, async () => {
|
|
1388
|
+
const entries = [
|
|
1389
|
+
{ sessionId: 's1', path: 'p1', deletedAt: 1 },
|
|
1390
|
+
{ sessionId: 's2', path: 'p2', deletedAt: 2 },
|
|
1391
|
+
]
|
|
1392
|
+
const { handlers, ledger } = makeCtx({
|
|
1393
|
+
archivedIds: [],
|
|
1394
|
+
headers: [],
|
|
1395
|
+
agents: new Map(),
|
|
1396
|
+
ledger: makeLedgerDomain(entries),
|
|
1397
|
+
})
|
|
1398
|
+
const hit = response()
|
|
1399
|
+
await handlers.get('/api/session-manager/forget')(request('s1'), hit)
|
|
1400
|
+
assert.equal(hit.status, 200)
|
|
1401
|
+
assert.deepEqual(hit.body, { ok: true })
|
|
1402
|
+
assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s2'])
|
|
1403
|
+
assert.equal(ledger.writes, 1)
|
|
1404
|
+
const miss = response()
|
|
1405
|
+
await handlers.get('/api/session-manager/forget')(request('s9'), miss)
|
|
1406
|
+
assert.equal(miss.status, 200)
|
|
1407
|
+
assert.equal(ledger.writes, 1)
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1410
|
+
// ── 历史输入路由(GET /api/session-manager/inputs;工作区持久缓存)──
|
|
1411
|
+
|
|
1412
|
+
function getRequest(query) {
|
|
1413
|
+
const req = new EventEmitter()
|
|
1414
|
+
req.method = 'GET'
|
|
1415
|
+
req.url = '/api/session-manager/inputs' + query
|
|
1416
|
+
return req
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function userMessageEvent(text, at) {
|
|
1420
|
+
return {
|
|
1421
|
+
type: 'user/message',
|
|
1422
|
+
seq: at,
|
|
1423
|
+
time: at,
|
|
1424
|
+
data: { id: 'm' + at, role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text }] },
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function makeHistoryCtx() {
|
|
1429
|
+
return makeCtx({
|
|
1430
|
+
archivedIds: [],
|
|
1431
|
+
headers: [
|
|
1432
|
+
{ id: 's1', cwd: 'C:\\x', createdAt: 0 },
|
|
1433
|
+
{ id: 's2', cwd: 'C:\\x', createdAt: 0 },
|
|
1434
|
+
{ id: 's3', cwd: 'C:\\other', createdAt: 0 },
|
|
1435
|
+
],
|
|
1436
|
+
agents: new Map(),
|
|
1437
|
+
readSessions: {
|
|
1438
|
+
s1: [userMessageEvent('更早的输入', 100), userMessageEvent('最新输入', 900)],
|
|
1439
|
+
s2: [userMessageEvent('第二条', 500)],
|
|
1440
|
+
s3: [userMessageEvent('别的工作区', 999)],
|
|
1441
|
+
},
|
|
1442
|
+
})
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// 后台对齐是异步的:轮询等待响应满足条件(默认 aligned 翻真,可断言数据就绪)
|
|
1446
|
+
async function requestUntil(handlers, query, predicate, maxMs = 3000) {
|
|
1447
|
+
for (let waited = 0; waited <= maxMs; waited += 100) {
|
|
1448
|
+
const res = response()
|
|
1449
|
+
await handlers.get('/api/session-manager/inputs')(getRequest(query), res)
|
|
1450
|
+
if (predicate(res.body)) return res
|
|
1451
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1452
|
+
}
|
|
1453
|
+
throw new Error('条件未满足: ' + query)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function requestUntilAligned(handlers, query) {
|
|
1457
|
+
return requestUntil(handlers, query, (body) => body.aligned)
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// POST 夹具:请求体走 readBody 的 data/end 事件流,handler 按路径查找
|
|
1461
|
+
async function postJson(handlers, routePath, body) {
|
|
1462
|
+
const req = new EventEmitter()
|
|
1463
|
+
req.method = 'POST'
|
|
1464
|
+
req.url = routePath
|
|
1465
|
+
const res = response()
|
|
1466
|
+
const done = handlers.get(routePath)(req, res)
|
|
1467
|
+
req.emit('data', Buffer.from(JSON.stringify(body), 'utf8'))
|
|
1468
|
+
req.emit('end')
|
|
1469
|
+
await done
|
|
1470
|
+
return res
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// 每个测试独占临时缓存目录:插件 factory 在 makeCtx 时读取 env 解析缓存目录。
|
|
1474
|
+
// 后台对齐 fire-and-forget,测试结束仍在写目录——清理重试躲开 Windows 文件占用
|
|
1475
|
+
async function withHistoryCacheDir(run) {
|
|
1476
|
+
const cacheDir = await mkdtemp(path.join(tmpdir(), 'sm-hist-'))
|
|
1477
|
+
process.env.DSH_HISTORY_CACHE_DIR = cacheDir
|
|
1478
|
+
try {
|
|
1479
|
+
return await run(cacheDir)
|
|
1480
|
+
} finally {
|
|
1481
|
+
delete process.env.DSH_HISTORY_CACHE_DIR
|
|
1482
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
1483
|
+
try {
|
|
1484
|
+
await rm(cacheDir, { recursive: true, force: true })
|
|
1485
|
+
break
|
|
1486
|
+
} catch {
|
|
1487
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
test('历史输入路由:首次 aligned=false 触发后台对齐,对齐后三范围正确且 session 按 sid 过滤', skipMissingDeps, async () => {
|
|
1494
|
+
await withHistoryCacheDir(async () => {
|
|
1495
|
+
const { handlers } = makeHistoryCtx()
|
|
1496
|
+
const first = response()
|
|
1497
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=workspace'), first)
|
|
1498
|
+
assert.equal(first.status, 200)
|
|
1499
|
+
assert.deepEqual(first.body.inputs, [])
|
|
1500
|
+
assert.equal(first.body.aligned, false)
|
|
1501
|
+
const workspaceRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
|
|
1502
|
+
assert.deepEqual(workspaceRes.body.inputs.map((item) => item.text), ['最新输入', '第二条', '更早的输入'])
|
|
1503
|
+
const sessionRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=session')
|
|
1504
|
+
assert.deepEqual(sessionRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
|
|
1505
|
+
const otherSessionRes = await requestUntilAligned(handlers, '?sessionId=s2&scope=session')
|
|
1506
|
+
assert.deepEqual(otherSessionRes.body.inputs.map((item) => item.text), ['第二条'])
|
|
1507
|
+
// global 合并全部工作区缓存:先触发 C:\other 对齐(模拟其已就绪),再等 global 汇聚
|
|
1508
|
+
await requestUntilAligned(handlers, '?sessionId=s3&scope=workspace')
|
|
1509
|
+
const globalRes = await requestUntil(handlers, '?sessionId=s1&scope=global',
|
|
1510
|
+
(body) => body.aligned && body.inputs.some((item) => item.text === '别的工作区'))
|
|
1511
|
+
assert.deepEqual(globalRes.body.inputs.map((item) => item.text), ['别的工作区', '最新输入', '第二条', '更早的输入'])
|
|
1512
|
+
})
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
test('历史输入路由:对齐与既有缓存合并——旧条目保留、新输入追加、sid 溯源不变', skipMissingDeps, async () => {
|
|
1516
|
+
await withHistoryCacheDir(async () => {
|
|
1517
|
+
await ensureCacheDir(process.env.DSH_HISTORY_CACHE_DIR)
|
|
1518
|
+
await writeWorkspaceCache(process.env.DSH_HISTORY_CACHE_DIR, 'C:\\x', {
|
|
1519
|
+
entries: [
|
|
1520
|
+
{ text: '更早的输入', at: 100, sid: 's1' },
|
|
1521
|
+
{ text: '已消失会话的输入', at: 50, sid: 'gone' },
|
|
1522
|
+
],
|
|
1523
|
+
extracts: {},
|
|
1524
|
+
})
|
|
1525
|
+
const { handlers } = makeHistoryCtx()
|
|
1526
|
+
const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
|
|
1527
|
+
(body) => body.aligned && body.inputs.some((item) => item.text === '最新输入'))
|
|
1528
|
+
assert.deepEqual(res.body.inputs.map((item) => item.text), ['最新输入', '第二条', '更早的输入', '已消失会话的输入'])
|
|
1529
|
+
const sessionRes = await requestUntil(handlers, '?sessionId=s1&scope=session',
|
|
1530
|
+
(body) => body.aligned && body.inputs.some((item) => item.text === '最新输入'))
|
|
1531
|
+
assert.deepEqual(sessionRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
|
|
1532
|
+
})
|
|
1533
|
+
})
|
|
1534
|
+
|
|
1535
|
+
test('历史输入路由:scope 缺省回退 session;未知会话/非 GET/缺 sessionId 拒绝', skipMissingDeps, async () => {
|
|
1536
|
+
await withHistoryCacheDir(async () => {
|
|
1537
|
+
const { handlers } = makeHistoryCtx()
|
|
1538
|
+
const defaultRes = await requestUntilAligned(handlers, '?sessionId=s1')
|
|
1539
|
+
assert.deepEqual(defaultRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
|
|
1540
|
+
const invalidRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=bogus')
|
|
1541
|
+
assert.deepEqual(invalidRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
|
|
1542
|
+
const unknown = response()
|
|
1543
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=ghost'), unknown)
|
|
1544
|
+
assert.equal(unknown.status, 400)
|
|
1545
|
+
assert.equal(unknown.body.error, MESSAGES.unknownSession)
|
|
1546
|
+
const wrongMethod = response()
|
|
1547
|
+
await handlers.get('/api/session-manager/inputs')(request('s1'), wrongMethod)
|
|
1548
|
+
assert.equal(wrongMethod.status, 405)
|
|
1549
|
+
const missing = response()
|
|
1550
|
+
await handlers.get('/api/session-manager/inputs')(getRequest(''), missing)
|
|
1551
|
+
assert.equal(missing.status, 400)
|
|
1552
|
+
})
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1555
|
+
test('历史输入路由:损坏会话对齐跳过不中断;无 cwd 会话 workspace/session 返回空且 aligned', skipMissingDeps, async () => {
|
|
1556
|
+
await withHistoryCacheDir(async () => {
|
|
1557
|
+
const { handlers, logger } = makeCtx({
|
|
1558
|
+
archivedIds: [],
|
|
1559
|
+
headers: [
|
|
1560
|
+
{ id: 's1', cwd: 'C:\\x', createdAt: 0 },
|
|
1561
|
+
{ id: 'broken', cwd: 'C:\\x', createdAt: 0 },
|
|
1562
|
+
{ id: 'nocwd', createdAt: 0 },
|
|
1563
|
+
],
|
|
1564
|
+
agents: new Map(),
|
|
1565
|
+
readSessions: {
|
|
1566
|
+
s1: [userMessageEvent('存活输入', 100)],
|
|
1567
|
+
broken: () => { throw new Error('产物损坏') },
|
|
1568
|
+
},
|
|
1569
|
+
})
|
|
1570
|
+
const res = await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
|
|
1571
|
+
assert.deepEqual(res.body.inputs.map((item) => item.text), ['存活输入'])
|
|
1572
|
+
assert.ok(logger.warns.some((line) => line.includes('broken')), '对齐失败未进服务端日志')
|
|
1573
|
+
const noCwdWorkspace = response()
|
|
1574
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=nocwd&scope=workspace'), noCwdWorkspace)
|
|
1575
|
+
assert.deepEqual(noCwdWorkspace.body, { inputs: [], aligned: true })
|
|
1576
|
+
const noCwdSession = response()
|
|
1577
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=nocwd&scope=session'), noCwdSession)
|
|
1578
|
+
assert.deepEqual(noCwdSession.body, { inputs: [], aligned: true })
|
|
1579
|
+
})
|
|
1580
|
+
})
|
|
1581
|
+
|
|
1582
|
+
test('历史输入路由:运行中会话参与对齐(实时追加),缓存上限裁剪保留最新', skipMissingDeps, async () => {
|
|
1583
|
+
await withHistoryCacheDir(async () => {
|
|
1584
|
+
const agents = new Map([['running1', { status: 'running' }]])
|
|
1585
|
+
const manyEvents = Array.from({ length: HISTORY_INPUT_LIMIT + 50 }, (_, i) => userMessageEvent('运行输入' + i, 1000 + i))
|
|
1586
|
+
const { handlers, readCounts } = makeCtx({
|
|
1587
|
+
archivedIds: [],
|
|
1588
|
+
headers: [
|
|
1589
|
+
{ id: 's1', cwd: 'C:\\x', createdAt: 0 },
|
|
1590
|
+
{ id: 'running1', cwd: 'C:\\x', createdAt: 0 },
|
|
1591
|
+
],
|
|
1592
|
+
agents,
|
|
1593
|
+
readSessions: {
|
|
1594
|
+
s1: [userMessageEvent('历史输入', 1000 + manyEvents.length + 10)],
|
|
1595
|
+
running1: manyEvents,
|
|
1596
|
+
},
|
|
1597
|
+
})
|
|
1598
|
+
const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
|
|
1599
|
+
(body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT)
|
|
1600
|
+
const texts = res.body.inputs.map((item) => item.text)
|
|
1601
|
+
assert.equal(texts.length, HISTORY_INPUT_LIMIT)
|
|
1602
|
+
assert.ok(texts.includes('历史输入'), '最新既有输入保留')
|
|
1603
|
+
assert.ok(texts.includes('运行输入' + (HISTORY_INPUT_LIMIT + 49)), '次新运行输入保留')
|
|
1604
|
+
assert.ok(!texts.includes('运行输入0'), '最旧输入被上限裁剪')
|
|
1605
|
+
assert.ok(readCounts.get('running1') >= 1, '运行中会话被对齐解压')
|
|
1606
|
+
const sessionRes = await requestUntilAligned(handlers, '?sessionId=running1&scope=session')
|
|
1607
|
+
assert.ok(sessionRes.body.inputs.length > 0, 'session 范围可取运行中会话自身输入')
|
|
1608
|
+
})
|
|
1609
|
+
})
|
|
1610
|
+
|
|
1611
|
+
test('历史输入路由:再次请求读缓存不再解压产物(对齐节流内)', skipMissingDeps, async () => {
|
|
1612
|
+
await withHistoryCacheDir(async () => {
|
|
1613
|
+
const { handlers, readCounts } = makeCtx({
|
|
1614
|
+
archivedIds: [],
|
|
1615
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
1616
|
+
agents: new Map(),
|
|
1617
|
+
readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
|
|
1618
|
+
})
|
|
1619
|
+
await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
|
|
1620
|
+
const readsAfterAlign = readCounts.get('s1')
|
|
1621
|
+
const second = response()
|
|
1622
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=workspace'), second)
|
|
1623
|
+
assert.deepEqual(second.body.inputs.map((item) => item.text), ['工作区输入'])
|
|
1624
|
+
assert.equal(second.body.aligned, true)
|
|
1625
|
+
assert.equal(readCounts.get('s1'), readsAfterAlign, '读路径不得解压产物')
|
|
1626
|
+
})
|
|
1627
|
+
})
|
|
1628
|
+
|
|
1629
|
+
test('历史输入路由:会话产物变化即席聚焦对齐,单次请求内拿到新输入(首条<3s)', skipMissingDeps, async () => {
|
|
1630
|
+
await withHistoryCacheDir(async () => {
|
|
1631
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-focus-'))
|
|
1632
|
+
const artifactPath = path.join(dir, 'session.jsonl.zstd')
|
|
1633
|
+
await writeFile(artifactPath, 'log-v1')
|
|
1634
|
+
try {
|
|
1635
|
+
const shared = {
|
|
1636
|
+
archivedIds: [],
|
|
1637
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
1638
|
+
agents: new Map(),
|
|
1639
|
+
sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
|
|
1640
|
+
}
|
|
1641
|
+
const first = makeCtx({ ...shared, readSessions: { s1: [userMessageEvent('旧输入', 100)] } })
|
|
1642
|
+
await requestUntilAligned(first.handlers, '?sessionId=s1&scope=session')
|
|
1643
|
+
// 产物增长(会话有新输入):指纹失效——路由就地等待聚焦对齐,
|
|
1644
|
+
// 测试桩的 readSession 立即完成,单次请求内应直接拿到新输入且 aligned
|
|
1645
|
+
await writeFile(artifactPath, 'log-v1-much-longer-appended')
|
|
1646
|
+
const second = makeCtx({ ...shared, readSessions: { s1: [userMessageEvent('旧输入', 100), userMessageEvent('新输入XYZ', 200)] } })
|
|
1647
|
+
const res = response()
|
|
1648
|
+
await second.handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=session'), res)
|
|
1649
|
+
assert.equal(res.body.aligned, true, '焦点对齐快于等待时限,首请求即就绪')
|
|
1650
|
+
assert.deepEqual(res.body.inputs.map((item) => item.text), ['新输入XYZ', '旧输入'])
|
|
1651
|
+
} finally {
|
|
1652
|
+
await rm(dir, { recursive: true, force: true })
|
|
1653
|
+
}
|
|
1654
|
+
})
|
|
1655
|
+
})
|
|
1656
|
+
|
|
1657
|
+
test('历史输入路由:聚焦对齐只更新目标会话,不抹其他会话的持久 extracts', skipMissingDeps, async () => {
|
|
1658
|
+
await withHistoryCacheDir(async () => {
|
|
1659
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-prune-'))
|
|
1660
|
+
const artifactPath = path.join(dir, 'session.jsonl.zstd')
|
|
1661
|
+
await writeFile(artifactPath, 'log-v1')
|
|
1662
|
+
try {
|
|
1663
|
+
await ensureCacheDir(process.env.DSH_HISTORY_CACHE_DIR)
|
|
1664
|
+
await writeWorkspaceCache(process.env.DSH_HISTORY_CACHE_DIR, 'C:\\x', {
|
|
1665
|
+
entries: [{ text: '更早的输入', at: 100, sid: 's1' }],
|
|
1666
|
+
extracts: { s2: { mtimeMs: 1, size: 2, entries: [{ text: '邻居会话输入', at: 50 }] } },
|
|
1667
|
+
})
|
|
1668
|
+
const { handlers } = makeCtx({
|
|
1669
|
+
archivedIds: [],
|
|
1670
|
+
headers: [
|
|
1671
|
+
{ id: 's1', cwd: 'C:\\x', createdAt: 0 },
|
|
1672
|
+
{ id: 's2', cwd: 'C:\\x', createdAt: 0 },
|
|
1673
|
+
],
|
|
1674
|
+
agents: new Map(),
|
|
1675
|
+
sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
|
|
1676
|
+
readSessions: { s1: [userMessageEvent('更早的输入', 100), userMessageEvent('聚焦新输入', 200)] },
|
|
1677
|
+
})
|
|
1678
|
+
await requestUntil(handlers, '?sessionId=s1&scope=session',
|
|
1679
|
+
(body) => body.aligned && body.inputs.some((item) => item.text === '聚焦新输入'))
|
|
1680
|
+
const cached = JSON.parse(await readFile(path.join(process.env.DSH_HISTORY_CACHE_DIR, 'x-5358b306.json'), 'utf8'))
|
|
1681
|
+
assert.ok(cached.extracts.s2, '邻居会话 extracts 不得被聚焦对齐抹掉')
|
|
1682
|
+
assert.ok(cached.extracts.s1, '目标会话 extracts 已更新')
|
|
1683
|
+
} finally {
|
|
1684
|
+
await rm(dir, { recursive: true, force: true })
|
|
1685
|
+
}
|
|
1686
|
+
})
|
|
1687
|
+
})
|
|
1688
|
+
|
|
1689
|
+
test('历史输入路由:产物未变的会话跨对齐零解压(磁盘 extracts 指纹命中)', skipMissingDeps, async () => {
|
|
1690
|
+
await withHistoryCacheDir(async () => {
|
|
1691
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-art-'))
|
|
1692
|
+
const artifactPath = path.join(dir, 'session.jsonl.zstd')
|
|
1693
|
+
await writeFile(artifactPath, 'log-bytes')
|
|
1694
|
+
try {
|
|
1695
|
+
const { handlers, readCounts } = makeCtx({
|
|
1696
|
+
archivedIds: [],
|
|
1697
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
1698
|
+
agents: new Map(),
|
|
1699
|
+
sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
|
|
1700
|
+
readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
|
|
1701
|
+
})
|
|
1702
|
+
await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
|
|
1703
|
+
const readsAfterFirstAlign = readCounts.get('s1')
|
|
1704
|
+
assert.ok(readsAfterFirstAlign >= 1, '首次对齐必然解压')
|
|
1705
|
+
// 节流壳 30s 挡住同工作区二次触发:直接以新 makeCtx(新节流 Map)模拟"重启后再次对齐"
|
|
1706
|
+
const { handlers: handlers2, readCounts: readCounts2 } = makeCtx({
|
|
1707
|
+
archivedIds: [],
|
|
1708
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
1709
|
+
agents: new Map(),
|
|
1710
|
+
sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
|
|
1711
|
+
readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
|
|
1712
|
+
})
|
|
1713
|
+
// 同一缓存目录(env 未变,withHistoryCacheDir 复用):指纹命中 → 零解压
|
|
1714
|
+
const second = response()
|
|
1715
|
+
await handlers2.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=workspace'), second)
|
|
1716
|
+
assert.equal(second.body.aligned, true)
|
|
1717
|
+
assert.deepEqual(second.body.inputs.map((item) => item.text), ['工作区输入'])
|
|
1718
|
+
// 触发一次对齐(绕过节流:新 ctx 节流 Map 为空,请求即触发)
|
|
1719
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
1720
|
+
assert.equal(readCounts2.get('s1') === undefined, true, '产物未变,重启后对齐零解压')
|
|
1721
|
+
} finally {
|
|
1722
|
+
await rm(dir, { recursive: true, force: true })
|
|
1723
|
+
}
|
|
1724
|
+
})
|
|
1725
|
+
})
|
|
1726
|
+
|
|
1727
|
+
test('历史输入路由:启动对齐只回溯最近 STARTUP_SCAN 个会话,老会话不解压', skipMissingDeps, async () => {
|
|
1728
|
+
await withHistoryCacheDir(async () => {
|
|
1729
|
+
const total = HISTORY_STARTUP_SCAN_LIMIT + 20
|
|
1730
|
+
// 宿主 listSessions 为最新在前:数组首元素即最新会话
|
|
1731
|
+
const headers = Array.from({ length: total }, (_, i) => ({ id: 's' + (total - 1 - i), cwd: 'C:\\x', createdAt: 0 }))
|
|
1732
|
+
const readSessions = {}
|
|
1733
|
+
for (let i = 0; i < total; i++) readSessions['s' + i] = [userMessageEvent('输入' + i, 1000 + i)]
|
|
1734
|
+
const { handlers, readCounts } = makeCtx({ archivedIds: [], headers, agents: new Map(), readSessions })
|
|
1735
|
+
// 触发对齐(请求工作区),等队列排空后窗口外老会话(s0..s19)不得被解压
|
|
1736
|
+
await requestUntil(handlers, '?sessionId=s' + (total - 1) + '&scope=workspace',
|
|
1737
|
+
(body) => body.aligned && body.inputs.length > 0)
|
|
1738
|
+
assert.equal(readCounts.get('s0'), undefined, '窗口外最老会话不被启动对齐解压')
|
|
1739
|
+
assert.ok(readCounts.get('s' + (total - 1)) >= 1, '窗口内最新会话被解压')
|
|
1740
|
+
})
|
|
1741
|
+
})
|
|
1742
|
+
|
|
1743
|
+
test('常用提示词:toggle 收藏/取消,prompts 范围可见,与工作区缓存互不污染', skipMissingDeps, async () => {
|
|
1744
|
+
await withHistoryCacheDir(async () => {
|
|
1745
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-prompts-'))
|
|
1746
|
+
const artifactPath = path.join(dir, 'session.jsonl.zstd')
|
|
1747
|
+
try {
|
|
1748
|
+
await writeFile(artifactPath, 'log-v1')
|
|
1749
|
+
const { handlers } = makeCtx({
|
|
1750
|
+
archivedIds: [],
|
|
1751
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
1752
|
+
agents: new Map(),
|
|
1753
|
+
sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
|
|
1754
|
+
readSessions: { s1: [userMessageEvent('历史输入A', 100)] },
|
|
1755
|
+
})
|
|
1756
|
+
// 初始为空
|
|
1757
|
+
const empty = response()
|
|
1758
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), empty)
|
|
1759
|
+
assert.deepEqual(empty.body.inputs, [])
|
|
1760
|
+
// 收藏 → prompts 可见;工作区范围不混入
|
|
1761
|
+
await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '常用句子X' })
|
|
1762
|
+
const listed = response()
|
|
1763
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), listed)
|
|
1764
|
+
assert.deepEqual(listed.body.inputs.map((item) => item.text), ['常用句子X'])
|
|
1765
|
+
const workspace = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
|
|
1766
|
+
(body) => body.aligned && body.inputs.some((item) => item.text === '历史输入A'))
|
|
1767
|
+
assert.equal(workspace.body.inputs.some((item) => item.text === '常用句子X'), false, '收藏不混入工作区历史')
|
|
1768
|
+
// 再 toggle = 取消
|
|
1769
|
+
await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '常用句子X' })
|
|
1770
|
+
const cleared = response()
|
|
1771
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), cleared)
|
|
1772
|
+
assert.deepEqual(cleared.body.inputs, [])
|
|
1773
|
+
} finally {
|
|
1774
|
+
await rm(dir, { recursive: true, force: true })
|
|
1775
|
+
}
|
|
1776
|
+
})
|
|
1777
|
+
})
|
|
1778
|
+
|
|
1779
|
+
test('常用提示词:超长截断、上限裁剪、空 text 拒绝', skipMissingDeps, async () => {
|
|
1780
|
+
await withHistoryCacheDir(async () => {
|
|
1781
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-prompts-cap-'))
|
|
1782
|
+
const artifactPath = path.join(dir, 'session.jsonl.zstd')
|
|
1783
|
+
try {
|
|
1784
|
+
await writeFile(artifactPath, 'log-v1')
|
|
1785
|
+
const { handlers } = makeCtx({
|
|
1786
|
+
archivedIds: [],
|
|
1787
|
+
headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
|
|
1788
|
+
agents: new Map(),
|
|
1789
|
+
sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
|
|
1790
|
+
readSessions: { s1: [userMessageEvent('占位', 100)] },
|
|
1791
|
+
})
|
|
1792
|
+
await requestUntilAligned(handlers, '?sessionId=s1&scope=prompts')
|
|
1793
|
+
// 空白文本拒绝
|
|
1794
|
+
const blank = await postJson(handlers, '/api/session-manager/prompts/toggle', { text: ' ' })
|
|
1795
|
+
assert.equal(blank.status, 400)
|
|
1796
|
+
// 截断:HISTORY_INPUT_MAX_CHARS 以上裁到上限
|
|
1797
|
+
const long = '长'.repeat(HISTORY_INPUT_MAX_CHARS + 10)
|
|
1798
|
+
await postJson(handlers, '/api/session-manager/prompts/toggle', { text: long })
|
|
1799
|
+
const listed = response()
|
|
1800
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), listed)
|
|
1801
|
+
assert.equal(listed.body.inputs[0].text.length, HISTORY_INPUT_MAX_CHARS)
|
|
1802
|
+
await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '长'.repeat(HISTORY_INPUT_MAX_CHARS) })
|
|
1803
|
+
// 上限:超过 HISTORY_PROMPTS_MAX 后最旧被裁
|
|
1804
|
+
for (let i = 0; i < HISTORY_PROMPTS_MAX + 1; i++) {
|
|
1805
|
+
await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '条目' + i })
|
|
1806
|
+
}
|
|
1807
|
+
const capped = response()
|
|
1808
|
+
await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), capped)
|
|
1809
|
+
assert.equal(capped.body.inputs.length, HISTORY_PROMPTS_MAX)
|
|
1810
|
+
assert.equal(capped.body.inputs.some((item) => item.text === '条目0'), false, '最旧条目被裁掉')
|
|
1811
|
+
assert.equal(capped.body.inputs[0].text, '条目' + HISTORY_PROMPTS_MAX, '最新条目在顶部')
|
|
1812
|
+
} finally {
|
|
1813
|
+
await rm(dir, { recursive: true, force: true })
|
|
1814
|
+
}
|
|
1815
|
+
})
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
test('常用提示词:client 与 core 的 HISTORY_SCOPES 镜像同序同值,默认落点为当前会话', { skip: process.env.TEST_SKIP_PARITY }, async () => {
|
|
1819
|
+
const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
1820
|
+
const declared = clientSource.match(/const HISTORY_SCOPES = (\[[^\]]*\])/)
|
|
1821
|
+
assert.ok(declared, 'client.js 必须内联声明 HISTORY_SCOPES')
|
|
1822
|
+
assert.deepEqual(JSON.parse(declared[1].replace(/'/g, '"')), HISTORY_SCOPES)
|
|
1823
|
+
const labels = clientSource.match(/const HISTORY_SCOPE_LABELS = (\[[^\]]*\])/)
|
|
1824
|
+
assert.ok(labels, 'client.js 必须内联声明 HISTORY_SCOPE_LABELS')
|
|
1825
|
+
assert.equal(JSON.parse(labels[1].replace(/'/g, '"')).length, HISTORY_SCOPES.length, '标签与范围一一对应')
|
|
1826
|
+
assert.equal(HISTORY_SCOPES.indexOf('session'), 1, '浮层默认落点为当前会话(索引 1)')
|
|
1827
|
+
})
|
|
1828
|
+
|
|
1829
|
+
test('历史浮层启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
|
|
1830
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
1831
|
+
const initial = response()
|
|
1832
|
+
await handlers.get('/api/session-manager/history-enabled')(getRequest2('/api/session-manager/history-enabled', 'GET'), initial)
|
|
1833
|
+
assert.equal(initial.body.enabled, true, '默认启用')
|
|
1834
|
+
const off = await postJson(handlers, '/api/session-manager/history-enabled', { enabled: false })
|
|
1835
|
+
assert.equal(off.body.enabled, false)
|
|
1836
|
+
// settings 持久:重读反映关闭态;POST 回 true 恢复
|
|
1837
|
+
const reread = response()
|
|
1838
|
+
await handlers.get('/api/session-manager/history-enabled')(getRequest2('/api/session-manager/history-enabled', 'GET'), reread)
|
|
1839
|
+
assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
|
|
1840
|
+
await postJson(handlers, '/api/session-manager/history-enabled', { enabled: true })
|
|
1841
|
+
const restored = response()
|
|
1842
|
+
await handlers.get('/api/session-manager/history-enabled')(getRequest2('/api/session-manager/history-enabled', 'GET'), restored)
|
|
1843
|
+
assert.equal(restored.body.enabled, true)
|
|
1844
|
+
})
|
|
1845
|
+
|
|
1846
|
+
function getRequest2(url, method) {
|
|
1847
|
+
const req = new EventEmitter()
|
|
1848
|
+
req.method = method
|
|
1849
|
+
req.url = url
|
|
1850
|
+
return req
|
|
1851
|
+
}
|