dsh-notify-say 0.4.21 → 0.4.25

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.
Files changed (54) hide show
  1. package/README.md +216 -176
  2. package/docs/audio-audit/README.md +164 -0
  3. package/docs/audio-audit/fetch-snapshots.ps1 +76 -0
  4. package/docs/audio-audit/snapshots/ameharu-hau.txt +89 -0
  5. package/docs/audio-audit/snapshots/goki-nuigurumi.txt +73 -0
  6. package/docs/audio-audit/snapshots/kasukabe-tsumugi.txt +197 -0
  7. package/docs/audio-audit/snapshots/manbetsu-hanamaru.txt +27 -0
  8. package/docs/audio-audit/snapshots/meimei-meria.txt +84 -0
  9. package/docs/audio-audit/snapshots/mochiko.txt +27 -0
  10. package/docs/audio-audit/snapshots/namiine-ritsu.txt +76 -0
  11. package/docs/audio-audit/snapshots/shirakami-kotaro.txt +158 -0
  12. package/docs/audio-audit/snapshots/sss-anonymous-common.txt +142 -0
  13. package/docs/audio-audit/snapshots/sss-zundamon.txt +142 -0
  14. package/docs/audio-audit/snapshots/voicevox-software.txt +41 -0
  15. package/docs/audio-audit/snapshots/voicevox-vvm-terms.txt +385 -0
  16. package/docs/audio-audit/snapshots/yurei-chan.txt +112 -0
  17. package/docs//345/276/205/345/201/232/346/270/205/345/215/225.md +218 -0
  18. package/docs//350/257/255/351/237/263/345/214/205/345/210/206/345/217/221/346/226/271/346/241/210-/345/276/205/345/256/241/351/230/205.md +378 -0
  19. package/lib/client.js +50 -8
  20. package/lib/config-route.js +251 -209
  21. package/lib/http-guard.js +222 -0
  22. package/lib/index.js +1822 -1768
  23. package/lib/session-bridge.js +138 -117
  24. package/package.json +11 -2
  25. package/test/audio-source.test.mjs +184 -0
  26. package/test/audio-wiring.test.mjs +287 -0
  27. package/test/client-bundle.test.mjs +671 -0
  28. package/test/client-contract.test.mjs +118 -0
  29. package/test/config-coverage.test.mjs +109 -0
  30. package/test/config-route.test.mjs +212 -0
  31. package/test/config-schema.test.mjs +147 -0
  32. package/test/config-store.test.mjs +256 -0
  33. package/test/dev-reload.test.mjs +132 -0
  34. package/test/effective-for-client.test.mjs +142 -0
  35. package/test/fail-route.test.mjs +128 -0
  36. package/test/fail-voice.test.mjs +193 -0
  37. package/test/http-guard.test.mjs +143 -0
  38. package/test/http-integration.test.mjs +284 -0
  39. package/test/idle-gate.test.mjs +167 -0
  40. package/test/preview.test.mjs +92 -0
  41. package/test/real-cordis.test.mjs +204 -0
  42. package/test/release-voice-guard.test.mjs +185 -0
  43. package/test/reload-live.test.mjs +261 -0
  44. package/test/reload-verification.test.mjs +218 -0
  45. package/test/route-loader-register.mjs +22 -0
  46. package/test/scalar-guard.test.mjs +125 -0
  47. package/test/session-jump.test.mjs +266 -0
  48. package/test/stub-child-process.mjs +43 -0
  49. package/test/tts-lines.test.mjs +112 -0
  50. package/test/version-consistency.test.mjs +98 -0
  51. package/test/voice-availability.test.mjs +90 -0
  52. package/test/wav-gain.test.mjs +138 -0
  53. package/test/wiring.test.mjs +287 -0
  54. package/tools/notify-config.ps1 +472 -471
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 语音包增益与限幅测试(v0.4.16)。
3
+ *
4
+ * ## 为什么需要增益
5
+ *
6
+ * 实测本包 247 个语音包峰值:最小 24.7%、中位 40.4%、最大 84.4%。
7
+ * 素材本身录得偏轻。而 Windows MCI 的 `setaudio volume to 1000`
8
+ * 上限**只等于原始幅度 1.0 倍**,无法超过 —— 所以 GUI 音量拉满
9
+ * 也只能听到原始电平的 35%~40%,听感必然偏轻。
10
+ *
11
+ * 唯一出路是**数字增益**:把样本乘一个系数。
12
+ *
13
+ * ## 为什么必须限幅
14
+ *
15
+ * 最响的文件已 84.4%,硬乘 2 倍会超过满刻度 → 削波(破音)。
16
+ * 所以实际系数 = min(期望倍数, 95% / 实测峰值)。
17
+ *
18
+ * 这些用例直接调用 ps1 里的函数(真跑 PowerShell,不是 mock),
19
+ * 因为增益涉及字节级操作,mock 测不出真问题。
20
+ */
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { existsSync, unlinkSync, mkdtempSync, writeFileSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join, resolve, dirname } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
30
+ const GAIN_LIB = join(ROOT, 'lib', 'wav-gain.ps1');
31
+ const VOICE_DIR = join(ROOT, 'sounds', 'voice-lines');
32
+
33
+ /** 跑一段 PowerShell,返回 stdout。 */
34
+ function ps(code) {
35
+ const dir = mkdtempSync(join(tmpdir(), 'gain-test-'));
36
+ const file = join(dir, 'run.ps1');
37
+ // 必须带 BOM:Windows PowerShell 5.1 读无 BOM 的 UTF-8 会按 ANSI 解释,
38
+ // 中文全变乱码并报语法错(本项目踩过)。
39
+ writeFileSync(file, '\uFEFF' + code, 'utf8');
40
+ try {
41
+ return execFileSync('powershell.exe', [
42
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file,
43
+ ], { encoding: 'utf8', timeout: 120_000 });
44
+ } finally {
45
+ try { unlinkSync(file); } catch { /* ignore */ }
46
+ }
47
+ }
48
+
49
+ // ── G1: 增益函数存在且能测峰值 ───────────────────────────────────────────
50
+ test('G1: Get-WavPeakPercent 能读出峰值百分比', () => {
51
+ const f = join(VOICE_DIR, 'zundamon-normal.wav');
52
+ if (!existsSync(f)) return; // 语音包未随仓库分发时跳过
53
+ const out = ps(`. '${GAIN_LIB}'; Get-WavPeakPercent -Path '${f}'`);
54
+ const v = Number(out.trim());
55
+ assert.ok(Number.isFinite(v), `应返回数值,实际: ${out}`);
56
+ assert.ok(v > 0 && v <= 100, `峰值应在 0~100,实际 ${v}`);
57
+ });
58
+
59
+ // ── G2: 增益确实放大,且不超过 95% 安全线 ────────────────────────────────
60
+ test('G2: 增益生效且峰值不超过 95%(不削波)', () => {
61
+ const f = join(VOICE_DIR, 'zundamon-normal.wav');
62
+ if (!existsSync(f)) return;
63
+ const tmpOut = join(tmpdir(), `g2-${Date.now()}.wav`);
64
+ try {
65
+ const out = ps(`
66
+ . '${GAIN_LIB}'
67
+ $r = Invoke-WavGain -Path '${f}' -OutPath '${tmpOut}' -Gain 2.0
68
+ Write-Output ("{0}|{1}|{2}" -f $r.Ok, $r.PeakBefore, $r.PeakAfter)
69
+ `);
70
+ const [ok, before, after] = out.trim().split('|');
71
+ assert.equal(ok, 'True', '增益应成功');
72
+ assert.ok(Number(after) > Number(before), `放大后峰值(${after})应高于原值(${before})`);
73
+ assert.ok(Number(after) <= 95.5, `绝不能超过安全线,实际 ${after}%`);
74
+ } finally {
75
+ try { unlinkSync(tmpOut); } catch { /* ignore */ }
76
+ }
77
+ });
78
+
79
+ // ── G3: 很响的文件自动降倍(关键护栏)──────────────────────────────────
80
+ test('G3: 本来就响的文件自动降到安全倍数,不削波', () => {
81
+ const f = join(VOICE_DIR, 'my-voice-ja-2.wav');
82
+ if (!existsSync(f)) return;
83
+ const tmpOut = join(tmpdir(), `g3-${Date.now()}.wav`);
84
+ try {
85
+ const out = ps(`
86
+ . '${GAIN_LIB}'
87
+ $r = Invoke-WavGain -Path '${f}' -OutPath '${tmpOut}' -Gain 2.0
88
+ Write-Output ("{0}|{1}|{2}" -f $r.AppliedGain, $r.PeakBefore, $r.PeakAfter)
89
+ `);
90
+ const [applied, before, after] = out.trim().split('|');
91
+ // 这个文件原峰值 84.4%,2 倍必然削波 -> 必须自动降倍
92
+ assert.ok(Number(applied) < 2.0,
93
+ `应为避免削波而降倍,实际倍数 ${applied}`);
94
+ assert.ok(Number(after) <= 95.5, `峰值 ${after}% 超过安全线`);
95
+ assert.ok(Number(before) > 80, `选定文件应确实很响,实际 ${before}%`);
96
+ } finally {
97
+ try { unlinkSync(tmpOut); } catch { /* ignore */ }
98
+ }
99
+ });
100
+
101
+ // ── G4: 非 16-bit PCM / 非 wav 不做增益,退回原样 ───────────────────────
102
+ test('G4: 不支持的文件返回 Ok=false(调用方据此用原文件)', () => {
103
+ const dir = mkdtempSync(join(tmpdir(), 'gain-bad-'));
104
+ const fake = join(dir, 'not-a-wav.mp3');
105
+ writeFileSync(fake, Buffer.from('ID3 fake mp3 content'));
106
+ const tmpOut = join(dir, 'out.wav');
107
+ try {
108
+ const out = ps(`
109
+ . '${GAIN_LIB}'
110
+ $r = Invoke-WavGain -Path '${fake}' -OutPath '${tmpOut}' -Gain 2.0
111
+ Write-Output ("{0}|{1}" -f $r.Ok, $r.Reason)
112
+ `);
113
+ const [ok, reason] = out.trim().split('|');
114
+ assert.equal(ok, 'False', '不是 16-bit PCM wav 时不该声称成功');
115
+ assert.ok(reason.length > 0, '应给出原因');
116
+ } finally {
117
+ try { unlinkSync(fake); unlinkSync(tmpOut); } catch { /* ignore */ }
118
+ }
119
+ });
120
+
121
+ // ── G5: Gain <= 1.0 时什么都不做 ─────────────────────────────────────────
122
+ test('G5: Gain<=1.0 时跳过(不加倍也不写文件)', () => {
123
+ const f = join(VOICE_DIR, 'zundamon-normal.wav');
124
+ if (!existsSync(f)) return;
125
+ const tmpOut = join(tmpdir(), `g5-${Date.now()}.wav`);
126
+ try {
127
+ const out = ps(`
128
+ . '${GAIN_LIB}'
129
+ $r = Invoke-WavGain -Path '${f}' -OutPath '${tmpOut}' -Gain 1.0
130
+ Write-Output ("{0}|{1}" -f $r.Ok, $r.Reason)
131
+ `);
132
+ const [ok, reason] = out.trim().split('|');
133
+ assert.equal(ok, 'False');
134
+ assert.equal(reason, 'gain<=1');
135
+ } finally {
136
+ try { unlinkSync(tmpOut); } catch { /* ignore */ }
137
+ }
138
+ });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * dsh-notify-desktop 空闲门「事件接线」集成测试。
3
+ *
4
+ * 与 idle-gate.test.mjs 的分工:那边测**纯函数**的判定逻辑;这边测 apply() 的
5
+ * **事件接线与挂起状态机**——即"主会话 idle 时若有子代理在跑,到底有没有挂起"。
6
+ *
7
+ * 安全性:本测试把 `process.env.SystemRoot` 指向不存在的路径,使插件解析出的
8
+ * POWERSHELL_EXE 无效 → spawn 只会异步 emit 'error'(不启动任何进程、不播声音)。
9
+ * 测试的观测量是插件的 console 日志(挂起/未挂起),不是 Toast 本身。
10
+ *
11
+ * 运行:node --test tools/plugins/dsh-notify-desktop/test/
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ // ⚠️ 必须在 import lib/index.js **之前**把 DSH_HOME 指到空目录。
16
+ //
17
+ // 插件启动时会读 `$DSH_HOME/storages/dsh-notify-desktop/config.json` 作为
18
+ // 「运行时覆盖层」,其优先级**高于** apply() 传入的 config。若用真实 DSH_HOME,
19
+ // 用户只要在 GUI 里改过任何一项(实测:把 notifyOnAborted 打开),
20
+ // 那些假定默认值的用例就会红——那是用户配置导致的,不是代码缺陷。
21
+ import { mkdtempSync, rmSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ const ISOLATED_HOME = mkdtempSync(join(tmpdir(), 'notify-test-home-'));
25
+ process.env.DSH_HOME = ISOLATED_HOME;
26
+ process.on('exit', () => { try { rmSync(ISOLATED_HOME, { recursive: true, force: true }); } catch { /* best effort */ } });
27
+
28
+ // 必须在 import 插件之前设置:插件在模块顶层读 SystemRoot 计算 POWERSHELL_EXE。
29
+ process.env.SystemRoot = 'Z:\\__no_such_root__';
30
+
31
+ const { apply } = await import('../lib/index.js');
32
+
33
+ /**
34
+ * 造一个最小 ctx 替身:捕获事件处理器,记录 effect/dispose。
35
+ * @param {{ jobs?: any, agents?: any }} services - 要注入的宿主服务。
36
+ */
37
+ function mkCtx(services = {}) {
38
+ const handlers = new Map();
39
+ const effects = [];
40
+ return {
41
+ handlers,
42
+ effects,
43
+ on(name, fn) {
44
+ if (!handlers.has(name)) handlers.set(name, []);
45
+ handlers.get(name).push(fn);
46
+ },
47
+ get(name) {
48
+ return services[name];
49
+ },
50
+ effect(fn) {
51
+ const d = fn();
52
+ effects.push(d);
53
+ return () => { if (typeof d === 'function') d(); };
54
+ },
55
+ /** 触发某事件的全部处理器。 */
56
+ emit(name, ...args) {
57
+ for (const fn of handlers.get(name) || []) fn(...args);
58
+ },
59
+ };
60
+ }
61
+
62
+ /** 造一个最小 agent 替身。 */
63
+ function mkAgent(id, { origin, parentSession, status = 'idle' } = {}) {
64
+ return { id, status, session: { id, header: { id, origin, parentSession } } };
65
+ }
66
+
67
+ /** 捕获插件日志(挂起路径会打 'idle-gate: 挂起完成播报')。 */
68
+ function captureLogs() {
69
+ const lines = [];
70
+ const origLog = console.log;
71
+ const origWarn = console.warn;
72
+ console.log = (...a) => { lines.push(a.join(' ')); };
73
+ console.warn = (...a) => { lines.push(a.join(' ')); };
74
+ return {
75
+ lines,
76
+ restore() { console.log = origLog; console.warn = origWarn; },
77
+ get text() { return lines.join('\n'); },
78
+ };
79
+ }
80
+
81
+ /** 等待若干轮微任务/定时器,让异步 spawn 的 error 事件落地。 */
82
+ function settle() {
83
+ return new Promise((r) => setTimeout(r, 20));
84
+ }
85
+
86
+ test('接线:apply() 注册了 session/event、agent/status、approval/request 三个处理器', () => {
87
+ const ctx = mkCtx();
88
+ apply(ctx, {});
89
+ assert.ok(ctx.handlers.has('session/event'), '应注册 session/event');
90
+ assert.ok(ctx.handlers.has('agent/status'), '应注册 agent/status');
91
+ assert.ok(ctx.handlers.has('approval/request'), '应注册 approval/request');
92
+ });
93
+
94
+ test('接线:jobs 服务存在时订阅 onJobsChanged(并挂进 effect)', () => {
95
+ let subscribed = false;
96
+ const ctx = mkCtx({
97
+ jobs: { list: () => [], onJobsChanged: () => { subscribed = true; return () => {}; } },
98
+ });
99
+ apply(ctx, {});
100
+ assert.equal(subscribed, true, '应调用 jobs.onJobsChanged');
101
+ assert.ok(ctx.effects.length > 0, '应通过 ctx.effect 注册');
102
+ });
103
+
104
+ // ── 核心场景:主会话 idle 时子代理仍在跑 → 必须挂起,不立即播报 ─────────────
105
+ test('空闲门:主会话 idle 但 continuable 子代理 running → 挂起(不立即播报)', () => {
106
+ const main = mkAgent('main-1', { status: 'running' });
107
+ const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'main-1', status: 'running' });
108
+ const ctx = mkCtx({ agents: { list: () => [main, child], get: (id) => (id === 'main-1' ? main : undefined) } });
109
+
110
+ const logs = captureLogs();
111
+ try {
112
+ apply(ctx, { idleGate: true });
113
+ // 主会话这一轮结束:turn/end completed
114
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
115
+ // 主会话进入 idle(running→idle 边沿)
116
+ main.status = 'idle';
117
+ ctx.emit('agent/status', { agent: main, status: 'running' });
118
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
119
+ assert.match(logs.text, /idle-gate: 挂起完成播报/, '有子代理在跑时应挂起');
120
+ } finally {
121
+ logs.restore();
122
+ }
123
+ });
124
+
125
+ test('空闲门:子代理结束后重估 → 兑现播报(挂起被清)', () => {
126
+ const main = mkAgent('main-1', { status: 'idle' });
127
+ const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'main-1', status: 'running' });
128
+ const ctx = mkCtx({ agents: { list: () => [main, child], get: (id) => (id === 'main-1' ? main : undefined) } });
129
+
130
+ const logs = captureLogs();
131
+ try {
132
+ apply(ctx, { idleGate: true });
133
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
134
+ ctx.emit('agent/status', { agent: main, status: 'running' });
135
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
136
+ assert.match(logs.text, /idle-gate: 挂起完成播报/, '前置:应先挂起');
137
+
138
+ // 子代理跑完 → 状态变化触发重估
139
+ child.status = 'idle';
140
+ ctx.emit('agent/status', { agent: child, status: 'idle' });
141
+ // 兑现路径不打挂起日志;用"再次重估不再有挂起"间接确认已清
142
+ const before = logs.lines.length;
143
+ ctx.emit('agent/status', { agent: child, status: 'idle' });
144
+ assert.equal(logs.lines.length, before, '挂起已兑现后不应再产生新的挂起日志');
145
+ } finally {
146
+ logs.restore();
147
+ }
148
+ });
149
+
150
+ test('空闲门:无子代理/后台任务时**不**挂起(立即播报路径)', () => {
151
+ const main = mkAgent('main-1', { status: 'running' });
152
+ const ctx = mkCtx({ agents: { list: () => [main], get: () => main } });
153
+
154
+ const logs = captureLogs();
155
+ try {
156
+ apply(ctx, { idleGate: true });
157
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
158
+ main.status = 'idle';
159
+ ctx.emit('agent/status', { agent: main, status: 'running' });
160
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
161
+ assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, '空闲时不应挂起');
162
+ } finally {
163
+ logs.restore();
164
+ }
165
+ });
166
+
167
+ test('空闲门:后台 job running 也触发挂起(jobs 一路)', () => {
168
+ const main = mkAgent('main-1', { status: 'running' });
169
+ const ctx = mkCtx({
170
+ agents: { list: () => [main], get: () => main },
171
+ jobs: { list: () => [{ id: 'bash-1', label: 'sleep', status: 'running' }], onJobsChanged: () => () => {} },
172
+ });
173
+
174
+ const logs = captureLogs();
175
+ try {
176
+ apply(ctx, { idleGate: true });
177
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
178
+ main.status = 'idle';
179
+ ctx.emit('agent/status', { agent: main, status: 'running' });
180
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
181
+ assert.match(logs.text, /idle-gate: 挂起完成播报/, '有后台 job 在跑时应挂起');
182
+ } finally {
183
+ logs.restore();
184
+ }
185
+ });
186
+
187
+ test('空闲门:idleGate=false 时完全不过门(回归开关可用)', () => {
188
+ const main = mkAgent('main-1', { status: 'running' });
189
+ const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'main-1', status: 'running' });
190
+ const ctx = mkCtx({ agents: { list: () => [main, child], get: () => main } });
191
+
192
+ const logs = captureLogs();
193
+ try {
194
+ apply(ctx, { idleGate: false });
195
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
196
+ main.status = 'idle';
197
+ ctx.emit('agent/status', { agent: main, status: 'running' });
198
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
199
+ assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, 'idleGate=false 时应照旧立即播报');
200
+ } finally {
201
+ logs.restore();
202
+ }
203
+ });
204
+
205
+ test('空闲门:error 分支不过门(有子代理在跑也立即播报)', () => {
206
+ const main = mkAgent('main-1', { status: 'running' });
207
+ const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'main-1', status: 'running' });
208
+ const ctx = mkCtx({ agents: { list: () => [main, child], get: () => main } });
209
+
210
+ const logs = captureLogs();
211
+ try {
212
+ apply(ctx, { idleGate: true });
213
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'error' } } });
214
+ main.status = 'idle';
215
+ ctx.emit('agent/status', { agent: main, status: 'running' });
216
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
217
+ assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, 'error 需立即介入,不应被挂起');
218
+ } finally {
219
+ logs.restore();
220
+ }
221
+ });
222
+
223
+ test('空闲门:子代理自身的完成不播报(subagents=false 默认过滤仍生效)', () => {
224
+ const child = mkAgent('child-1', { origin: 'subagent', parentSession: 'main-1', status: 'running' });
225
+ const ctx = mkCtx({ agents: { list: () => [child], get: () => undefined } });
226
+
227
+ const logs = captureLogs();
228
+ try {
229
+ apply(ctx, { idleGate: true, subagents: false });
230
+ ctx.emit('session/event', child.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
231
+ child.status = 'idle';
232
+ ctx.emit('agent/status', { agent: child, status: 'running' });
233
+ ctx.emit('agent/status', { agent: child, status: 'idle' });
234
+ assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, '子代理自身完成不应触发主会话播报');
235
+ } finally {
236
+ logs.restore();
237
+ }
238
+ });
239
+
240
+ test('空闲门:宿主服务全部缺失时退化为立即播报(fail-visible,不静默)', async () => {
241
+ const main = mkAgent('main-1', { status: 'running' });
242
+ const ctx = mkCtx(); // 无 jobs、无 agents
243
+
244
+ const logs = captureLogs();
245
+ try {
246
+ apply(ctx, { idleGate: true });
247
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
248
+ main.status = 'idle';
249
+ ctx.emit('agent/status', { agent: main, status: 'running' });
250
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
251
+ assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, '服务不可读时应退化为立即播报(宁可多报不静默)');
252
+ } finally {
253
+ logs.restore();
254
+ }
255
+ await settle();
256
+ });
257
+
258
+ test('空闲门:jobs.list 抛异常时不崩、退化为立即播报', () => {
259
+ const main = mkAgent('main-1', { status: 'running' });
260
+ const ctx = mkCtx({
261
+ agents: { list: () => [main], get: () => main },
262
+ jobs: { list: () => { throw new Error('boom'); }, onJobsChanged: () => () => {} },
263
+ });
264
+
265
+ const logs = captureLogs();
266
+ try {
267
+ apply(ctx, { idleGate: true });
268
+ ctx.emit('session/event', main.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
269
+ main.status = 'idle';
270
+ ctx.emit('agent/status', { agent: main, status: 'running' });
271
+ ctx.emit('agent/status', { agent: main, status: 'idle' });
272
+ assert.match(logs.text, /jobs\.list failed/, '应记录 jobs.list 失败');
273
+ assert.doesNotMatch(logs.text, /idle-gate: 挂起完成播报/, '读取失败应退化为立即播报');
274
+ } finally {
275
+ logs.restore();
276
+ }
277
+ });
278
+
279
+ test('审批 waterfall:始终 next() 放行并透传返回值(绝不阻断)', () => {
280
+ const ctx = mkCtx();
281
+ apply(ctx, {});
282
+ const handler = (ctx.handlers.get('approval/request') || [])[0];
283
+ assert.ok(handler, '应有 approval/request 处理器');
284
+ const sentinel = { decision: 'allow' };
285
+ const got = handler({ agent: mkAgent('main-1'), toolName: 'pwsh' }, () => sentinel);
286
+ assert.equal(got, sentinel, '必须透传 next() 的返回值');
287
+ });