switchroom 0.16.28 → 0.16.38
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/dist/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -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,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
|
+
})
|