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,352 @@
1
+ /**
2
+ * Unit tests for the LOCAL TTS sidecar synthesis path (voice PR-C2):
3
+ * - synthesizeViaSidecar (HTTP POST to /tts, mocked fetch)
4
+ *
5
+ * The OUTBOUND mirror of voice-transcribe-sidecar.test.ts: every error
6
+ * reason is pinned so a refactor can't silently change the contract the
7
+ * gateway depends on (any non-ok → null → text-only fallback). The /tts
8
+ * endpoint returns RAW OGG bytes on success (not JSON), so the happy-path
9
+ * asserts on arrayBuffer + the audio/ogg-style meta headers.
10
+ */
11
+
12
+ import { describe, it, expect } from 'bun:test'
13
+ import {
14
+ synthesizeViaSidecar,
15
+ chunkTtsText,
16
+ clampTtsSpeed,
17
+ SIDECAR_TTS_MAX_CHARS,
18
+ DEFAULT_SIDECAR_BASE_URL,
19
+ } from '../voice-synthesize-sidecar.js'
20
+
21
+ const OGG_BYTES = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
22
+
23
+ /** Mock a 200 audio/ogg response carrying `bytes`. */
24
+ function makeOggFetch(
25
+ bytes: Uint8Array,
26
+ headers: Record<string, string> = {},
27
+ ): typeof fetch {
28
+ return (async () => ({
29
+ ok: true,
30
+ status: 200,
31
+ arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
32
+ text: async () => '',
33
+ headers: new Headers(headers),
34
+ })) as unknown as typeof fetch
35
+ }
36
+
37
+ /** Mock a non-2xx JSON error response. */
38
+ function makeErrFetch(status: number, body: unknown): typeof fetch {
39
+ return (async () => ({
40
+ ok: false,
41
+ status,
42
+ arrayBuffer: async () => new ArrayBuffer(0),
43
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
44
+ headers: new Headers(),
45
+ })) as unknown as typeof fetch
46
+ }
47
+
48
+ describe('synthesizeViaSidecar — pre-flight checks', () => {
49
+ it('returns no-token when token is empty', async () => {
50
+ const r = await synthesizeViaSidecar({
51
+ token: '',
52
+ text: 'hello',
53
+ fetchImpl: makeOggFetch(OGG_BYTES),
54
+ })
55
+ expect(r).toMatchObject({ ok: false, reason: 'no-token' })
56
+ })
57
+
58
+ it('returns empty-text on whitespace-only text', async () => {
59
+ const r = await synthesizeViaSidecar({ token: 't', text: ' ' })
60
+ expect(r).toMatchObject({ ok: false, reason: 'empty-text' })
61
+ })
62
+
63
+ it('returns text-too-long above the cap', async () => {
64
+ const r = await synthesizeViaSidecar({
65
+ token: 't',
66
+ text: 'x'.repeat(SIDECAR_TTS_MAX_CHARS + 1),
67
+ })
68
+ expect(r).toMatchObject({ ok: false, reason: 'text-too-long' })
69
+ })
70
+ })
71
+
72
+ describe('synthesizeViaSidecar — happy path', () => {
73
+ it('returns OGG bytes + meta on 200 audio/ogg', async () => {
74
+ const r = await synthesizeViaSidecar({
75
+ token: 't',
76
+ text: 'Hello world',
77
+ fetchImpl: makeOggFetch(OGG_BYTES, {
78
+ 'X-Voice-Duration-Ms': '1234',
79
+ 'X-Voice-Audio-Seconds': '2.5',
80
+ 'X-Voice-Voice': 'af_heart',
81
+ }),
82
+ })
83
+ expect(r.ok).toBe(true)
84
+ if (r.ok) {
85
+ expect(Array.from(r.audio)).toEqual(Array.from(OGG_BYTES))
86
+ expect(r.durationMs).toBe(1234)
87
+ expect(r.audioSeconds).toBe(2.5)
88
+ expect(r.voice).toBe('af_heart')
89
+ }
90
+ })
91
+
92
+ it('POSTs JSON {text,voice,format:ogg} + X-Voice-Token to <base>/tts', async () => {
93
+ let receivedUrl = ''
94
+ let receivedToken: string | null = null
95
+ let receivedBody: string | null = null
96
+ const fakeFetch: typeof fetch = (async (url: string, init?: RequestInit) => {
97
+ receivedUrl = url
98
+ receivedToken = (init?.headers as Record<string, string>)['X-Voice-Token']
99
+ receivedBody = init?.body as string
100
+ return {
101
+ ok: true,
102
+ status: 200,
103
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
104
+ text: async () => '',
105
+ headers: new Headers(),
106
+ }
107
+ }) as unknown as typeof fetch
108
+
109
+ const r = await synthesizeViaSidecar({
110
+ token: 'secret-token',
111
+ text: 'bonjour',
112
+ voice: 'af_sky',
113
+ fetchImpl: fakeFetch,
114
+ })
115
+ expect(r.ok).toBe(true)
116
+ expect(receivedUrl).toBe(`${DEFAULT_SIDECAR_BASE_URL}/tts`)
117
+ expect(receivedToken).toBe('secret-token')
118
+ const parsed = JSON.parse(receivedBody!)
119
+ expect(parsed.text).toBe('bonjour')
120
+ expect(parsed.voice).toBe('af_sky')
121
+ expect(parsed.format).toBe('ogg')
122
+ })
123
+
124
+ it('omits the voice field when none is supplied', async () => {
125
+ let receivedBody: string | null = null
126
+ const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
127
+ receivedBody = init?.body as string
128
+ return {
129
+ ok: true,
130
+ status: 200,
131
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
132
+ text: async () => '',
133
+ headers: new Headers(),
134
+ }
135
+ }) as unknown as typeof fetch
136
+ await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: fakeFetch })
137
+ const parsed = JSON.parse(receivedBody!)
138
+ expect('voice' in parsed).toBe(false)
139
+ })
140
+
141
+ it('omits the speed field when none is supplied (sidecar defaults to 1.0)', async () => {
142
+ let receivedBody: string | null = null
143
+ const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
144
+ receivedBody = init?.body as string
145
+ return {
146
+ ok: true,
147
+ status: 200,
148
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
149
+ text: async () => '',
150
+ headers: new Headers(),
151
+ }
152
+ }) as unknown as typeof fetch
153
+ await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: fakeFetch })
154
+ const parsed = JSON.parse(receivedBody!)
155
+ expect('speed' in parsed).toBe(false)
156
+ })
157
+
158
+ it('includes speed in the /tts body when supplied', async () => {
159
+ let receivedBody: string | null = null
160
+ const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
161
+ receivedBody = init?.body as string
162
+ return {
163
+ ok: true,
164
+ status: 200,
165
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
166
+ text: async () => '',
167
+ headers: new Headers(),
168
+ }
169
+ }) as unknown as typeof fetch
170
+ await synthesizeViaSidecar({ token: 't', text: 'hi', speed: 1.1, fetchImpl: fakeFetch })
171
+ const parsed = JSON.parse(receivedBody!)
172
+ expect(parsed.speed).toBe(1.1)
173
+ })
174
+
175
+ it('clamps an out-of-range speed before putting it on the wire', async () => {
176
+ const cap = async (speed: number): Promise<number> => {
177
+ let receivedBody: string | null = null
178
+ const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
179
+ receivedBody = init?.body as string
180
+ return {
181
+ ok: true,
182
+ status: 200,
183
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
184
+ text: async () => '',
185
+ headers: new Headers(),
186
+ }
187
+ }) as unknown as typeof fetch
188
+ await synthesizeViaSidecar({ token: 't', text: 'hi', speed, fetchImpl: fakeFetch })
189
+ return JSON.parse(receivedBody!).speed
190
+ }
191
+ expect(await cap(5.0)).toBe(2.0) // above max → 2.0
192
+ expect(await cap(0.1)).toBe(0.5) // below min → 0.5
193
+ })
194
+ })
195
+
196
+ describe('clampTtsSpeed', () => {
197
+ it('passes in-range values through', () => {
198
+ for (const v of [0.5, 0.75, 1.0, 1.1, 1.5, 2.0]) {
199
+ expect(clampTtsSpeed(v)).toBe(v)
200
+ }
201
+ })
202
+
203
+ it('clamps out-of-range numbers to the nearest bound', () => {
204
+ expect(clampTtsSpeed(0.1)).toBe(0.5)
205
+ expect(clampTtsSpeed(-3)).toBe(0.5)
206
+ expect(clampTtsSpeed(2.5)).toBe(2.0)
207
+ expect(clampTtsSpeed(100)).toBe(2.0)
208
+ })
209
+
210
+ it('falls back to the default for non-finite / non-numeric input', () => {
211
+ expect(clampTtsSpeed(undefined)).toBe(1.0)
212
+ expect(clampTtsSpeed(null)).toBe(1.0)
213
+ expect(clampTtsSpeed(NaN)).toBe(1.0)
214
+ expect(clampTtsSpeed('fast')).toBe(1.0)
215
+ expect(clampTtsSpeed(undefined, 1.1)).toBe(1.1) // custom fallback
216
+ })
217
+
218
+ it('falls back to wall-clock durationMs when headers are absent', async () => {
219
+ const r = await synthesizeViaSidecar({
220
+ token: 't',
221
+ text: 'hi',
222
+ fetchImpl: makeOggFetch(OGG_BYTES),
223
+ })
224
+ expect(r.ok).toBe(true)
225
+ if (r.ok) {
226
+ expect(r.durationMs).toBeGreaterThanOrEqual(0)
227
+ expect(r.audioSeconds).toBeNull()
228
+ expect(r.voice).toBeNull()
229
+ }
230
+ })
231
+ })
232
+
233
+ describe('synthesizeViaSidecar — error paths', () => {
234
+ it('echoes the sidecar reason from a non-2xx JSON body', async () => {
235
+ const r = await synthesizeViaSidecar({
236
+ token: 't',
237
+ text: 'hi',
238
+ fetchImpl: makeErrFetch(413, { ok: false, reason: 'text-too-long', detail: '9999 > 1200' }),
239
+ })
240
+ expect(r).toMatchObject({ ok: false, reason: 'text-too-long', detail: '9999 > 1200' })
241
+ })
242
+
243
+ it('falls back to http-<n> when the error body is not JSON', async () => {
244
+ const r = await synthesizeViaSidecar({
245
+ token: 't',
246
+ text: 'hi',
247
+ fetchImpl: makeErrFetch(503, 'service unavailable'),
248
+ })
249
+ expect(r).toMatchObject({ ok: false, reason: 'http-503' })
250
+ })
251
+
252
+ it('returns empty-audio on a 200 with no bytes', async () => {
253
+ const r = await synthesizeViaSidecar({
254
+ token: 't',
255
+ text: 'hi',
256
+ fetchImpl: makeOggFetch(new Uint8Array(0)),
257
+ })
258
+ expect(r).toMatchObject({ ok: false, reason: 'empty-audio' })
259
+ })
260
+
261
+ it('returns fetch-failed on a network error', async () => {
262
+ const boom: typeof fetch = (async () => {
263
+ throw new Error('ECONNREFUSED')
264
+ }) as unknown as typeof fetch
265
+ const r = await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: boom })
266
+ expect(r).toMatchObject({ ok: false, reason: 'fetch-failed' })
267
+ })
268
+
269
+ it('returns timeout when the fetch aborts', async () => {
270
+ const aborted: typeof fetch = (async () => {
271
+ throw new Error('The operation was aborted')
272
+ }) as unknown as typeof fetch
273
+ const r = await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: aborted })
274
+ expect(r).toMatchObject({ ok: false, reason: 'timeout' })
275
+ })
276
+ })
277
+
278
+ describe('chunkTtsText — long-reply voice chunking (PR-C2)', () => {
279
+ it('returns a single chunk when text fits the cap', () => {
280
+ expect(chunkTtsText('Short answer.', 600)).toEqual(['Short answer.'])
281
+ })
282
+
283
+ it('returns no chunks for empty text', () => {
284
+ expect(chunkTtsText('', 600)).toEqual([])
285
+ expect(chunkTtsText(' ', 600)).toEqual([])
286
+ })
287
+
288
+ it('splits a long reply into multiple chunks, each under the cap', () => {
289
+ const sentence = 'This is a sentence that is reasonably long. '
290
+ const text = sentence.repeat(20).trim() // ~880 chars
291
+ const chunks = chunkTtsText(text, 200)
292
+ expect(chunks.length).toBeGreaterThan(1)
293
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(200)
294
+ // No text is lost: every sentence's words survive across the chunks.
295
+ const rejoined = chunks.join(' ')
296
+ expect(rejoined).toContain('reasonably long')
297
+ expect(rejoined.split('reasonably long').length - 1).toBe(20)
298
+ })
299
+
300
+ it('prefers sentence boundaries — does not cut mid-sentence', () => {
301
+ const text =
302
+ 'First sentence here. Second sentence here. Third sentence here. Fourth one too.'
303
+ const chunks = chunkTtsText(text, 45)
304
+ // Each chunk should end at a sentence terminator (no dangling fragment).
305
+ for (const c of chunks) expect(/[.!?]$/.test(c)).toBe(true)
306
+ })
307
+
308
+ it('hard-slices a single sentence longer than the cap (no boundary fits)', () => {
309
+ const longWord = 'a'.repeat(50)
310
+ const text = `${longWord} ${longWord} ${longWord}` // one boundary-less-ish run
311
+ const chunks = chunkTtsText(text, 60)
312
+ expect(chunks.length).toBeGreaterThan(1)
313
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(60)
314
+ })
315
+
316
+ it('never emits a chunk over the cap even with a giant tokenless blob', () => {
317
+ const blob = 'x'.repeat(5000)
318
+ const chunks = chunkTtsText(blob, 1200)
319
+ expect(chunks.length).toBeGreaterThan(1)
320
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(1200)
321
+ })
322
+ })
323
+
324
+ describe('synthesizeViaSidecar — multi-chunk send path (PR-C2)', () => {
325
+ it('synthesizes each chunk independently (one POST per chunk)', async () => {
326
+ const text = 'Sentence one is here. '.repeat(40).trim() // long → many chunks
327
+ const chunks = chunkTtsText(text, 200)
328
+ expect(chunks.length).toBeGreaterThan(1)
329
+
330
+ let calls = 0
331
+ const fakeFetch: typeof fetch = (async () => {
332
+ calls++
333
+ return {
334
+ ok: true,
335
+ status: 200,
336
+ arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
337
+ text: async () => '',
338
+ headers: new Headers(),
339
+ }
340
+ }) as unknown as typeof fetch
341
+
342
+ // Mirror the gateway's per-chunk synthesis loop.
343
+ const oggs: Uint8Array[] = []
344
+ for (const c of chunks) {
345
+ const r = await synthesizeViaSidecar({ token: 't', text: c, fetchImpl: fakeFetch })
346
+ expect(r.ok).toBe(true)
347
+ if (r.ok) oggs.push(r.audio)
348
+ }
349
+ expect(calls).toBe(chunks.length)
350
+ expect(oggs.length).toBe(chunks.length)
351
+ })
352
+ })
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Unit tests for the LOCAL STT sidecar transcription path (voice PR-B2):
3
+ * - transcribeViaSidecar (HTTP POST to /stt, mocked fetch)
4
+ * - materializeSidecarToken (vault resolution, mocked broker)
5
+ *
6
+ * Mirrors the cloud twin's test (voice-transcribe.test.ts): every error
7
+ * reason is pinned so a refactor can't silently change the contract the
8
+ * gateway depends on (any non-ok → null → "(voice message)" fallback).
9
+ */
10
+
11
+ import { describe, it, expect } from 'bun:test'
12
+ import {
13
+ transcribeViaSidecar,
14
+ SIDECAR_MAX_BYTES,
15
+ DEFAULT_SIDECAR_BASE_URL,
16
+ } from '../voice-transcribe-sidecar.js'
17
+ import {
18
+ materializeSidecarToken,
19
+ DEFAULT_VOICE_SIDECAR_TOKEN_REF,
20
+ VOICE_SIDECAR_TOKEN_KEY,
21
+ } from '../../src/telegram/materialize-sidecar-token.js'
22
+ import type { resolveVaultReferencesViaBroker } from '../../src/vault/resolver.js'
23
+ import type { SwitchroomConfig } from '../../src/config/schema.js'
24
+
25
+ const SMALL_AUDIO = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
26
+
27
+ function makeFetch(status: number, body: unknown): typeof fetch {
28
+ return (async () => ({
29
+ ok: status >= 200 && status < 300,
30
+ status,
31
+ json: async () => body,
32
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
33
+ headers: new Headers(),
34
+ })) as unknown as typeof fetch
35
+ }
36
+
37
+ describe('transcribeViaSidecar — pre-flight checks', () => {
38
+ it('returns no-token when token is empty', async () => {
39
+ const r = await transcribeViaSidecar({
40
+ token: '',
41
+ audio: SMALL_AUDIO,
42
+ filename: 'voice.ogg',
43
+ fetchImpl: makeFetch(200, { ok: true, text: 'x' }),
44
+ })
45
+ expect(r).toMatchObject({ ok: false, reason: 'no-token' })
46
+ })
47
+
48
+ it('returns audio-too-short on empty audio', async () => {
49
+ const r = await transcribeViaSidecar({
50
+ token: 't',
51
+ audio: new Uint8Array(0),
52
+ filename: 'voice.ogg',
53
+ })
54
+ expect(r).toMatchObject({ ok: false, reason: 'audio-too-short' })
55
+ })
56
+
57
+ it('returns audio-too-large above the cap', async () => {
58
+ const tooBig = new Uint8Array(SIDECAR_MAX_BYTES + 1)
59
+ const r = await transcribeViaSidecar({
60
+ token: 't',
61
+ audio: tooBig,
62
+ filename: 'voice.ogg',
63
+ })
64
+ expect(r).toMatchObject({ ok: false, reason: 'audio-too-large' })
65
+ })
66
+ })
67
+
68
+ describe('transcribeViaSidecar — happy path', () => {
69
+ it('returns transcript on 200 + valid JSON', async () => {
70
+ const r = await transcribeViaSidecar({
71
+ token: 't',
72
+ audio: SMALL_AUDIO,
73
+ filename: 'voice.ogg',
74
+ fetchImpl: makeFetch(200, {
75
+ ok: true,
76
+ text: 'Hello world',
77
+ language: 'en',
78
+ durationMs: 1234,
79
+ audioSeconds: 3.2,
80
+ }),
81
+ })
82
+ expect(r.ok).toBe(true)
83
+ if (r.ok) {
84
+ expect(r.text).toBe('Hello world')
85
+ expect(r.language).toBe('en')
86
+ expect(r.durationMs).toBe(1234)
87
+ expect(r.audioSeconds).toBe(3.2)
88
+ }
89
+ })
90
+
91
+ it('sends the X-Voice-Token header, audio + language fields, to <base>/stt', async () => {
92
+ let receivedUrl = ''
93
+ let receivedToken: string | null = null
94
+ let receivedForm: FormData | null = null
95
+ const fakeFetch: typeof fetch = (async (url: string, init?: RequestInit) => {
96
+ receivedUrl = url
97
+ receivedToken = (init?.headers as Record<string, string>)['X-Voice-Token']
98
+ receivedForm = init?.body as FormData
99
+ return {
100
+ ok: true,
101
+ status: 200,
102
+ json: async () => ({ ok: true, text: 'bonjour' }),
103
+ text: async () => '',
104
+ headers: new Headers(),
105
+ }
106
+ }) as unknown as typeof fetch
107
+
108
+ const r = await transcribeViaSidecar({
109
+ token: 'secret-token',
110
+ audio: SMALL_AUDIO,
111
+ filename: 'voice.ogg',
112
+ language: 'fr',
113
+ fetchImpl: fakeFetch,
114
+ })
115
+ expect(r.ok).toBe(true)
116
+ expect(receivedUrl).toBe(`${DEFAULT_SIDECAR_BASE_URL}/stt`)
117
+ expect(receivedToken).toBe('secret-token')
118
+ expect(receivedForm).not.toBeNull()
119
+ expect(receivedForm!.get('language')).toBe('fr')
120
+ expect(receivedForm!.get('audio')).not.toBeNull()
121
+ })
122
+
123
+ it('falls back to wall-clock durationMs when the sidecar omits it', async () => {
124
+ const r = await transcribeViaSidecar({
125
+ token: 't',
126
+ audio: SMALL_AUDIO,
127
+ filename: 'voice.ogg',
128
+ fetchImpl: makeFetch(200, { ok: true, text: 'hi' }),
129
+ })
130
+ expect(r.ok).toBe(true)
131
+ if (r.ok) {
132
+ expect(r.durationMs).toBeGreaterThanOrEqual(0)
133
+ expect(r.audioSeconds).toBeNull()
134
+ }
135
+ })
136
+ })
137
+
138
+ describe('transcribeViaSidecar — error paths', () => {
139
+ it('returns http-401 on a wrong/missing token (sidecar rejects)', async () => {
140
+ const r = await transcribeViaSidecar({
141
+ token: 't',
142
+ audio: SMALL_AUDIO,
143
+ filename: 'voice.ogg',
144
+ fetchImpl: makeFetch(401, { ok: false, reason: 'unauthorized' }),
145
+ })
146
+ expect(r).toMatchObject({ ok: false, reason: 'http-401' })
147
+ })
148
+
149
+ it('surfaces the sidecar reason on a 2xx { ok: false } body', async () => {
150
+ const r = await transcribeViaSidecar({
151
+ token: 't',
152
+ audio: SMALL_AUDIO,
153
+ filename: 'voice.ogg',
154
+ fetchImpl: makeFetch(200, { ok: false, reason: 'gpu-timeout', detail: 'busy' }),
155
+ })
156
+ expect(r).toMatchObject({ ok: false, reason: 'gpu-timeout' })
157
+ })
158
+
159
+ it('returns malformed-response when 200 body has no text field', async () => {
160
+ const r = await transcribeViaSidecar({
161
+ token: 't',
162
+ audio: SMALL_AUDIO,
163
+ filename: 'voice.ogg',
164
+ fetchImpl: makeFetch(200, { ok: true, unexpected: 'shape' }),
165
+ })
166
+ expect(r).toMatchObject({ ok: false, reason: 'malformed-response' })
167
+ })
168
+
169
+ it('returns fetch-failed on a thrown network error', async () => {
170
+ const fakeFetch: typeof fetch = (async () => {
171
+ throw new Error('ECONNREFUSED')
172
+ }) as unknown as typeof fetch
173
+ const r = await transcribeViaSidecar({
174
+ token: 't',
175
+ audio: SMALL_AUDIO,
176
+ filename: 'voice.ogg',
177
+ fetchImpl: fakeFetch,
178
+ })
179
+ expect(r).toMatchObject({ ok: false, reason: 'fetch-failed' })
180
+ expect(r.detail).toContain('ECONNREFUSED')
181
+ })
182
+
183
+ it('returns timeout when fetch is aborted', async () => {
184
+ const fakeFetch: typeof fetch = (async () => {
185
+ throw new Error('The operation was aborted')
186
+ }) as unknown as typeof fetch
187
+ const r = await transcribeViaSidecar({
188
+ token: 't',
189
+ audio: SMALL_AUDIO,
190
+ filename: 'voice.ogg',
191
+ timeoutMs: 5,
192
+ fetchImpl: fakeFetch,
193
+ })
194
+ expect(r).toMatchObject({ ok: false, reason: 'timeout' })
195
+ })
196
+ })
197
+
198
+ // ── materializeSidecarToken — vault resolution (mirrors the key helper) ──
199
+
200
+ const EMPTY_CONFIG = {} as unknown as SwitchroomConfig
201
+
202
+ function makeBrokerResolver(
203
+ resolvedRefs: Record<string, string>,
204
+ ): typeof resolveVaultReferencesViaBroker {
205
+ return (async (config: SwitchroomConfig) => {
206
+ const ref = config.telegram?.bot_token
207
+ if (ref && resolvedRefs[ref] !== undefined) {
208
+ return {
209
+ ok: true,
210
+ config: {
211
+ ...config,
212
+ telegram: { ...config.telegram, bot_token: resolvedRefs[ref] },
213
+ },
214
+ }
215
+ }
216
+ return { ok: false, reason: 'not_found' as const }
217
+ }) as unknown as typeof resolveVaultReferencesViaBroker
218
+ }
219
+
220
+ describe('materializeSidecarToken — vault resolution', () => {
221
+ it('defaults to vault:voice/sidecar-token', () => {
222
+ expect(VOICE_SIDECAR_TOKEN_KEY).toBe('voice/sidecar-token')
223
+ expect(DEFAULT_VOICE_SIDECAR_TOKEN_REF).toBe('vault:voice/sidecar-token')
224
+ })
225
+
226
+ it('resolves the configured vault ref via the broker', async () => {
227
+ const tok = await materializeSidecarToken({
228
+ config: EMPTY_CONFIG,
229
+ env: {},
230
+ resolveViaBroker: makeBrokerResolver({
231
+ 'vault:voice/sidecar-token': 'deadbeef' + 'cafe',
232
+ }),
233
+ })
234
+ expect(tok).toBe('deadbeef' + 'cafe')
235
+ })
236
+
237
+ it('returns a literal (non-vault) token directly without the broker', async () => {
238
+ let brokerCalled = false
239
+ const tok = await materializeSidecarToken({
240
+ tokenRef: 'raw' + 'token',
241
+ config: EMPTY_CONFIG,
242
+ env: {},
243
+ resolveViaBroker: (async () => {
244
+ brokerCalled = true
245
+ return { ok: false, reason: 'not_found' as const }
246
+ }) as unknown as typeof resolveVaultReferencesViaBroker,
247
+ })
248
+ expect(tok).toBe('raw' + 'token')
249
+ expect(brokerCalled).toBe(false)
250
+ })
251
+
252
+ it('returns null (graceful fallback, no throw) when unresolvable', async () => {
253
+ const tok = await materializeSidecarToken(
254
+ {
255
+ config: EMPTY_CONFIG,
256
+ env: {},
257
+ resolveViaBroker: makeBrokerResolver({}),
258
+ },
259
+ () => {},
260
+ )
261
+ expect(tok).toBeNull()
262
+ })
263
+
264
+ // ── Regression: single-key isolation (voice token bug) ──
265
+ //
266
+ // resolveVaultReferencesViaBroker fails the WHOLE batch unless EVERY vault:
267
+ // ref resolves. Admin-only refs (litellm/master-key,
268
+ // google/client-secret) are correctly DENIED to a normal agent — so if the
269
+ // materializer passes the full config, those denials sink the resolution
270
+ // even though voice/sidecar-token itself is granted. The fix passes a
271
+ // MINIMAL config carrying only the one ref. These tests fail against the
272
+ // old `...config` spread.
273
+
274
+ function collectRefs(v: unknown, out: Set<string>): void {
275
+ if (typeof v === 'string' && v.startsWith('vault:')) out.add(v.slice('vault:'.length).split('#')[0])
276
+ else if (Array.isArray(v)) for (const i of v) collectRefs(i, out)
277
+ else if (v !== null && typeof v === 'object') for (const val of Object.values(v as Record<string, unknown>)) collectRefs(val, out)
278
+ }
279
+
280
+ /** Resolver stub with the real batch semantics: denies unless every collected ref is granted. */
281
+ function makeBatchResolver(
282
+ granted: Record<string, string>,
283
+ onCall?: (refs: Set<string>) => void,
284
+ ): typeof resolveVaultReferencesViaBroker {
285
+ return (async (config: SwitchroomConfig) => {
286
+ const refs = new Set<string>()
287
+ collectRefs(config, refs)
288
+ onCall?.(refs)
289
+ for (const r of refs) {
290
+ if (granted[`vault:${r}`] === undefined) {
291
+ return { ok: false, reason: 'denied' as const }
292
+ }
293
+ }
294
+ const resolved = granted[config.telegram?.bot_token ?? '']
295
+ return {
296
+ ok: true,
297
+ config: { ...config, telegram: { ...config.telegram, bot_token: resolved } },
298
+ }
299
+ }) as unknown as typeof resolveVaultReferencesViaBroker
300
+ }
301
+
302
+ const CONFIG_WITH_ADMIN_REFS = {
303
+ telegram: { bot_token: 'vault:tg/bot' },
304
+ litellm: { admin_key: 'vault:litellm/master-key' },
305
+ google_workspace: { google_client_secret: 'vault:google/client-secret' },
306
+ agents: { klanker: { some_key: 'vault:per-agent/secret' } },
307
+ vault: { path: '~/.switchroom/vault.enc' },
308
+ } as unknown as SwitchroomConfig
309
+
310
+ it('resolves the sidecar token even when the config carries admin-only vault refs the broker DENIES', async () => {
311
+ const tok = await materializeSidecarToken({
312
+ config: CONFIG_WITH_ADMIN_REFS,
313
+ env: {},
314
+ resolveViaBroker: makeBatchResolver({ 'vault:voice/sidecar-token': 'deadbeefcafe' }),
315
+ })
316
+ expect(tok).toBe('deadbeefcafe')
317
+ })
318
+
319
+ it('invokes the resolver with a config containing exactly ONE vault ref (the sidecar token)', async () => {
320
+ let seen: Set<string> | null = null
321
+ await materializeSidecarToken({
322
+ config: CONFIG_WITH_ADMIN_REFS,
323
+ env: {},
324
+ resolveViaBroker: makeBatchResolver(
325
+ { 'vault:voice/sidecar-token': 'deadbeefcafe' },
326
+ (refs) => { seen = refs },
327
+ ),
328
+ })
329
+ expect(seen).not.toBeNull()
330
+ expect([...seen!]).toEqual(['voice/sidecar-token'])
331
+ })
332
+ })