@mzzsfy/dsh-turn-notify 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,736 @@
1
+ // 纯逻辑层测试:分类映射 / 过滤决策 / 投影 / 认领状态机 / webhook 组装 / 音效映射 / 上传校验 / 标题提取。
2
+ import test from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+ import {
5
+ CATEGORIES,
6
+ CATEGORY_DONE,
7
+ CATEGORY_ERROR,
8
+ CATEGORY_APPROVAL,
9
+ CATEGORY_ASK,
10
+ mapEventToCategory,
11
+ isSubagent,
12
+ isSubagentWakeTurn,
13
+ shouldNotify,
14
+ SUBAGENT_WAKE_WINDOW_MS,
15
+ buildUnit,
16
+ buildWebhookPayload,
17
+ createProjection,
18
+ decideClaim,
19
+ chooseChannels,
20
+ USER_IDLE_AWAY_MS,
21
+ resolveSound,
22
+ mergeMapping,
23
+ deadCustomIds,
24
+ validateSoundName,
25
+ SOUND_NAME_MAX_CHARS,
26
+ parseVolume,
27
+ DEFAULT_VOLUME,
28
+ validateMappingId,
29
+ validateConfigPatch,
30
+ resolvedConfig,
31
+ publicConfig,
32
+ validateUpload,
33
+ collectSessionEvents,
34
+ storedSessionTitle,
35
+ SESSION_EVENTS_MAX,
36
+ pruneTimestamps,
37
+ TITLE_MAX_CHARS,
38
+ readRawBody,
39
+ sessionTitle,
40
+ createApprovalTap,
41
+ sendWebhook,
42
+ TONE_BELL,
43
+ TONE_UP_ARPEGGIO,
44
+ TONE_DOUBLE_PING,
45
+ MIME_BY_EXT,
46
+ mimeOf,
47
+ } from '../src/core.mjs'
48
+ import { EventEmitter } from 'node:events'
49
+
50
+ const MIN_TURN_MS = 5 * 1000
51
+
52
+ test('validateConfigPatch 拒绝非法 imTargets:非数组/超上限/重复/字符集与字段形态', () => {
53
+ const idMax = 'x'.repeat(128)
54
+ const cases = [
55
+ { imTargets: 'nope' },
56
+ { imTargets: [{ botId: 'wx_a', targetId: 't', extra: 1 }] },
57
+ { imTargets: [{ botId: 'wx a', targetId: 't' }] },
58
+ { imTargets: [{ botId: 'wx_a', targetId: 't;' }] },
59
+ { imTargets: [{ botId: 'wx_a' + 'x'.repeat(128), targetId: 't' }] },
60
+ { imTargets: [{ botId: 'wx_a', targetId: idMax + 'x' }] },
61
+ { imTargets: [{ botId: 123, targetId: 't' }] },
62
+ { imTargets: [{ botId: 'wx_a', targetId: 't' }, { botId: 'wx_a', targetId: 't' }] },
63
+ { imTargets: Array.from({ length: 17 }, (_, i) => ({ botId: 'wx_' + i, targetId: 't' })) },
64
+ { imTargets: [['wx_a', 't']] },
65
+ { imTargets: [{ botId: ' ', targetId: 't' }] },
66
+ ]
67
+ for (const patch of cases) {
68
+ const verdict = validateConfigPatch(patch)
69
+ assert.equal(verdict.ok, false, '应拒绝: ' + JSON.stringify(patch))
70
+ }
71
+ // 边界值合法:128 字符 ID、恰好 16 项目标、targetId 全部合法字符
72
+ assert.equal(validateConfigPatch({ imTargets: [{ botId: idMax, targetId: 'x'.repeat(123) + '._:@-' }] }).ok, true)
73
+ assert.equal(validateConfigPatch({ imTargets: Array.from({ length: 16 }, (_, i) => ({ botId: 'wx_' + i, targetId: 't' })) }).ok, true)
74
+ })
75
+
76
+ test('resolvedConfig 缺省与脏数据归一化 imTargets', () => {
77
+ assert.deepEqual(resolvedConfig({}).imTargets, [])
78
+ assert.deepEqual(resolvedConfig({ imTargets: 'junk' }).imTargets, [])
79
+ assert.deepEqual(resolvedConfig({ imTargets: [{ botId: 'a', targetId: 'b', extra: 1 }, null, { botId: 2, targetId: 'x' }, { botId: 'c', targetId: 'd' }] }).imTargets,
80
+ [{ botId: 'a', targetId: 'b' }, { botId: 'c', targetId: 'd' }])
81
+ })
82
+
83
+ test('publicConfig 回显 imTargets', () => {
84
+ const config = publicConfig({ imTargets: [{ botId: 'wx_a', targetId: 'owner' }] })
85
+ assert.deepEqual(config.imTargets, [{ botId: 'wx_a', targetId: 'owner' }])
86
+ assert.deepEqual(publicConfig({}).imTargets, [])
87
+ })
88
+
89
+ test('validateConfigPatch 接受合法 imTargets 并归一化 trim 且保持顺序', () => {
90
+ const verdict = validateConfigPatch({
91
+ imTargets: [
92
+ { botId: ' wx_abc ', targetId: 'owner' },
93
+ { botId: 'wx_def', targetId: 'tgt_1234' },
94
+ ],
95
+ })
96
+ assert.equal(verdict.ok, true)
97
+ assert.deepEqual(verdict.patch.imTargets, [
98
+ { botId: 'wx_abc', targetId: 'owner' },
99
+ { botId: 'wx_def', targetId: 'tgt_1234' },
100
+ ])
101
+ })
102
+
103
+ function baseSettings(overrides) {
104
+ return {
105
+ enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
106
+ rootsOnly: true,
107
+ minTurnDurationMs: MIN_TURN_MS,
108
+ ...overrides,
109
+ }
110
+ }
111
+
112
+ test('turn/end reason.kind 映射到六分类', () => {
113
+ assert.equal(mapEventToCategory('turn/end', { reason: { kind: 'completed' } }), CATEGORY_DONE)
114
+ assert.equal(mapEventToCategory('turn/end', { reason: { kind: 'error' } }), CATEGORY_ERROR)
115
+ assert.equal(mapEventToCategory('turn/end', { reason: { kind: 'aborted' } }), CATEGORY_ERROR)
116
+ assert.equal(mapEventToCategory('turn/end', { reason: { kind: 'interrupted' } }), 'interrupted')
117
+ assert.equal(mapEventToCategory('turn/end', { reason: { kind: 'blocked' } }), CATEGORY_APPROVAL)
118
+ assert.equal(mapEventToCategory('turn/end', { reason: { kind: 'max-tokens' } }), 'max-tokens')
119
+ })
120
+
121
+ test('非回合结束事件仅 ask_user_question tool/call 命中提问分类', () => {
122
+ assert.equal(mapEventToCategory('tool/call', { name: 'ask_user_question' }), CATEGORY_ASK)
123
+ assert.equal(mapEventToCategory('tool/call', { name: 'bash' }), null)
124
+ assert.equal(mapEventToCategory('assistant/chunk', {}), null)
125
+ assert.equal(mapEventToCategory('turn/start', {}), null)
126
+ })
127
+
128
+ test('rootsOnly 按子代理会话过滤', () => {
129
+ const subByOrigin = { origin: 'subagent' }
130
+ const subByDepth = { delegationDepth: 1 }
131
+ const top = { delegationDepth: 0 }
132
+ assert.equal(isSubagent(subByOrigin), true)
133
+ assert.equal(isSubagent(subByDepth), true)
134
+ assert.equal(isSubagent(top), false)
135
+ assert.equal(isSubagent({}), false)
136
+ const settings = baseSettings()
137
+ assert.equal(shouldNotify({
138
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10, settings, header: subByOrigin,
139
+ }), false)
140
+ assert.equal(shouldNotify({
141
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10, settings, header: top,
142
+ }), true)
143
+ assert.equal(shouldNotify({
144
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
145
+ settings: baseSettings({ rootsOnly: false }), header: subByDepth,
146
+ }), true)
147
+ })
148
+
149
+ test('碎轮过滤仅作用于 turn/end 类,时长恰等边界放行', () => {
150
+ const settings = baseSettings()
151
+ const top = {}
152
+ assert.equal(shouldNotify({
153
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS - 1, settings, header: top,
154
+ }), false)
155
+ assert.equal(shouldNotify({
156
+ category: CATEGORY_ERROR, kind: 'turn/end', durationMs: MIN_TURN_MS - 1, settings, header: top,
157
+ }), false)
158
+ // 恰等阈值不属碎轮
159
+ assert.equal(shouldNotify({
160
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS, settings, header: top,
161
+ }), true)
162
+ assert.equal(shouldNotify({
163
+ category: CATEGORY_APPROVAL, kind: 'approval/request', durationMs: null, settings, header: top,
164
+ }), true)
165
+ assert.equal(shouldNotify({
166
+ category: CATEGORY_ASK, kind: 'tool/call', durationMs: null, settings, header: top,
167
+ }), true)
168
+ })
169
+
170
+ test('分类开关关闭即不通知', () => {
171
+ const settings = baseSettings({ enabled: { [CATEGORY_ERROR]: false } })
172
+ const full = baseSettings()
173
+ assert.equal(shouldNotify({
174
+ category: CATEGORY_ERROR, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
175
+ settings: { ...settings, enabled: { ...full.enabled, [CATEGORY_ERROR]: false } }, header: {},
176
+ }), false)
177
+ })
178
+
179
+ test('子代理回执静默:唤醒回合 completed 不通知,其余分类与开关关闭不受影响', () => {
180
+ const now = 1_000_000
181
+ const windowMs = SUBAGENT_WAKE_WINDOW_MS
182
+ const settings = baseSettings()
183
+ // 唤醒判定:父会话回合开始时刻落在子代理结束后窗口内,恰等边界含
184
+ assert.equal(isSubagentWakeTurn({ childDoneAt: now - windowMs, turnStartMs: now }), true)
185
+ assert.equal(isSubagentWakeTurn({ childDoneAt: now, turnStartMs: now }), true)
186
+ assert.equal(isSubagentWakeTurn({ childDoneAt: now - windowMs - 1, turnStartMs: now }), false)
187
+ assert.equal(isSubagentWakeTurn({ childDoneAt: now + 1, turnStartMs: now }), false)
188
+ assert.equal(isSubagentWakeTurn({ childDoneAt: null, turnStartMs: now }), false)
189
+ // 唤醒回合 completed + 开关开 → 抑制
190
+ assert.equal(shouldNotify({
191
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
192
+ settings, header: {}, wakeTurn: true,
193
+ }), false)
194
+ // 开关关 → 通知
195
+ assert.equal(shouldNotify({
196
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
197
+ settings: baseSettings({ suppressSubagentWake: false }), header: {}, wakeTurn: true,
198
+ }), true)
199
+ // 非唤醒回合 → 正常通知(不误伤)
200
+ assert.equal(shouldNotify({
201
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
202
+ settings, header: {}, wakeTurn: false,
203
+ }), true)
204
+ // 唤醒回合但异常分类 → 仍通知
205
+ assert.equal(shouldNotify({
206
+ category: CATEGORY_ERROR, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
207
+ settings, header: {}, wakeTurn: true,
208
+ }), true)
209
+ })
210
+
211
+ test('等待子代理静默:主回合挂起等委托时 completed 不通知,其余分类与开关关闭不受影响', () => {
212
+ const settings = baseSettings()
213
+ // 等待子代理 + completed + 开关开 → 抑制
214
+ assert.equal(shouldNotify({
215
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
216
+ settings, header: {}, awaitingChildren: true,
217
+ }), false)
218
+ // 开关关 → 通知
219
+ assert.equal(shouldNotify({
220
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
221
+ settings: baseSettings({ suppressSubagentWake: false }), header: {}, awaitingChildren: true,
222
+ }), true)
223
+ // 无等待 → 正常通知(不误伤)
224
+ assert.equal(shouldNotify({
225
+ category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
226
+ settings, header: {}, awaitingChildren: false,
227
+ }), true)
228
+ // 等待中但异常分类 → 仍通知
229
+ assert.equal(shouldNotify({
230
+ category: CATEGORY_ERROR, kind: 'turn/end', durationMs: MIN_TURN_MS * 10,
231
+ settings, header: {}, awaitingChildren: true,
232
+ }), true)
233
+ })
234
+
235
+ test('投影环形容量与过期清理', () => {
236
+ const capacity = 20
237
+ const ttlMs = 60 * 1000
238
+ let now = 1000
239
+ const projection = createProjection({ capacity, ttlMs, now: () => now })
240
+ for (let index = 0; index < capacity + 3; index += 1) {
241
+ projection.push({ id: 'e' + index, category: CATEGORY_DONE, ts: now })
242
+ }
243
+ let listed = projection.list()
244
+ assert.equal(listed.length, capacity)
245
+ assert.equal(listed[0].id, 'e3')
246
+ now += ttlMs + 1
247
+ listed = projection.list()
248
+ assert.equal(listed.length, 0)
249
+ })
250
+
251
+ test('投影版本:push 与 bump 各递增,version 读取当前值', () => {
252
+ const projection = createProjection({})
253
+ assert.equal(projection.version(), 0)
254
+ projection.push({ id: 'e1', category: CATEGORY_DONE, ts: 1 })
255
+ assert.equal(projection.version(), 1)
256
+ projection.bump()
257
+ assert.equal(projection.version(), 2)
258
+ })
259
+
260
+ test('投影等待:已有新版本立即返回,落后 cursor 挂起至 push 唤醒', async () => {
261
+ const projection = createProjection({})
262
+ projection.push({ id: 'e1', category: CATEGORY_DONE, ts: 1 })
263
+ // cursor 已落后:立即返回当前版本,不挂起
264
+ assert.equal(await projection.wait(0, 60 * 1000), 1)
265
+ // cursor 追平:挂起,push 后唤醒并返回新版本
266
+ const pending = projection.wait(1, 60 * 1000)
267
+ let settled = false
268
+ void pending.then(() => { settled = true })
269
+ await new Promise((resolve) => { setTimeout(resolve, 10) })
270
+ assert.equal(settled, false, '未 push 前不应唤醒')
271
+ projection.push({ id: 'e2', category: CATEGORY_DONE, ts: 2 })
272
+ assert.equal(await pending, 2)
273
+ })
274
+
275
+ test('投影等待:超时以当前版本收尾,bump 同样唤醒等待者', async () => {
276
+ const timeoutMs = 10
277
+ const projection = createProjection({})
278
+ const timedOut = projection.wait(0, timeoutMs)
279
+ assert.equal(await timedOut, 0)
280
+ const pending = projection.wait(0, 60 * 1000)
281
+ projection.bump()
282
+ assert.equal(await pending, 1)
283
+ })
284
+
285
+ test('投影停用:dispose 唤醒全部等待者并清空定时器', async () => {
286
+ const projection = createProjection({})
287
+ const first = projection.wait(0, 60 * 1000)
288
+ const second = projection.wait(0, 60 * 1000)
289
+ projection.dispose()
290
+ assert.equal(await first, 0)
291
+ assert.equal(await second, 0)
292
+ })
293
+
294
+ test('认领状态机:无锁认领 / 他锁跳过 / 过期接管 / 完成标记终态', () => {
295
+ const lockTtlMs = 30 * 1000
296
+ const t0 = 1000
297
+ assert.equal(decideClaim({ stored: null, done: null, now: t0, windowId: 'w1', lockTtlMs }), 'claim')
298
+ assert.equal(decideClaim({ stored: JSON.stringify({ wid: 'w2', at: t0 }), done: null, now: t0 + lockTtlMs - 1, windowId: 'w1', lockTtlMs }), 'skip')
299
+ assert.equal(decideClaim({ stored: JSON.stringify({ wid: 'w1', at: t0 }), done: null, now: t0 + lockTtlMs - 1, windowId: 'w1', lockTtlMs }), 'claim')
300
+ assert.equal(decideClaim({ stored: JSON.stringify({ wid: 'w2', at: t0 }), done: null, now: t0 + lockTtlMs + 1, windowId: 'w1', lockTtlMs }), 'takeover')
301
+ assert.equal(decideClaim({ stored: null, done: '1', now: t0, windowId: 'w1', lockTtlMs }), 'done')
302
+ assert.equal(decideClaim({ stored: 'not-json', done: null, now: t0, windowId: 'w1', lockTtlMs }), 'takeover')
303
+ })
304
+
305
+ test('发声通道判定:聚焦静默压声音与系统弹窗,页内提示与系统弹窗独立开关', () => {
306
+ const base = { hasFocus: false, permission: 'granted' }
307
+ assert.deepEqual(chooseChannels(base), { toast: true, sound: true, system: true, blink: false, pageSound: false })
308
+ // 聚焦静默:仅页内提示
309
+ assert.deepEqual(chooseChannels({ ...base, hasFocus: true }), { toast: true, sound: false, system: false, blink: false, pageSound: false })
310
+ // 聚焦静默可关:聚焦窗口照常发声
311
+ assert.deepEqual(chooseChannels({ ...base, hasFocus: true, focusQuiet: false }).sound, true)
312
+ // 页内提示独立关闭
313
+ assert.deepEqual(chooseChannels({ ...base, toastEnabled: false }).toast, false)
314
+ // 系统弹窗独立关闭:不弹不闪
315
+ assert.deepEqual(chooseChannels({ ...base, systemEnabled: false }), { toast: true, sound: true, system: false, blink: false, pageSound: false })
316
+ // 想弹未授权:降级闪烁
317
+ assert.deepEqual(chooseChannels({ ...base, permission: 'default' }), { toast: true, sound: true, system: false, blink: true, pageSound: false })
318
+ assert.deepEqual(chooseChannels({ ...base, permission: 'denied' }).blink, true)
319
+ // 关闭系统弹窗后未授权不再闪
320
+ assert.deepEqual(chooseChannels({ ...base, permission: 'denied', systemEnabled: false }).blink, false)
321
+ })
322
+
323
+ test('页内提示音:聚焦补位发声,失焦让位通知声音,页内分类独立配置', () => {
324
+ const base = { hasFocus: false, permission: 'granted', pageSoundEnabled: true }
325
+ // 聚焦静默压制通知声音,页内提示音补位:核心场景
326
+ assert.equal(chooseChannels({ ...base, hasFocus: true }).pageSound, true)
327
+ // 失焦时通知声音已播,页内提示音让位,同一通知至多一声
328
+ assert.equal(chooseChannels(base).pageSound, false)
329
+ // 通知声音总开关关闭:失焦时页内提示音顶上
330
+ assert.equal(chooseChannels({ ...base, soundEnabled: false }).pageSound, true)
331
+ // 页内提示关闭:无卡片即无声
332
+ assert.equal(chooseChannels({ ...base, hasFocus: true, toastEnabled: false }).pageSound, false)
333
+ // 页内分类显式静音:该分类不补位
334
+ assert.equal(chooseChannels({ ...base, hasFocus: true, pageSoundCategories: { ask: false }, category: 'ask' }).pageSound, false)
335
+ // 页内分类静音只压本分类:其他分类照常
336
+ assert.equal(chooseChannels({ ...base, hasFocus: true, pageSoundCategories: { ask: false }, category: 'completed' }).pageSound, true)
337
+ // 提示音分类静音不连带页内提示音:两套分类配置独立
338
+ assert.equal(chooseChannels({ ...base, hasFocus: true, soundCategories: { ask: false }, category: 'ask' }).pageSound, true)
339
+ // 未提供页内分类:全放行(null 与 undefined 等价)
340
+ assert.equal(chooseChannels({ ...base, hasFocus: true, pageSoundCategories: { ask: false } }).pageSound, true)
341
+ // 开关缺省关闭:行为与旧版一致
342
+ assert.equal(chooseChannels({ hasFocus: true, permission: 'granted' }).pageSound, false)
343
+ })
344
+
345
+ test('提示音开关:总开关与分类配置独立静音,缺省键与空分类放行', () => {
346
+ const base = { hasFocus: false, permission: 'granted' }
347
+ // 提示音总开关独立关闭:页内提示与系统弹窗不受影响
348
+ assert.deepEqual(chooseChannels({ ...base, soundEnabled: false }), { toast: true, sound: false, system: true, blink: false, pageSound: false })
349
+ // 分类显式 false:该分类静音
350
+ assert.equal(chooseChannels({ ...base, soundCategories: { ask: false }, category: 'ask' }).sound, false)
351
+ // 同配置其他分类照常出声
352
+ assert.equal(chooseChannels({ ...base, soundCategories: { ask: false }, category: 'completed' }).sound, true)
353
+ // 未提供分类:全放行(与既有调用形态兼容)
354
+ assert.equal(chooseChannels({ ...base, soundCategories: { ask: false } }).sound, true)
355
+ // 分类空缺:null 与 undefined 等价放行
356
+ assert.equal(chooseChannels({ ...base, soundCategories: { ask: false }, category: null }).sound, true)
357
+ })
358
+
359
+ test('用户行动空闲满阈值:聚焦也全通道齐发,活跃时维持聚焦静默', () => {
360
+ const base = { hasFocus: true, permission: 'granted' }
361
+ const idle = USER_IDLE_AWAY_MS
362
+ // 空闲满阈值:离开,聚焦静默不再适用,全通道
363
+ assert.deepEqual(chooseChannels({ ...base, idleMs: idle }), { toast: true, sound: true, system: true, blink: false, pageSound: false })
364
+ // 恰等边界含
365
+ assert.equal(chooseChannels({ ...base, idleMs: idle - 1 }).sound, false)
366
+ // 活跃(刚行动):聚焦静默维持
367
+ assert.equal(chooseChannels({ ...base, idleMs: 0 }).sound, false)
368
+ // 空闲但未聚焦:行为不变
369
+ assert.equal(chooseChannels({ hasFocus: false, permission: 'granted', idleMs: idle }).sound, true)
370
+ // 未提供空闲时长:行为与旧版一致
371
+ assert.deepEqual(chooseChannels(base), { toast: true, sound: false, system: false, blink: false, pageSound: false })
372
+ // 空闲时聚焦静默关闭依旧生效
373
+ assert.equal(chooseChannels({ ...base, idleMs: 0, focusQuiet: false }).sound, true)
374
+ })
375
+
376
+ test('webhook payload 字段映射', () => {
377
+ const unit = buildUnit({
378
+ id: 'n1', category: CATEGORY_DONE, status: 'completed',
379
+ sessionTitle: '修复登录', workspace: '/repo', durationMs: 12 * 1000, ts: 1234,
380
+ })
381
+ const payload = buildWebhookPayload(unit)
382
+ assert.deepEqual(payload, {
383
+ text: '[dsh] 任务完成: 修复登录',
384
+ event: 'n1',
385
+ category: CATEGORY_DONE,
386
+ status: 'completed',
387
+ session: '修复登录',
388
+ workspace: '/repo',
389
+ durationMs: 12 * 1000,
390
+ ts: 1234,
391
+ })
392
+ })
393
+
394
+ test('音效映射:自定义命中用自定义,内置备选与失效回落', () => {
395
+ const mapping = { [CATEGORY_ERROR]: 'snd-9', [CATEGORY_DONE]: TONE_BELL, [CATEGORY_ASK]: 'gone' }
396
+ assert.deepEqual(resolveSound({ category: CATEGORY_ERROR, mapping, uploadedIds: ['snd-9'] }), { kind: 'custom', id: 'snd-9' })
397
+ assert.deepEqual(resolveSound({ category: CATEGORY_DONE, mapping, uploadedIds: [] }), { kind: 'builtin', name: TONE_BELL })
398
+ const fallback = resolveSound({ category: CATEGORY_ASK, mapping, uploadedIds: [] })
399
+ assert.deepEqual(fallback, { kind: 'builtin', name: TONE_DOUBLE_PING })
400
+ assert.deepEqual(resolveSound({ category: CATEGORY_DONE, mapping: {}, uploadedIds: [] }), { kind: 'builtin', name: TONE_UP_ARPEGGIO })
401
+ })
402
+
403
+ test('映射合并:本地覆盖全局,缺键回落,空串为显式内置默认,入参容错', () => {
404
+ assert.deepEqual(mergeMapping({ completed: 'a', error: 'b' }, { completed: 'c' }), { completed: 'c', error: 'b' })
405
+ assert.deepEqual(mergeMapping({ completed: 'a' }, {}), { completed: 'a' })
406
+ assert.deepEqual(mergeMapping({ completed: 'a' }, { completed: '' }), { completed: '' })
407
+ assert.deepEqual(mergeMapping(null, { completed: 'a' }), { completed: 'a' })
408
+ assert.deepEqual(mergeMapping({ completed: 'a' }, null), { completed: 'a' })
409
+ assert.deepEqual(mergeMapping(undefined, undefined), {})
410
+ const globalMapping = { completed: 'a' }
411
+ assert.deepEqual(mergeMapping(globalMapping, { completed: 'b' }), { completed: 'b' })
412
+ assert.deepEqual(globalMapping, { completed: 'a' })
413
+ })
414
+
415
+ test('死链识别:非内置非上传即死链,去重按首次出现排序,空值与非字符串跳过,列表空缺容错', () => {
416
+ const mapping = { completed: 'gone-2', error: TONE_BELL, interrupted: 'gone-1', approval: '', ask: 'gone-2', 'max-tokens': 'snd-1' }
417
+ assert.deepEqual(deadCustomIds(mapping, ['snd-1']), ['gone-2', 'gone-1'])
418
+ assert.deepEqual(deadCustomIds({ completed: TONE_BELL }, []), [])
419
+ assert.deepEqual(deadCustomIds({ completed: 'snd-1' }, ['snd-1']), [])
420
+ assert.deepEqual(deadCustomIds({}, ['snd-1']), [])
421
+ assert.deepEqual(deadCustomIds(null, []), [])
422
+ assert.deepEqual(deadCustomIds({ completed: 7, error: null, ask: { id: 'x' } }, ['snd-1']), [])
423
+ assert.deepEqual(deadCustomIds({ completed: 'gone-1' }, null), ['gone-1'])
424
+ })
425
+
426
+ test('音量解析:未设置回默认,显式零保留,非法回落默认', () => {
427
+ assert.equal(parseVolume(null), DEFAULT_VOLUME)
428
+ assert.equal(parseVolume(undefined), DEFAULT_VOLUME)
429
+ assert.equal(parseVolume('0'), 0)
430
+ assert.equal(parseVolume('0.5'), 0.5)
431
+ assert.equal(parseVolume('1'), 1)
432
+ assert.equal(parseVolume('abc'), DEFAULT_VOLUME)
433
+ assert.equal(parseVolume('2'), DEFAULT_VOLUME)
434
+ assert.equal(parseVolume('-1'), DEFAULT_VOLUME)
435
+ })
436
+
437
+ test('音效名校验:合法名通过并归一化,非法名拒绝', () => {
438
+ assert.deepEqual(validateSoundName(' 提示音甲 '), { ok: true, name: '提示音甲' })
439
+ assert.equal(validateSoundName('bell').ok, false)
440
+ assert.equal(validateSoundName('').ok, false)
441
+ assert.equal(validateSoundName(' ').ok, false)
442
+ assert.equal(validateSoundName('a/b').ok, false)
443
+ assert.equal(validateSoundName('a\\b').ok, false)
444
+ assert.equal(validateSoundName('a.b').ok, false)
445
+ assert.equal(validateSoundName('a:b').ok, false)
446
+ assert.equal(validateSoundName('a*b').ok, false)
447
+ assert.equal(validateSoundName('a?b').ok, false)
448
+ assert.equal(validateSoundName('a"b').ok, false)
449
+ assert.equal(validateSoundName('a<b').ok, false)
450
+ assert.equal(validateSoundName('a>b').ok, false)
451
+ assert.equal(validateSoundName('a|b').ok, false)
452
+ assert.equal(validateSoundName('a\u0000b').ok, false)
453
+ assert.equal(validateSoundName('长'.repeat(SOUND_NAME_MAX_CHARS + 1)).ok, false)
454
+ assert.equal(validateSoundName('长'.repeat(SOUND_NAME_MAX_CHARS)).ok, true)
455
+ })
456
+
457
+ test('上传校验:扩展名 / 单文件上限 / 总量上限 / 零与负体拒绝 / 总量恰等放行', () => {
458
+ const fileMax = 2 * 1024 * 1024
459
+ const totalMax = 10 * 1024 * 1024
460
+ assert.equal(validateUpload({ filename: 'a.MP3', size: fileMax, totalBytes: 0 }).ok, true)
461
+ assert.equal(validateUpload({ filename: 'a.txt', size: 1, totalBytes: 0 }).ok, false)
462
+ assert.equal(validateUpload({ filename: 'a.mp3', size: fileMax + 1, totalBytes: 0 }).ok, false)
463
+ assert.equal(validateUpload({ filename: 'a.mp3', size: 1, totalBytes: totalMax }).ok, false)
464
+ assert.equal(validateUpload({ filename: 'a.mp3', size: 0, totalBytes: 0 }).ok, false)
465
+ assert.equal(validateUpload({ filename: 'a.mp3', size: -1, totalBytes: 0 }).ok, false)
466
+ assert.equal(validateUpload({ filename: 'a.mp3', size: fileMax, totalBytes: totalMax - fileMax }).ok, true)
467
+ })
468
+
469
+ test('标题提取:首个用户文本并截断', () => {
470
+ const events = [
471
+ { type: 'turn/start', data: { turn: 0 } },
472
+ { type: 'user/message', data: { content: [{ type: 'text', text: '帮我修复登录页面的崩溃问题' }] } },
473
+ ]
474
+ const long = '长'.repeat(200)
475
+ const eventsLong = [
476
+ { type: 'user/message', data: { content: [{ type: 'text', text: long }] } },
477
+ ]
478
+ assert.equal(sessionTitle(events), '帮我修复登录页面的崩溃问题')
479
+ assert.equal(sessionTitle(eventsLong).length, TITLE_MAX_CHARS)
480
+ assert.equal(sessionTitle([]), null)
481
+ })
482
+
483
+ test('审批观察器 next() 立即放行且只通知一次', () => {
484
+ const notified = []
485
+ const scheduled = []
486
+ const tap = createApprovalTap((unit) => { notified.push(unit) }, (fn) => { scheduled.push(fn) })
487
+ let nextCalled = 0
488
+ const returned = tap({ toolName: 'bash' }, () => { nextCalled += 1; return 'next-result' })
489
+ assert.equal(nextCalled, 1)
490
+ assert.equal(returned, 'next-result')
491
+ assert.equal(notified.length, 0)
492
+ for (const fn of scheduled) fn()
493
+ assert.equal(notified.length, 1)
494
+ assert.equal(notified[0].category, CATEGORY_APPROVAL)
495
+ })
496
+
497
+ test('webhook 发送吞错不抛出', async () => {
498
+ const calls = []
499
+ const failing = async () => { calls.push(1); throw new Error('unreachable') }
500
+ await assert.doesNotReject(() => sendWebhook({ url: 'https://hook.example', payload: { text: 'x' }, fetchImpl: failing }))
501
+ assert.equal(calls.length, 1)
502
+ const ok = async () => ({ ok: true })
503
+ await assert.doesNotReject(() => sendWebhook({ url: '', payload: { text: 'x' }, fetchImpl: ok }))
504
+ })
505
+
506
+ test('webhook 发送返回真实投递结果', async () => {
507
+ const delivered = async () => ({ ok: true, status: 200 })
508
+ assert.deepEqual(
509
+ await sendWebhook({ url: ' https://hook.example ', payload: { text: 'x' }, fetchImpl: delivered }),
510
+ { ok: true, detail: 'HTTP 200' },
511
+ )
512
+ const rejected = async () => ({ ok: false, status: 500 })
513
+ assert.deepEqual(
514
+ await sendWebhook({ url: 'https://hook.example', payload: { text: 'x' }, fetchImpl: rejected }),
515
+ { ok: false, detail: 'HTTP 500' },
516
+ )
517
+ const failing = async () => { throw new Error('unreachable') }
518
+ assert.deepEqual(
519
+ await sendWebhook({ url: 'https://hook.example', payload: { text: 'x' }, fetchImpl: failing }),
520
+ { ok: false, detail: 'unreachable' },
521
+ )
522
+ let called = 0
523
+ const probe = async () => { called += 1; return { ok: true, status: 200 } }
524
+ assert.deepEqual(
525
+ await sendWebhook({ url: ' ', payload: { text: 'x' }, fetchImpl: probe }),
526
+ { ok: false, detail: '未配置 webhook' },
527
+ )
528
+ assert.equal(called, 0)
529
+ })
530
+
531
+ test('webhook 非 Error 抛出物以字符串形式回填 detail', async () => {
532
+ const throwingString = async () => { throw 'boom' }
533
+ assert.deepEqual(
534
+ await sendWebhook({ url: 'https://hook.example', payload: { text: 'x' }, fetchImpl: throwingString }),
535
+ { ok: false, detail: 'boom' },
536
+ )
537
+ })
538
+
539
+ test('MIME 映射:扩展名一比一对应,未知扩展回退通用类型', () => {
540
+ assert.deepEqual(MIME_BY_EXT, { wav: 'audio/wav', ogg: 'audio/ogg', mp3: 'audio/mpeg' })
541
+ assert.equal(mimeOf('wav'), 'audio/wav')
542
+ assert.equal(mimeOf('mp3'), 'audio/mpeg')
543
+ assert.equal(mimeOf('ogg'), 'audio/ogg')
544
+ assert.equal(mimeOf('txt'), 'application/octet-stream')
545
+ assert.equal(mimeOf(''), 'application/octet-stream')
546
+ })
547
+
548
+ test('配置补丁校验:合法整补丁与部分补丁放行并归一化', () => {
549
+ const full = validateConfigPatch({
550
+ webhookUrl: ' https://hook.example ',
551
+ minTurnDurationMs: 1500,
552
+ rootsOnly: false,
553
+ suppressSubagentWake: false,
554
+ enabled: { completed: false, error: true },
555
+ })
556
+ assert.equal(full.ok, true)
557
+ assert.deepEqual(full.patch, {
558
+ webhookUrl: 'https://hook.example',
559
+ minTurnDurationMs: 1500,
560
+ rootsOnly: false,
561
+ suppressSubagentWake: false,
562
+ enabled: { completed: false, error: true },
563
+ })
564
+ assert.deepEqual(validateConfigPatch({ webhookUrl: '' }), { ok: true, patch: { webhookUrl: '' } })
565
+ assert.deepEqual(validateConfigPatch({ rootsOnly: true }), { ok: true, patch: { rootsOnly: true } })
566
+ assert.deepEqual(validateConfigPatch({ suppressSubagentWake: true }), { ok: true, patch: { suppressSubagentWake: true } })
567
+ assert.deepEqual(validateConfigPatch({}), { ok: true, patch: {} })
568
+ })
569
+
570
+ test('配置补丁校验:非法输入逐类拒绝', () => {
571
+ assert.equal(validateConfigPatch(null).ok, false)
572
+ assert.equal(validateConfigPatch('x').ok, false)
573
+ assert.equal(validateConfigPatch({ other: 1 }).ok, false)
574
+ assert.equal(validateConfigPatch({ webhookUrl: 123 }).ok, false)
575
+ assert.equal(validateConfigPatch({ webhookUrl: 'ftp://hook.example' }).ok, false)
576
+ assert.equal(validateConfigPatch({ webhookUrl: 'not a url' }).ok, false)
577
+ assert.equal(validateConfigPatch({ minTurnDurationMs: -1 }).ok, false)
578
+ assert.equal(validateConfigPatch({ minTurnDurationMs: 1.5 }).ok, false)
579
+ assert.equal(validateConfigPatch({ minTurnDurationMs: 'fast' }).ok, false)
580
+ assert.equal(validateConfigPatch({ rootsOnly: 'yes' }).ok, false)
581
+ assert.equal(validateConfigPatch({ suppressSubagentWake: 'yes' }).ok, false)
582
+ assert.equal(validateConfigPatch({ enabled: { unknown: true } }).ok, false)
583
+ assert.equal(validateConfigPatch({ enabled: { completed: 'no' } }).ok, false)
584
+ assert.equal(validateConfigPatch({ enabled: [true] }).ok, false)
585
+ })
586
+
587
+ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () => {
588
+ assert.deepEqual(
589
+ resolvedConfig({ webhookUrl: 'https://hook.example', enabled: { completed: false }, soundMapping: { completed: 'snd-1' } }),
590
+ {
591
+ webhookUrl: 'https://hook.example',
592
+ minTurnDurationMs: MIN_TURN_MS,
593
+ rootsOnly: true,
594
+ suppressSubagentWake: true,
595
+ enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
596
+ soundMapping: { completed: 'snd-1' },
597
+ imTargets: [],
598
+ },
599
+ )
600
+ assert.deepEqual(resolvedConfig({}), {
601
+ webhookUrl: '',
602
+ minTurnDurationMs: MIN_TURN_MS,
603
+ rootsOnly: true,
604
+ suppressSubagentWake: true,
605
+ enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
606
+ soundMapping: {},
607
+ imTargets: [],
608
+ })
609
+ assert.equal(resolvedConfig({ minTurnDurationMs: Number.NaN }).minTurnDurationMs, MIN_TURN_MS)
610
+ assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
611
+ })
612
+
613
+ test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
614
+ assert.deepEqual(
615
+ publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' } }),
616
+ {
617
+ minTurnDurationMs: MIN_TURN_MS,
618
+ rootsOnly: true,
619
+ suppressSubagentWake: true,
620
+ enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
621
+ soundMapping: { completed: 'snd-1' },
622
+ imTargets: [],
623
+ webhookConfigured: true,
624
+ },
625
+ )
626
+ assert.equal(publicConfig({}).webhookConfigured, false)
627
+ assert.equal(publicConfig({ webhookUrl: ' ' }).webhookConfigured, false)
628
+ assert.equal('webhookUrl' in publicConfig({ webhookUrl: 'https://hook.example' }), false)
629
+ })
630
+
631
+ test('会话事件有界累积:标题提取后封账为字符串,内存不再增长', () => {
632
+ const store = new Map()
633
+ const titled = new Set()
634
+ const sessionId = 's1'
635
+ const userEvent = (text) => ({ type: 'user/message', data: { content: [{ type: 'text', text }] } })
636
+ collectSessionEvents(store, titled, sessionId, userEvent('第一句'))
637
+ assert.equal(titled.has(sessionId), true)
638
+ // 封账后 store 中以标题字符串替代事件数组
639
+ assert.equal(store.get(sessionId), '第一句')
640
+ assert.equal(storedSessionTitle(store, sessionId), '第一句')
641
+ for (let index = 0; index < 100; index += 1) {
642
+ collectSessionEvents(store, titled, sessionId, userEvent('追加 ' + index))
643
+ }
644
+ assert.equal(store.get(sessionId), '第一句')
645
+ // 非用户消息事件不入库
646
+ collectSessionEvents(store, titled, sessionId, { type: 'turn/start', data: {} })
647
+ assert.equal(store.get(sessionId), '第一句')
648
+ // 未提取到标题的会话继续累积,超上限截尾
649
+ collectSessionEvents(store, titled, 's2', { type: 'user/message', data: { content: [{ type: 'text', text: ' ' }] } })
650
+ for (let index = 0; index < SESSION_EVENTS_MAX + 3; index += 1) {
651
+ collectSessionEvents(store, titled, 's2', { type: 'user/message', data: { content: [{ type: 'image' }] } })
652
+ }
653
+ const untitled = store.get('s2')
654
+ assert.equal(Array.isArray(untitled), true)
655
+ assert.equal(untitled.length, SESSION_EVENTS_MAX)
656
+ assert.equal(titled.has('s2'), false)
657
+ assert.equal(storedSessionTitle(store, 's2'), null)
658
+ // 截尾后出现文本:标题提取照常生效并封账
659
+ collectSessionEvents(store, titled, 's2', userEvent('正式内容'))
660
+ assert.equal(titled.has('s2'), true)
661
+ assert.equal(store.get('s2'), '正式内容')
662
+ })
663
+
664
+ test('会话标题读取:未封账数组内含文本时提取,空缺返回 null', () => {
665
+ const store = new Map()
666
+ assert.equal(storedSessionTitle(store, 'none'), null)
667
+ store.set('arr', [{ type: 'user/message', data: { content: [{ type: 'text', text: '数组内标题' }] } }])
668
+ assert.equal(storedSessionTitle(store, 'arr'), '数组内标题')
669
+ store.set('bad', 42)
670
+ assert.equal(storedSessionTitle(store, 'bad'), null)
671
+ })
672
+
673
+ test('会话标题按码点截断:增补平面字符不产生孤立代理项', () => {
674
+ const prefix = 'x'.repeat(TITLE_MAX_CHARS - 1)
675
+ const events = [{ type: 'user/message', data: { content: [{ type: 'text', text: prefix + '😀😀😀' }] } }]
676
+ const title = sessionTitle(events)
677
+ assert.equal(Array.from(title).length, TITLE_MAX_CHARS)
678
+ for (const unit of title) assert.equal((unit.codePointAt(0) & 0xf800) !== 0xd800, true)
679
+ })
680
+
681
+ test('时间戳惰性回收:超龄条目删除,新鲜与恰等边界保留', () => {
682
+ const map = new Map([['old', 1000], ['fresh', 2000], ['edge', 2000]])
683
+ pruneTimestamps(map, 2000 + SUBAGENT_WAKE_WINDOW_MS, SUBAGENT_WAKE_WINDOW_MS)
684
+ assert.equal(map.has('old'), false)
685
+ assert.equal(map.has('fresh'), true)
686
+ // 恰等阈值不算超龄
687
+ assert.equal(map.has('edge'), true)
688
+ })
689
+
690
+ test('映射路由 id 校验:空串放行,仅收已上传 id 与内置音名', () => {
691
+ assert.equal(validateMappingId('', []), true)
692
+ assert.equal(validateMappingId('snd-1', ['snd-1']), true)
693
+ assert.equal(validateMappingId(TONE_BELL, []), true)
694
+ assert.equal(validateMappingId('gone', ['snd-1']), false)
695
+ assert.equal(validateMappingId('gone', []), false)
696
+ })
697
+
698
+ test('readRawBody 超限 reject 并断流', async () => {
699
+ const req = new EventEmitter()
700
+ req.destroy = () => { req.destroyed = true }
701
+ const pending = readRawBody(req, 8)
702
+ req.emit('data', Buffer.alloc(4))
703
+ req.emit('data', Buffer.alloc(4))
704
+ req.emit('data', Buffer.alloc(4))
705
+ await assert.rejects(() => pending, /超过上限/)
706
+ assert.equal(req.destroyed, true)
707
+ })
708
+
709
+ test('readRawBody 连接中断 close 兜底 reject', async () => {
710
+ const req = new EventEmitter()
711
+ req.destroy = () => {}
712
+ req.readableEnded = false
713
+ const pending = readRawBody(req, 1024)
714
+ req.emit('close')
715
+ await assert.rejects(() => pending, /中断/)
716
+ })
717
+
718
+ test('readRawBody error 事件兜底 reject', async () => {
719
+ const req = new EventEmitter()
720
+ req.destroy = () => {}
721
+ const pending = readRawBody(req, 1024)
722
+ req.emit('error', new Error('socket reset'))
723
+ await assert.rejects(() => pending, /socket reset/)
724
+ })
725
+
726
+ test('readRawBody 正常聚合', async () => {
727
+ const req = new EventEmitter()
728
+ req.destroy = () => {}
729
+ req.readableEnded = false
730
+ const pending = readRawBody(req, 1024)
731
+ req.emit('data', Buffer.from('ab'))
732
+ req.emit('data', Buffer.from('cd'))
733
+ req.readableEnded = true
734
+ req.emit('end')
735
+ assert.equal((await pending).toString('utf8'), 'abcd')
736
+ })