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.
- package/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- 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 +1524 -129
- 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/model-command.ts +173 -19
- 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/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- 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/package.json +1 -1
- 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/session-tail.ts +47 -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 +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- 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 +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -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/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -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/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- 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/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -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/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- 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/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -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 +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -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 +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
|
+
})
|