switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -0,0 +1,153 @@
1
+ /**
2
+ * One-voice-note-per-response contract test for the kokoro (local sidecar)
3
+ * voice-out path.
4
+ *
5
+ * The gateway send path (gateway.ts resolveVoiceOutPlan → synth loop →
6
+ * sendVoice loop) now, for the kokoro engine, sends the WHOLE speech-
7
+ * normalized reply to /tts in a SINGLE call and issues a SINGLE sendVoice —
8
+ * the sidecar chunks + concatenates internally and returns one ogg/opus file.
9
+ *
10
+ * gateway.ts is a large module with import-time side effects, so rather than
11
+ * import resolveVoiceOutPlan (private) we reproduce the exact decision it
12
+ * makes for the kokoro path and assert the observable contract: for a LONG
13
+ * reply, normalization yields the single text sent to /tts, synthesis issues
14
+ * exactly ONE /tts fetch, and exactly ONE sendVoice is issued.
15
+ */
16
+
17
+ import { describe, it, expect } from 'bun:test'
18
+ import { normalizeForSpeech } from '../voice-normalize-text.js'
19
+ import { synthesizeViaSidecar, clampTtsSpeed } from '../voice-synthesize-sidecar.js'
20
+
21
+ /** The fleet default speed the gateway applies when voice_out is enabled but
22
+ * speed is unset (mirrors VOICE_OUT_DEFAULT_SPEED in gateway.ts). */
23
+ const VOICE_OUT_DEFAULT_SPEED = 1.1
24
+
25
+ /** Reproduce resolveVoiceOutPlan's speed resolution: default to 1.1 when
26
+ * unset, clamp to 0.5–2.0. */
27
+ function resolveSpeed(speed: number | undefined): number {
28
+ return clampTtsSpeed(speed, VOICE_OUT_DEFAULT_SPEED)
29
+ }
30
+
31
+ const OGG_BYTES = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
32
+
33
+ /** Build the kokoro voice-note chunks exactly as resolveVoiceOutPlan does:
34
+ * normalize the whole reply, then a SINGLE element (no client-side split). */
35
+ function kokoroTtsChunks(replyText: string): string[] {
36
+ const ttsText = normalizeForSpeech(replyText)
37
+ if (ttsText.length === 0) return []
38
+ return [ttsText] // kokoro: one call, one note
39
+ }
40
+
41
+ describe('voice-out kokoro path — ONE synth call, ONE sendVoice', () => {
42
+ it('splits a long markdown reply into exactly one voice-note chunk', () => {
43
+ const longReply = Array.from(
44
+ { length: 40 },
45
+ (_, i) => `## Section ${i}\nSome **bold** prose with \`code\` and a [link](https://x.com/${i}).`,
46
+ ).join('\n\n')
47
+ expect(longReply.length).toBeGreaterThan(1200) // would have been multi-chunk before
48
+
49
+ const chunks = kokoroTtsChunks(longReply)
50
+ expect(chunks).toHaveLength(1)
51
+ // Normalization is applied: no markdown/symbols leak into the spoken text.
52
+ expect(chunks[0]).not.toContain('##')
53
+ expect(chunks[0]).not.toContain('`')
54
+ expect(chunks[0]).not.toContain('**')
55
+ expect(chunks[0]).not.toContain('https://')
56
+ })
57
+
58
+ it('issues exactly ONE /tts fetch for the whole long reply', async () => {
59
+ const longReply = 'A '.repeat(2000) // ~4000 chars, well over the old 1200 cap
60
+ const chunks = kokoroTtsChunks(longReply)
61
+ expect(chunks).toHaveLength(1)
62
+
63
+ let fetchCalls = 0
64
+ const countingFetch = (async (_url: string, init: RequestInit) => {
65
+ fetchCalls++
66
+ // The single call must carry the full normalized text.
67
+ const body = JSON.parse(init.body as string) as { text: string }
68
+ expect(body.text.length).toBeGreaterThan(1200)
69
+ return {
70
+ ok: true,
71
+ status: 200,
72
+ arrayBuffer: async () =>
73
+ OGG_BYTES.buffer.slice(OGG_BYTES.byteOffset, OGG_BYTES.byteOffset + OGG_BYTES.byteLength),
74
+ text: async () => '',
75
+ headers: new Headers(),
76
+ }
77
+ }) as unknown as typeof fetch
78
+
79
+ // Simulate the gateway synth loop over the (single) chunk set.
80
+ const voiceOggs: Uint8Array[] = []
81
+ for (const chunkText of chunks) {
82
+ const r = await synthesizeViaSidecar({ token: 'tok', text: chunkText, fetchImpl: countingFetch })
83
+ if (r.ok) voiceOggs.push(r.audio)
84
+ }
85
+
86
+ expect(fetchCalls).toBe(1) // ONE synth call
87
+ expect(voiceOggs).toHaveLength(1) // ONE ogg → ONE sendVoice
88
+ })
89
+
90
+ it('issues exactly ONE sendVoice (simulating the gateway send loop)', async () => {
91
+ const chunks = kokoroTtsChunks('Hello **world**, this is a `test` reply.\n\nSecond paragraph.')
92
+ expect(chunks).toHaveLength(1)
93
+
94
+ const okFetch = (async () => ({
95
+ ok: true,
96
+ status: 200,
97
+ arrayBuffer: async () =>
98
+ OGG_BYTES.buffer.slice(OGG_BYTES.byteOffset, OGG_BYTES.byteOffset + OGG_BYTES.byteLength),
99
+ text: async () => '',
100
+ headers: new Headers(),
101
+ })) as unknown as typeof fetch
102
+
103
+ const voiceOggs: Uint8Array[] = []
104
+ for (const chunkText of chunks) {
105
+ const r = await synthesizeViaSidecar({ token: 'tok', text: chunkText, fetchImpl: okFetch })
106
+ if (r.ok) voiceOggs.push(r.audio)
107
+ }
108
+
109
+ let sendVoiceCalls = 0
110
+ for (const _ogg of voiceOggs) {
111
+ sendVoiceCalls++ // gateway: one sendVoice per ogg
112
+ }
113
+ expect(sendVoiceCalls).toBe(1)
114
+ })
115
+ })
116
+
117
+ describe('voice-out kokoro path — speed resolution (gateway default 1.1)', () => {
118
+ it('defaults to 1.1 when voice_out.speed is unset', () => {
119
+ expect(resolveSpeed(undefined)).toBe(1.1)
120
+ })
121
+
122
+ it('honours an explicit in-range speed', () => {
123
+ expect(resolveSpeed(0.9)).toBe(0.9)
124
+ expect(resolveSpeed(1.5)).toBe(1.5)
125
+ })
126
+
127
+ it('clamps an out-of-range configured speed to 0.5–2.0', () => {
128
+ expect(resolveSpeed(3.0)).toBe(2.0)
129
+ expect(resolveSpeed(0.1)).toBe(0.5)
130
+ })
131
+
132
+ it('threads the resolved speed into the single /tts body', async () => {
133
+ let sentSpeed: number | undefined
134
+ const capturingFetch = (async (_url: string, init: RequestInit) => {
135
+ sentSpeed = (JSON.parse(init.body as string) as { speed?: number }).speed
136
+ return {
137
+ ok: true,
138
+ status: 200,
139
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
140
+ text: async () => '',
141
+ headers: new Headers(),
142
+ }
143
+ }) as unknown as typeof fetch
144
+
145
+ await synthesizeViaSidecar({
146
+ token: 't',
147
+ text: 'hello there',
148
+ speed: resolveSpeed(undefined), // gateway default path
149
+ fetchImpl: capturingFetch,
150
+ })
151
+ expect(sentSpeed).toBe(1.1)
152
+ })
153
+ })
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Eager voice pre-synthesis + rolling cleanup unit tests (issue #2763).
3
+ *
4
+ * gateway.ts is a 25k-line module with import-time side effects, so the
5
+ * primitives live in ../voice-presynth.ts (imported by the gateway). These
6
+ * tests exercise the real primitives + reproduce the exact gateway decisions
7
+ * (queue bounding, TTL/size sweep, attach-vs-fallback, kill switch) without
8
+ * importing the gateway.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
12
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, utimesSync, mkdirSync } from 'fs'
13
+ import { join } from 'path'
14
+ import { tmpdir } from 'os'
15
+ import {
16
+ PreSynthQueue,
17
+ sweepVoiceCacheDir,
18
+ writeVoiceCacheFile,
19
+ voiceCacheFilePath,
20
+ eagerVoiceEnabled,
21
+ VOICE_FILE_TTL_MS,
22
+ PRESYNTH_MAX_PENDING,
23
+ type PreSynthJob,
24
+ type SweepFs,
25
+ } from '../voice-presynth.js'
26
+ import { VoiceOnDemandCache } from '../voice-ondemand.js'
27
+
28
+ const KILL = 'SWITCHROOM_DISABLE_EAGER_VOICE'
29
+ let savedKill: string | undefined
30
+
31
+ beforeEach(() => {
32
+ savedKill = process.env[KILL]
33
+ delete process.env[KILL]
34
+ })
35
+
36
+ afterEach(() => {
37
+ if (savedKill === undefined) delete process.env[KILL]
38
+ else process.env[KILL] = savedKill
39
+ })
40
+
41
+ // ─── Kill switch ────────────────────────────────────────────────────────────
42
+
43
+ describe('eagerVoiceEnabled (kill switch)', () => {
44
+ it('is on by default', () => {
45
+ expect(eagerVoiceEnabled()).toBe(true)
46
+ })
47
+
48
+ it('is off when SWITCHROOM_DISABLE_EAGER_VOICE=1 or =true', () => {
49
+ process.env[KILL] = '1'
50
+ expect(eagerVoiceEnabled()).toBe(false)
51
+ process.env[KILL] = 'true'
52
+ expect(eagerVoiceEnabled()).toBe(false)
53
+ })
54
+
55
+ it('other values do not disable', () => {
56
+ process.env[KILL] = '0'
57
+ expect(eagerVoiceEnabled()).toBe(true)
58
+ process.env[KILL] = ''
59
+ expect(eagerVoiceEnabled()).toBe(true)
60
+ })
61
+
62
+ it('gateway put-site decision: kill switch means no job is enqueued', () => {
63
+ // Reproduces the exact guard the gateway uses around enqueue().
64
+ process.env[KILL] = '1'
65
+ const ran: string[] = []
66
+ const q = new PreSynthQueue({
67
+ runJob: async (j) => void ran.push(j.token),
68
+ defer: (fn) => fn(),
69
+ log: () => {},
70
+ })
71
+ if (eagerVoiceEnabled()) q.enqueue({ token: 't1', text: 'hello' })
72
+ expect(q.size).toBe(0)
73
+ expect(ran).toEqual([])
74
+ })
75
+ })
76
+
77
+ // ─── Queue bounding + serialization ─────────────────────────────────────────
78
+
79
+ describe('PreSynthQueue', () => {
80
+ it('drains jobs FIFO with concurrency 1', async () => {
81
+ const order: string[] = []
82
+ let inFlight = 0
83
+ let maxInFlight = 0
84
+ const deferred: Array<() => void> = []
85
+ const q = new PreSynthQueue({
86
+ runJob: async (j) => {
87
+ inFlight++
88
+ maxInFlight = Math.max(maxInFlight, inFlight)
89
+ await Promise.resolve() // yield — lets a concurrent runner interleave if one existed
90
+ order.push(j.token)
91
+ inFlight--
92
+ },
93
+ defer: (fn) => deferred.push(fn),
94
+ log: () => {},
95
+ })
96
+ q.enqueue({ token: 'a', text: '1' })
97
+ q.enqueue({ token: 'b', text: '2' })
98
+ q.enqueue({ token: 'c', text: '3' })
99
+ expect(deferred.length).toBe(1) // one drain kicked, not one per job
100
+ deferred[0]!()
101
+ await new Promise((r) => setTimeout(r, 0))
102
+ expect(order).toEqual(['a', 'b', 'c'])
103
+ expect(maxInFlight).toBe(1)
104
+ expect(q.size).toBe(0)
105
+ expect(q.busy).toBe(false)
106
+ })
107
+
108
+ it('drops the OLDEST pending job past the backlog cap', () => {
109
+ const q = new PreSynthQueue({
110
+ runJob: async () => {},
111
+ maxPending: 3,
112
+ defer: () => {}, // never drain — pure backlog test
113
+ log: () => {},
114
+ })
115
+ for (const t of ['a', 'b', 'c', 'd', 'e']) q.enqueue({ token: t, text: t })
116
+ expect(q.size).toBe(3)
117
+ })
118
+
119
+ it('drop-oldest under a blocked runner keeps only the newest maxPending', async () => {
120
+ let release: () => void = () => {}
121
+ const gate = new Promise<void>((r) => (release = r))
122
+ const ran: string[] = []
123
+ const q = new PreSynthQueue({
124
+ runJob: async (j) => {
125
+ ran.push(j.token)
126
+ if (j.token === 'first') await gate // block the drain on the first job
127
+ },
128
+ maxPending: 2,
129
+ defer: (fn) => fn(),
130
+ log: () => {},
131
+ })
132
+ q.enqueue({ token: 'first', text: 'x' }) // starts running, blocks
133
+ await new Promise((r) => setTimeout(r, 0))
134
+ q.enqueue({ token: 'a', text: '1' })
135
+ q.enqueue({ token: 'b', text: '2' })
136
+ q.enqueue({ token: 'c', text: '3' }) // over cap → 'a' dropped
137
+ expect(q.size).toBe(2)
138
+ release()
139
+ await new Promise((r) => setTimeout(r, 0))
140
+ expect(ran).toEqual(['first', 'b', 'c'])
141
+ })
142
+
143
+ it('default cap matches the design (~50)', () => {
144
+ expect(PRESYNTH_MAX_PENDING).toBe(50)
145
+ })
146
+
147
+ it('a throwing job never propagates and does not stall the queue', async () => {
148
+ const ran: string[] = []
149
+ const logs: string[] = []
150
+ const q = new PreSynthQueue({
151
+ runJob: async (j) => {
152
+ if (j.token === 'boom') throw new Error('synth exploded')
153
+ ran.push(j.token)
154
+ },
155
+ defer: (fn) => fn(),
156
+ log: (l) => void logs.push(l),
157
+ })
158
+ q.enqueue({ token: 'boom', text: 'x' })
159
+ q.enqueue({ token: 'ok', text: 'y' })
160
+ await new Promise((r) => setTimeout(r, 0))
161
+ expect(ran).toEqual(['ok'])
162
+ expect(logs.some((l) => l.includes('boom'))).toBe(true)
163
+ expect(q.busy).toBe(false)
164
+ })
165
+ })
166
+
167
+ // ─── File persistence helpers ────────────────────────────────────────────────
168
+
169
+ describe('writeVoiceCacheFile', () => {
170
+ it('writes atomically under the dir, creating it as needed', () => {
171
+ const dir = mkdtempSync(join(tmpdir(), 'voice-cache-'))
172
+ try {
173
+ const nested = join(dir, 'voice-cache')
174
+ const audio = new Uint8Array([1, 2, 3, 4])
175
+ const path = writeVoiceCacheFile(nested, 'tok1', audio)
176
+ expect(path).toBe(voiceCacheFilePath(nested, 'tok1'))
177
+ expect(existsSync(path)).toBe(true)
178
+ expect(Array.from(readFileSync(path))).toEqual([1, 2, 3, 4])
179
+ expect(existsSync(path + '.tmp')).toBe(false)
180
+ } finally {
181
+ rmSync(dir, { recursive: true, force: true })
182
+ }
183
+ })
184
+ })
185
+
186
+ // ─── Sweep: TTL + size budget ────────────────────────────────────────────────
187
+
188
+ function makeFakeFs(files: Record<string, { mtimeMs: number; size: number }>): {
189
+ fs: SweepFs
190
+ deleted: string[]
191
+ } {
192
+ const deleted: string[] = []
193
+ const state = { ...files }
194
+ return {
195
+ deleted,
196
+ fs: {
197
+ readdirSync: () => Object.keys(state),
198
+ statSync: (path: string) => {
199
+ const name = path.split('/').pop()!
200
+ const f = state[name]
201
+ if (f == null) throw new Error('ENOENT')
202
+ return { mtimeMs: f.mtimeMs, size: f.size, isFile: () => true }
203
+ },
204
+ unlinkSync: (path: string) => {
205
+ const name = path.split('/').pop()!
206
+ if (state[name] == null) throw new Error('ENOENT')
207
+ delete state[name]
208
+ deleted.push(name)
209
+ },
210
+ },
211
+ }
212
+ }
213
+
214
+ describe('sweepVoiceCacheDir', () => {
215
+ const NOW = 1_800_000_000_000
216
+ const DAY = 24 * 60 * 60 * 1000
217
+
218
+ it('deletes files older than 7 days and reports their tokens', () => {
219
+ const { fs, deleted } = makeFakeFs({
220
+ 'old.ogg': { mtimeMs: NOW - 8 * DAY, size: 100 },
221
+ 'edge.ogg': { mtimeMs: NOW - VOICE_FILE_TTL_MS, size: 100 }, // exactly at cutoff → gone
222
+ 'fresh.ogg': { mtimeMs: NOW - 1 * DAY, size: 100 },
223
+ })
224
+ const result = sweepVoiceCacheDir({ dir: '/x', now: () => NOW, fs, log: () => {} })
225
+ expect(deleted.sort()).toEqual(['edge.ogg', 'old.ogg'])
226
+ expect(result.deletedTokens.sort()).toEqual(['edge', 'old'])
227
+ expect(result.remainingBytes).toBe(100)
228
+ })
229
+
230
+ it('size budget: deletes oldest-first down to maxBytes even when fresh', () => {
231
+ const { fs, deleted } = makeFakeFs({
232
+ 'a.ogg': { mtimeMs: NOW - 3 * DAY, size: 300 },
233
+ 'b.ogg': { mtimeMs: NOW - 2 * DAY, size: 300 },
234
+ 'c.ogg': { mtimeMs: NOW - 1 * DAY, size: 300 },
235
+ })
236
+ const result = sweepVoiceCacheDir({
237
+ dir: '/x',
238
+ now: () => NOW,
239
+ fs,
240
+ maxBytes: 650,
241
+ log: () => {},
242
+ })
243
+ expect(deleted).toEqual(['a.ogg']) // oldest first, stop once under budget
244
+ expect(result.remainingBytes).toBe(600)
245
+ })
246
+
247
+ it('missing dir is a no-op (crash-safe)', () => {
248
+ const result = sweepVoiceCacheDir({
249
+ dir: join(tmpdir(), 'definitely-not-there-' + Date.now()),
250
+ log: () => {},
251
+ })
252
+ expect(result.deletedTokens).toEqual([])
253
+ expect(result.remainingBytes).toBe(0)
254
+ })
255
+
256
+ it('a file vanishing mid-sweep (stat or unlink ENOENT) never aborts', () => {
257
+ let statCalls = 0
258
+ const fs: SweepFs = {
259
+ readdirSync: () => ['gone.ogg', 'old.ogg'],
260
+ statSync: (path: string) => {
261
+ statCalls++
262
+ if (path.endsWith('gone.ogg')) throw new Error('ENOENT')
263
+ return { mtimeMs: NOW - 8 * DAY, size: 10, isFile: () => true }
264
+ },
265
+ unlinkSync: () => {
266
+ throw new Error('ENOENT') // concurrent delete — tolerated
267
+ },
268
+ }
269
+ const result = sweepVoiceCacheDir({ dir: '/x', now: () => NOW, fs, log: () => {} })
270
+ expect(statCalls).toBe(2)
271
+ expect(result.deletedTokens).toEqual(['old'])
272
+ })
273
+
274
+ it('real-fs end-to-end: TTL sweep removes old, keeps fresh', () => {
275
+ const dir = mkdtempSync(join(tmpdir(), 'voice-sweep-'))
276
+ try {
277
+ const oldPath = join(dir, 'oldtok.ogg')
278
+ const freshPath = join(dir, 'freshtok.ogg')
279
+ writeFileSync(oldPath, 'old')
280
+ writeFileSync(freshPath, 'fresh')
281
+ const oldTime = (Date.now() - 8 * DAY) / 1000
282
+ utimesSync(oldPath, oldTime, oldTime)
283
+ const result = sweepVoiceCacheDir({ dir, log: () => {} })
284
+ expect(result.deletedTokens).toEqual(['oldtok'])
285
+ expect(existsSync(oldPath)).toBe(false)
286
+ expect(existsSync(freshPath)).toBe(true)
287
+ } finally {
288
+ rmSync(dir, { recursive: true, force: true })
289
+ }
290
+ })
291
+ })
292
+
293
+ // ─── Attach-vs-fallback decision + cache integration ────────────────────────
294
+
295
+ describe('attach-on-tap decision (gateway contract)', () => {
296
+ /** Reproduces the exact gateway tap decision: pre-made file readable →
297
+ * attach; otherwise → lazy synth fallback. */
298
+ function decideTap(entry: { filePath?: string } | null): 'expired' | 'attach' | 'lazy' {
299
+ if (entry == null) return 'expired'
300
+ if (entry.filePath != null) {
301
+ try {
302
+ readFileSync(entry.filePath)
303
+ return 'attach'
304
+ } catch {
305
+ return 'lazy'
306
+ }
307
+ }
308
+ return 'lazy'
309
+ }
310
+
311
+ it('attaches when the pre-made file exists and the entry is live', () => {
312
+ const dir = mkdtempSync(join(tmpdir(), 'voice-attach-'))
313
+ try {
314
+ const cache = new VoiceOnDemandCache()
315
+ cache.put('tok', { text: 'hello world', speed: 1.1 })
316
+ const filePath = writeVoiceCacheFile(dir, 'tok', new Uint8Array([9, 9]))
317
+ cache.setFilePath('tok', filePath)
318
+ const entry = cache.get('tok')
319
+ expect(entry?.filePath).toBe(filePath)
320
+ expect(decideTap(entry)).toBe('attach')
321
+ } finally {
322
+ rmSync(dir, { recursive: true, force: true })
323
+ }
324
+ })
325
+
326
+ it('falls back to lazy when the file was swept from disk', () => {
327
+ const dir = mkdtempSync(join(tmpdir(), 'voice-attach-'))
328
+ try {
329
+ const cache = new VoiceOnDemandCache()
330
+ cache.put('tok', { text: 'hello', speed: 1.0 })
331
+ const filePath = writeVoiceCacheFile(dir, 'tok', new Uint8Array([1]))
332
+ cache.setFilePath('tok', filePath)
333
+ rmSync(filePath) // sweep/crash took the file
334
+ expect(decideTap(cache.get('tok'))).toBe('lazy')
335
+ } finally {
336
+ rmSync(dir, { recursive: true, force: true })
337
+ }
338
+ })
339
+
340
+ it('pre-feature entries (no filePath) take the lazy path', () => {
341
+ const cache = new VoiceOnDemandCache()
342
+ cache.put('tok', { text: 'hello', speed: 1.0 })
343
+ const entry = cache.get('tok')
344
+ expect(entry?.filePath).toBeUndefined()
345
+ expect(decideTap(entry)).toBe('lazy')
346
+ })
347
+
348
+ it('expired entries degrade to the expired toast (7-day TTL)', () => {
349
+ let nowMs = 1_000
350
+ const cache = new VoiceOnDemandCache({ now: () => nowMs })
351
+ cache.put('tok', { text: 'hello', speed: 1.0 })
352
+ nowMs += VOICE_FILE_TTL_MS + 1 // > 7 days later
353
+ expect(cache.get('tok')).toBeNull()
354
+ expect(decideTap(cache.get('tok'))).toBe('expired')
355
+ })
356
+
357
+ it('setFilePath on an expired/evicted entry is a no-op', () => {
358
+ let nowMs = 1_000
359
+ const cache = new VoiceOnDemandCache({ now: () => nowMs })
360
+ cache.put('tok', { text: 'hello', speed: 1.0 })
361
+ nowMs += VOICE_FILE_TTL_MS + 1
362
+ cache.setFilePath('tok', '/some/file.ogg') // must not resurrect
363
+ expect(cache.get('tok')).toBeNull()
364
+ })
365
+
366
+ it('prune drops entries whose files the sweep deleted', () => {
367
+ const cache = new VoiceOnDemandCache()
368
+ cache.put('a', { text: 'x', speed: 1 })
369
+ cache.put('b', { text: 'y', speed: 1 })
370
+ cache.prune(['a', 'unknown-token'])
371
+ expect(cache.get('a')).toBeNull()
372
+ expect(cache.get('b')).not.toBeNull()
373
+ })
374
+
375
+ it('filePath + createdAt survive the persistence round-trip', () => {
376
+ const dir = mkdtempSync(join(tmpdir(), 'voice-persist-'))
377
+ try {
378
+ const persistPath = join(dir, 'voice-ondemand.json')
379
+ const c1 = new VoiceOnDemandCache({ persistPath })
380
+ c1.put('tok', { text: 'hello', speed: 1.2 })
381
+ c1.setFilePath('tok', '/state/voice-cache/tok.ogg')
382
+ const c2 = new VoiceOnDemandCache({ persistPath })
383
+ const entry = c2.get('tok')
384
+ expect(entry?.filePath).toBe('/state/voice-cache/tok.ogg')
385
+ // createdAt is persisted on the stored entry (sweep/introspection aid)
386
+ // even though get() keeps it internal.
387
+ const raw = JSON.parse(readFileSync(persistPath, 'utf8')) as {
388
+ entries: Record<string, { createdAt?: number }>
389
+ }
390
+ expect(typeof raw.entries['tok']?.createdAt).toBe('number')
391
+ } finally {
392
+ rmSync(dir, { recursive: true, force: true })
393
+ }
394
+ })
395
+ })
396
+
397
+ // ─── End-to-end: queue → file → cache → sweep ───────────────────────────────
398
+
399
+ describe('pre-synth pipeline integration (no gateway import)', () => {
400
+ it('a job writes the file, records it, and the sweep later prunes both', async () => {
401
+ const dir = mkdtempSync(join(tmpdir(), 'voice-e2e-'))
402
+ try {
403
+ const cacheDir = join(dir, 'voice-cache')
404
+ mkdirSync(cacheDir, { recursive: true })
405
+ const cache = new VoiceOnDemandCache()
406
+ cache.put('tok', { text: 'spoken reply', speed: 1.1 })
407
+ const q = new PreSynthQueue({
408
+ // Mirrors the gateway runJob: fake sidecar → write → record.
409
+ runJob: async (job: PreSynthJob) => {
410
+ const audio = new TextEncoder().encode(`AUDIO:${job.text}`)
411
+ const filePath = writeVoiceCacheFile(cacheDir, job.token, audio)
412
+ cache.setFilePath(job.token, filePath)
413
+ },
414
+ defer: (fn) => fn(),
415
+ log: () => {},
416
+ })
417
+ q.enqueue({ token: 'tok', text: 'spoken reply', speed: 1.1 })
418
+ await new Promise((r) => setTimeout(r, 0))
419
+ const entry = cache.get('tok')
420
+ expect(entry?.filePath).toBeDefined()
421
+ expect(readFileSync(entry!.filePath!, 'utf8')).toBe('AUDIO:spoken reply')
422
+
423
+ // 8 days later the sweep removes the file and the entry is pruned.
424
+ const DAY = 24 * 60 * 60 * 1000
425
+ const result = sweepVoiceCacheDir({
426
+ dir: cacheDir,
427
+ now: () => Date.now() + 8 * DAY,
428
+ log: () => {},
429
+ })
430
+ expect(result.deletedTokens).toEqual(['tok'])
431
+ cache.prune(result.deletedTokens)
432
+ expect(existsSync(entry!.filePath!)).toBe(false)
433
+ } finally {
434
+ rmSync(dir, { recursive: true, force: true })
435
+ }
436
+ })
437
+ })