@mzzsfy/dsh-turn-notify 0.12.1 → 0.12.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.
package/README.md CHANGED
@@ -152,7 +152,7 @@ turn-notify:
152
152
 
153
153
  按数据流向分五步:
154
154
 
155
- 1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
155
+ 1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)、`approval/asked` 审批事件(审批通知唯一信号:生产者先 append 后发 waterfall,会话事件流恒先达且不依赖应答方放行)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
156
156
  2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
157
157
  3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤),不依赖浏览器;宿主通知按浏览器在场(在途长轮询,来源不限本机)浏览器优先去重,离场补位;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
158
158
  4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
@@ -192,4 +192,10 @@ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依
192
192
 
193
193
  ## dsh 版本兼容
194
194
 
195
- 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:通知开关与播报形态(四 tab 计数、事件开关、音效卡试听、IM 自洽)、激活 live。0.1.6 控件数 13→19 属宿主演进,不构成不兼容。
195
+ - 0.2.0-rc.2:完全适配(装载 + 功能面逐格验证通过)
196
+ - 0.1.7-rc.2:不崩溃底线通过(全家桶 crash-only)
197
+ - 0.1.5-rc.3:未承诺(仅 shell-select 有宿主要求豁免;全家桶在该版本不启动,详见仓库 docs/兼容性测试/逐包测试进度.md)
198
+
199
+ 历史验收(Round 30,2026-09-27):0.1.7-rc.2 完全适配(设置 HTTP 写读闭环 deepMerge 语义 + 「消息通知」分区事件芯片渲染;11/11 判定面);0.1.5-rc.3 曾判不崩溃底线通过(已被 2026-09-30 复测推翻,见上);历史窗口 0.1.2-rc.1 全格通过(四 tab 计数/事件开关/音效卡试听/IM 自洽)。0.1.6 控件数 13→19 属宿主演进,不构成不兼容。
200
+
201
+ 更细的逐包结论见仓库 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
3
  "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook、IM 与宿主桌面通知;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.12.1",
4
+ "version": "0.12.2",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/core.mjs CHANGED
@@ -87,6 +87,11 @@ const IM_TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/
87
87
  export const isValidImBotId = (value) => typeof value === 'string' && IM_BOT_ID_PATTERN.test(value)
88
88
 
89
89
  const TURN_END_KIND = 'turn/end'
90
+ // 审批请求的会话事件类型(DSH SessionEventMap 官方定义:
91
+ // 'approval/asked': { id; toolName; callId?; reason? })。
92
+ // 审批通知唯一信号:生产者(dsh-user-approval request)先 append 后发 waterfall,
93
+ // 会话事件流恒先达且不依赖应答方放行;approval/request waterfall 只读不可靠,不作信号源。
94
+ export const APPROVAL_ASKED_KIND = 'approval/asked'
90
95
 
91
96
  // turn/end reason.kind 到通知分类的映射;未知 kind(插件可扩展)返回 null。
92
97
  const REASON_KIND_TO_CATEGORY = {
@@ -103,6 +108,7 @@ export function mapEventToCategory(type, data) {
103
108
  const kind = data && data.reason && data.reason.kind
104
109
  return Object.prototype.hasOwnProperty.call(REASON_KIND_TO_CATEGORY, kind) ? REASON_KIND_TO_CATEGORY[kind] : null
105
110
  }
111
+ if (type === APPROVAL_ASKED_KIND) return CATEGORY_APPROVAL
106
112
  if (type === 'tool/call' && data && data.name === 'ask_user_question') return CATEGORY_ASK
107
113
  return null
108
114
  }
@@ -468,14 +474,6 @@ export function readRawBody(req, maxBytes) {
468
474
  })
469
475
  }
470
476
 
471
- // 审批观察器包装:notify 异步投递,next() 同步立即放行,不阻塞 waterfall。
472
- export function createApprovalTap(notify, schedule) {
473
- return function approvalTap(_req, next) {
474
- schedule(() => notify({ category: CATEGORY_APPROVAL }))
475
- return next()
476
- }
477
- }
478
-
479
477
  // webhook 直发:未配置跳过;任何失败不抛出(fire-and-forget,不重试)。
480
478
  // 返回真实投递结果供测试按钮呈现,真实通知路径以 void 忽略。
481
479
  export async function sendWebhook({ url, payload, fetchImpl = fetch }) {
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-turn-notify Host 半区:回合通知单源决策。观察 session/event 与 approval/request,
1
+ // dsh-turn-notify Host 半区:回合通知单源决策。观察 session/event,
2
2
  // 命中分类即发 webhook、写入内存投影;webServer 路由供浏览器半区轮询投影与管理音效。
3
3
  // 音效持久化在 ~/.dsh/dsh-turn-notify/sounds/,投影不落盘。
4
4
 
@@ -9,6 +9,7 @@ import { homedir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
  import z from '@deepseek-ai/schemastery'
11
11
  import {
12
+ APPROVAL_ASKED_KIND,
12
13
  AUDIO_EXTS,
13
14
  CATEGORIES,
14
15
  CATEGORY_ASK,
@@ -21,7 +22,6 @@ import {
21
22
  buildWebhookPayload,
22
23
  collectAssistantTail,
23
24
  collectSessionEvents,
24
- createApprovalTap,
25
25
  createProjection,
26
26
  isSubagent,
27
27
  isSubagentWakeTurn,
@@ -446,20 +446,16 @@ export function apply(ctx, config) {
446
446
  const category = mapEventToCategory(event.type, event.data)
447
447
  if (category === null) return
448
448
  const reasonKind = event.type === TURN_END_KIND && event.data.reason ? event.data.reason.kind : null
449
- // tool/call 的提问事件与 turn/end 分类共用入口;提问通知不受碎轮过滤
450
- const kind = category === CATEGORY_ASK ? TOOL_CALL_KIND : TURN_END_KIND
449
+ // 即时送达类事件(tool/call 提问、approval/asked 审批)用自身 kind,
450
+ // 不落入 turn/end 的碎轮时长过滤;其余归类沿用 turn/end。
451
+ const kind = category === CATEGORY_ASK
452
+ ? TOOL_CALL_KIND
453
+ : category === CATEGORY_APPROVAL && event.type === APPROVAL_ASKED_KIND
454
+ ? APPROVAL_ASKED_KIND
455
+ : TURN_END_KIND
451
456
  notifyUnit({ category, kind, reasonKind, session, ...(endedTurn ?? {}) })
452
457
  })
453
458
 
454
- // 审批 waterfall 观察者:next() 同步放行,通知异步投递
455
- ctx.on('approval/request', createApprovalTap(
456
- ({ category }) => {
457
- // waterfall 请求不携带会话对象,标题与工作区留空,仅分类与时间有效
458
- notifyUnit({ category, kind: 'approval/request', reasonKind: category, session: { header: {} } })
459
- },
460
- (fn) => { fn() },
461
- ))
462
-
463
459
  ctx.inject(['settings'], (settingsCtx) => {
464
460
  // 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支;
465
461
  // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
@@ -2,6 +2,7 @@
2
2
  import test from 'node:test'
3
3
  import assert from 'node:assert/strict'
4
4
  import {
5
+ APPROVAL_ASKED_KIND,
5
6
  CATEGORIES,
6
7
  CATEGORY_DONE,
7
8
  CATEGORY_ERROR,
@@ -41,7 +42,6 @@ import {
41
42
  TITLE_MAX_CHARS,
42
43
  readRawBody,
43
44
  sessionTitle,
44
- createApprovalTap,
45
45
  sendWebhook,
46
46
  TONE_BELL,
47
47
  TONE_UP_ARPEGGIO,
@@ -129,6 +129,36 @@ test('非回合结束事件仅 ask_user_question tool/call 命中提问分类',
129
129
  assert.equal(mapEventToCategory('turn/start', {}), null)
130
130
  })
131
131
 
132
+ test('approval/asked 会话事件命中审批分类(宿主 waterfall 被 UI 独占时的唯一信号)', () => {
133
+ assert.equal(
134
+ mapEventToCategory('approval/asked', { id: 'a-1', toolName: 'bash', reason: 'escalate' }),
135
+ CATEGORY_APPROVAL,
136
+ )
137
+ // 同一命名空间下的其他事件不误判为审批
138
+ assert.equal(mapEventToCategory('approval/decided', { id: 'a-1', outcome: 'allowed-once' }), null)
139
+ assert.equal(mapEventToCategory('approval/policy', { policy: 'ask' }), null)
140
+ })
141
+
142
+ test('审批通知即时送达,不受最短回合时长过滤', () => {
143
+ const settings = { enabled: { approval: true }, rootsOnly: true, minTurnDurationMs: 5000 }
144
+ // 即使时长为 0(< 阈值),approval/asked 仍应放行
145
+ assert.equal(shouldNotify({
146
+ category: CATEGORY_APPROVAL,
147
+ kind: APPROVAL_ASKED_KIND,
148
+ durationMs: 0,
149
+ settings,
150
+ header: {},
151
+ }), true)
152
+ // 对照:turn/end 类仍受碎轮过滤
153
+ assert.equal(shouldNotify({
154
+ category: CATEGORY_DONE,
155
+ kind: 'turn/end',
156
+ durationMs: 0,
157
+ settings,
158
+ header: {},
159
+ }), false)
160
+ })
161
+
132
162
  test('rootsOnly 按子代理会话过滤', () => {
133
163
  const subByOrigin = { origin: 'subagent' }
134
164
  const subByDepth = { delegationDepth: 1 }
@@ -164,7 +194,7 @@ test('碎轮过滤仅作用于 turn/end 类,时长恰等边界放行', () => {
164
194
  category: CATEGORY_DONE, kind: 'turn/end', durationMs: MIN_TURN_MS, settings, header: top,
165
195
  }), true)
166
196
  assert.equal(shouldNotify({
167
- category: CATEGORY_APPROVAL, kind: 'approval/request', durationMs: null, settings, header: top,
197
+ category: CATEGORY_APPROVAL, kind: APPROVAL_ASKED_KIND, durationMs: null, settings, header: top,
168
198
  }), true)
169
199
  assert.equal(shouldNotify({
170
200
  category: CATEGORY_ASK, kind: 'tool/call', durationMs: null, settings, header: top,
@@ -547,20 +577,6 @@ test('标题提取:首个用户文本并截断', () => {
547
577
  assert.equal(sessionTitle([]), null)
548
578
  })
549
579
 
550
- test('审批观察器 next() 立即放行且只通知一次', () => {
551
- const notified = []
552
- const scheduled = []
553
- const tap = createApprovalTap((unit) => { notified.push(unit) }, (fn) => { scheduled.push(fn) })
554
- let nextCalled = 0
555
- const returned = tap({ toolName: 'bash' }, () => { nextCalled += 1; return 'next-result' })
556
- assert.equal(nextCalled, 1)
557
- assert.equal(returned, 'next-result')
558
- assert.equal(notified.length, 0)
559
- for (const fn of scheduled) fn()
560
- assert.equal(notified.length, 1)
561
- assert.equal(notified[0].category, CATEGORY_APPROVAL)
562
- })
563
-
564
580
  test('webhook 发送吞错不抛出', async () => {
565
581
  const calls = []
566
582
  const failing = async () => { calls.push(1); throw new Error('unreachable') }
@@ -462,13 +462,13 @@ test('Given 非提问的 tool/call When 事件到达 Then 不产生通知', asyn
462
462
  assert.equal(units.length, 0)
463
463
  })
464
464
 
465
- test('Given approval/request 观察者 When 触发 Then 投影审批单元且 next 同步放行', async () => {
465
+ test('Given approval/asked 会话事件 When 到达 Then 投影恰好一条审批单元且 approval/request 无观察者', async () => {
466
466
  const { ctx, routes, handlers } = makeCtx()
467
467
  apply(ctx)
468
- const tap = handlers.get('approval/request')
469
- assert.equal(typeof tap, 'function')
470
- const returned = tap({}, () => 'next-value')
471
- assert.equal(returned, 'next-value')
468
+ // 回归锁定:审批通知单源为 session/event,waterfall 观察者不得回潮(双路重复送达)
469
+ assert.equal(handlers.get('approval/request'), undefined)
470
+ const onEvent = handlers.get('session/event')
471
+ onEvent(MAIN, { type: 'approval/asked', data: { id: 'a-1', toolName: 'bash' } })
472
472
  const units = await projectionUnits(routes)
473
473
  assert.equal(units.filter((unit) => unit.category === 'approval').length, 1)
474
474
  })
@@ -508,8 +508,8 @@ test('Given 会话无事件日志可读 When 通知投递 Then 不抛并回落
508
508
  sessionTitle: { get: () => { throw new Error('no event log') } },
509
509
  })
510
510
  apply(ctx)
511
- const tap = handlers.get('approval/request')
512
- tap({}, () => 'next')
511
+ const onEvent = handlers.get('session/event')
512
+ onEvent(MAIN, { type: 'approval/asked', data: { id: 'a-1', toolName: 'bash' } })
513
513
  const units = await projectionUnits(routes)
514
514
  const approval = units.find((unit) => unit.category === 'approval')
515
515
  assert.equal(approval.session, null)