@mzzsfy/dsh-turn-notify 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // 命中分类即发 webhook、写入内存投影;webServer 路由供浏览器半区轮询投影与管理音效。
3
3
  // 音效持久化在 ~/.dsh/dsh-turn-notify/sounds/,投影不落盘。
4
4
 
5
+ import { execFile } from 'node:child_process'
5
6
  import { createHash } from 'node:crypto'
6
7
  import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
7
8
  import { homedir } from 'node:os'
@@ -25,6 +26,9 @@ import {
25
26
  isSubagent,
26
27
  isSubagentWakeTurn,
27
28
  isValidImBotId,
29
+ hostNotifyWanted,
30
+ isLocalBrowserPoll,
31
+ isLocalBrowserPresent,
28
32
  mapEventToCategory,
29
33
  mimeOf,
30
34
  normalizeImTargets,
@@ -32,6 +36,7 @@ import {
32
36
  readRawBody,
33
37
  resolvedConfig,
34
38
  sendWebhook,
39
+ sendHostNotify,
35
40
  shouldNotify,
36
41
  storedSessionTitle,
37
42
  unitRoutes,
@@ -49,11 +54,17 @@ export const name = 'dsh-turn-notify'
49
54
 
50
55
  export const inject = ['webServer']
51
56
 
57
+ // spawn 实现缝:测试注入观测 stub,生产恒为 child_process.execFile(client 半区 __test 同先例)
58
+ export const __hostSpawn = { impl: execFile }
59
+
52
60
  const NAMESPACE = 'turn-notify'
53
61
  const SOUNDS_DIR = join(homedir(), '.dsh', 'dsh-turn-notify', 'sounds')
54
62
  const REQUEST_BODY_MAX_BYTES = 64 * 1024
55
63
  // 长轮询挂起上限:低于客户端请求超时,保证客户端总在服务端放弃后才超时
56
64
  const LONG_POLL_WAIT_MS = 25 * 1000
65
+ // 本机浏览器在场认可窗口:在途长轮询出账后的宽限时长,浏览器优先去重的时间边界;
66
+ // 窗口内事件仍视为浏览器呈现,超窗即宿主补位(宁可重复不可漏)
67
+ export const CLIENT_PRESENCE_WINDOW_MS = 2 * 1000
57
68
 
58
69
  // dsh-im 投递错误码到 HTTP 状态的映射,未收录错误按网关失败处理
59
70
  const IM_ERROR_STATUS = { 'bad-request': 400, 'unknown-bot': 404, 'bot-not-connected': 503 }
@@ -81,10 +92,13 @@ const SETTINGS_SCHEMA = z.object({
81
92
  minTurnDurationMs: z.number().default(MIN_TURN_DURATION_MS).description('最短回合时长(毫秒),回合结束类通知短于此不送达;AI 提问与审批请求即时送达'),
82
93
  rootsOnly: z.boolean().default(true).description('子代理会话不通知'),
83
94
  suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
95
+ hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast);浏览器优先,本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现宿主不重复弹,离场时宿主补位'),
96
+ hostNotifyFallback: z.boolean().default(false).description('宿主通知回退:本机浏览器在场时由浏览器呈现,离场(2 秒内无在途长轮询)时宿主补位;与总开关判定一致,任一开启即生效'),
97
+ folderRunningEnabled: z.boolean().default(true).description('工作区文件夹运行标记:侧边栏分组模式下,有运行中会话的文件夹组头显示运行点并给图标着色;关闭后标记整体不渲染,变更刷新页面生效'),
84
98
  enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
85
99
  soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
86
100
  imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
87
- kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im),未配置的分类全通道放行'),
101
+ kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im/host),未配置的分类全通道放行'),
88
102
  })
89
103
 
90
104
  // 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
@@ -194,6 +208,10 @@ export function apply(ctx) {
194
208
  const titledSessions = new Set()
195
209
  // 回合尾部统计:assistant/message 的 tokens 累加与回答前缀,回合结束随通知消费即清
196
210
  const assistantTails = new Map()
211
+ // 本机(回环来源)浏览器在场信号:在途长轮询计数与最近活动时刻,
212
+ // 在场 = 有在途请求或认可窗口内有活动,宿主通知的浏览器优先去重据此判定
213
+ let inFlightLocalPolls = 0
214
+ let lastLocalClientSeenAt = 0
197
215
  let seq = 0
198
216
  // 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
199
217
  let soundWriteQueue = Promise.resolve()
@@ -253,6 +271,25 @@ export function apply(ctx) {
253
271
  void sendWebhook({ url: settings.webhookUrl, payload: buildWebhookPayload(unit) })
254
272
  }
255
273
  if (routes === null || routes.indexOf('im') >= 0) deliverIm(unit, settings)
274
+ if (routes === null || routes.indexOf('host') >= 0) deliverHostNotify(unit, settings)
275
+ }
276
+
277
+ // 宿主桌面通知:浏览器优先,本机浏览器在场由浏览器呈现,宿主让位(正常路径
278
+ // 零重复);离场(无在途长轮询且超认可窗口)由宿主补位;fire-and-forget,
279
+ // spawn 失败不影响其余通道
280
+ function deliverHostNotify(unit, settings) {
281
+ const wanted = hostNotifyWanted({
282
+ hostNotify: settings.hostNotify,
283
+ hostNotifyFallback: settings.hostNotifyFallback,
284
+ localBrowserPresent: isLocalBrowserPresent({
285
+ inFlightPolls: inFlightLocalPolls,
286
+ lastSeenAt: lastLocalClientSeenAt,
287
+ now: Date.now(),
288
+ windowMs: CLIENT_PRESENCE_WINDOW_MS,
289
+ }),
290
+ })
291
+ if (!wanted) return
292
+ void sendHostNotify({ title: unit.text, body: unit.summary ?? '', platform: process.platform, execFileImpl: __hostSpawn.impl })
256
293
  }
257
294
 
258
295
  // 会话显示标题:优先 session-title 服务(与侧边栏行标题同一投影,客户端文本匹配高亮
@@ -404,13 +441,39 @@ export function apply(ctx) {
404
441
  path: '/api/turn-notify/projection',
405
442
  handler: route('GET', {}, async (req, res) => {
406
443
  const query = new URL(req.url, 'http://localhost').searchParams
407
- const rawCursor = Number(query.get('cursor'))
408
- const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
409
- if (query.has('cursor') && projection.version() === cursor) {
410
- await projection.wait(cursor, LONG_POLL_WAIT_MS)
444
+ // 回环来源的浏览器长轮询即本机在场信号,宿主通知的浏览器优先去重据此判定;
445
+ // 记账形态收紧防伪造:裸 GET(img 探活/监控探测)与跨源 fetch 不入账。
446
+ // 记账期间计入在途,断连(浏览器关闭/中止)经 res close 事件即时出账,
447
+ // 离场盲区收敛到认可窗口
448
+ let releaseLocalPoll = null
449
+ if (isLocalBrowserPoll({
450
+ remoteAddress: req.socket && req.socket.remoteAddress,
451
+ hasCursor: query.has('cursor'),
452
+ secFetchMode: req.headers ? req.headers['sec-fetch-mode'] : undefined,
453
+ origin: req.headers ? req.headers.origin : undefined,
454
+ host: req.headers ? req.headers.host : undefined,
455
+ })) {
456
+ inFlightLocalPolls += 1
457
+ let settled = false
458
+ releaseLocalPoll = () => {
459
+ if (settled) return
460
+ settled = true
461
+ inFlightLocalPolls -= 1
462
+ lastLocalClientSeenAt = Date.now()
463
+ }
464
+ res.once('close', releaseLocalPoll)
465
+ }
466
+ try {
467
+ const rawCursor = Number(query.get('cursor'))
468
+ const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
469
+ if (query.has('cursor') && projection.version() === cursor) {
470
+ await projection.wait(cursor, LONG_POLL_WAIT_MS)
471
+ }
472
+ const settings = readSettings(ctx)
473
+ sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
474
+ } finally {
475
+ if (releaseLocalPoll !== null) releaseLocalPoll()
411
476
  }
412
- const settings = readSettings(ctx)
413
- sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
414
477
  }),
415
478
  })
416
479
  return () => projection.dispose()
@@ -631,6 +694,29 @@ export function apply(ctx) {
631
694
  }),
632
695
  }), 'turn-notify test-webhook route')
633
696
 
697
+ // 宿主通知测试点火互斥:spawn 代价高于既有端点(powershell/osascript 秒级),
698
+ // 单在途防连点堆积
699
+ let hostTestInFlight = false
700
+ ctx.effect(() =>
701
+ ctx.webServer.register({
702
+ kind: 'exact',
703
+ path: '/api/turn-notify/test-host',
704
+ // 等待 spawn 结束,真实结果随响应返回,测试按钮不谎报
705
+ handler: route('POST', { crossOrigin: true }, async (req, res) => {
706
+ if (hostTestInFlight) {
707
+ sendJson(res, 429, { error: '宿主通知测试进行中,请稍候' })
708
+ return
709
+ }
710
+ hostTestInFlight = true
711
+ try {
712
+ const unit = buildTestUnit()
713
+ sendJson(res, 200, await sendHostNotify({ title: unit.text, body: unit.summary ?? '', platform: process.platform, execFileImpl: __hostSpawn.impl }))
714
+ } finally {
715
+ hostTestInFlight = false
716
+ }
717
+ }),
718
+ }), 'turn-notify test-host route')
719
+
634
720
  ctx.effect(() =>
635
721
  ctx.webServer.register({
636
722
  kind: 'exact',
@@ -0,0 +1,405 @@
1
+ // 工作区文件夹运行标记:纯函数投影切片 + effect 扫描接线测试。
2
+ // 扫描逻辑闭包在 apply 的 effect 内,经捕获 effect 函数 + DOM 桩驱动,
3
+ // 锁定数量不变量守卫、下标对位、开关兜底与卸载清理。
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+ import { mock } from 'node:test'
10
+
11
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
13
+
14
+ // ── 纯函数切片:常量与 folderRunningState(无外部依赖) ──
15
+
16
+ const PURE_START = CLIENT_SRC.indexOf('const FOLD_SECTION_SELECTOR')
17
+ const PURE_END = CLIENT_SRC.indexOf('// ---- 点击直达会话')
18
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 文件夹运行标记纯函数切片定位失败')
19
+ const pure = new Function(
20
+ CLIENT_SRC.slice(PURE_START, PURE_END)
21
+ + '; return { folderRunningState: folderRunningState, sectionSelector: FOLD_SECTION_SELECTOR, runningFlag: FOLD_RUNNING_FLAG, scanDelayMs: FOLD_SCAN_DELAY_MS }',
22
+ )()
23
+
24
+ const WS = (id, sessionIds) => ({ workspaceId: id, sessionIds })
25
+ const ROW = (id, extra) => ({ id, ...extra })
26
+
27
+ test('folderRunningState 主路径:运行中会话所在工作区入集合,非运行中不入', () => {
28
+ const sessions = {
29
+ ids: ['a', 'b'],
30
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
31
+ }
32
+ const workspaces = { items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] }
33
+ const state = pure.folderRunningState(sessions, workspaces)
34
+ assert.deepEqual(state.ids, ['w1', 'w2'])
35
+ assert.deepEqual([...state.running], ['w1'])
36
+ assert.equal(state.ungrouped, false)
37
+ })
38
+
39
+ test('folderRunningState 未分组:游离运行中会话使末位未分组桶标记', () => {
40
+ const sessions = {
41
+ ids: ['a', 'stray'],
42
+ byId: { a: ROW('a', { running: false }), stray: ROW('stray', { running: true }) },
43
+ }
44
+ const workspaces = { items: [WS('w1', ['a'])], archivedSessionIds: [] }
45
+ const state = pure.folderRunningState(sessions, workspaces)
46
+ assert.equal(state.running.has('w1'), false)
47
+ assert.equal(state.ungrouped, true)
48
+ })
49
+
50
+ test('folderRunningState 排除项:subagent、已归档、空白非当前不标记,空白当前例外', () => {
51
+ const sessions = {
52
+ ids: ['sub', 'arch', 'blank', 'cur'],
53
+ byId: {
54
+ sub: ROW('sub', { running: true, origin: 'subagent' }),
55
+ arch: ROW('arch', { running: true }),
56
+ blank: ROW('blank', { running: true, blank: true }),
57
+ cur: ROW('cur', { running: true, blank: true }),
58
+ },
59
+ current: 'cur',
60
+ }
61
+ const workspaces = {
62
+ items: [WS('w1', ['sub']), WS('w2', ['arch']), WS('w3', ['blank']), WS('w4', ['cur'])],
63
+ archivedSessionIds: ['arch'],
64
+ }
65
+ const state = pure.folderRunningState(sessions, workspaces)
66
+ assert.equal(state.running.has('w1'), false, 'subagent 运行不标记')
67
+ assert.equal(state.running.has('w2'), false, '已归档运行不标记')
68
+ assert.equal(state.running.has('w3'), false, '空白非当前运行不标记')
69
+ assert.equal(state.running.has('w4'), true, '空白且为当前会话标记')
70
+ })
71
+
72
+ test('folderRunningState 缺省守卫:快照缺失或账本悬空不抛错', () => {
73
+ assert.deepEqual(pure.folderRunningState(undefined, undefined), { ids: [], running: new Set(), ungrouped: false })
74
+ const state = pure.folderRunningState({ ids: ['gone'], byId: {} }, { items: [WS('w1', ['gone'])] })
75
+ assert.deepEqual([...state.running], [])
76
+ assert.equal(state.ungrouped, false)
77
+ })
78
+
79
+ // ── effect 扫描接线:DOM 桩 + 捕获 effect 函数驱动 ──
80
+
81
+ const SCAN_SETTLE_MS = pure.scanDelayMs * 4
82
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
83
+
84
+ // 组容器桩:属性集合语义与 Element 同构
85
+ function sectionStub() {
86
+ const attrs = new Set()
87
+ return {
88
+ attrs,
89
+ setAttribute: (name) => attrs.add(name),
90
+ removeAttribute: (name) => attrs.delete(name),
91
+ hasAttribute: (name) => attrs.has(name),
92
+ }
93
+ }
94
+
95
+ // 加载 client.js 并执行 apply(mock 最小服务面),effect 函数捕获不执行;
96
+ // MutationObserver 经工厂形参注入(源码自由变量),document 桩承载组容器查询;
97
+ // 通知长轮询经挂起 fetch 静默(测试只驱动 fold effect,不消费投影)
98
+ function loadClient() {
99
+ const modules = []
100
+ const effects = []
101
+ const windowStub = {
102
+ __ModuleLoader__: { load: (module) => modules.push(module) },
103
+ addEventListener: () => {},
104
+ removeEventListener: () => {},
105
+ }
106
+ const reactStub = {
107
+ useState: (value) => [value, () => {}],
108
+ useEffect: () => {},
109
+ useSyncExternalStore: () => [],
110
+ useRef: (value) => ({ current: value }),
111
+ createElement: () => null,
112
+ }
113
+ const requireStub = (name) => {
114
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
115
+ return reactStub
116
+ }
117
+ const sections = []
118
+ const documentStub = {
119
+ documentElement: {},
120
+ createElement: () => ({ style: {}, remove() {} }),
121
+ head: { appendChild: () => {} },
122
+ body: { appendChild: () => {} },
123
+ addEventListener: () => {},
124
+ removeEventListener: () => {},
125
+ querySelectorAll: (selector) => {
126
+ if (selector === pure.sectionSelector) return [...sections]
127
+ if (selector === '[' + pure.runningFlag + ']') return sections.filter((section) => section.hasAttribute(pure.runningFlag))
128
+ return []
129
+ },
130
+ }
131
+ class MutationObserverStub {
132
+ observe() {}
133
+ disconnect() {}
134
+ }
135
+ // 不传 fetch 工厂参数:源码内裸 fetch 解析到 globalThis.fetch,由 withFetch 显式 mock;
136
+ // 传参数会把 fetch 绑定进沙箱,mock 永远打不到
137
+ const factory = new Function('window', 'require', 'document', 'MutationObserver', CLIENT_SRC + '\n;return null')
138
+ factory(windowStub, requireStub, documentStub, MutationObserverStub)
139
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
140
+ const mod = modules[0].factory(requireStub)
141
+
142
+ // 快照源桩:监听器捕获 + emit,供测试模拟服务变更触发的重扫(生产由订阅驱动)
143
+ let sessionsState = { ids: [], byId: {} }
144
+ let workspacesState = undefined
145
+ function sourceStub(getSnapshot) {
146
+ const listeners = new Set()
147
+ return {
148
+ list: {
149
+ subscribe: (cb) => {
150
+ listeners.add(cb)
151
+ return () => listeners.delete(cb)
152
+ },
153
+ getSnapshot,
154
+ },
155
+ emit: () => {
156
+ for (const cb of [...listeners]) cb()
157
+ },
158
+ }
159
+ }
160
+ const sessions = { binding: () => undefined, ...sourceStub(() => sessionsState) }
161
+ const workspaces = sourceStub(() => workspacesState)
162
+ const ctx = {
163
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
164
+ effect: (fn, tag) => effects.push({ fn, tag }),
165
+ slots: { inject: () => {}, register: () => {} },
166
+ }
167
+ mod.apply(ctx)
168
+ const entry = effects.find((item) => item.tag === 'turn-notify folder running mark')
169
+ assert.ok(entry, 'apply 未挂文件夹运行标记 effect')
170
+ return {
171
+ sections,
172
+ runEffect: entry.fn,
173
+ emitSessions: sessions.emit,
174
+ setSessions: (next) => { sessionsState = next },
175
+ setWorkspaces: (next) => { workspacesState = next },
176
+ }
177
+ }
178
+
179
+ // 两工作区一运行一的基准快照
180
+ function baseSnapshots(client) {
181
+ client.setSessions({
182
+ ids: ['a', 'b'],
183
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
184
+ })
185
+ client.setWorkspaces({ items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] })
186
+ }
187
+
188
+ async function withFetch(implOrPayload, run) {
189
+ // mock.method 返回被 mock 的函数,还原须经 .mock.restore(),不可直接调用返回值;
190
+ // 传函数即显式 fetch 实现(构造拉取失败),传对象即固定响应,undefined 即固定
191
+ // 成功响应 {folderRunningEnabled:true}——默认路径显式 mock,不依赖运行环境对相对
192
+ // URL 的 fetch 行为(隐式环境耦合会随 Node 升级漂移);
193
+ // 通知长轮询路径恒返回挂起 Promise,测试期间不产生投影循环
194
+ const impl = typeof implOrPayload === 'function'
195
+ ? async (path) => (String(path).indexOf('/api/turn-notify/projection') === 0 ? new Promise(() => {}) : implOrPayload(path))
196
+ : async (path) => {
197
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
198
+ return { ok: true, json: async () => (implOrPayload === undefined ? { folderRunningEnabled: true } : implOrPayload) }
199
+ }
200
+ const mocked = mock.method(globalThis, 'fetch', impl)
201
+ try {
202
+ await run()
203
+ } finally {
204
+ mocked.mock.restore()
205
+ }
206
+ }
207
+
208
+ test('Given 两工作区组容器与快照同序, When 扫描, Then 运行中工作区组容器带标记', async () => {
209
+ await withFetch(undefined, async () => {
210
+ const client = loadClient()
211
+ baseSnapshots(client)
212
+ const [s1, s2] = [sectionStub(), sectionStub()]
213
+ client.sections.push(s1, s2)
214
+ client.runEffect()
215
+ await sleep(SCAN_SETTLE_MS)
216
+ assert.ok(s1.hasAttribute(pure.runningFlag), '运行中工作区组容器应带标记')
217
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '空闲工作区组容器不带标记')
218
+ })
219
+ })
220
+
221
+ test('Given 末位未分组桶存在, When 扫描, Then 组容器数 = 工作区数+1 且游离运行中落末位', async () => {
222
+ await withFetch(undefined, async () => {
223
+ const client = loadClient()
224
+ baseSnapshots(client)
225
+ client.setSessions({
226
+ ids: ['a', 'b', 'stray'],
227
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }), stray: ROW('stray', { running: true }) },
228
+ })
229
+ const [s1, s2, s3] = [sectionStub(), sectionStub(), sectionStub()]
230
+ client.sections.push(s1, s2, s3)
231
+ client.runEffect()
232
+ await sleep(SCAN_SETTLE_MS)
233
+ assert.ok(s1.hasAttribute(pure.runningFlag), '首工作区(运行中)带标记')
234
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '次工作区(空闲)不带标记')
235
+ assert.ok(s3.hasAttribute(pure.runningFlag), '末位未分组桶(游离运行中)带标记')
236
+ })
237
+ })
238
+
239
+ test('Given 组容器数与不变量漂移(单列表/搜索态/结构变更), When 扫描, Then 清除全部标记不误标', async () => {
240
+ await withFetch(undefined, async () => {
241
+ const client = loadClient()
242
+ baseSnapshots(client)
243
+ const [s1, s2] = [sectionStub(), sectionStub()]
244
+ client.sections.push(s1, s2)
245
+ client.runEffect()
246
+ await sleep(SCAN_SETTLE_MS)
247
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:正常对位时首组带标记')
248
+ // 漂移:两工作区只剩一个组容器(或多余两个)均不匹配不变量
249
+ client.sections.length = 0
250
+ client.sections.push(s1)
251
+ client.emitSessions()
252
+ await sleep(SCAN_SETTLE_MS)
253
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '数量漂移后标记被清除')
254
+ })
255
+ })
256
+
257
+ test('Given 开关关闭(拉取返回 folderRunningEnabled:false), When 扫描, Then 不标记且既有标记清除', async () => {
258
+ await withFetch({ folderRunningEnabled: false }, async () => {
259
+ const client = loadClient()
260
+ baseSnapshots(client)
261
+ const s1 = sectionStub()
262
+ client.sections.push(s1)
263
+ client.runEffect()
264
+ await sleep(SCAN_SETTLE_MS)
265
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '停用后不新增标记')
266
+ })
267
+ })
268
+
269
+ test('Given 开关拉取失败, When 扫描, Then 按启用兜底照常标记', async () => {
270
+ await withFetch(async (path) => {
271
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
272
+ throw new Error('network down')
273
+ }, async () => {
274
+ const client = loadClient()
275
+ baseSnapshots(client)
276
+ const [s1, s2] = [sectionStub(), sectionStub()]
277
+ client.sections.push(s1, s2)
278
+ client.runEffect()
279
+ await sleep(SCAN_SETTLE_MS)
280
+ assert.ok(s1.hasAttribute(pure.runningFlag), '拉取失败按启用兜底(默认启用)')
281
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '兜底不改变运行态判定')
282
+ })
283
+ })
284
+
285
+ test('Given 运行态变化经服务订阅到达(折叠组无 DOM 变更), When 快照更新, Then 标记跟随', async () => {
286
+ await withFetch(undefined, async () => {
287
+ const client = loadClient()
288
+ baseSnapshots(client)
289
+ const [s1, s2] = [sectionStub(), sectionStub()]
290
+ client.sections.push(s1, s2)
291
+ client.runEffect()
292
+ await sleep(SCAN_SETTLE_MS)
293
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:首工作区运行中')
294
+ // 唯一运行会话转入另一工作区(模拟会话被归档/挂到他处后的快照差)
295
+ client.setSessions({
296
+ ids: ['a', 'b'],
297
+ byId: { a: ROW('a', { running: false }), b: ROW('b', { running: true }) },
298
+ })
299
+ client.emitSessions()
300
+ await sleep(SCAN_SETTLE_MS)
301
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '原运行工作区标记消除')
302
+ assert.ok(s2.hasAttribute(pure.runningFlag), '新运行工作区获得标记')
303
+ })
304
+ })
305
+
306
+ test('Given effect 卸载, When 清理执行, Then 标记移除且资源退订', async () => {
307
+ await withFetch(undefined, async () => {
308
+ const client = loadClient()
309
+ baseSnapshots(client)
310
+ const [s1, s2] = [sectionStub(), sectionStub()]
311
+ client.sections.push(s1, s2)
312
+ const cleanup = client.runEffect()
313
+ await sleep(SCAN_SETTLE_MS)
314
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:标记已应用')
315
+ cleanup()
316
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载清除运行中工作区的标记')
317
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '卸载不残留其他标记')
318
+ })
319
+ })
320
+
321
+ test('Given 无工作区且游离会话运行, When 扫描, Then 末位未分组桶正确标记', async () => {
322
+ await withFetch(undefined, async () => {
323
+ const client = loadClient()
324
+ client.setSessions({ ids: ['stray'], byId: { stray: ROW('stray', { running: true }) } })
325
+ client.setWorkspaces({ items: [], archivedSessionIds: [] })
326
+ const s1 = sectionStub()
327
+ client.sections.push(s1)
328
+ client.runEffect()
329
+ await sleep(SCAN_SETTLE_MS)
330
+ assert.ok(s1.hasAttribute(pure.runningFlag), '零工作区时组容器数 1 = 工作区数 0 + 1,末位按未分组桶标记')
331
+ })
332
+ })
333
+
334
+ test('Given 组容器数多于不变量上界, When 扫描, Then 清除全部标记不误标', async () => {
335
+ await withFetch(undefined, async () => {
336
+ const client = loadClient()
337
+ baseSnapshots(client)
338
+ // 数量漂移「变多」:两工作区出现四个组容器(> 工作区数+1)
339
+ const sections = [sectionStub(), sectionStub(), sectionStub(), sectionStub()]
340
+ client.sections.push(...sections)
341
+ client.runEffect()
342
+ await sleep(SCAN_SETTLE_MS)
343
+ for (const [index, section] of sections.entries()) {
344
+ assert.equal(section.hasAttribute(pure.runningFlag), false, '容器 ' + index + ' 数量漂移时不带标记')
345
+ }
346
+ })
347
+ })
348
+
349
+ test('Given 拉取未完成即卸载, When 后续拉取完成并触发调度, Then 标记不复活', async () => {
350
+ // 拉取挂起(永不resolve),标记先经拉取完成前的唯一路径产生:
351
+ // 未决期 scan 仅清除,故先注入「已启用 + 已标记」状态再卸载
352
+ let release
353
+ await withFetch((path) => {
354
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
355
+ return new Promise((resolve) => { release = resolve })
356
+ }, async () => {
357
+ const client = loadClient()
358
+ baseSnapshots(client)
359
+ const s1 = sectionStub()
360
+ client.sections.push(s1)
361
+ const cleanup = client.runEffect()
362
+ await sleep(SCAN_SETTLE_MS)
363
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期间不产生标记')
364
+ // 模拟标记已在(如卸载前最后一次 scan 的残留):卸载后拉取完成不得复活
365
+ s1.setAttribute(pure.runningFlag, '')
366
+ cleanup()
367
+ release({ ok: true, json: async () => ({ folderRunningEnabled: true }) })
368
+ await sleep(SCAN_SETTLE_MS)
369
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载后拉取完成不复活标记')
370
+ })
371
+ })
372
+
373
+ test('Given 开关停用, When 拉取完成前的首次扫描, Then 不产生标记(未决期仅清除)', async () => {
374
+ // 拉取延迟释放(3 个扫描窗口后返回停用),未决期内的扫描不得标记
375
+ await withFetch((path) => new Promise((resolve) => {
376
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return
377
+ setTimeout(() => resolve({ ok: true, json: async () => ({ folderRunningEnabled: false }) }), pure.scanDelayMs * 3)
378
+ }), async () => {
379
+ const client = loadClient()
380
+ baseSnapshots(client)
381
+ const s1 = sectionStub()
382
+ client.sections.push(s1)
383
+ client.runEffect()
384
+ await sleep(SCAN_SETTLE_MS)
385
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期首次扫描不标记')
386
+ await sleep(SCAN_SETTLE_MS * 2)
387
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取返回停用后维持无标记')
388
+ })
389
+ })
390
+
391
+ // ── 源码契约:CSS 规则、面板开关挂载、选择器与标记属性 ──
392
+
393
+ test('源码契约:运行点与图标着色规则、官方组容器锚点、面板开关挂载齐备', () => {
394
+ assert.ok(CLIENT_SRC.includes('div[class$="_groupSection"][data-tn-folder-running]'), '缺组容器标记 CSS 锚点')
395
+ assert.ok(CLIENT_SRC.includes('var(--dsw-static-deepseek-450)'), '运行点色源应为官方 ongoing 实际色值(--dsh-state-ongoing 仅存在于官方状态点组件局部,组容器读不到)')
396
+ assert.ok(!CLIENT_SRC.includes('--tn-fold-running-color: var(--dsh-state-ongoing'), '死变量引用:组容器上下文读不到 --dsh-state-ongoing,恒走回退源,不得引入永不生效的引用')
397
+ assert.ok(CLIENT_SRC.includes('[class$="_folderActive"]'), '图标着色应让位官方 folderActive 态')
398
+ assert.ok(CLIENT_SRC.includes('tn-fold-running-pulse'), '缺运行点脉冲动画')
399
+ assert.ok(CLIENT_SRC.includes('prefers-reduced-motion'), '动画应尊重减少动态偏好')
400
+ assert.ok(CLIENT_SRC.includes('toggleFolderRunning'), '面板缺运行标记开关处理函数')
401
+ assert.ok(CLIENT_SRC.includes('title: FOLD_SWITCH_TITLE'), '开关行缺悬停说明挂载')
402
+ assert.ok(CLIENT_SRC.includes('const FOLD_SWITCH_TITLE'), '缺悬停说明常量')
403
+ // 开关经既有 config 路由读写:不新增路由,host 与 client 字面量一致
404
+ assert.ok(!CLIENT_SRC.includes("'/api/turn-notify/folder-running"), '运行标记开关不得另开路由(经 config 路由读写)')
405
+ })
@@ -659,6 +659,9 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
659
659
  minTurnDurationMs: MIN_TURN_MS,
660
660
  rootsOnly: true,
661
661
  suppressSubagentWake: true,
662
+ hostNotify: false,
663
+ hostNotifyFallback: false,
664
+ folderRunningEnabled: true,
662
665
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
663
666
  soundMapping: { completed: 'snd-1' },
664
667
  imTargets: [],
@@ -670,6 +673,9 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
670
673
  minTurnDurationMs: MIN_TURN_MS,
671
674
  rootsOnly: true,
672
675
  suppressSubagentWake: true,
676
+ hostNotify: false,
677
+ hostNotifyFallback: false,
678
+ folderRunningEnabled: true,
673
679
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
674
680
  soundMapping: {},
675
681
  imTargets: [],
@@ -686,6 +692,9 @@ test('面板可见配置:webhookUrl 原文回显', () => {
686
692
  minTurnDurationMs: MIN_TURN_MS,
687
693
  rootsOnly: true,
688
694
  suppressSubagentWake: true,
695
+ hostNotify: false,
696
+ hostNotifyFallback: false,
697
+ folderRunningEnabled: true,
689
698
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
690
699
  soundMapping: { completed: 'snd-1' },
691
700
  imTargets: [],
@@ -6,13 +6,14 @@ import { EventEmitter } from 'node:events'
6
6
 
7
7
  import { apply } from '../src/index.js'
8
8
 
9
+ // res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once
9
10
  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
- }
11
+ const res = new EventEmitter()
12
+ res.status = null
13
+ res.body = null
14
+ res.writeHead = (status) => { res.status = status }
15
+ res.end = (body) => { res.body = JSON.parse(body) }
16
+ return res
16
17
  }
17
18
 
18
19
  function makeReq(method, payload, headers) {