@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/src/trash.mjs ADDED
@@ -0,0 +1,49 @@
1
+ // 回收站:跨平台把文件或目录移入系统回收站,不做直接删除降级。
2
+ // 执行器可注入以便测试;失败按原样抛出,由调用方按失败矩阵处理。
3
+
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+
7
+ const TRASH_TIMEOUT_MS = 60 * 1000
8
+ const execFilep = promisify(execFile)
9
+
10
+ /** 回收站目标的传递变量名;宿主半区与脚本共用,改一侧须同步 */
11
+ export const TRASH_ENV_NAME = 'DSH_TRASH_PATH'
12
+
13
+ // Windows 路径经环境变量传入:-Command 会把 argv 以空格重拼接为命令文本再解析,
14
+ // 路径走 argv 会在空格处断裂且存在被解析为脚本语句的注入面;环境变量不经
15
+ // PowerShell 文本解析,任意路径形态安全。按目标为目录或文件分派
16
+ // DeleteDirectory / DeleteFile;UIOption 枚举为 API 必需重载参数,在 -NonInteractive
17
+ // 宿主下无对话框,失败经 catch 置非零退出码直达失败矩阵(powershell.exe 对终止错误默认退出 0)。
18
+ const WIN_SCRIPT =
19
+ '$ErrorActionPreference=\'Stop\'; Add-Type -AssemblyName Microsoft.VisualBasic; try { '
20
+ + '$p=$env:' + TRASH_ENV_NAME + '; '
21
+ + 'if ((Get-Item -LiteralPath $p).PSIsContainer) { '
22
+ + "[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, 'OnlyErrorDialogs', 'SendToRecycleBin') } "
23
+ + "else { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, 'OnlyErrorDialogs', 'SendToRecycleBin') } "
24
+ + 'exit 0 } catch { $_ | Out-String | Write-Error; exit 1 }'
25
+
26
+ /** 按平台产出回收站命令;win32 路径经 env 传递,其余平台走 argv 末位。 */
27
+ export function trashCommandFor(platform, path) {
28
+ if (platform === 'win32') {
29
+ return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', WIN_SCRIPT], env: { [TRASH_ENV_NAME]: path } }
30
+ }
31
+ if (platform === 'darwin') {
32
+ return {
33
+ file: 'osascript',
34
+ args: ['-e', 'on run argv', '-e', 'tell application "Finder" to delete POSIX file (item 1 of argv)', '-e', 'end run', path],
35
+ }
36
+ }
37
+ return { file: 'gio', args: ['trash', path] }
38
+ }
39
+
40
+ /**
41
+ * 把一个已存在的文件或目录移入系统回收站。
42
+ * @param options.run - 注入的执行器,默认 promisify(execFile)
43
+ */
44
+ export async function trashPath(path, options = {}) {
45
+ const { platform = process.platform, run = execFilep, timeoutMs = TRASH_TIMEOUT_MS } = options
46
+ const command = trashCommandFor(platform, path)
47
+ const env = command.env ? { ...process.env, ...command.env } : undefined
48
+ await run(command.file, command.args, { timeout: timeoutMs, env })
49
+ }
@@ -0,0 +1,16 @@
1
+ // client.js 注册 id 守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败。
2
+ import { readFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
9
+ const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
10
+
11
+ test('client.js 注册 id 为完整包名', () => {
12
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
13
+ const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
14
+ assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
15
+ assert.equal(match[1], name)
16
+ })
@@ -0,0 +1,89 @@
1
+ // Toast 接线源码级测试:加载真实 src/client.js,以捕获桩驱动 apply 的归档差分,
2
+ // 锁定 external require specifier 与通知出口接线(specifier 拼错当场暴露)。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, join } from 'node:path'
8
+ import { mock } from 'node:test'
9
+
10
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
11
+
12
+ // 加载 client.js 并执行 apply(mock 最小服务面),返回捕获的 toast 调用与控制器
13
+ function loadClient() {
14
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
15
+ const modules = []
16
+ const required = []
17
+ const shown = []
18
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
19
+ const reactStub = { useState: (value) => [value, () => {}], useEffect: () => {}, useSyncExternalStore: () => [], createElement: () => null }
20
+ const requireStub = (name) => {
21
+ required.push(name)
22
+ // 通知出口:公共依赖 @mzzsfy/dsh-toast 的捕获桩
23
+ if (name === '@mzzsfy/dsh-toast/client') {
24
+ return { show: (text, opts) => { shown.push({ text, opts }); return shown.length } }
25
+ }
26
+ return reactStub
27
+ }
28
+ const factory = new Function('window', 'require', 'document', source + '\n;return null')
29
+ factory(
30
+ windowStub,
31
+ requireStub,
32
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
33
+ )
34
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
35
+ const mod = modules[0].factory(requireStub)
36
+
37
+ // 差分快照序列由测试手动推进
38
+ let listener = null
39
+ let snapshot = undefined
40
+ const workspaces = {
41
+ list: {
42
+ subscribe: (cb) => { listener = cb; return () => { listener = null } },
43
+ getSnapshot: () => snapshot,
44
+ },
45
+ }
46
+ const effects = []
47
+ const ctx = {
48
+ get: (name) => (name === 'workspaces' ? workspaces : {}),
49
+ effect: (fn, tag) => effects.push(tag),
50
+ slots: { inject: () => {}, register: () => {} },
51
+ }
52
+ mod.apply(ctx)
53
+ return {
54
+ shown,
55
+ required,
56
+ emit(next) { snapshot = next; if (listener !== null) listener() },
57
+ effects,
58
+ }
59
+ }
60
+
61
+ const READY = (ids) => ({ phase: 'ready', archivedSessionIds: ids })
62
+
63
+ test('apply:external require specifier 锁定为 @mzzsfy/dsh-toast/client', () => {
64
+ const client = loadClient()
65
+ assert.ok(client.required.includes('@mzzsfy/dsh-toast/client'),
66
+ 'client.js 未按约定 specifier require 公共通知依赖: ' + client.required.join(', '))
67
+ })
68
+
69
+ test('apply:归档差分经 toast 出口,连续 ready 才计新增', () => {
70
+ mock.timers.enable({ apis: ['setTimeout'] })
71
+ try {
72
+ const client = loadClient()
73
+ assert.deepEqual(client.shown, [], 'apply 本身不发通知')
74
+ client.emit(READY(['a', 'b']))
75
+ assert.deepEqual(client.shown, [], '首帧基线(存量归档)不通知')
76
+ client.emit(READY(['a', 'b', 'c', 'd']))
77
+ assert.deepEqual(client.shown.map((call) => call.text), ['有 2 个会话已归档'])
78
+ assert.deepEqual(client.shown.map((call) => call.opts), [undefined])
79
+ client.emit(READY(['a', 'b', 'c', 'd']))
80
+ assert.equal(client.shown.length, 1, '无新增不重复通知')
81
+ } finally {
82
+ mock.timers.reset()
83
+ }
84
+ })
85
+
86
+ test('apply:effect 挂样式与差分退订两个副作用', () => {
87
+ const client = loadClient()
88
+ assert.deepEqual(client.effects, ['session-manager styles', 'session-manager archived diff'])
89
+ })
@@ -0,0 +1,307 @@
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
+ DAY_MS,
9
+ DEFAULT_AUTO_ARCHIVE_DAYS,
10
+ DELETE_MESSAGES,
11
+ aggregateDeleteOutcome,
12
+ aggregateInputs,
13
+ archiveToastStep,
14
+ artifactLooksBlank,
15
+ deleteEligibility,
16
+ diffArchived,
17
+ extractUserInputs,
18
+ isSessionRunning,
19
+ mergeDeletedEntry,
20
+ projectArchiveRows,
21
+ projectDeletedRows,
22
+ removeDeletedEntry,
23
+ selectArchiveCandidates,
24
+ updatedAtOf,
25
+ } from '../src/core.mjs'
26
+
27
+ const NOW = Date.parse('2026-01-10T00:00:00Z')
28
+ const ACTIVE = NOW - 1 * DAY_MS
29
+ const STALE = NOW - 8 * DAY_MS
30
+
31
+ function record(overrides) {
32
+ return { id: 's1', archived: false, running: false, blank: false, updatedAt: STALE, ...overrides }
33
+ }
34
+
35
+ test('阈值天数默认值与常量自洽', () => {
36
+ assert.equal(DEFAULT_AUTO_ARCHIVE_DAYS, 7)
37
+ assert.equal(DAY_MS, 24 * 60 * 60 * 1000)
38
+ })
39
+
40
+ test('新会话触发自动归档:超期候选被选中', () => {
41
+ const picked = selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: 7 })
42
+ assert.deepEqual(picked, ['s1'])
43
+ })
44
+
45
+ test('未超期与恰好等于阈值的会话不归档', () => {
46
+ const records = [record({ updatedAt: ACTIVE }), record({ updatedAt: NOW - 7 * DAY_MS })]
47
+ assert.deepEqual(selectArchiveCandidates({ records, nowMs: NOW, thresholdDays: 7 }), [])
48
+ })
49
+
50
+ test('运行中会话豁免', () => {
51
+ const picked = selectArchiveCandidates({ records: [record({ running: true })], nowMs: NOW, thresholdDays: 7 })
52
+ assert.deepEqual(picked, [])
53
+ })
54
+
55
+ test('空白会话豁免', () => {
56
+ const picked = selectArchiveCandidates({ records: [record({ blank: true })], nowMs: NOW, thresholdDays: 7 })
57
+ assert.deepEqual(picked, [])
58
+ })
59
+
60
+ test('已归档会话不参与评估:幂等', () => {
61
+ const records = [record({ archived: true })]
62
+ const first = selectArchiveCandidates({ records, nowMs: NOW, thresholdDays: 7 })
63
+ assert.deepEqual(first, [])
64
+ })
65
+
66
+ test('阈值为零关闭功能', () => {
67
+ const picked = selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: 0 })
68
+ assert.deepEqual(picked, [])
69
+ })
70
+
71
+ test('阈值负值与非有限值防御性关闭', () => {
72
+ assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: -1 }), [])
73
+ assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: Number.NaN }), [])
74
+ })
75
+
76
+ test('updatedAt 取创建时间与最近活跃的较大者', () => {
77
+ assert.equal(updatedAtOf({ createdAt: 100 }, 50), 100)
78
+ assert.equal(updatedAtOf({ createdAt: 100 }, 500), 500)
79
+ assert.equal(updatedAtOf({ createdAt: 100 }, undefined), 100)
80
+ })
81
+
82
+ test('归档面板投影:交集过滤且按更新时间倒序', () => {
83
+ const rows = [
84
+ { id: 'b', title: 'B', updatedAt: 200 },
85
+ { id: 'a', title: 'A', updatedAt: 300 },
86
+ { id: 'c', title: 'C', updatedAt: 100 },
87
+ { id: 'd', title: 'D', updatedAt: 400 },
88
+ ]
89
+ const projected = projectArchiveRows({ rows, archivedIds: ['b', 'c', 'gone'] })
90
+ assert.deepEqual(projected, [
91
+ { id: 'b', title: 'B', updatedAt: 200 },
92
+ { id: 'c', title: 'C', updatedAt: 100 },
93
+ ])
94
+ })
95
+
96
+ test('归档面板投影:标题缺失回退会话 id(与 client 镜像同规)', () => {
97
+ const projected = projectArchiveRows({ rows: [{ id: 'a', title: '', updatedAt: 5 }], archivedIds: ['a'] })
98
+ assert.deepEqual(projected, [{ id: 'a', title: 'a', updatedAt: 5 }])
99
+ })
100
+
101
+ test('归档集合差分只报新增,首帧基线不提示', () => {
102
+ assert.deepEqual(diffArchived(undefined, ['x', 'y']), [])
103
+ assert.deepEqual(diffArchived(['x'], ['x', 'y', 'z']), ['y', 'z'])
104
+ assert.deepEqual(diffArchived(['x', 'y'], ['x']), [])
105
+ })
106
+
107
+ test('Toast 差分:pending 空态与基线首装不提示,ready 后新增才提示', () => {
108
+ let previous
109
+ let step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: [] })
110
+ previous = step.state
111
+ assert.deepEqual(step.added, [])
112
+ // 基线安装:存量 29 个不算新增(模型 pending 期发射,notify 时序不定,两种相位都守卫)
113
+ step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: ['a', 'b'] })
114
+ previous = step.state
115
+ assert.deepEqual(step.added, [])
116
+ step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] })
117
+ previous = step.state
118
+ assert.deepEqual(step.added, [])
119
+ // ready 建立后:增量帧触发提示
120
+ step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] })
121
+ assert.deepEqual(step.added, ['c'])
122
+ })
123
+
124
+ test('Toast 差分:订阅即 ready(无 pending 帧)时首帧守卫仍生效', () => {
125
+ let previous
126
+ const step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a'] })
127
+ previous = step.state
128
+ assert.deepEqual(step.added, [])
129
+ assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] }).added, ['b'])
130
+ })
131
+
132
+ test('Toast 差分:ready→pending→ready 重连序列不误报存量', () => {
133
+ let previous
134
+ let step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a'] })
135
+ previous = step.state
136
+ // 断连:pending 帧中断 ready 链
137
+ step = archiveToastStep(previous, { phase: 'pending', archivedSessionIds: ['a', 'b'] })
138
+ previous = step.state
139
+ assert.deepEqual(step.added, [])
140
+ // 重连基线首装:不提示存量(离期新增的提示语义见 core 注释)
141
+ step = archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b'] })
142
+ previous = step.state
143
+ assert.deepEqual(step.added, [])
144
+ // 重连建立后:增量照常提示
145
+ assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] }).added, ['c'])
146
+ })
147
+
148
+ test('非归档会话拒绝删除', () => {
149
+ assert.equal(deleteEligibility({ archivedIds: ['a'], sessionId: 'a' }).ok, true)
150
+ const denied = deleteEligibility({ archivedIds: ['a'], sessionId: 'b' })
151
+ assert.equal(denied.ok, false)
152
+ assert.equal(denied.code, 'not-archived')
153
+ })
154
+
155
+ test('删除收尾聚合:失败矩阵折叠为三形态响应体', () => {
156
+ const R = DELETE_MESSAGES.runningDuringTrash
157
+ // 全成功无警告:无多余键(index.test deepEqual 锁定同形态)
158
+ assert.deepEqual(aggregateDeleteOutcome({}), { ok: true })
159
+ // 单一失败:主文案优先级 detach → 归档清理 → 台账
160
+ assert.deepEqual(aggregateDeleteOutcome({ detachFailed: true }), { ok: true, partial: true, message: DELETE_MESSAGES.partial })
161
+ assert.deepEqual(aggregateDeleteOutcome({ archiveCleanupFailed: true }), { ok: true, partial: true, message: DELETE_MESSAGES.archiveCleanup })
162
+ assert.deepEqual(aggregateDeleteOutcome({ ledgerFailed: true }), { ok: true, partial: true, message: DELETE_MESSAGES.ledgerFailed })
163
+ // 双失败:高优先级主文案 + 台账后缀
164
+ assert.deepEqual(
165
+ aggregateDeleteOutcome({ detachFailed: true, ledgerFailed: true }),
166
+ { ok: true, partial: true, message: DELETE_MESSAGES.partial + DELETE_MESSAGES.ledgerSuffix },
167
+ )
168
+ // 运行中翻转警告后缀并入一切形态
169
+ assert.deepEqual(
170
+ aggregateDeleteOutcome({ detachFailed: true, ledgerFailed: true, runningDuringTrash: true }),
171
+ { ok: true, partial: true, message: DELETE_MESSAGES.partial + DELETE_MESSAGES.ledgerSuffix + ';' + R },
172
+ )
173
+ // 全失败
174
+ assert.deepEqual(
175
+ aggregateDeleteOutcome({ detachFailed: true, archiveCleanupFailed: true, ledgerFailed: true, runningDuringTrash: true }),
176
+ { ok: true, partial: true, message: DELETE_MESSAGES.partial + DELETE_MESSAGES.ledgerSuffix + ';' + R },
177
+ )
178
+ // 全成功警告态:非 partial 形态(ok+message,无 partial 键)
179
+ assert.deepEqual(aggregateDeleteOutcome({ runningDuringTrash: true }), { ok: true, message: R })
180
+ })
181
+
182
+ test('运行中判定:agent status running 即运行中,注册表缺失视为非运行', () => {
183
+ const agents = new Map([['s1', { status: 'running' }], ['s2', { status: 'idle' }]])
184
+ assert.equal(isSessionRunning({ agents, sessionId: 's1' }), true)
185
+ assert.equal(isSessionRunning({ agents, sessionId: 's2' }), false)
186
+ assert.equal(isSessionRunning({ agents, sessionId: 'gone' }), false)
187
+ assert.equal(isSessionRunning({ agents: undefined, sessionId: 's1' }), false)
188
+ })
189
+
190
+ test('空白产物判定:JSONL 单行(仅 header)为空白', () => {
191
+ assert.equal(artifactLooksBlank('{"header":1}\n', false), true)
192
+ assert.equal(artifactLooksBlank('', false), true)
193
+ assert.equal(artifactLooksBlank('{"header":1}\n{"event":0}\n', false), false)
194
+ assert.equal(artifactLooksBlank('{"header":1}\n{"event":0', true), false)
195
+ })
196
+
197
+ test('空白产物判定:整块边界与 CRLF 行尾', () => {
198
+ // 恰满整块且换行不足两行:hasMore=true 判非空白(保守方向,防漏读)
199
+ assert.equal(artifactLooksBlank('{"header":1}\n', true), false)
200
+ // CRLF 行尾:\r 不计数,单行 CRLF header 仍为空白
201
+ assert.equal(artifactLooksBlank('{"header":1}\r\n', false), true)
202
+ assert.equal(artifactLooksBlank('{"header":1}\r\n{"event":0}\r\n', false), false)
203
+ })
204
+
205
+ test('已删除面板投影:标题回退会话 id,按删除时间倒序', () => {
206
+ const deleted = [
207
+ { sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100 },
208
+ { sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300 },
209
+ { sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200 },
210
+ ]
211
+ const rows = projectDeletedRows(deleted, { a: { displayTitle: '会话 A' } })
212
+ assert.deepEqual(rows, [
213
+ { sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300, title: 'b' },
214
+ { sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200, title: 'c' },
215
+ { sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100, title: '会话 A' },
216
+ ])
217
+ })
218
+
219
+ test('已删除面板投影:空台账投影为空', () => {
220
+ assert.deepEqual(projectDeletedRows([], {}), [])
221
+ })
222
+
223
+ test('台账合并:同 id 替换置顶,新 id 插入头部,入参不变', () => {
224
+ const existing = [{ sessionId: 'a', path: 'p1', deletedAt: 1 }]
225
+ assert.deepEqual(
226
+ mergeDeletedEntry(existing, { sessionId: 'a', path: 'p2', deletedAt: 2 }),
227
+ [{ sessionId: 'a', path: 'p2', deletedAt: 2 }],
228
+ )
229
+ assert.deepEqual(
230
+ mergeDeletedEntry(existing, { sessionId: 'b', path: 'p3', deletedAt: 3 }),
231
+ [{ sessionId: 'b', path: 'p3', deletedAt: 3 }, { sessionId: 'a', path: 'p1', deletedAt: 1 }],
232
+ )
233
+ assert.deepEqual(existing, [{ sessionId: 'a', path: 'p1', deletedAt: 1 }])
234
+ })
235
+
236
+ test('台账移除:命中删除并报变化,未命中幂等不报变化', () => {
237
+ const existing = [
238
+ { sessionId: 'a', path: 'p1', deletedAt: 1 },
239
+ { sessionId: 'b', path: 'p2', deletedAt: 2 },
240
+ ]
241
+ const hit = removeDeletedEntry(existing, 'a')
242
+ assert.deepEqual(hit.deleted, [{ sessionId: 'b', path: 'p2', deletedAt: 2 }])
243
+ assert.equal(hit.removed, true)
244
+ const miss = removeDeletedEntry(existing, 'z')
245
+ assert.deepEqual(miss.deleted, existing)
246
+ assert.equal(miss.removed, false)
247
+ })
248
+
249
+ // ── 历史输入:事件提取(G1-G3)──
250
+
251
+ function userEvent(overrides, text, at) {
252
+ return {
253
+ type: 'user/message',
254
+ seq: 1,
255
+ time: at,
256
+ ...overrides,
257
+ data: {
258
+ id: 'm1',
259
+ role: 'user',
260
+ source: { kind: 'user' },
261
+ content: text === undefined ? [] : [{ type: 'text', text }],
262
+ ...overrides?.data,
263
+ },
264
+ }
265
+ }
266
+
267
+ test('历史输入提取:user/message 且 kind=user 产出文本与时间', () => {
268
+ const events = [userEvent(null, '修复归档面板', 500)]
269
+ assert.deepEqual(extractUserInputs(events), [{ text: '修复归档面板', at: 500 }])
270
+ })
271
+
272
+ test('历史输入提取:tool 结果与 plugin 注入不产出', () => {
273
+ const tool = userEvent({ data: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [] }] } }, undefined, 100)
274
+ const plugin = userEvent({ data: { source: { kind: 'plugin', plugin: 'x', form: 'instructions' }, content: [{ type: 'text', text: '注入' }] } }, undefined, 200)
275
+ assert.deepEqual(extractUserInputs([tool, plugin]), [])
276
+ })
277
+
278
+ test('历史输入提取:纯图与空文本跳过', () => {
279
+ const image = userEvent({ data: { content: [{ type: 'image', attachment: { id: 'a1' } }] } }, undefined, 100)
280
+ const blank = userEvent(null, '', 200)
281
+ const whitespace = userEvent(null, ' \n ', 300)
282
+ assert.deepEqual(extractUserInputs([image, blank, whitespace]), [])
283
+ })
284
+
285
+ test('历史输入提取:多 text block 按行拼接为单条', () => {
286
+ const events = [userEvent({ data: { content: [{ type: 'text', text: '第一段' }, { type: 'text', text: '第二段' }] } }, undefined, 300)]
287
+ assert.deepEqual(extractUserInputs(events), [{ text: '第一段\n第二段', at: 300 }])
288
+ })
289
+
290
+ // ── 历史输入:聚合(G4-G5)──
291
+
292
+ test('历史输入聚合:同文本去重保留最新时间,按时间倒序', () => {
293
+ const merged = aggregateInputs([
294
+ { text: 'a', at: 1 },
295
+ { text: 'b', at: 5 },
296
+ { text: 'a', at: 9 },
297
+ ], { limit: 10, maxChars: 100 })
298
+ assert.deepEqual(merged, [{ text: 'a', at: 9 }, { text: 'b', at: 5 }])
299
+ })
300
+
301
+ test('历史输入聚合:limit 裁剪与单条截断', () => {
302
+ const merged = aggregateInputs([
303
+ { text: 'abcdef', at: 1 },
304
+ { text: 'xy', at: 2 },
305
+ ], { limit: 1, maxChars: 3 })
306
+ assert.deepEqual(merged, [{ text: 'xy', at: 2 }])
307
+ })