@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,585 @@
1
+ // host 事件接线集成测试:stub ctx 装载真实 src/index.js,伪造 session/event 序列,
2
+ // 经投影路由断言子代理回执静默的标记/消费/清理时序与异常分类放行。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { EventEmitter } from 'node:events'
6
+
7
+ import { apply } from '../src/index.js'
8
+
9
+ function makeRes() {
10
+ return {
11
+ status: null,
12
+ body: null,
13
+ writeHead(status) { this.status = status },
14
+ end(body) { this.body = JSON.parse(body) },
15
+ }
16
+ }
17
+
18
+ function makeReq(method, payload, headers) {
19
+ const req = new EventEmitter()
20
+ req.method = method
21
+ req.url = '/api/turn-notify/config'
22
+ req.headers = { host: '127.0.0.1:3080', ...(headers || {}) }
23
+ const data = payload === undefined ? null : Buffer.from(JSON.stringify(payload))
24
+ process.nextTick(() => {
25
+ if (data !== null) req.emit('data', data)
26
+ req.readableEnded = true
27
+ req.emit('end')
28
+ })
29
+ return req
30
+ }
31
+
32
+ const JSON_HEADERS = { 'content-type': 'application/json' }
33
+
34
+ function makeSettings() {
35
+ const doc = new Map()
36
+ return {
37
+ register(ns) {
38
+ if (!doc.has(ns)) doc.set(ns, {})
39
+ return { get: () => doc.get(ns), watch: () => () => {} }
40
+ },
41
+ get: (ns) => doc.get(ns),
42
+ update: async (ns, patch) => { doc.set(ns, { ...(doc.get(ns) ?? {}), ...patch }) },
43
+ }
44
+ }
45
+
46
+ function makeCtx(extraServices) {
47
+ const routes = new Map()
48
+ const handlers = new Map()
49
+ const settingsService = makeSettings()
50
+ const ctx = {
51
+ on(event, fn) { handlers.set(event, fn) },
52
+ get(key) {
53
+ if (key === 'settings') return settingsService
54
+ return extraServices ? extraServices[key] : undefined
55
+ },
56
+ inject(deps, fn) { fn({ settings: settingsService }) },
57
+ effect(thunk) { thunk() },
58
+ webServer: { register(route) { routes.set(route.path, route.handler) } },
59
+ }
60
+ return { ctx, routes, handlers }
61
+ }
62
+
63
+ const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve))
64
+
65
+ function makeImDshIm(sends, failTargetId) {
66
+ return {
67
+ send: async (botId, targetId, text) => {
68
+ sends.push({ botId, targetId, text })
69
+ if (targetId === failTargetId) throw Object.assign(new Error('offline'), { code: 'bot-not-connected' })
70
+ return { sent: true }
71
+ },
72
+ listTargets: async () => [],
73
+ }
74
+ }
75
+
76
+ async function configureImTargets(routes, targets) {
77
+ const res = makeRes()
78
+ await routes.get('/api/turn-notify/config')(makeReq('POST', { imTargets: targets }, JSON_HEADERS), res)
79
+ assert.equal(res.status, 200)
80
+ }
81
+
82
+ const MAIN = { id: 'M', header: { delegationDepth: 0 } }
83
+ const CHILD = { id: 'S', header: { origin: 'subagent', parentSession: 'M', delegationDepth: 1 } }
84
+
85
+ function turnEnd(kind) { return { type: 'turn/end', data: { reason: { kind } } } }
86
+ function toolCall(name) { return { type: 'tool/call', data: { name } } }
87
+
88
+ // 后台委托场景:主回合内调用 subagent 工具,子代理随后开跑
89
+ function backgroundDelegationStart(onEvent) {
90
+ onEvent(MAIN, toolCall('subagent'))
91
+ onEvent(CHILD, { type: 'turn/start' })
92
+ }
93
+
94
+ // 关闭碎轮过滤:同毫秒回合不被时长过滤,聚焦事件接线本身
95
+ async function disableDurationFilter(routes) {
96
+ const res = makeRes()
97
+ await routes.get('/api/turn-notify/config')(makeReq('POST', { minTurnDurationMs: 0 }, JSON_HEADERS), res)
98
+ assert.equal(res.status, 200)
99
+ }
100
+
101
+ async function projectionUnits(routes) {
102
+ const res = makeRes()
103
+ await routes.get('/api/turn-notify/projection')(makeReq('GET'), res)
104
+ assert.equal(res.status, 200)
105
+ return res.body.units
106
+ }
107
+
108
+ test('Given 子代理刚结束 When 父会话唤醒回合以 completed 结束 Then 投影无完成通知', async () => {
109
+ const { ctx, routes, handlers } = makeCtx()
110
+ apply(ctx)
111
+ await disableDurationFilter(routes)
112
+ const onEvent = handlers.get('session/event')
113
+ onEvent(CHILD, turnEnd('completed'))
114
+ onEvent(MAIN, { type: 'turn/start' })
115
+ onEvent(MAIN, turnEnd('completed'))
116
+ const units = await projectionUnits(routes)
117
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
118
+ })
119
+
120
+ test('Given 唤醒回合已消费 When 窗口内父会话再开新回合完成 Then 通知恢复', async () => {
121
+ const { ctx, routes, handlers } = makeCtx()
122
+ apply(ctx)
123
+ await disableDurationFilter(routes)
124
+ const onEvent = handlers.get('session/event')
125
+ onEvent(CHILD, turnEnd('completed'))
126
+ // 第一个唤醒回合:被静默,记录被消费
127
+ onEvent(MAIN, { type: 'turn/start' })
128
+ onEvent(MAIN, turnEnd('completed'))
129
+ // 第二个回合:窗口内但记录已消费,正常通知
130
+ onEvent(MAIN, { type: 'turn/start' })
131
+ onEvent(MAIN, turnEnd('completed'))
132
+ const units = await projectionUnits(routes)
133
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
134
+ })
135
+
136
+ test('Given 无子代理事件 When 普通父会话回合完成 Then 正常通知不误伤', async () => {
137
+ const { ctx, routes, handlers } = makeCtx()
138
+ apply(ctx)
139
+ await disableDurationFilter(routes)
140
+ const onEvent = handlers.get('session/event')
141
+ onEvent(MAIN, { type: 'turn/start' })
142
+ onEvent(MAIN, turnEnd('completed'))
143
+ const units = await projectionUnits(routes)
144
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
145
+ })
146
+
147
+ test('Given 唤醒回合 When 以 error 结束 Then 异常分类仍通知', async () => {
148
+ const { ctx, routes, handlers } = makeCtx()
149
+ apply(ctx)
150
+ await disableDurationFilter(routes)
151
+ const onEvent = handlers.get('session/event')
152
+ onEvent(CHILD, turnEnd('completed'))
153
+ onEvent(MAIN, { type: 'turn/start' })
154
+ onEvent(MAIN, turnEnd('error'))
155
+ const units = await projectionUnits(routes)
156
+ assert.equal(units.filter((unit) => unit.category === 'error').length, 1)
157
+ })
158
+
159
+ test('Given 父会话已暂停收尾 When 子代理随后完成并唤醒父会话 Then 唤醒回合 completed 被静默', async () => {
160
+ const { ctx, routes, handlers } = makeCtx()
161
+ apply(ctx)
162
+ await disableDurationFilter(routes)
163
+ const onEvent = handlers.get('session/event')
164
+ // 暂停前的正常回合:正常通知
165
+ onEvent(MAIN, { type: 'turn/start' })
166
+ onEvent(MAIN, turnEnd('completed'))
167
+ // 父会话已收尾后子代理完成,唤醒父会话:该回执回合被静默
168
+ onEvent(CHILD, turnEnd('completed'))
169
+ onEvent(MAIN, { type: 'turn/start' })
170
+ onEvent(MAIN, turnEnd('completed'))
171
+ const units = await projectionUnits(routes)
172
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
173
+ })
174
+
175
+ test('Given 后台子代理运行中 When 主会话回合以 completed 结束 Then 无完成通知', async () => {
176
+ const { ctx, routes, handlers } = makeCtx()
177
+ apply(ctx)
178
+ await disableDurationFilter(routes)
179
+ const onEvent = handlers.get('session/event')
180
+ onEvent(MAIN, { type: 'turn/start' })
181
+ backgroundDelegationStart(onEvent)
182
+ onEvent(MAIN, turnEnd('completed'))
183
+ const units = await projectionUnits(routes)
184
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
185
+ })
186
+
187
+ test('Given subagent 已调用但子代理 turn/start 未到 When 主会话回合完成 Then 无完成通知', async () => {
188
+ const { ctx, routes, handlers } = makeCtx()
189
+ apply(ctx)
190
+ await disableDurationFilter(routes)
191
+ const onEvent = handlers.get('session/event')
192
+ onEvent(MAIN, { type: 'turn/start' })
193
+ onEvent(MAIN, toolCall('subagent'))
194
+ onEvent(MAIN, turnEnd('completed'))
195
+ const units = await projectionUnits(routes)
196
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
197
+ })
198
+
199
+ test('Given 前台子代理已在本回合内收尾 When 主会话回合完成 Then 正常通知', async () => {
200
+ const { ctx, routes, handlers } = makeCtx()
201
+ apply(ctx)
202
+ await disableDurationFilter(routes)
203
+ const onEvent = handlers.get('session/event')
204
+ onEvent(MAIN, { type: 'turn/start' })
205
+ onEvent(MAIN, toolCall('subagent'))
206
+ onEvent(CHILD, { type: 'turn/start' })
207
+ onEvent(CHILD, turnEnd('completed'))
208
+ onEvent(MAIN, turnEnd('completed'))
209
+ const units = await projectionUnits(routes)
210
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
211
+ })
212
+
213
+ test('Given 混合委托前台已收尾后台仍挂起 When 主会话回合完成 Then 无完成通知', async () => {
214
+ const { ctx, routes, handlers } = makeCtx()
215
+ apply(ctx)
216
+ await disableDurationFilter(routes)
217
+ const onEvent = handlers.get('session/event')
218
+ onEvent(MAIN, { type: 'turn/start' })
219
+ // 前台委托:子代理在本回合内收尾
220
+ onEvent(MAIN, toolCall('subagent'))
221
+ onEvent(CHILD, { type: 'turn/start' })
222
+ onEvent(CHILD, turnEnd('completed'))
223
+ // 随后再发起后台委托,子代理 turn/start 晚于主回合结束(启动竞态)
224
+ onEvent(MAIN, toolCall('subagent'))
225
+ onEvent(MAIN, turnEnd('completed'))
226
+ const units = await projectionUnits(routes)
227
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
228
+ })
229
+
230
+ test('Given 两个前台子代理均在本回合内收尾 When 主会话回合完成 Then 正常通知', async () => {
231
+ const { ctx, routes, handlers } = makeCtx()
232
+ apply(ctx)
233
+ await disableDurationFilter(routes)
234
+ const onEvent = handlers.get('session/event')
235
+ const CHILD2 = { id: 'S2', header: { origin: 'subagent', parentSession: 'M', delegationDepth: 1 } }
236
+ onEvent(MAIN, { type: 'turn/start' })
237
+ onEvent(MAIN, toolCall('subagent'))
238
+ onEvent(CHILD, { type: 'turn/start' })
239
+ onEvent(CHILD, turnEnd('completed'))
240
+ onEvent(MAIN, toolCall('subagent'))
241
+ onEvent(CHILD2, { type: 'turn/start' })
242
+ onEvent(CHILD2, turnEnd('completed'))
243
+ onEvent(MAIN, turnEnd('completed'))
244
+ const units = await projectionUnits(routes)
245
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
246
+ })
247
+
248
+ test('Given 后台子代理运行中 When 主会话回合以 error 结束 Then 异常分类仍通知', async () => {
249
+ const { ctx, routes, handlers } = makeCtx()
250
+ apply(ctx)
251
+ await disableDurationFilter(routes)
252
+ const onEvent = handlers.get('session/event')
253
+ onEvent(MAIN, { type: 'turn/start' })
254
+ backgroundDelegationStart(onEvent)
255
+ onEvent(MAIN, turnEnd('error'))
256
+ const units = await projectionUnits(routes)
257
+ assert.equal(units.filter((unit) => unit.category === 'error').length, 1)
258
+ })
259
+
260
+ test('Given 后台子代理运行中 When 开关关闭且主会话回合完成 Then 通知恢复', async () => {
261
+ const { ctx, routes, handlers } = makeCtx()
262
+ apply(ctx)
263
+ await disableDurationFilter(routes)
264
+ const config = makeRes()
265
+ await routes.get('/api/turn-notify/config')(makeReq('POST', { suppressSubagentWake: false }, JSON_HEADERS), config)
266
+ assert.equal(config.status, 200)
267
+ const onEvent = handlers.get('session/event')
268
+ onEvent(MAIN, { type: 'turn/start' })
269
+ backgroundDelegationStart(onEvent)
270
+ onEvent(MAIN, turnEnd('completed'))
271
+ const units = await projectionUnits(routes)
272
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
273
+ })
274
+
275
+ test('Given 子代理完成后主会话开启全新回合 When 该回合完成 Then 正常通知不误伤', async () => {
276
+ const { ctx, routes, handlers } = makeCtx()
277
+ apply(ctx)
278
+ await disableDurationFilter(routes)
279
+ const onEvent = handlers.get('session/event')
280
+ // 后台委托回合:静默
281
+ onEvent(MAIN, { type: 'turn/start' })
282
+ backgroundDelegationStart(onEvent)
283
+ onEvent(MAIN, turnEnd('completed'))
284
+ // 子代理收尾触发既有 childDoneAt 记录,随后被新回合消费为唤醒静默;
285
+ // 再下一个回合与子代理无关,须恢复通知
286
+ onEvent(CHILD, turnEnd('completed'))
287
+ onEvent(MAIN, { type: 'turn/start' })
288
+ onEvent(MAIN, turnEnd('completed'))
289
+ onEvent(MAIN, { type: 'turn/start' })
290
+ onEvent(MAIN, turnEnd('completed'))
291
+ const units = await projectionUnits(routes)
292
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
293
+ })
294
+
295
+ test('Given 等待回合已静默 When 子代理收尾后唤醒回合完成 Then 两条均不通知', async () => {
296
+ const { ctx, routes, handlers } = makeCtx()
297
+ apply(ctx)
298
+ await disableDurationFilter(routes)
299
+ const onEvent = handlers.get('session/event')
300
+ onEvent(MAIN, { type: 'turn/start' })
301
+ backgroundDelegationStart(onEvent)
302
+ onEvent(MAIN, turnEnd('completed'))
303
+ onEvent(CHILD, turnEnd('completed'))
304
+ onEvent(MAIN, { type: 'turn/start' })
305
+ onEvent(MAIN, turnEnd('completed'))
306
+ const units = await projectionUnits(routes)
307
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
308
+ })
309
+
310
+ test('Given 上一回合后台子代理跨回合存活 When 新回合无委托并以 completed 结束 Then 无完成通知', async () => {
311
+ const { ctx, routes, handlers } = makeCtx()
312
+ apply(ctx)
313
+ await disableDurationFilter(routes)
314
+ const onEvent = handlers.get('session/event')
315
+ // 回合1:后台委托后挂起,子代理未收尾
316
+ onEvent(MAIN, { type: 'turn/start' })
317
+ backgroundDelegationStart(onEvent)
318
+ onEvent(MAIN, turnEnd('completed'))
319
+ // 回合2:无任何委托,陈旧子代理仍存活
320
+ onEvent(MAIN, { type: 'turn/start' })
321
+ onEvent(MAIN, turnEnd('completed'))
322
+ const units = await projectionUnits(routes)
323
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
324
+ })
325
+
326
+ test('Given 上一回合后台子代理在新回合内收尾 When 新回合委托的子代理未启动并以 completed 结束 Then 无完成通知', async () => {
327
+ const { ctx, routes, handlers } = makeCtx()
328
+ apply(ctx)
329
+ await disableDurationFilter(routes)
330
+ const onEvent = handlers.get('session/event')
331
+ // 回合1:后台委托后挂起
332
+ onEvent(MAIN, { type: 'turn/start' })
333
+ backgroundDelegationStart(onEvent)
334
+ onEvent(MAIN, turnEnd('completed'))
335
+ // 回合2:再次委托(子代理 turn/start 迟到),陈旧子代理在本回合内收尾
336
+ onEvent(MAIN, { type: 'turn/start' })
337
+ onEvent(MAIN, toolCall('subagent'))
338
+ onEvent(CHILD, turnEnd('completed'))
339
+ onEvent(MAIN, turnEnd('completed'))
340
+ const units = await projectionUnits(routes)
341
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
342
+ })
343
+
344
+ test('Given 子代理注册晚于父回合结束 When 其在新回合委托期间收尾 Then 该收尾不豁免本回合委托', async () => {
345
+ const { ctx, routes, handlers } = makeCtx()
346
+ apply(ctx)
347
+ await disableDurationFilter(routes)
348
+ const onEvent = handlers.get('session/event')
349
+ // 回合1:委托后父回合先结束,子代理 turn/start 迟到(注册时父回合已无开启记录)
350
+ onEvent(MAIN, { type: 'turn/start' })
351
+ onEvent(MAIN, toolCall('subagent'))
352
+ onEvent(MAIN, turnEnd('completed'))
353
+ onEvent(CHILD, { type: 'turn/start' })
354
+ // 回合2:再次委托(子代理未启动),迟到的子代理在本回合内收尾
355
+ onEvent(MAIN, { type: 'turn/start' })
356
+ onEvent(MAIN, toolCall('subagent'))
357
+ onEvent(CHILD, turnEnd('completed'))
358
+ onEvent(MAIN, turnEnd('completed'))
359
+ const units = await projectionUnits(routes)
360
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
361
+ })
362
+
363
+ test('Given dshIm 在场且已配多目标 When 回合完成 Then 逐目标投递 unit.text', async () => {
364
+ const sends = []
365
+ const { ctx, routes, handlers } = makeCtx({ dshIm: makeImDshIm(sends) })
366
+ apply(ctx)
367
+ await disableDurationFilter(routes)
368
+ await configureImTargets(routes, [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'group' }])
369
+ const onEvent = handlers.get('session/event')
370
+ onEvent(MAIN, { type: 'turn/start' })
371
+ onEvent(MAIN, turnEnd('completed'))
372
+ await flushMicrotasks()
373
+ assert.equal(sends.length, 2)
374
+ assert.deepEqual(sends.map((call) => call.targetId).sort(), ['group', 'owner'])
375
+ assert.ok(sends.every((call) => call.botId === 'wx_a' || call.botId === 'wx_b'))
376
+ assert.ok(sends.every((call) => String(call.text).startsWith('[dsh]')), 'IM 文本应与通知单元一致')
377
+ })
378
+
379
+ test('Given send 拒绝 When 回合完成 Then 无未处理拒绝且投影照常', async () => {
380
+ const rejections = []
381
+ const onUnhandled = (reason) => rejections.push(reason)
382
+ process.on('unhandledRejection', onUnhandled)
383
+ try {
384
+ const sends = []
385
+ const { ctx, routes, handlers } = makeCtx({ dshIm: makeImDshIm(sends, 'bad') })
386
+ apply(ctx)
387
+ await disableDurationFilter(routes)
388
+ await configureImTargets(routes, [{ botId: 'wx_a', targetId: 'bad' }])
389
+ const onEvent = handlers.get('session/event')
390
+ onEvent(MAIN, { type: 'turn/start' })
391
+ onEvent(MAIN, turnEnd('completed'))
392
+ await flushMicrotasks()
393
+ await flushMicrotasks()
394
+ assert.equal(rejections.length, 0)
395
+ const units = await projectionUnits(routes)
396
+ assert.equal(units.length, 1)
397
+ } finally { process.off('unhandledRejection', onUnhandled) }
398
+ })
399
+
400
+ test('Given dshIm 缺席或未配目标 When 回合完成 Then 不投递且流程无感', async () => {
401
+ const { ctx, routes, handlers } = makeCtx()
402
+ apply(ctx)
403
+ await disableDurationFilter(routes)
404
+ const onEvent = handlers.get('session/event')
405
+ onEvent(MAIN, { type: 'turn/start' })
406
+ onEvent(MAIN, turnEnd('completed'))
407
+ const units = await projectionUnits(routes)
408
+ assert.equal(units.length, 1)
409
+ const sends = []
410
+ const second = makeCtx({ dshIm: makeImDshIm(sends) })
411
+ apply(second.ctx)
412
+ await disableDurationFilter(second.routes)
413
+ const onEvent2 = second.handlers.get('session/event')
414
+ onEvent2(MAIN, { type: 'turn/start' })
415
+ onEvent2(MAIN, turnEnd('completed'))
416
+ await flushMicrotasks()
417
+ assert.equal(sends.length, 0)
418
+ })
419
+
420
+ test('Given 默认碎轮过滤未关闭 When 同毫秒回合完成 Then 碎轮被滤而提问通知放行', async () => {
421
+ const { ctx, routes, handlers } = makeCtx()
422
+ apply(ctx)
423
+ const onEvent = handlers.get('session/event')
424
+ // 同毫秒完成的回合:时长不足被滤
425
+ onEvent(MAIN, { type: 'turn/start' })
426
+ onEvent(MAIN, turnEnd('completed'))
427
+ // 提问通知不受碎轮过滤
428
+ onEvent(MAIN, { type: 'tool/call', data: { name: 'ask_user_question' } })
429
+ const units = await projectionUnits(routes)
430
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 0)
431
+ assert.equal(units.filter((unit) => unit.category === 'ask').length, 1)
432
+ })
433
+
434
+ test('Given 非提问的 tool/call When 事件到达 Then 不产生通知', async () => {
435
+ const { ctx, routes, handlers } = makeCtx()
436
+ apply(ctx)
437
+ const onEvent = handlers.get('session/event')
438
+ onEvent(MAIN, { type: 'tool/call', data: { name: 'read_file' } })
439
+ const units = await projectionUnits(routes)
440
+ assert.equal(units.length, 0)
441
+ })
442
+
443
+ test('Given approval/request 观察者 When 触发 Then 投影审批单元且 next 同步放行', async () => {
444
+ const { ctx, routes, handlers } = makeCtx()
445
+ apply(ctx)
446
+ const tap = handlers.get('approval/request')
447
+ assert.equal(typeof tap, 'function')
448
+ const returned = tap({}, () => 'next-value')
449
+ assert.equal(returned, 'next-value')
450
+ const units = await projectionUnits(routes)
451
+ assert.equal(units.filter((unit) => unit.category === 'approval').length, 1)
452
+ })
453
+
454
+ // ---- 会话显示标题:与侧边栏行标题同源(session-title 投影),文本匹配高亮才成立 ----
455
+
456
+ const userMessage = (text) => ({ type: 'user/message', data: { content: [{ type: 'text', text }] } })
457
+
458
+ test('Given sessionTitle 服务在位 When 通知投递 Then 单元标题为会话标题而非首条消息', async () => {
459
+ const { ctx, routes, handlers } = makeCtx({
460
+ sessionTitle: { get: () => ({ title: '会话真标题' }) },
461
+ })
462
+ apply(ctx)
463
+ await disableDurationFilter(routes)
464
+ const onEvent = handlers.get('session/event')
465
+ onEvent(MAIN, userMessage('首条消息文本'))
466
+ onEvent(MAIN, toolCall('ask_user_question'))
467
+ const units = await projectionUnits(routes)
468
+ const ask = units.find((unit) => unit.category === 'ask')
469
+ assert.equal(ask.session, '会话真标题')
470
+ })
471
+
472
+ test('Given sessionTitle 服务缺失 When 通知投递 Then 回落首条消息文本', async () => {
473
+ const { ctx, routes, handlers } = makeCtx()
474
+ apply(ctx)
475
+ await disableDurationFilter(routes)
476
+ const onEvent = handlers.get('session/event')
477
+ onEvent(MAIN, userMessage('首条消息文本'))
478
+ onEvent(MAIN, toolCall('ask_user_question'))
479
+ const units = await projectionUnits(routes)
480
+ const ask = units.find((unit) => unit.category === 'ask')
481
+ assert.equal(ask.session, '首条消息文本')
482
+ })
483
+
484
+ test('Given 会话无事件日志可读 When 通知投递 Then 不抛并回落可用标题', async () => {
485
+ const { ctx, routes, handlers } = makeCtx({
486
+ sessionTitle: { get: () => { throw new Error('no event log') } },
487
+ })
488
+ apply(ctx)
489
+ const tap = handlers.get('approval/request')
490
+ tap({}, () => 'next')
491
+ const units = await projectionUnits(routes)
492
+ const approval = units.find((unit) => unit.category === 'approval')
493
+ assert.equal(approval.session, null)
494
+ })
495
+
496
+ test('Given 已配置 webhook When 真实回合完成 Then fetch 收到 payload 形态', async () => {
497
+ const calls = []
498
+ const original = globalThis.fetch
499
+ globalThis.fetch = async (url, options) => {
500
+ calls.push({ url, body: JSON.parse(options.body) })
501
+ return { ok: true, status: 200 }
502
+ }
503
+ try {
504
+ const { ctx, routes, handlers } = makeCtx()
505
+ apply(ctx)
506
+ await disableDurationFilter(routes)
507
+ const config = makeRes()
508
+ await routes.get('/api/turn-notify/config')(makeReq('POST', { webhookUrl: 'https://hook.example' }, JSON_HEADERS), config)
509
+ assert.equal(config.status, 200)
510
+ const onEvent = handlers.get('session/event')
511
+ onEvent(MAIN, { type: 'turn/start' })
512
+ onEvent(MAIN, turnEnd('completed'))
513
+ await flushMicrotasks()
514
+ assert.equal(calls.length, 1)
515
+ assert.equal(calls[0].url, 'https://hook.example')
516
+ assert.equal(calls[0].body.category, 'completed')
517
+ assert.ok(String(calls[0].body.event).startsWith('n-'), 'event 应为通知 id')
518
+ assert.ok(String(calls[0].body.text).startsWith('[dsh]'), 'webhook text 应与通知单元一致')
519
+ } finally { globalThis.fetch = original }
520
+ })
521
+
522
+ function projectionRequest(query) {
523
+ const res = makeRes()
524
+ const req = makeReq('GET')
525
+ req.url = '/api/turn-notify/projection' + query
526
+ return { req, res }
527
+ }
528
+
529
+ test('Given 无 cursor 的首拉请求 When 投影为空 Then 立即返回空投影与当前版本', async () => {
530
+ const { ctx, routes } = makeCtx()
531
+ apply(ctx)
532
+ const { req, res } = projectionRequest('')
533
+ await routes.get('/api/turn-notify/projection')(req, res)
534
+ assert.equal(res.status, 200)
535
+ assert.deepEqual(res.body.units, [])
536
+ assert.equal(res.body.version, 0)
537
+ })
538
+
539
+ test('Given cursor 追平当前版本 When 新事件 push Then 挂起被唤醒并返回新投影', async () => {
540
+ const { ctx, routes, handlers } = makeCtx()
541
+ apply(ctx)
542
+ await disableDurationFilter(routes)
543
+ // config POST 成功即 bump,当前版本为 1;cursor 追平后请求挂起
544
+ const { req, res } = projectionRequest('?cursor=1')
545
+ const pending = routes.get('/api/turn-notify/projection')(req, res)
546
+ await flushMicrotasks()
547
+ assert.equal(res.body, null, '未唤醒前不应响应')
548
+ const onEvent = handlers.get('session/event')
549
+ onEvent(MAIN, { type: 'turn/start' })
550
+ onEvent(MAIN, turnEnd('completed'))
551
+ await pending
552
+ assert.equal(res.status, 200)
553
+ assert.equal(res.body.version, 2, 'push 应递增版本并唤醒等待者')
554
+ assert.equal(res.body.units.filter((unit) => unit.category === 'completed').length, 1)
555
+ })
556
+
557
+ test('Given cursor 落后当前版本 When 请求到达 Then 立即返回无需挂起', async () => {
558
+ const { ctx, routes, handlers } = makeCtx()
559
+ apply(ctx)
560
+ await disableDurationFilter(routes)
561
+ const onEvent = handlers.get('session/event')
562
+ onEvent(MAIN, { type: 'turn/start' })
563
+ onEvent(MAIN, turnEnd('completed'))
564
+ const { req, res } = projectionRequest('?cursor=0')
565
+ await routes.get('/api/turn-notify/projection')(req, res)
566
+ assert.equal(res.status, 200)
567
+ assert.equal(res.body.units.length, 1)
568
+ assert.ok(res.body.version > 0)
569
+ })
570
+
571
+ test('Given 配置 POST 成功 When 长轮询挂起 Then bump 唤醒返回新版本', async () => {
572
+ const { ctx, routes } = makeCtx()
573
+ apply(ctx)
574
+ const { req, res } = projectionRequest('?cursor=0')
575
+ const pending = routes.get('/api/turn-notify/projection')(req, res)
576
+ await flushMicrotasks()
577
+ assert.equal(res.body, null, '未唤醒前不应响应')
578
+ const config = makeRes()
579
+ await routes.get('/api/turn-notify/config')(makeReq('POST', { suppressSubagentWake: false }, JSON_HEADERS), config)
580
+ assert.equal(config.status, 200)
581
+ await pending
582
+ assert.equal(res.status, 200)
583
+ assert.ok(res.body.version > 0, '配置变更应唤醒挂起连接')
584
+ assert.equal(config.body.suppressSubagentWake, false)
585
+ })