@mzzsfy/dsh-session-manager 0.8.1 → 0.9.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.
@@ -1,595 +1,485 @@
1
- // 纯逻辑层测试:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分、空白产物判定。
2
- // BDD 场景对应 docs/design/dsh-session-manager.md。
3
-
4
- import test from 'node:test'
5
- import assert from 'node:assert/strict'
6
-
7
- import {
8
- ARCHIVE_PAGE_SIZE,
9
- DAY_MS,
10
- DEFAULT_AUTO_ARCHIVE_DAYS,
11
- DELETE_MESSAGES,
12
- HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES,
13
- HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES,
14
- TOAST_MAX_TITLES,
15
- aggregateDeleteOutcome,
16
- aggregateInputs,
17
- archiveToastStep,
18
- archiveToastText,
19
- artifactLooksBlank,
20
- deleteEligibility,
21
- diffArchived,
22
- extractUserInputs,
23
- filterArchiveRows,
24
- groupArchiveRowsByWorkspace,
25
- isSessionRunning,
26
- maxArtifactBytesForHost,
27
- mergeDeletedEntry,
28
- pageArchiveRows,
29
- projectArchiveRows,
30
- projectDeletedRows,
31
- removeDeletedEntry,
32
- selectArchiveCandidates,
33
- updatedAtOf,
34
- workspaceTitleForSession,
35
- workspaceTitleOf,
36
- } from '../src/core.mjs'
37
-
38
- const NOW = Date.parse('2026-01-10T00:00:00Z')
39
- const ACTIVE = NOW - 1 * DAY_MS
40
- const STALE = NOW - 8 * DAY_MS
41
-
42
- function record(overrides) {
43
- return { id: 's1', archived: false, running: false, blank: false, updatedAt: STALE, ...overrides }
44
- }
45
-
46
- test('阈值天数默认值与常量自洽', () => {
47
- assert.equal(DEFAULT_AUTO_ARCHIVE_DAYS, 7)
48
- assert.equal(DAY_MS, 24 * 60 * 60 * 1000)
49
- })
50
-
51
- test('新会话触发自动归档:超期候选被选中', () => {
52
- const picked = selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: 7 })
53
- assert.deepEqual(picked, ['s1'])
54
- })
55
-
56
- test('未超期与恰好等于阈值的会话不归档', () => {
57
- const records = [record({ updatedAt: ACTIVE }), record({ updatedAt: NOW - 7 * DAY_MS })]
58
- assert.deepEqual(selectArchiveCandidates({ records, nowMs: NOW, thresholdDays: 7 }), [])
59
- })
60
-
61
- test('运行中会话豁免', () => {
62
- const picked = selectArchiveCandidates({ records: [record({ running: true })], nowMs: NOW, thresholdDays: 7 })
63
- assert.deepEqual(picked, [])
64
- })
65
-
66
- test('空白会话豁免', () => {
67
- const picked = selectArchiveCandidates({ records: [record({ blank: true })], nowMs: NOW, thresholdDays: 7 })
68
- assert.deepEqual(picked, [])
69
- })
70
-
71
- test('已归档会话不参与评估:幂等', () => {
72
- const records = [record({ archived: true })]
73
- const first = selectArchiveCandidates({ records, nowMs: NOW, thresholdDays: 7 })
74
- assert.deepEqual(first, [])
75
- })
76
-
77
- test('阈值为零关闭功能', () => {
78
- const picked = selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: 0 })
79
- assert.deepEqual(picked, [])
80
- })
81
-
82
- test('阈值负值与非有限值防御性关闭', () => {
83
- assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: -1 }), [])
84
- assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: Number.NaN }), [])
85
- })
86
-
87
- test('updatedAt 取创建时间与最近活跃的较大者', () => {
88
- assert.equal(updatedAtOf({ createdAt: 100 }, 50), 100)
89
- assert.equal(updatedAtOf({ createdAt: 100 }, 500), 500)
90
- assert.equal(updatedAtOf({ createdAt: 100 }, undefined), 100)
91
- })
92
-
93
- test('归档面板投影:交集过滤且按更新时间倒序', () => {
94
- const rows = [
95
- { id: 'b', title: 'B', updatedAt: 200 },
96
- { id: 'a', title: 'A', updatedAt: 300 },
97
- { id: 'c', title: 'C', updatedAt: 100 },
98
- { id: 'd', title: 'D', updatedAt: 400 },
99
- ]
100
- const projected = projectArchiveRows({ rows, archivedIds: ['b', 'c', 'gone'] })
101
- assert.deepEqual(projected, [
102
- { id: 'b', title: 'B', updatedAt: 200, workspace: null },
103
- { id: 'c', title: 'C', updatedAt: 100, workspace: null },
104
- ])
105
- })
106
-
107
- test('归档面板投影:标题缺失回退会话 id(与 client 镜像同规)', () => {
108
- const projected = projectArchiveRows({ rows: [{ id: 'a', title: '', updatedAt: 5 }], archivedIds: ['a'] })
109
- assert.deepEqual(projected, [{ id: 'a', title: 'a', updatedAt: 5, workspace: null }])
110
- })
111
-
112
- const WORKSPACES = [
113
- { workspaceId: 'w1', path: 'C:\\work\\alpha', title: 'alpha', sessionIds: ['a', 'b'] },
114
- { workspaceId: 'w2', path: 'C:\\work\\beta', title: 'beta', sessionIds: ['c'] },
115
- ]
116
-
117
- test('归档面板投影:会话经 workspace 账本映射到工作区标题', () => {
118
- const rows = [
119
- { id: 'a', title: 'A', updatedAt: 300 },
120
- { id: 'c', title: 'C', updatedAt: 100 },
121
- ]
122
- const projected = projectArchiveRows({ rows, archivedIds: ['a', 'c'], workspaces: WORKSPACES })
123
- assert.deepEqual(projected, [
124
- { id: 'a', title: 'A', updatedAt: 300, workspace: 'alpha' },
125
- { id: 'c', title: 'C', updatedAt: 100, workspace: 'beta' },
126
- ])
127
- })
128
-
129
- test('归档面板投影:账本缺失时 cwd 匹配工作区路径回退', () => {
130
- const rows = [{ id: 'x', title: 'X', updatedAt: 1, cwd: 'C:\\work\\beta' }]
131
- const projected = projectArchiveRows({ rows, archivedIds: ['x'], workspaces: WORKSPACES })
132
- assert.deepEqual(projected, [{ id: 'x', title: 'X', updatedAt: 1, workspace: 'beta' }])
133
- })
134
-
135
- test('归档面板投影:无匹配工作区输出 null,账本缺 title 回退路径末段', () => {
136
- const rows = [
137
- { id: 'x', title: 'X', updatedAt: 3 },
138
- { id: 'y', title: 'Y', updatedAt: 2, cwd: 'C:\\nowhere\\lost\\' },
139
- { id: 'z', title: 'Z', updatedAt: 1 },
140
- ]
141
- const workspaces = [
142
- ...WORKSPACES,
143
- { workspaceId: 'w3', path: 'C:\\work\\titleless', sessionIds: ['z'] },
144
- ]
145
- const projected = projectArchiveRows({ rows, archivedIds: ['x', 'y', 'z'], workspaces })
146
- assert.deepEqual(projected, [
147
- { id: 'x', title: 'X', updatedAt: 3, workspace: null },
148
- { id: 'y', title: 'Y', updatedAt: 2, workspace: null },
149
- { id: 'z', title: 'Z', updatedAt: 1, workspace: 'titleless' },
150
- ])
151
- })
152
-
153
- test('归档面板投影:workspaces 缺省时全部回退 null', () => {
154
- const rows = [{ id: 'a', title: 'A', updatedAt: 1, cwd: 'C:\\work\\alpha' }]
155
- assert.deepEqual(
156
- projectArchiveRows({ rows, archivedIds: ['a'] }),
157
- [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }],
158
- )
159
- })
160
-
161
- test('workspaceTitleOf 路径末段边界', () => {
162
- assert.equal(workspaceTitleOf('C:\\work\\alpha'), 'alpha')
163
- assert.equal(workspaceTitleOf('/home/user/project/'), 'project')
164
- assert.equal(workspaceTitleOf('C:\\work\\mixed/slash\\path'), 'path')
165
- assert.equal(workspaceTitleOf('C:\\'), 'C:')
166
- assert.equal(workspaceTitleOf('/'), '')
167
- assert.equal(workspaceTitleOf('///'), '')
168
- })
169
-
170
- test('workspaceTitleForSession 直接边界:title 与 path 皆缺为 null,title 空串回退路径末段', () => {
171
- assert.equal(workspaceTitleForSession({ workspaces: [{ sessionIds: ['a'] }], sessionId: 'a' }), null)
172
- assert.equal(
173
- workspaceTitleForSession({ workspaces: [{ path: 'C:\\work\\x', title: '', sessionIds: ['a'] }], sessionId: 'a' }),
174
- 'x',
175
- )
176
- assert.equal(workspaceTitleForSession({ workspaces: 'bad', sessionId: 'a' }), null)
177
- assert.equal(workspaceTitleForSession({ workspaces: [{ path: 'C:\\w\\a', sessionIds: ['a'] }], sessionId: 'a', cwd: undefined }), 'a')
178
- })
179
-
180
- // ── 归档库:主列表分页 / 工作区分组 / 标题搜索 ──
181
-
182
- test('归档库分页:页粒度常量', () => {
183
- assert.equal(ARCHIVE_PAGE_SIZE, 100)
184
- })
185
-
186
- test('归档库分页:默认只取前 100 条,剩余数准确', () => {
187
- const rows = Array.from({ length: 250 }, (_, i) => ({ id: 's' + i, title: 'T' + i, updatedAt: 1000 - i, workspace: null }))
188
- const page = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
189
- assert.equal(page.visible.length, 100)
190
- assert.equal(page.remaining, 150)
191
- assert.equal(page.visible[0].id, 's0')
192
- assert.equal(page.visible[99].id, 's99')
193
- })
194
-
195
- test('归档库分页:增量展开与全量边界', () => {
196
- const rows = Array.from({ length: 250 }, (_, i) => ({ id: 's' + i, title: 'T' + i, updatedAt: i, workspace: null }))
197
- assert.equal(pageArchiveRows(rows, 200).visible.length, 200)
198
- assert.equal(pageArchiveRows(rows, 200).remaining, 50)
199
- assert.deepEqual(pageArchiveRows(rows, rows.length), { visible: rows, remaining: 0 })
200
- assert.equal(pageArchiveRows(rows, rows.length + 10).remaining, 0)
201
- })
202
-
203
- test('归档库分页:空列表与非有限可见数防御', () => {
204
- assert.deepEqual(pageArchiveRows([], ARCHIVE_PAGE_SIZE), { visible: [], remaining: 0 })
205
- const single = [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }]
206
- assert.deepEqual(pageArchiveRows(single, Number.NaN).visible, [])
207
- assert.equal(pageArchiveRows(single, Number.NaN).remaining, 1)
208
- assert.equal(pageArchiveRows(single, -5).remaining, 1)
209
- assert.deepEqual(pageArchiveRows(single, Infinity).visible, [])
210
- assert.equal(pageArchiveRows(single, Infinity).remaining, 1)
211
- })
212
-
213
- test('归档库分组:按工作区标题分桶,组内保持输入序,组间按最新活跃倒序', () => {
214
- const rows = [
215
- { id: 'a', title: 'A', updatedAt: 400, workspace: 'alpha' },
216
- { id: 'b', title: 'B', updatedAt: 300, workspace: 'beta' },
217
- { id: 'c', title: 'C', updatedAt: 200, workspace: 'alpha' },
218
- ]
219
- assert.deepEqual(groupArchiveRowsByWorkspace(rows), [
220
- { workspace: 'alpha', rows: [rows[0], rows[2]] },
221
- { workspace: 'beta', rows: [rows[1]] },
222
- ])
223
- })
224
-
225
- test('归档库分组:workspace 假值并桶为未分组(null)', () => {
226
- const rows = [
227
- { id: 'u1', title: 'U1', updatedAt: 100, workspace: null },
228
- { id: 'g', title: 'G', updatedAt: 350, workspace: 'gamma' },
229
- { id: 'u2', title: 'U2', updatedAt: 500, workspace: '' },
230
- { id: 'u3', title: 'U3', updatedAt: 50, workspace: null },
231
- ]
232
- assert.deepEqual(groupArchiveRowsByWorkspace(rows), [
233
- { workspace: null, rows: [rows[0], rows[2], rows[3]] },
234
- { workspace: 'gamma', rows: [rows[1]] },
235
- ])
236
- })
237
-
238
- test('归档库分组:空输入与非数组防御', () => {
239
- assert.deepEqual(groupArchiveRowsByWorkspace([]), [])
240
- assert.deepEqual(groupArchiveRowsByWorkspace(undefined), [])
241
- })
242
-
243
- test('归档库搜索:标题不区分大小写子串匹配,空查询返回全量', () => {
244
- const rows = [
245
- { id: 'a', title: '修复登录页', updatedAt: 3, workspace: null },
246
- { id: 'b', title: 'Refactor API', updatedAt: 2, workspace: null },
247
- { id: 'c', title: '登录超时排查', updatedAt: 1, workspace: null },
248
- ]
249
- assert.deepEqual(filterArchiveRows(rows, '登录'), [rows[0], rows[2]])
250
- assert.deepEqual(filterArchiveRows(rows, 'REFACT'), [rows[1]])
251
- assert.deepEqual(filterArchiveRows(rows, '不存在'), [])
252
- })
253
-
254
- test('归档库搜索:空查询与空白查询原样返回全量,非数组防御', () => {
255
- const rows = [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }]
256
- assert.deepEqual(filterArchiveRows(rows, ''), rows)
257
- assert.deepEqual(filterArchiveRows(rows, ' '), rows)
258
- assert.deepEqual(filterArchiveRows(undefined, 'A'), [])
259
- })
260
-
261
- test('归档库搜索:标题缺失经投影回退会话 id 后可被命中(与投影组合)', () => {
262
- const projected = projectArchiveRows({
263
- rows: [{ id: 'abc-123', title: '', updatedAt: 1 }],
264
- archivedIds: ['abc-123'],
265
- })
266
- assert.deepEqual(filterArchiveRows(projected, 'ABC-'), projected)
267
- })
268
-
269
- test('归档集合差分只报新增,首帧基线不提示', () => {
270
- assert.deepEqual(diffArchived(undefined, ['x', 'y']), [])
271
- assert.deepEqual(diffArchived(['x'], ['x', 'y', 'z']), ['y', 'z'])
272
- assert.deepEqual(diffArchived(['x', 'y'], ['x']), [])
273
- })
274
-
275
- test('Toast 差分:pending 空态与基线首装不提示,ready 后新增才提示', () => {
276
- let previous
277
- let step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: [] })
278
- previous = step.state
279
- assert.deepEqual(step.added, [])
280
- // 基线安装:存量 29 个不算新增(模型 pending 期发射,notify 时序不定,两种相位都守卫)
281
- step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: ['a', 'b'] })
282
- previous = step.state
283
- assert.deepEqual(step.added, [])
284
- step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] })
285
- previous = step.state
286
- assert.deepEqual(step.added, [])
287
- // ready 建立后:增量帧触发提示
288
- step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] })
289
- assert.deepEqual(step.added, ['c'])
290
- })
291
-
292
- test('Toast 差分:订阅即 ready(无 pending 帧)时首帧守卫仍生效', () => {
293
- let previous
294
- const step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a'] })
295
- previous = step.state
296
- assert.deepEqual(step.added, [])
297
- assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] }).added, ['b'])
298
- })
299
-
300
- test('Toast 差分:ready→pending→ready 重连序列不误报存量', () => {
301
- let previous
302
- let step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a'] })
303
- previous = step.state
304
- // 断连:pending 帧中断 ready 链
305
- step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: ['a', 'b'] })
306
- previous = step.state
307
- assert.deepEqual(step.added, [])
308
- // 重连基线首装:不提示存量(离期新增的提示语义见 core 注释)
309
- step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] })
310
- previous = step.state
311
- assert.deepEqual(step.added, [])
312
- // 重连建立后:增量照常提示
313
- assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] }).added, ['c'])
314
- })
315
-
316
- test('归档通知文案:截断阈值常量', () => {
317
- assert.equal(TOAST_MAX_TITLES, 3)
318
- })
319
-
320
- test('归档通知文案:单个会话报标题', () => {
321
- assert.equal(archiveToastText(['a'], [{ id: 'a', title: '修复登录页' }]), '会话「修复登录页」已归档')
322
- })
323
-
324
- test('归档通知文案:多个会话列举标题,计数为真实总数', () => {
325
- const rows = [
326
- { id: 'a', title: 'A' },
327
- { id: 'b', title: 'B' },
328
- ]
329
- assert.equal(archiveToastText(['a', 'b'], rows), '有 2 个会话已归档:A、B')
330
- })
331
-
332
- test('归档通知文案:恰好等于展示阈值不截断', () => {
333
- const rows = [
334
- { id: 'a', title: 'A' },
335
- { id: 'b', title: 'B' },
336
- { id: 'c', title: 'C' },
337
- ]
338
- assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:A、B、C')
339
- })
340
-
341
- test('归档通知文案:超展示阈值只列前段并以「 等」收尾,计数仍为真实总数', () => {
342
- const rows = [
343
- { id: 'a', title: 'A' },
344
- { id: 'b', title: 'B' },
345
- { id: 'c', title: 'C' },
346
- { id: 'd', title: 'D' },
347
- ]
348
- assert.equal(archiveToastText(['a', 'b', 'c', 'd'], rows), '有 4 个会话已归档:A、B、C 等')
349
- })
350
-
351
- test('归档通知文案:标题缺失、空白与行缺失回退会话 id,标题按去除首尾空白取值', () => {
352
- const rows = [{ id: 'a' }, { id: 'b', title: ' ' }, { id: 'x', title: 'X' }]
353
- assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:a、b、c')
354
- assert.equal(archiveToastText(['t'], [{ id: 't', title: ' T ' }]), '会话「T」已归档')
355
- })
356
-
357
- test('归档通知文案:行数据非数组按空处理,标题全部回退会话 id', () => {
358
- assert.equal(archiveToastText(['a', 'b'], undefined), '有 2 个会话已归档:a、b')
359
- })
360
-
361
- test('非归档会话拒绝删除', () => {
362
- assert.equal(deleteEligibility({ archivedIds: ['a'], sessionId: 'a' }).ok, true)
363
- const denied = deleteEligibility({ archivedIds: ['a'], sessionId: 'b' })
364
- assert.equal(denied.ok, false)
365
- assert.equal(denied.code, 'not-archived')
366
- })
367
-
368
- test('删除收尾聚合:失败矩阵折叠为三形态响应体', () => {
369
- const R = DELETE_MESSAGES.runningDuringTrash
370
- // 全成功无警告:无多余键(index.test deepEqual 锁定同形态)
371
- assert.deepEqual(aggregateDeleteOutcome({}), { ok: true })
372
- // 单一失败:主文案优先级 detach → 归档清理 → 台账
373
- assert.deepEqual(aggregateDeleteOutcome({ detachFailed: true }), { ok: true, partial: true, message: DELETE_MESSAGES.partial })
374
- assert.deepEqual(aggregateDeleteOutcome({ archiveCleanupFailed: true }), { ok: true, partial: true, message: DELETE_MESSAGES.archiveCleanup })
375
- assert.deepEqual(aggregateDeleteOutcome({ ledgerFailed: true }), { ok: true, partial: true, message: DELETE_MESSAGES.ledgerFailed })
376
- // 双失败:高优先级主文案 + 台账后缀
377
- assert.deepEqual(
378
- aggregateDeleteOutcome({ detachFailed: true, ledgerFailed: true }),
379
- { ok: true, partial: true, message: DELETE_MESSAGES.partial + DELETE_MESSAGES.ledgerSuffix },
380
- )
381
- // 运行中翻转警告后缀并入一切形态
382
- assert.deepEqual(
383
- aggregateDeleteOutcome({ detachFailed: true, ledgerFailed: true, runningDuringTrash: true }),
384
- { ok: true, partial: true, message: DELETE_MESSAGES.partial + DELETE_MESSAGES.ledgerSuffix + ';' + R },
385
- )
386
- // 全失败
387
- assert.deepEqual(
388
- aggregateDeleteOutcome({ detachFailed: true, archiveCleanupFailed: true, ledgerFailed: true, runningDuringTrash: true }),
389
- { ok: true, partial: true, message: DELETE_MESSAGES.partial + DELETE_MESSAGES.ledgerSuffix + ';' + R },
390
- )
391
- // 全成功警告态:非 partial 形态(ok+message,无 partial 键)
392
- assert.deepEqual(aggregateDeleteOutcome({ runningDuringTrash: true }), { ok: true, message: R })
393
- })
394
-
395
- test('运行中判定:agent status running 即运行中,注册表缺失视为非运行', () => {
396
- const agents = new Map([['s1', { status: 'running' }], ['s2', { status: 'idle' }]])
397
- assert.equal(isSessionRunning({ agents, sessionId: 's1' }), true)
398
- assert.equal(isSessionRunning({ agents, sessionId: 's2' }), false)
399
- assert.equal(isSessionRunning({ agents, sessionId: 'gone' }), false)
400
- assert.equal(isSessionRunning({ agents: undefined, sessionId: 's1' }), false)
401
- })
402
-
403
- test('空白产物判定:JSONL 单行(仅 header)为空白', () => {
404
- assert.equal(artifactLooksBlank('{"header":1}\n', false), true)
405
- assert.equal(artifactLooksBlank('', false), true)
406
- assert.equal(artifactLooksBlank('{"header":1}\n{"event":0}\n', false), false)
407
- assert.equal(artifactLooksBlank('{"header":1}\n{"event":0', true), false)
408
- })
409
-
410
- test('空白产物判定:整块边界与 CRLF 行尾', () => {
411
- // 恰满整块且换行不足两行:hasMore=true 判非空白(保守方向,防漏读)
412
- assert.equal(artifactLooksBlank('{"header":1}\n', true), false)
413
- // CRLF 行尾:\r 不计数,单行 CRLF header 仍为空白
414
- assert.equal(artifactLooksBlank('{"header":1}\r\n', false), true)
415
- assert.equal(artifactLooksBlank('{"header":1}\r\n{"event":0}\r\n', false), false)
416
- })
417
-
418
- test('已删除面板投影:标题回退会话 id,按删除时间倒序', () => {
419
- const deleted = [
420
- { sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100 },
421
- { sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300 },
422
- { sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200 },
423
- ]
424
- const rows = projectDeletedRows(deleted, { a: { displayTitle: '会话 A' } })
425
- assert.deepEqual(rows, [
426
- { sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300, title: 'b' },
427
- { sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200, title: 'c' },
428
- { sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100, title: '会话 A' },
429
- ])
430
- })
431
-
432
- test('已删除面板投影:空台账投影为空', () => {
433
- assert.deepEqual(projectDeletedRows([], {}), [])
434
- })
435
-
436
- test('台账合并:同 id 替换置顶,新 id 插入头部,入参不变', () => {
437
- const existing = [{ sessionId: 'a', path: 'p1', deletedAt: 1 }]
438
- assert.deepEqual(
439
- mergeDeletedEntry(existing, { sessionId: 'a', path: 'p2', deletedAt: 2 }),
440
- [{ sessionId: 'a', path: 'p2', deletedAt: 2 }],
441
- )
442
- assert.deepEqual(
443
- mergeDeletedEntry(existing, { sessionId: 'b', path: 'p3', deletedAt: 3 }),
444
- [{ sessionId: 'b', path: 'p3', deletedAt: 3 }, { sessionId: 'a', path: 'p1', deletedAt: 1 }],
445
- )
446
- assert.deepEqual(existing, [{ sessionId: 'a', path: 'p1', deletedAt: 1 }])
447
- })
448
-
449
- test('台账移除:命中删除并报变化,未命中幂等不报变化', () => {
450
- const existing = [
451
- { sessionId: 'a', path: 'p1', deletedAt: 1 },
452
- { sessionId: 'b', path: 'p2', deletedAt: 2 },
453
- ]
454
- const hit = removeDeletedEntry(existing, 'a')
455
- assert.deepEqual(hit.deleted, [{ sessionId: 'b', path: 'p2', deletedAt: 2 }])
456
- assert.equal(hit.removed, true)
457
- const miss = removeDeletedEntry(existing, 'z')
458
- assert.deepEqual(miss.deleted, existing)
459
- assert.equal(miss.removed, false)
460
- })
461
-
462
- // ── 历史输入:事件提取(G1-G3)──
463
-
464
- function userEvent(overrides, text, at) {
465
- return {
466
- type: 'user/message',
467
- seq: 1,
468
- time: at,
469
- ...overrides,
470
- data: {
471
- id: 'm1',
472
- role: 'user',
473
- source: { kind: 'user' },
474
- content: text === undefined ? [] : [{ type: 'text', text }],
475
- ...overrides?.data,
476
- },
477
- }
478
- }
479
-
480
- test('历史输入提取:user/message 且 kind=user 产出文本与时间', () => {
481
- const events = [userEvent(null, '修复归档面板', 500)]
482
- assert.deepEqual(extractUserInputs(events), [{ text: '修复归档面板', at: 500 }])
483
- })
484
-
485
- test('历史输入提取:tool 结果与 plugin 注入不产出', () => {
486
- const tool = userEvent({ data: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [] }] } }, undefined, 100)
487
- const plugin = userEvent({ data: { source: { kind: 'plugin', plugin: 'x', form: 'instructions' }, content: [{ type: 'text', text: '注入' }] } }, undefined, 200)
488
- assert.deepEqual(extractUserInputs([tool, plugin]), [])
489
- })
490
-
491
- test('历史输入提取:纯图与空文本跳过', () => {
492
- const image = userEvent({ data: { content: [{ type: 'image', attachment: { id: 'a1' } }] } }, undefined, 100)
493
- const blank = userEvent(null, '', 200)
494
- const whitespace = userEvent(null, ' \n ', 300)
495
- assert.deepEqual(extractUserInputs([image, blank, whitespace]), [])
496
- })
497
-
498
- test('历史输入提取:多 text block 按行拼接为单条', () => {
499
- const events = [userEvent({ data: { content: [{ type: 'text', text: '第一段' }, { type: 'text', text: '第二段' }] } }, undefined, 300)]
500
- assert.deepEqual(extractUserInputs(events), [{ text: '第一段\n第二段', at: 300 }])
501
- })
502
-
503
- // ── 历史输入:聚合(G4-G5)──
504
-
505
- test('历史输入聚合:同文本去重保留最新时间,按时间倒序', () => {
506
- const merged = aggregateInputs([
507
- { text: 'a', at: 1 },
508
- { text: 'b', at: 5 },
509
- { text: 'a', at: 9 },
510
- ], { limit: 10, maxChars: 100 })
511
- assert.deepEqual(merged, [{ text: 'a', at: 9 }, { text: 'b', at: 5 }])
512
- })
513
-
514
- test('历史输入聚合:limit 裁剪与单条截断', () => {
515
- const merged = aggregateInputs([
516
- { text: 'abcdef', at: 1 },
517
- { text: 'xy', at: 2 },
518
- ], { limit: 1, maxChars: 3 })
519
- assert.deepEqual(merged, [{ text: 'xy', at: 2 }])
520
- })
521
-
522
- // ── 历史输入:每会话首条保护(挤出不做永久丢弃)──
523
-
524
- test('历史输入聚合:超 limit 挤出时每个会话最早条目仍保留', () => {
525
- const merged = aggregateInputs([
526
- { text: '开场提示词', at: 1, sid: 's-old' },
527
- { text: '继续', at: 2, sid: 's-old' },
528
- { text: '新会话开场', at: 3, sid: 's-new' },
529
- { text: '收尾', at: 4, sid: 's-new' },
530
- ], { limit: 2, maxChars: 100 })
531
- assert.deepEqual(merged, [
532
- { text: '收尾', at: 4, sid: 's-new' },
533
- { text: '新会话开场', at: 3, sid: 's-new' },
534
- { text: '开场提示词', at: 1, sid: 's-old' },
535
- ])
536
- })
537
-
538
- test('历史输入聚合:首条已在 limit 内不多余追加,结果仍为时间倒序', () => {
539
- const merged = aggregateInputs([
540
- { text: 'a', at: 1, sid: 's1' },
541
- { text: 'b', at: 2, sid: 's2' },
542
- ], { limit: 10, maxChars: 100 })
543
- assert.deepEqual(merged, [{ text: 'b', at: 2, sid: 's2' }, { text: 'a', at: 1, sid: 's1' }])
544
- })
545
-
546
- test('历史输入聚合:首条保护在去重后集合上计算,与 limit 内文本重复不产生双条目', () => {
547
- // s-old 的首条文本与 s-new 的某条相同:去重保留最新,归属 s-new,不再重复追加
548
- const merged = aggregateInputs([
549
- { text: '继续', at: 1, sid: 's-old' },
550
- { text: '继续', at: 9, sid: 's-new' },
551
- { text: '其他', at: 5, sid: 's-new' },
552
- ], { limit: 1, maxChars: 100 })
553
- assert.deepEqual(merged, [{ text: '继续', at: 9, sid: 's-new' }, { text: '其他', at: 5, sid: 's-new' }])
554
- })
555
-
556
- test('历史输入聚合:无 sid 条目不触发首条保护', () => {
557
- const merged = aggregateInputs([
558
- { text: 'old', at: 1 },
559
- { text: 'new', at: 2 },
560
- ], { limit: 1, maxChars: 100 })
561
- assert.deepEqual(merged, [{ text: 'new', at: 2 }])
562
- })
563
-
564
- test('历史输入聚合:首条保护同样受 maxChars 截断', () => {
565
- const merged = aggregateInputs([
566
- { text: 'abcdef', at: 1, sid: 's1' },
567
- { text: 'xy', at: 2, sid: 's2' },
568
- ], { limit: 1, maxChars: 3 })
569
- assert.deepEqual(merged, [{ text: 'xy', at: 2, sid: 's2' }, { text: 'abc', at: 1, sid: 's1' }])
570
- })
571
-
572
- // ── 巨产物跳过线:宿主版本黑名单 ──
573
-
574
- test('巨产物跳过线:0.1.1 与 0.1.2 系列(含 prerelease)按旧线', () => {
575
- for (const version of ['0.1.1', '0.1.1-rc.2', '0.1.2', '0.1.2-rc.1', '0.1.2-alpha.2']) {
576
- assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, version)
577
- }
578
- })
579
-
580
- test('巨产物跳过线:0.1.3 起与未知未来版本按新线', () => {
581
- for (const version of ['0.1.3', '0.1.5', '0.1.5-rc.1', '0.2.0', '1.0.0']) {
582
- assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, version)
583
- }
584
- })
585
-
586
- test('巨产物跳过线:版本缺失或非法保守回退旧线', () => {
587
- for (const version of [null, undefined, '', 'garbage', '0.1']) {
588
- assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, String(version))
589
- }
590
- })
591
-
592
- test('巨产物跳过线:新旧线常量与实测依据自洽', () => {
593
- assert.equal(HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, 8 * 1024 * 1024)
594
- assert.equal(HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, 16 * 1024 * 1024)
595
- })
1
+ // 纯逻辑层测试:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分、空白产物判定。
2
+ // BDD 场景对应 docs/design/dsh-session-manager.md。
3
+
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+
7
+ import {
8
+ ARCHIVE_PAGE_SIZE,
9
+ DAY_MS,
10
+ DEFAULT_AUTO_ARCHIVE_DAYS,
11
+ DELETE_MESSAGES,
12
+ deleteOutcomeMessages,
13
+ TOAST_MAX_TITLES,
14
+ aggregateDeleteOutcome,
15
+ archiveToastStep,
16
+ archiveToastText,
17
+ artifactLooksBlank,
18
+ deleteEligibility,
19
+ diffArchived,
20
+ filterArchiveRows,
21
+ groupArchiveRowsByWorkspace,
22
+ isSessionRunning,
23
+ mergeDeletedEntry,
24
+ pageArchiveRows,
25
+ projectArchiveRows,
26
+ projectDeletedRows,
27
+ removeDeletedEntry,
28
+ selectArchiveCandidates,
29
+ updatedAtOf,
30
+ workspaceTitleForSession,
31
+ workspaceTitleOf,
32
+ } from '../src/core.mjs'
33
+
34
+ const NOW = Date.parse('2026-01-10T00:00:00Z')
35
+ const ACTIVE = NOW - 1 * DAY_MS
36
+ const STALE = NOW - 8 * DAY_MS
37
+
38
+ function record(overrides) {
39
+ return { id: 's1', archived: false, running: false, blank: false, updatedAt: STALE, ...overrides }
40
+ }
41
+
42
+ test('阈值天数默认值与常量自洽', () => {
43
+ assert.equal(DEFAULT_AUTO_ARCHIVE_DAYS, 7)
44
+ assert.equal(DAY_MS, 24 * 60 * 60 * 1000)
45
+ })
46
+
47
+ test('新会话触发自动归档:超期候选被选中', () => {
48
+ const picked = selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: 7 })
49
+ assert.deepEqual(picked, ['s1'])
50
+ })
51
+
52
+ test('未超期与恰好等于阈值的会话不归档', () => {
53
+ const records = [record({ updatedAt: ACTIVE }), record({ updatedAt: NOW - 7 * DAY_MS })]
54
+ assert.deepEqual(selectArchiveCandidates({ records, nowMs: NOW, thresholdDays: 7 }), [])
55
+ })
56
+
57
+ test('运行中会话豁免', () => {
58
+ const picked = selectArchiveCandidates({ records: [record({ running: true })], nowMs: NOW, thresholdDays: 7 })
59
+ assert.deepEqual(picked, [])
60
+ })
61
+
62
+ test('空白会话豁免', () => {
63
+ const picked = selectArchiveCandidates({ records: [record({ blank: true })], nowMs: NOW, thresholdDays: 7 })
64
+ assert.deepEqual(picked, [])
65
+ })
66
+
67
+ test('已归档会话不参与评估:幂等', () => {
68
+ const records = [record({ archived: true })]
69
+ const first = selectArchiveCandidates({ records, nowMs: NOW, thresholdDays: 7 })
70
+ assert.deepEqual(first, [])
71
+ })
72
+
73
+ test('阈值为零关闭功能', () => {
74
+ const picked = selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: 0 })
75
+ assert.deepEqual(picked, [])
76
+ })
77
+
78
+ test('阈值负值与非有限值防御性关闭', () => {
79
+ assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: -1 }), [])
80
+ assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: Number.NaN }), [])
81
+ })
82
+
83
+ test('updatedAt 取创建时间与最近活跃的较大者', () => {
84
+ assert.equal(updatedAtOf({ createdAt: 100 }, 50), 100)
85
+ assert.equal(updatedAtOf({ createdAt: 100 }, 500), 500)
86
+ assert.equal(updatedAtOf({ createdAt: 100 }, undefined), 100)
87
+ })
88
+
89
+ test('归档面板投影:交集过滤且按更新时间倒序', () => {
90
+ const rows = [
91
+ { id: 'b', title: 'B', updatedAt: 200 },
92
+ { id: 'a', title: 'A', updatedAt: 300 },
93
+ { id: 'c', title: 'C', updatedAt: 100 },
94
+ { id: 'd', title: 'D', updatedAt: 400 },
95
+ ]
96
+ const projected = projectArchiveRows({ rows, archivedIds: ['b', 'c', 'gone'] })
97
+ assert.deepEqual(projected, [
98
+ { id: 'b', title: 'B', updatedAt: 200, workspace: null },
99
+ { id: 'c', title: 'C', updatedAt: 100, workspace: null },
100
+ ])
101
+ })
102
+
103
+ test('归档面板投影:标题缺失回退会话 id(与 client 镜像同规)', () => {
104
+ const projected = projectArchiveRows({ rows: [{ id: 'a', title: '', updatedAt: 5 }], archivedIds: ['a'] })
105
+ assert.deepEqual(projected, [{ id: 'a', title: 'a', updatedAt: 5, workspace: null }])
106
+ })
107
+
108
+ const WORKSPACES = [
109
+ { workspaceId: 'w1', path: 'C:\\work\\alpha', title: 'alpha', sessionIds: ['a', 'b'] },
110
+ { workspaceId: 'w2', path: 'C:\\work\\beta', title: 'beta', sessionIds: ['c'] },
111
+ ]
112
+
113
+ test('归档面板投影:会话经 workspace 账本映射到工作区标题', () => {
114
+ const rows = [
115
+ { id: 'a', title: 'A', updatedAt: 300 },
116
+ { id: 'c', title: 'C', updatedAt: 100 },
117
+ ]
118
+ const projected = projectArchiveRows({ rows, archivedIds: ['a', 'c'], workspaces: WORKSPACES })
119
+ assert.deepEqual(projected, [
120
+ { id: 'a', title: 'A', updatedAt: 300, workspace: 'alpha' },
121
+ { id: 'c', title: 'C', updatedAt: 100, workspace: 'beta' },
122
+ ])
123
+ })
124
+
125
+ test('归档面板投影:账本缺失时 cwd 匹配工作区路径回退', () => {
126
+ const rows = [{ id: 'x', title: 'X', updatedAt: 1, cwd: 'C:\\work\\beta' }]
127
+ const projected = projectArchiveRows({ rows, archivedIds: ['x'], workspaces: WORKSPACES })
128
+ assert.deepEqual(projected, [{ id: 'x', title: 'X', updatedAt: 1, workspace: 'beta' }])
129
+ })
130
+
131
+ test('归档面板投影:无匹配工作区输出 null,账本缺 title 回退路径末段', () => {
132
+ const rows = [
133
+ { id: 'x', title: 'X', updatedAt: 3 },
134
+ { id: 'y', title: 'Y', updatedAt: 2, cwd: 'C:\\nowhere\\lost\\' },
135
+ { id: 'z', title: 'Z', updatedAt: 1 },
136
+ ]
137
+ const workspaces = [
138
+ ...WORKSPACES,
139
+ { workspaceId: 'w3', path: 'C:\\work\\titleless', sessionIds: ['z'] },
140
+ ]
141
+ const projected = projectArchiveRows({ rows, archivedIds: ['x', 'y', 'z'], workspaces })
142
+ assert.deepEqual(projected, [
143
+ { id: 'x', title: 'X', updatedAt: 3, workspace: null },
144
+ { id: 'y', title: 'Y', updatedAt: 2, workspace: null },
145
+ { id: 'z', title: 'Z', updatedAt: 1, workspace: 'titleless' },
146
+ ])
147
+ })
148
+
149
+ test('归档面板投影:workspaces 缺省时全部回退 null', () => {
150
+ const rows = [{ id: 'a', title: 'A', updatedAt: 1, cwd: 'C:\\work\\alpha' }]
151
+ assert.deepEqual(
152
+ projectArchiveRows({ rows, archivedIds: ['a'] }),
153
+ [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }],
154
+ )
155
+ })
156
+
157
+ test('workspaceTitleOf 路径末段边界', () => {
158
+ assert.equal(workspaceTitleOf('C:\\work\\alpha'), 'alpha')
159
+ assert.equal(workspaceTitleOf('/home/user/project/'), 'project')
160
+ assert.equal(workspaceTitleOf('C:\\work\\mixed/slash\\path'), 'path')
161
+ assert.equal(workspaceTitleOf('C:\\'), 'C:')
162
+ assert.equal(workspaceTitleOf('/'), '')
163
+ assert.equal(workspaceTitleOf('///'), '')
164
+ })
165
+
166
+ test('workspaceTitleForSession 直接边界:title 与 path 皆缺为 null,title 空串回退路径末段', () => {
167
+ assert.equal(workspaceTitleForSession({ workspaces: [{ sessionIds: ['a'] }], sessionId: 'a' }), null)
168
+ assert.equal(
169
+ workspaceTitleForSession({ workspaces: [{ path: 'C:\\work\\x', title: '', sessionIds: ['a'] }], sessionId: 'a' }),
170
+ 'x',
171
+ )
172
+ assert.equal(workspaceTitleForSession({ workspaces: 'bad', sessionId: 'a' }), null)
173
+ assert.equal(workspaceTitleForSession({ workspaces: [{ path: 'C:\\w\\a', sessionIds: ['a'] }], sessionId: 'a', cwd: undefined }), 'a')
174
+ })
175
+
176
+ // ── 归档库:主列表分页 / 工作区分组 / 标题搜索 ──
177
+
178
+ test('归档库分页:页粒度常量', () => {
179
+ assert.equal(ARCHIVE_PAGE_SIZE, 100)
180
+ })
181
+
182
+ test('归档库分页:默认只取前 100 条,剩余数准确', () => {
183
+ const rows = Array.from({ length: 250 }, (_, i) => ({ id: 's' + i, title: 'T' + i, updatedAt: 1000 - i, workspace: null }))
184
+ const page = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
185
+ assert.equal(page.visible.length, 100)
186
+ assert.equal(page.remaining, 150)
187
+ assert.equal(page.visible[0].id, 's0')
188
+ assert.equal(page.visible[99].id, 's99')
189
+ })
190
+
191
+ test('归档库分页:增量展开与全量边界', () => {
192
+ const rows = Array.from({ length: 250 }, (_, i) => ({ id: 's' + i, title: 'T' + i, updatedAt: i, workspace: null }))
193
+ assert.equal(pageArchiveRows(rows, 200).visible.length, 200)
194
+ assert.equal(pageArchiveRows(rows, 200).remaining, 50)
195
+ assert.deepEqual(pageArchiveRows(rows, rows.length), { visible: rows, remaining: 0 })
196
+ assert.equal(pageArchiveRows(rows, rows.length + 10).remaining, 0)
197
+ })
198
+
199
+ test('归档库分页:空列表与非有限可见数防御', () => {
200
+ assert.deepEqual(pageArchiveRows([], ARCHIVE_PAGE_SIZE), { visible: [], remaining: 0 })
201
+ const single = [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }]
202
+ assert.deepEqual(pageArchiveRows(single, Number.NaN).visible, [])
203
+ assert.equal(pageArchiveRows(single, Number.NaN).remaining, 1)
204
+ assert.equal(pageArchiveRows(single, -5).remaining, 1)
205
+ assert.deepEqual(pageArchiveRows(single, Infinity).visible, [])
206
+ assert.equal(pageArchiveRows(single, Infinity).remaining, 1)
207
+ })
208
+
209
+ test('归档库分组:按工作区标题分桶,组内保持输入序,组间按最新活跃倒序', () => {
210
+ const rows = [
211
+ { id: 'a', title: 'A', updatedAt: 400, workspace: 'alpha' },
212
+ { id: 'b', title: 'B', updatedAt: 300, workspace: 'beta' },
213
+ { id: 'c', title: 'C', updatedAt: 200, workspace: 'alpha' },
214
+ ]
215
+ assert.deepEqual(groupArchiveRowsByWorkspace(rows), [
216
+ { workspace: 'alpha', rows: [rows[0], rows[2]] },
217
+ { workspace: 'beta', rows: [rows[1]] },
218
+ ])
219
+ })
220
+
221
+ test('归档库分组:workspace 假值并桶为未分组(null)', () => {
222
+ const rows = [
223
+ { id: 'u1', title: 'U1', updatedAt: 100, workspace: null },
224
+ { id: 'g', title: 'G', updatedAt: 350, workspace: 'gamma' },
225
+ { id: 'u2', title: 'U2', updatedAt: 500, workspace: '' },
226
+ { id: 'u3', title: 'U3', updatedAt: 50, workspace: null },
227
+ ]
228
+ assert.deepEqual(groupArchiveRowsByWorkspace(rows), [
229
+ { workspace: null, rows: [rows[0], rows[2], rows[3]] },
230
+ { workspace: 'gamma', rows: [rows[1]] },
231
+ ])
232
+ })
233
+
234
+ test('归档库分组:空输入与非数组防御', () => {
235
+ assert.deepEqual(groupArchiveRowsByWorkspace([]), [])
236
+ assert.deepEqual(groupArchiveRowsByWorkspace(undefined), [])
237
+ })
238
+
239
+ test('归档库搜索:标题不区分大小写子串匹配,空查询返回全量', () => {
240
+ const rows = [
241
+ { id: 'a', title: '修复登录页', updatedAt: 3, workspace: null },
242
+ { id: 'b', title: 'Refactor API', updatedAt: 2, workspace: null },
243
+ { id: 'c', title: '登录超时排查', updatedAt: 1, workspace: null },
244
+ ]
245
+ assert.deepEqual(filterArchiveRows(rows, '登录'), [rows[0], rows[2]])
246
+ assert.deepEqual(filterArchiveRows(rows, 'REFACT'), [rows[1]])
247
+ assert.deepEqual(filterArchiveRows(rows, '不存在'), [])
248
+ })
249
+
250
+ test('归档库搜索:空查询与空白查询原样返回全量,非数组防御', () => {
251
+ const rows = [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }]
252
+ assert.deepEqual(filterArchiveRows(rows, ''), rows)
253
+ assert.deepEqual(filterArchiveRows(rows, ' '), rows)
254
+ assert.deepEqual(filterArchiveRows(undefined, 'A'), [])
255
+ })
256
+
257
+ test('归档库搜索:标题缺失经投影回退会话 id 后可被命中(与投影组合)', () => {
258
+ const projected = projectArchiveRows({
259
+ rows: [{ id: 'abc-123', title: '', updatedAt: 1 }],
260
+ archivedIds: ['abc-123'],
261
+ })
262
+ assert.deepEqual(filterArchiveRows(projected, 'ABC-'), projected)
263
+ })
264
+
265
+ test('归档集合差分只报新增,首帧基线不提示', () => {
266
+ assert.deepEqual(diffArchived(undefined, ['x', 'y']), [])
267
+ assert.deepEqual(diffArchived(['x'], ['x', 'y', 'z']), ['y', 'z'])
268
+ assert.deepEqual(diffArchived(['x', 'y'], ['x']), [])
269
+ })
270
+
271
+ test('Toast 差分:pending 空态与基线首装不提示,ready 后新增才提示', () => {
272
+ let previous
273
+ let step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: [] })
274
+ previous = step.state
275
+ assert.deepEqual(step.added, [])
276
+ // 基线安装:存量 29 个不算新增(模型 pending 期发射,notify 时序不定,两种相位都守卫)
277
+ step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: ['a', 'b'] })
278
+ previous = step.state
279
+ assert.deepEqual(step.added, [])
280
+ step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] })
281
+ previous = step.state
282
+ assert.deepEqual(step.added, [])
283
+ // ready 建立后:增量帧触发提示
284
+ step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] })
285
+ assert.deepEqual(step.added, ['c'])
286
+ })
287
+
288
+ test('Toast 差分:订阅即 ready(无 pending 帧)时首帧守卫仍生效', () => {
289
+ let previous
290
+ const step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a'] })
291
+ previous = step.state
292
+ assert.deepEqual(step.added, [])
293
+ assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] }).added, ['b'])
294
+ })
295
+
296
+ test('Toast 差分:ready→pending→ready 重连序列不误报存量', () => {
297
+ let previous
298
+ let step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a'] })
299
+ previous = step.state
300
+ // 断连:pending 帧中断 ready 链
301
+ step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: ['a', 'b'] })
302
+ previous = step.state
303
+ assert.deepEqual(step.added, [])
304
+ // 重连基线首装:不提示存量(离期新增的提示语义见 core 注释)
305
+ step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] })
306
+ previous = step.state
307
+ assert.deepEqual(step.added, [])
308
+ // 重连建立后:增量照常提示
309
+ assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] }).added, ['c'])
310
+ })
311
+
312
+ test('归档通知文案:截断阈值常量', () => {
313
+ assert.equal(TOAST_MAX_TITLES, 3)
314
+ })
315
+
316
+ test('归档通知文案:单个会话报标题', () => {
317
+ assert.equal(archiveToastText(['a'], [{ id: 'a', title: '修复登录页' }]), '会话「修复登录页」已归档')
318
+ })
319
+
320
+ test('归档通知文案:多个会话列举标题,计数为真实总数', () => {
321
+ const rows = [
322
+ { id: 'a', title: 'A' },
323
+ { id: 'b', title: 'B' },
324
+ ]
325
+ assert.equal(archiveToastText(['a', 'b'], rows), '有 2 个会话已归档:A、B')
326
+ })
327
+
328
+ test('归档通知文案:恰好等于展示阈值不截断', () => {
329
+ const rows = [
330
+ { id: 'a', title: 'A' },
331
+ { id: 'b', title: 'B' },
332
+ { id: 'c', title: 'C' },
333
+ ]
334
+ assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:A、B、C')
335
+ })
336
+
337
+ test('归档通知文案:超展示阈值只列前段并以「 等」收尾,计数仍为真实总数', () => {
338
+ const rows = [
339
+ { id: 'a', title: 'A' },
340
+ { id: 'b', title: 'B' },
341
+ { id: 'c', title: 'C' },
342
+ { id: 'd', title: 'D' },
343
+ ]
344
+ assert.equal(archiveToastText(['a', 'b', 'c', 'd'], rows), '有 4 个会话已归档:A、B、C 等')
345
+ })
346
+
347
+ test('归档通知文案:标题缺失、空白与行缺失回退会话 id,标题按去除首尾空白取值', () => {
348
+ const rows = [{ id: 'a' }, { id: 'b', title: ' ' }, { id: 'x', title: 'X' }]
349
+ assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:a、b、c')
350
+ assert.equal(archiveToastText(['t'], [{ id: 't', title: ' T ' }]), '会话「T」已归档')
351
+ })
352
+
353
+ test('归档通知文案:行数据非数组按空处理,标题全部回退会话 id', () => {
354
+ assert.equal(archiveToastText(['a', 'b'], undefined), '有 2 个会话已归档:a、b')
355
+ })
356
+
357
+ test('非归档会话拒绝删除', () => {
358
+ assert.equal(deleteEligibility({ archivedIds: ['a'], sessionId: 'a' }).ok, true)
359
+ const denied = deleteEligibility({ archivedIds: ['a'], sessionId: 'b' })
360
+ assert.equal(denied.ok, false)
361
+ assert.equal(denied.code, 'not-archived')
362
+ })
363
+
364
+ test('删除收尾聚合:失败矩阵折叠为三形态响应体', () => {
365
+ const M = deleteOutcomeMessages(false)
366
+ const R = M.runningDuringTrash
367
+ // 全成功无警告:无多余键(index.test deepEqual 锁定同形态)
368
+ assert.deepEqual(aggregateDeleteOutcome({}), { ok: true })
369
+ // 单一失败:主文案优先级 detach → 归档清理 → 台账
370
+ assert.deepEqual(aggregateDeleteOutcome({ detachFailed: true }), { ok: true, partial: true, message: M.partial })
371
+ assert.deepEqual(aggregateDeleteOutcome({ archiveCleanupFailed: true }), { ok: true, partial: true, message: M.archiveCleanup })
372
+ assert.deepEqual(aggregateDeleteOutcome({ ledgerFailed: true }), { ok: true, partial: true, message: M.ledgerFailed })
373
+ // 双失败:高优先级主文案 + 台账后缀
374
+ assert.deepEqual(
375
+ aggregateDeleteOutcome({ detachFailed: true, ledgerFailed: true }),
376
+ { ok: true, partial: true, message: M.partial + DELETE_MESSAGES.ledgerSuffix },
377
+ )
378
+ // 运行中翻转警告后缀并入一切形态
379
+ assert.deepEqual(
380
+ aggregateDeleteOutcome({ detachFailed: true, ledgerFailed: true, runningDuringTrash: true }),
381
+ { ok: true, partial: true, message: M.partial + DELETE_MESSAGES.ledgerSuffix + ';' + R },
382
+ )
383
+ // 全失败
384
+ assert.deepEqual(
385
+ aggregateDeleteOutcome({ detachFailed: true, archiveCleanupFailed: true, ledgerFailed: true, runningDuringTrash: true }),
386
+ { ok: true, partial: true, message: M.partial + DELETE_MESSAGES.ledgerSuffix + ';' + R },
387
+ )
388
+ // 全成功警告态:非 partial 形态(ok+message,无 partial 键)
389
+ assert.deepEqual(aggregateDeleteOutcome({ runningDuringTrash: true }), { ok: true, message: R })
390
+ })
391
+
392
+ // Given 回收区降级处置(quarantined), When 聚合, Then 基础前缀与警告措辞均区分两种模式且形态不变
393
+ test('删除收尾聚合:回收区模式前缀与警告措辞均为插件回收区', () => {
394
+ const M = deleteOutcomeMessages(true)
395
+ const R = M.runningDuringTrash
396
+ assert.ok(M.partial.startsWith('已移入插件回收区'), '回收区模式 partial 前缀')
397
+ assert.ok(M.archiveCleanup.startsWith('已移入插件回收区'), '回收区模式归档清理前缀')
398
+ assert.ok(M.ledgerFailed.startsWith('已移入插件回收区'), '回收区模式台账前缀')
399
+ assert.ok(R.includes('已移入插件回收区') && !R.includes('系统回收站'), '回收区模式警告不指向系统回收站')
400
+ const os = deleteOutcomeMessages(false)
401
+ assert.ok(os.partial.startsWith('已移入回收站'), 'OS 模式 partial 前缀')
402
+ assert.ok(os.runningDuringTrash.includes('产物已移入回收站'), 'OS 模式警告措辞')
403
+ assert.deepEqual(
404
+ aggregateDeleteOutcome({ detachFailed: true, quarantined: true }),
405
+ { ok: true, partial: true, message: M.partial },
406
+ )
407
+ assert.deepEqual(
408
+ aggregateDeleteOutcome({ ledgerFailed: true, runningDuringTrash: true, quarantined: true }),
409
+ { ok: true, partial: true, message: M.ledgerFailed + ';' + R },
410
+ )
411
+ // 回收区全成功警告态与非警告态形态同 OS 模式
412
+ assert.deepEqual(aggregateDeleteOutcome({ quarantined: true }), { ok: true })
413
+ assert.deepEqual(
414
+ aggregateDeleteOutcome({ runningDuringTrash: true, quarantined: true }),
415
+ { ok: true, message: R },
416
+ )
417
+ })
418
+
419
+ test('运行中判定:agent status running 即运行中,注册表缺失视为非运行', () => {
420
+ const agents = new Map([['s1', { status: 'running' }], ['s2', { status: 'idle' }]])
421
+ assert.equal(isSessionRunning({ agents, sessionId: 's1' }), true)
422
+ assert.equal(isSessionRunning({ agents, sessionId: 's2' }), false)
423
+ assert.equal(isSessionRunning({ agents, sessionId: 'gone' }), false)
424
+ assert.equal(isSessionRunning({ agents: undefined, sessionId: 's1' }), false)
425
+ })
426
+
427
+ test('空白产物判定:JSONL 单行(仅 header)为空白', () => {
428
+ assert.equal(artifactLooksBlank('{"header":1}\n', false), true)
429
+ assert.equal(artifactLooksBlank('', false), true)
430
+ assert.equal(artifactLooksBlank('{"header":1}\n{"event":0}\n', false), false)
431
+ assert.equal(artifactLooksBlank('{"header":1}\n{"event":0', true), false)
432
+ })
433
+
434
+ test('空白产物判定:整块边界与 CRLF 行尾', () => {
435
+ // 恰满整块且换行不足两行:hasMore=true 判非空白(保守方向,防漏读)
436
+ assert.equal(artifactLooksBlank('{"header":1}\n', true), false)
437
+ // CRLF 行尾:\r 不计数,单行 CRLF header 仍为空白
438
+ assert.equal(artifactLooksBlank('{"header":1}\r\n', false), true)
439
+ assert.equal(artifactLooksBlank('{"header":1}\r\n{"event":0}\r\n', false), false)
440
+ })
441
+
442
+ test('已删除面板投影:标题回退会话 id,按删除时间倒序', () => {
443
+ const deleted = [
444
+ { sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100 },
445
+ { sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300 },
446
+ { sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200 },
447
+ ]
448
+ const rows = projectDeletedRows(deleted, { a: { displayTitle: '会话 A' } })
449
+ assert.deepEqual(rows, [
450
+ { sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300, title: 'b' },
451
+ { sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200, title: 'c' },
452
+ { sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100, title: '会话 A' },
453
+ ])
454
+ })
455
+
456
+ test('已删除面板投影:空台账投影为空', () => {
457
+ assert.deepEqual(projectDeletedRows([], {}), [])
458
+ })
459
+
460
+ test('台账合并:同 id 替换置顶,新 id 插入头部,入参不变', () => {
461
+ const existing = [{ sessionId: 'a', path: 'p1', deletedAt: 1 }]
462
+ assert.deepEqual(
463
+ mergeDeletedEntry(existing, { sessionId: 'a', path: 'p2', deletedAt: 2 }),
464
+ [{ sessionId: 'a', path: 'p2', deletedAt: 2 }],
465
+ )
466
+ assert.deepEqual(
467
+ mergeDeletedEntry(existing, { sessionId: 'b', path: 'p3', deletedAt: 3 }),
468
+ [{ sessionId: 'b', path: 'p3', deletedAt: 3 }, { sessionId: 'a', path: 'p1', deletedAt: 1 }],
469
+ )
470
+ assert.deepEqual(existing, [{ sessionId: 'a', path: 'p1', deletedAt: 1 }])
471
+ })
472
+
473
+ test('台账移除:命中删除并报变化,未命中幂等不报变化', () => {
474
+ const existing = [
475
+ { sessionId: 'a', path: 'p1', deletedAt: 1 },
476
+ { sessionId: 'b', path: 'p2', deletedAt: 2 },
477
+ ]
478
+ const hit = removeDeletedEntry(existing, 'a')
479
+ assert.deepEqual(hit.deleted, [{ sessionId: 'b', path: 'p2', deletedAt: 2 }])
480
+ assert.equal(hit.removed, true)
481
+ const miss = removeDeletedEntry(existing, 'z')
482
+ assert.deepEqual(miss.deleted, existing)
483
+ assert.equal(miss.removed, false)
484
+ })
485
+