switchroom 0.16.38 → 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 +88 -82
- package/dist/auth-broker/index.js +87 -81
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +89 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1621 -737
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +271 -239
- package/dist/vault/approvals/kernel-server.js +90 -84
- package/dist/vault/broker/server.js +91 -85
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
- package/telegram-plugin/dist/server.js +180 -162
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +262 -63
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -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/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- 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-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -1
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect } from 'bun:test'
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
import { tmpdir } from 'os'
|
|
12
15
|
import {
|
|
13
16
|
VoiceOnDemandCache,
|
|
14
17
|
VOICE_ONDEMAND_CALLBACK_PREFIX,
|
|
@@ -71,7 +74,7 @@ describe('on-demand: bounded TTL LRU cache', () => {
|
|
|
71
74
|
|
|
72
75
|
it('expires entries past the TTL (miss → graceful null)', () => {
|
|
73
76
|
let now = 1_000
|
|
74
|
-
const cache = new VoiceOnDemandCache(
|
|
77
|
+
const cache = new VoiceOnDemandCache({ ttlMs: 100, maxEntries: 500, now: () => now })
|
|
75
78
|
cache.put('tok', { text: 'x', speed: 1 })
|
|
76
79
|
now = 1_050
|
|
77
80
|
expect(cache.get('tok')).not.toBeNull() // still fresh
|
|
@@ -80,7 +83,7 @@ describe('on-demand: bounded TTL LRU cache', () => {
|
|
|
80
83
|
})
|
|
81
84
|
|
|
82
85
|
it('evicts the oldest entry past the size cap (LRU)', () => {
|
|
83
|
-
const cache = new VoiceOnDemandCache(60_000, 2
|
|
86
|
+
const cache = new VoiceOnDemandCache({ ttlMs: 60_000, maxEntries: 2 })
|
|
84
87
|
cache.put('a', { text: 'a', speed: 1 })
|
|
85
88
|
cache.put('b', { text: 'b', speed: 1 })
|
|
86
89
|
cache.put('c', { text: 'c', speed: 1 }) // evicts 'a'
|
|
@@ -91,6 +94,100 @@ describe('on-demand: bounded TTL LRU cache', () => {
|
|
|
91
94
|
})
|
|
92
95
|
})
|
|
93
96
|
|
|
97
|
+
describe('on-demand: cache survives a restart (persistPath)', () => {
|
|
98
|
+
it('reloads tokens from disk so a Listen tap after restart still resolves', () => {
|
|
99
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-ondemand-persist-'))
|
|
100
|
+
const persistPath = join(dir, 'voice-ondemand.json')
|
|
101
|
+
try {
|
|
102
|
+
// First "process": mint + store a token, then drop the instance
|
|
103
|
+
// (simulating a gateway restart wiping the in-memory Map).
|
|
104
|
+
const before = new VoiceOnDemandCache({ persistPath })
|
|
105
|
+
before.put('tok', { text: 'hello after restart', voice: 'af_bella', speed: 1.1 })
|
|
106
|
+
expect(existsSync(persistPath)).toBe(true)
|
|
107
|
+
|
|
108
|
+
// Second "process": a fresh instance backed by the same file must see it.
|
|
109
|
+
const after = new VoiceOnDemandCache({ persistPath })
|
|
110
|
+
expect(after.get('tok')).toEqual({
|
|
111
|
+
text: 'hello after restart',
|
|
112
|
+
voice: 'af_bella',
|
|
113
|
+
speed: 1.1,
|
|
114
|
+
})
|
|
115
|
+
} finally {
|
|
116
|
+
rmSync(dir, { recursive: true, force: true })
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('drops entries that expired while the gateway was down', () => {
|
|
121
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-ondemand-expire-'))
|
|
122
|
+
const persistPath = join(dir, 'voice-ondemand.json')
|
|
123
|
+
try {
|
|
124
|
+
let now = 1_000
|
|
125
|
+
const before = new VoiceOnDemandCache({ ttlMs: 100, persistPath, now: () => now })
|
|
126
|
+
before.put('tok', { text: 'x', speed: 1 })
|
|
127
|
+
// Restart happens "later" — past the TTL.
|
|
128
|
+
now = 5_000
|
|
129
|
+
const after = new VoiceOnDemandCache({ ttlMs: 100, persistPath, now: () => now })
|
|
130
|
+
expect(after.get('tok')).toBeNull()
|
|
131
|
+
expect(after.size).toBe(0)
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true })
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('preserves LRU insertion order + cap across a reload', () => {
|
|
138
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-ondemand-lru-'))
|
|
139
|
+
const persistPath = join(dir, 'voice-ondemand.json')
|
|
140
|
+
try {
|
|
141
|
+
const before = new VoiceOnDemandCache({ maxEntries: 3, persistPath })
|
|
142
|
+
before.put('a', { text: 'a', speed: 1 })
|
|
143
|
+
before.put('b', { text: 'b', speed: 1 })
|
|
144
|
+
before.put('c', { text: 'c', speed: 1 })
|
|
145
|
+
|
|
146
|
+
// Reload with a SMALLER cap — the oldest ('a') must be dropped.
|
|
147
|
+
const after = new VoiceOnDemandCache({ maxEntries: 2, persistPath })
|
|
148
|
+
expect(after.size).toBe(2)
|
|
149
|
+
expect(after.get('a')).toBeNull()
|
|
150
|
+
expect(after.get('b')).not.toBeNull()
|
|
151
|
+
expect(after.get('c')).not.toBeNull()
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(dir, { recursive: true, force: true })
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('a corrupt persistence file degrades to an empty cache (no throw)', () => {
|
|
158
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-ondemand-corrupt-'))
|
|
159
|
+
const persistPath = join(dir, 'voice-ondemand.json')
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(persistPath, '{ this is not valid json', 'utf8')
|
|
162
|
+
let cache: VoiceOnDemandCache | undefined
|
|
163
|
+
expect(() => {
|
|
164
|
+
cache = new VoiceOnDemandCache({ persistPath })
|
|
165
|
+
}).not.toThrow()
|
|
166
|
+
expect(cache!.size).toBe(0)
|
|
167
|
+
// Still usable — a fresh put + get round-trips and rewrites the file clean.
|
|
168
|
+
cache!.put('tok', { text: 'ok', speed: 1 })
|
|
169
|
+
expect(cache!.get('tok')).toEqual({ text: 'ok', speed: 1 })
|
|
170
|
+
// File is now valid JSON again.
|
|
171
|
+
expect(() => JSON.parse(readFileSync(persistPath, 'utf8'))).not.toThrow()
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(dir, { recursive: true, force: true })
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('an in-memory cache (no persistPath) writes no file — unchanged behaviour', () => {
|
|
178
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-ondemand-mem-'))
|
|
179
|
+
try {
|
|
180
|
+
const cache = new VoiceOnDemandCache()
|
|
181
|
+
cache.put('tok', { text: 'x', speed: 1 })
|
|
182
|
+
// Nothing under the tmp dir was written.
|
|
183
|
+
expect(existsSync(join(dir, 'voice-ondemand.json'))).toBe(false)
|
|
184
|
+
expect(cache.get('tok')).not.toBeNull()
|
|
185
|
+
} finally {
|
|
186
|
+
rmSync(dir, { recursive: true, force: true })
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
94
191
|
describe('on-demand: reply-time behaviour (gate + no synthesis)', () => {
|
|
95
192
|
it('reply_mode=on-demand + local engine → Listen button, no synth at reply time', () => {
|
|
96
193
|
// Reproduce the gateway reply path decision for reply_mode='on-demand':
|
|
@@ -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
|
+
})
|
|
@@ -667,6 +667,55 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
667
667
|
expect(edit1!.text).toContain(`· ${Math.floor((26_000 - dispatchAt) / 1000)}s`)
|
|
668
668
|
})
|
|
669
669
|
|
|
670
|
+
it('(i-b) heartbeat repaint keeps the header master elapsed >= the step timer (same clock anchor)', async () => {
|
|
671
|
+
// Ken-observed defect: the heartbeat computed a LIVE `· Ns` step suffix
|
|
672
|
+
// from dispatchAtMs but re-rendered the header from the STALE
|
|
673
|
+
// lastView.elapsedMs (frozen at the last watcher event), so the current
|
|
674
|
+
// step's timer could read MORE than the card's master elapsed. Both must
|
|
675
|
+
// derive from the same anchor at render time.
|
|
676
|
+
const bot = makeFakeBot()
|
|
677
|
+
let clock = 10_000
|
|
678
|
+
const feed = createWorkerActivityFeed({
|
|
679
|
+
bot,
|
|
680
|
+
now: () => clock,
|
|
681
|
+
minEditIntervalMs: 2500,
|
|
682
|
+
heartbeatTickMs: 6000,
|
|
683
|
+
setInterval: () => 1,
|
|
684
|
+
clearInterval: () => {},
|
|
685
|
+
})
|
|
686
|
+
// First paint: view says elapsed 9s → dispatchAt = 19_000 - 9_000 = 10_000.
|
|
687
|
+
clock = 19_000
|
|
688
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
|
|
689
|
+
expect(bot.sent).toHaveLength(1)
|
|
690
|
+
|
|
691
|
+
// No watcher events for a long stretch; the heartbeat repaints at 80s.
|
|
692
|
+
clock = 80_000 // live elapsed = 70_000 ms = 1m10s
|
|
693
|
+
feed.heartbeatTick()
|
|
694
|
+
// Drain the handle's promise chain.
|
|
695
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
696
|
+
|
|
697
|
+
const edit = bot.edits[bot.edits.length - 1]
|
|
698
|
+
expect(edit).toBeDefined()
|
|
699
|
+
// Header line 2 (`_{elapsed} · {n} tools_`) must show the LIVE master
|
|
700
|
+
// elapsed — not the stale 9s from the last view.
|
|
701
|
+
const headerMatch = /_(\d+(?:m\d+)?s) · \d+ tools?_/.exec(edit.text)
|
|
702
|
+
expect(headerMatch, `no header elapsed in: ${edit.text}`).not.toBeNull()
|
|
703
|
+
// Step suffix (`· Ns**`) on the in-progress line.
|
|
704
|
+
const stepMatch = /· (\d+(?:m\d+)?s)\*\*/.exec(edit.text)
|
|
705
|
+
expect(stepMatch, `no step suffix in: ${edit.text}`).not.toBeNull()
|
|
706
|
+
|
|
707
|
+
const toMs = (s: string): number => {
|
|
708
|
+
const m = /^(?:(\d+)m)?(\d+)s$/.exec(s)!
|
|
709
|
+
return (m[1] ? Number(m[1]) * 60_000 : 0) + Number(m[2]) * 1000
|
|
710
|
+
}
|
|
711
|
+
const headerMs = toMs(headerMatch![1])
|
|
712
|
+
const stepMs = toMs(stepMatch![1])
|
|
713
|
+
// Outcome: both timers advance together off one anchor — the header is
|
|
714
|
+
// live (not frozen at 9s) and the step never exceeds the master.
|
|
715
|
+
expect(headerMs).toBeGreaterThanOrEqual(stepMs)
|
|
716
|
+
expect(headerMs).toBe(70_000) // 1m10s — refreshed to the live value
|
|
717
|
+
})
|
|
718
|
+
|
|
670
719
|
it('(ii) respects a 429 cooldown — no edit while cooldownUntil is in the future', async () => {
|
|
671
720
|
const bot = makeFakeBot()
|
|
672
721
|
let clock = 10_000
|