@mzzsfy/dsh-turn-notify 0.12.0 → 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 +8 -2
- package/package.json +1 -1
- package/src/core.mjs +6 -8
- package/src/index.js +75 -35
- package/test/core.test.mjs +32 -16
- package/test/event-wiring.test.mjs +7 -7
- package/test/route.test.mjs +138 -0
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 消息)
|
|
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
|
-
|
|
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
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
|
|
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,
|
|
@@ -103,9 +103,51 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
103
103
|
})
|
|
104
104
|
|
|
105
105
|
// 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
|
|
106
|
-
const readSettings = (ctx) => {
|
|
106
|
+
const readSettings = (ctx, config) => {
|
|
107
107
|
const settings = ctx.get('settings')
|
|
108
|
-
|
|
108
|
+
// 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
|
|
109
|
+
// config 整节解包。方法面守卫兼防:服务在但缺 get 时回落默认配置,防路由 handler 抛错
|
|
110
|
+
return resolvedConfig(settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
|
|
114
|
+
// legacy 宿主 config 为普通对象原样透传
|
|
115
|
+
function unwrapVolatile(value) {
|
|
116
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构)。根级 volatile
|
|
120
|
+
// 包装:整节 live 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile
|
|
121
|
+
// 方法,特性检测原样返回
|
|
122
|
+
export const Config = typeof SETTINGS_SCHEMA.volatile === 'function' ? SETTINGS_SCHEMA.volatile() : SETTINGS_SCHEMA
|
|
123
|
+
|
|
124
|
+
// 0.1.7 写路径:configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
|
|
125
|
+
// 嵌套补丁(enabled.{category}/soundMapping.{category})须深合并:浅展开会把未提交
|
|
126
|
+
// 的兄弟分类键抹成 undefined;null 值原样落盘(清除语义,读侧 resolvedConfig 过滤)
|
|
127
|
+
async function persistPatch(ctx, patch) {
|
|
128
|
+
const settings = ctx.get('settings')
|
|
129
|
+
// legacy 方法面以 register 为准(0.1.7 的 settings.update 名义在场但按命名
|
|
130
|
+
// 空间找条目必败,不可作判据)
|
|
131
|
+
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
132
|
+
await settings.update(NAMESPACE, patch)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const editor = ctx.get('configEditor')
|
|
136
|
+
const entry = ctx.fiber?.entry
|
|
137
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('settings 服务不可用')
|
|
138
|
+
await editor.edit(entry, (current) => deepMerge(current, patch))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function deepMerge(under, over) {
|
|
142
|
+
const out = { ...under }
|
|
143
|
+
for (const key of Object.keys(over)) {
|
|
144
|
+
const next = over[key]
|
|
145
|
+
const underValue = out[key]
|
|
146
|
+
const bothPlain = (next !== null && typeof next === 'object' && !Array.isArray(next))
|
|
147
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
148
|
+
out[key] = bothPlain ? deepMerge(underValue, next) : next
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
109
151
|
}
|
|
110
152
|
|
|
111
153
|
function sendJson(res, status, payload) {
|
|
@@ -187,7 +229,7 @@ async function readSoundIndex() {
|
|
|
187
229
|
const writeSoundIndex = (index) => writeFile(soundIndexPath, JSON.stringify(index))
|
|
188
230
|
|
|
189
231
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
190
|
-
export function apply(ctx) {
|
|
232
|
+
export function apply(ctx, config) {
|
|
191
233
|
const projection = createProjection({})
|
|
192
234
|
// 打开回合:按会话 id 记回合序号与起始时间,回合结束即清;序号严格递增,
|
|
193
235
|
// 供子代理注册归属判定,同毫秒内多回合不受时钟精度歧义影响
|
|
@@ -241,7 +283,7 @@ export function apply(ctx) {
|
|
|
241
283
|
|
|
242
284
|
// durationMs 与 wakeTurn / awaitingChildren 由调用方在清理状态前读取后传入,防先删后读
|
|
243
285
|
function notifyUnit({ category, kind, reasonKind, session, durationMs = null, wakeTurn = false, awaitingChildren = false }) {
|
|
244
|
-
const settings = readSettings(ctx)
|
|
286
|
+
const settings = readSettings(ctx, config)
|
|
245
287
|
const header = session.header || {}
|
|
246
288
|
const sessionId = String(session.id ?? '')
|
|
247
289
|
if (!shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren })) return
|
|
@@ -404,21 +446,25 @@ export function apply(ctx) {
|
|
|
404
446
|
const category = mapEventToCategory(event.type, event.data)
|
|
405
447
|
if (category === null) return
|
|
406
448
|
const reasonKind = event.type === TURN_END_KIND && event.data.reason ? event.data.reason.kind : null
|
|
407
|
-
// tool/call
|
|
408
|
-
|
|
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
|
|
409
456
|
notifyUnit({ category, kind, reasonKind, session, ...(endedTurn ?? {}) })
|
|
410
457
|
})
|
|
411
458
|
|
|
412
|
-
// 审批 waterfall 观察者:next() 同步放行,通知异步投递
|
|
413
|
-
ctx.on('approval/request', createApprovalTap(
|
|
414
|
-
({ category }) => {
|
|
415
|
-
// waterfall 请求不携带会话对象,标题与工作区留空,仅分类与时间有效
|
|
416
|
-
notifyUnit({ category, kind: 'approval/request', reasonKind: category, session: { header: {} } })
|
|
417
|
-
},
|
|
418
|
-
(fn) => { fn() },
|
|
419
|
-
))
|
|
420
|
-
|
|
421
459
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
460
|
+
// 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支;
|
|
461
|
+
// 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
|
|
462
|
+
if (typeof settingsCtx.settings.register !== 'function') {
|
|
463
|
+
if (typeof settingsCtx.settings?.configure === 'function') {
|
|
464
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
465
|
+
}
|
|
466
|
+
return
|
|
467
|
+
}
|
|
422
468
|
settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
|
|
423
469
|
})
|
|
424
470
|
|
|
@@ -471,7 +517,7 @@ export function apply(ctx) {
|
|
|
471
517
|
if (query.has('cursor') && projection.version() === cursor) {
|
|
472
518
|
await projection.wait(cursor, LONG_POLL_WAIT_MS)
|
|
473
519
|
}
|
|
474
|
-
const settings = readSettings(ctx)
|
|
520
|
+
const settings = readSettings(ctx, config)
|
|
475
521
|
sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
|
|
476
522
|
} finally {
|
|
477
523
|
if (releaseBrowserPoll !== null) releaseBrowserPoll()
|
|
@@ -602,15 +648,14 @@ export function apply(ctx) {
|
|
|
602
648
|
await writeSoundIndex(index)
|
|
603
649
|
}
|
|
604
650
|
// 被引用即回落:深合并语义下引用置 null 清除,resolveSound 兜底回内置默认
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const current = readSettings(ctx)
|
|
651
|
+
if (ctx.get('settings')) {
|
|
652
|
+
const current = readSettings(ctx, config)
|
|
608
653
|
const patch = {}
|
|
609
654
|
for (const key of Object.keys(current.soundMapping)) {
|
|
610
655
|
if (current.soundMapping[key] === id) patch[key] = null
|
|
611
656
|
}
|
|
612
657
|
if (Object.keys(patch).length > 0) {
|
|
613
|
-
await
|
|
658
|
+
await persistPatch(ctx, { soundMapping: patch })
|
|
614
659
|
projection.bump()
|
|
615
660
|
}
|
|
616
661
|
}
|
|
@@ -636,15 +681,15 @@ export function apply(ctx) {
|
|
|
636
681
|
return
|
|
637
682
|
}
|
|
638
683
|
// 校验到写回整段入互斥域:防与 DELETE 清引用并发时把已删音效的 id 写回映射;
|
|
639
|
-
//
|
|
684
|
+
// 深合并无法删键,清除以 null 表达,读侧 resolvedConfig 过滤
|
|
640
685
|
await serializedSoundWrite(async () => {
|
|
641
686
|
if (!validateMappingId(id, (await listSounds()).map((sound) => sound.id))) {
|
|
642
687
|
sendJson(res, 400, { error: '未知音效: ' + id })
|
|
643
688
|
return
|
|
644
689
|
}
|
|
645
|
-
await
|
|
690
|
+
await persistPatch(ctx, { soundMapping: { [category]: id.length === 0 ? null : id } })
|
|
646
691
|
projection.bump()
|
|
647
|
-
sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx).soundMapping })
|
|
692
|
+
sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx, config).soundMapping })
|
|
648
693
|
})
|
|
649
694
|
}),
|
|
650
695
|
}), 'turn-notify mapping route')
|
|
@@ -657,7 +702,7 @@ export function apply(ctx) {
|
|
|
657
702
|
handler: async (req, res) => {
|
|
658
703
|
// imAvailable 不进 core 纯函数,config 响应处合流;实时判定,无装载时序假设
|
|
659
704
|
if (req.method === 'GET') {
|
|
660
|
-
sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
|
|
705
|
+
sendJson(res, 200, { ...publicConfig(readSettings(ctx, config)), imAvailable: imReady() })
|
|
661
706
|
return
|
|
662
707
|
}
|
|
663
708
|
if (req.method !== 'POST') {
|
|
@@ -671,14 +716,9 @@ export function apply(ctx) {
|
|
|
671
716
|
sendJson(res, 400, { error: verdict.reason })
|
|
672
717
|
return
|
|
673
718
|
}
|
|
674
|
-
|
|
675
|
-
if (!settings) {
|
|
676
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
677
|
-
return
|
|
678
|
-
}
|
|
679
|
-
await settings.update(NAMESPACE, verdict.patch)
|
|
719
|
+
await persistPatch(ctx, verdict.patch)
|
|
680
720
|
projection.bump()
|
|
681
|
-
sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
|
|
721
|
+
sendJson(res, 200, { ...publicConfig(readSettings(ctx, config)), imAvailable: imReady() })
|
|
682
722
|
} catch (error) {
|
|
683
723
|
sendError(res, error)
|
|
684
724
|
}
|
|
@@ -691,7 +731,7 @@ export function apply(ctx) {
|
|
|
691
731
|
path: '/api/turn-notify/test-webhook',
|
|
692
732
|
handler: route('POST', { crossOrigin: true }, async (req, res) => {
|
|
693
733
|
// 等待投递完成,真实结果随响应返回,测试按钮不再谎报
|
|
694
|
-
const result = await sendWebhook({ url: readSettings(ctx).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
|
|
734
|
+
const result = await sendWebhook({ url: readSettings(ctx, config).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
|
|
695
735
|
sendJson(res, 200, result)
|
|
696
736
|
}),
|
|
697
737
|
}), 'turn-notify test-webhook route')
|
|
@@ -755,7 +795,7 @@ export function apply(ctx) {
|
|
|
755
795
|
sendJson(res, 200, { ok: false, detail: 'dsh-im 未安装' })
|
|
756
796
|
return
|
|
757
797
|
}
|
|
758
|
-
const targets = normalizeImTargets(readSettings(ctx).imTargets)
|
|
798
|
+
const targets = normalizeImTargets(readSettings(ctx, config).imTargets)
|
|
759
799
|
if (targets.length === 0) {
|
|
760
800
|
sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
|
|
761
801
|
return
|
package/test/core.test.mjs
CHANGED
|
@@ -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:
|
|
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/
|
|
465
|
+
test('Given approval/asked 会话事件 When 到达 Then 投影恰好一条审批单元且 approval/request 无观察者', async () => {
|
|
466
466
|
const { ctx, routes, handlers } = makeCtx()
|
|
467
467
|
apply(ctx)
|
|
468
|
-
|
|
469
|
-
assert.equal(
|
|
470
|
-
const
|
|
471
|
-
|
|
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
|
|
512
|
-
|
|
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)
|
package/test/route.test.mjs
CHANGED
|
@@ -413,3 +413,141 @@ test('test-webhook 已配置时送达并返回真实结果', async () => {
|
|
|
413
413
|
assert.ok(String(calls[0].body.text).startsWith('[dsh]'), 'webhook payload 缺少 text')
|
|
414
414
|
} finally { globalThis.fetch = original }
|
|
415
415
|
})
|
|
416
|
+
|
|
417
|
+
// --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
|
|
418
|
+
|
|
419
|
+
test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', async () => {
|
|
420
|
+
const mod = await import('../src/index.js')
|
|
421
|
+
assert.ok(mod.Config, 'Config 导出必须在场')
|
|
422
|
+
const resolved = mod.Config['~standard'].validate({})
|
|
423
|
+
assert.equal(resolved.issues, undefined)
|
|
424
|
+
const section = resolved.value.get()
|
|
425
|
+
assert.equal(section.webhookUrl, '')
|
|
426
|
+
assert.equal(section.rootsOnly, true)
|
|
427
|
+
assert.equal(section.imEnabled, true)
|
|
428
|
+
assert.ok(section.enabled && typeof section.enabled === 'object')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// 0.1.7 形态装配:settings 无 register/get(宿主已移除),config 为整节单 ref 桩,
|
|
432
|
+
// configEditor 桩经深合并后原样写回
|
|
433
|
+
function makeRc17Ctx({ configStore = {}, configEditorAvailable = true } = {}) {
|
|
434
|
+
const routes = new Map()
|
|
435
|
+
const editCalls = []
|
|
436
|
+
const config = { get: () => ({ ...configStore }) }
|
|
437
|
+
const settingsService = {}
|
|
438
|
+
const configEditor = {
|
|
439
|
+
async edit(entry, change) {
|
|
440
|
+
editCalls.push(entry)
|
|
441
|
+
const merge = (under, over) => {
|
|
442
|
+
const out = { ...under }
|
|
443
|
+
for (const key of Object.keys(over)) {
|
|
444
|
+
const next = over[key]
|
|
445
|
+
const underValue = out[key]
|
|
446
|
+
const bothPlain = (next !== null && typeof next === 'object' && !Array.isArray(next))
|
|
447
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
448
|
+
out[key] = bothPlain ? merge(underValue, next) : next
|
|
449
|
+
}
|
|
450
|
+
return out
|
|
451
|
+
}
|
|
452
|
+
const next = merge({ ...configStore }, change({}, {}))
|
|
453
|
+
for (const key of Object.keys(next)) configStore[key] = next[key]
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
const configureCalls = []
|
|
457
|
+
const ctx = {
|
|
458
|
+
on() {},
|
|
459
|
+
fiber: { entry: { options: { id: 'turn-notify', name: '@mzzsfy/dsh-turn-notify' } } },
|
|
460
|
+
get(key) {
|
|
461
|
+
if (key === 'settings') return settingsService
|
|
462
|
+
if (key === 'configEditor') return configEditorAvailable ? configEditor : undefined
|
|
463
|
+
return undefined
|
|
464
|
+
},
|
|
465
|
+
inject(deps, fn) {
|
|
466
|
+
if (deps[0] === 'settings') {
|
|
467
|
+
fn({
|
|
468
|
+
settings: settingsService,
|
|
469
|
+
effect(stubEffect) {
|
|
470
|
+
const disposer = stubEffect()
|
|
471
|
+
if (typeof disposer === 'function') configureCalls.push(disposer)
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
effect(thunk) { thunk() },
|
|
477
|
+
webServer: { register(route) { routes.set(route.path, route.handler) } },
|
|
478
|
+
}
|
|
479
|
+
return { ctx, routes, config, configStore, editCalls, configureCalls }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
test('0.1.7 形态:config GET 读 config ref 节值,POST 经 configEditor.edit 深合并', async () => {
|
|
483
|
+
// Given config ref 携带用户值 + configEditor 桩
|
|
484
|
+
const { ctx, routes, config, configStore, editCalls } = makeRc17Ctx({
|
|
485
|
+
configStore: { enabled: { completed: false }, webhookUrl: 'https://hook.example' },
|
|
486
|
+
})
|
|
487
|
+
apply(ctx, config)
|
|
488
|
+
// When GET config
|
|
489
|
+
const read = makeRes()
|
|
490
|
+
await routes.get('/api/turn-notify/config')(makeReq('GET'), read)
|
|
491
|
+
// Then 返回 config 节值
|
|
492
|
+
assert.equal(read.body.enabled.completed, false)
|
|
493
|
+
assert.equal(read.body.webhookUrl, 'https://hook.example')
|
|
494
|
+
// When POST 嵌套补丁(仅关一个分类 + 改时长)
|
|
495
|
+
const saved = makeRes()
|
|
496
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { enabled: { error: false }, minTurnDurationMs: 1234 }, JSON_HEADERS), saved)
|
|
497
|
+
// Then 深合并落盘:未提交的兄弟分类保留,新键写入
|
|
498
|
+
assert.equal(saved.status, 200)
|
|
499
|
+
assert.equal(editCalls.length, 1)
|
|
500
|
+
assert.equal(editCalls[0].options.id, 'turn-notify')
|
|
501
|
+
assert.equal(configStore.enabled.completed, false, '既有分类开关保留')
|
|
502
|
+
assert.equal(configStore.enabled.error, false, '新分类开关写入')
|
|
503
|
+
assert.equal(configStore.minTurnDurationMs, 1234)
|
|
504
|
+
// When 重读
|
|
505
|
+
const reread = makeRes()
|
|
506
|
+
await routes.get('/api/turn-notify/config')(makeReq('GET'), reread)
|
|
507
|
+
// Then 回显合并后节值
|
|
508
|
+
assert.equal(reread.body.enabled.error, false)
|
|
509
|
+
assert.equal(reread.body.enabled.completed, false)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('0.1.7 形态:soundMapping 单分类写经 configEditor.edit 深合并,null 清除保留', async () => {
|
|
513
|
+
// Given 既有映射两个分类
|
|
514
|
+
const { ctx, routes, config, configStore } = makeRc17Ctx({
|
|
515
|
+
configStore: { soundMapping: { completed: 'ding', 'max-tokens': 'buzz' } },
|
|
516
|
+
})
|
|
517
|
+
apply(ctx, config)
|
|
518
|
+
// When POST 单分类改映射
|
|
519
|
+
const saved = makeRes()
|
|
520
|
+
await routes.get('/api/turn-notify/mapping')(makeReq('POST', { category: 'completed', id: 'bell' }, JSON_HEADERS), saved)
|
|
521
|
+
// Then 兄弟分类保留,目标分类更新
|
|
522
|
+
assert.equal(saved.status, 200)
|
|
523
|
+
assert.equal(configStore.soundMapping.completed, 'bell')
|
|
524
|
+
assert.equal(configStore.soundMapping['max-tokens'], 'buzz')
|
|
525
|
+
// When POST 空 id(清除语义,null 落盘)
|
|
526
|
+
await routes.get('/api/turn-notify/mapping')(makeReq('POST', { category: 'max-tokens', id: '' }, JSON_HEADERS), makeRes())
|
|
527
|
+
// Then null 原样落盘(读侧过滤)
|
|
528
|
+
assert.equal(configStore.soundMapping['max-tokens'], null)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
test('0.1.7 形态:configEditor 缺失即 500 拒写,不崩溃', async () => {
|
|
532
|
+
// Given configEditor 服务缺席
|
|
533
|
+
const { ctx, routes, config } = makeRc17Ctx({ configEditorAvailable: false })
|
|
534
|
+
apply(ctx, config)
|
|
535
|
+
// When POST 写配置
|
|
536
|
+
const denied = makeRes()
|
|
537
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { rootsOnly: false }, JSON_HEADERS), denied)
|
|
538
|
+
// Then 400 错误响应(sendError 归一,settings 服务不可用非 fs 错误),进程存活
|
|
539
|
+
assert.equal(denied.status, 400)
|
|
540
|
+
assert.ok(denied.body.error)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('0.1.7 形态:settings.configure 在场即关闭原生自动页,register 缺失不抛错', () => {
|
|
544
|
+
// Given settingsForms 带 configure 无 register
|
|
545
|
+
const configureCalls = []
|
|
546
|
+
const { ctx, config } = makeRc17Ctx()
|
|
547
|
+
ctx.get('settings').configure = (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} }
|
|
548
|
+
// When apply
|
|
549
|
+
apply(ctx, config)
|
|
550
|
+
// Then 关闭自动页且 owner 为本插件 fiber
|
|
551
|
+
assert.equal(configureCalls.length, 1)
|
|
552
|
+
assert.deepEqual(configureCalls[0].presentation, { auto: false })
|
|
553
|
+
})
|