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.
Files changed (61) hide show
  1. package/dist/agent-scheduler/index.js +88 -82
  2. package/dist/auth-broker/index.js +87 -81
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +89 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1621 -737
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +271 -239
  10. package/dist/vault/approvals/kernel-server.js +90 -84
  11. package/dist/vault/broker/server.js +91 -85
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +2 -2
  15. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -1
  18. package/telegram-plugin/card-format.ts +7 -1
  19. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  20. package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
  21. package/telegram-plugin/dist/server.js +180 -162
  22. package/telegram-plugin/format.ts +305 -31
  23. package/telegram-plugin/gateway/gateway.ts +262 -63
  24. package/telegram-plugin/gateway/model-command.ts +173 -19
  25. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  26. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  27. package/telegram-plugin/package.json +1 -1
  28. package/telegram-plugin/session-tail.ts +47 -1
  29. package/telegram-plugin/stream-reply-handler.ts +19 -1
  30. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  31. package/telegram-plugin/tests/card-format.test.ts +28 -0
  32. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  33. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  34. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  35. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  36. package/telegram-plugin/tests/model-command.test.ts +213 -47
  37. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  38. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  40. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  41. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  42. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  43. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  44. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  45. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  46. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  47. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  48. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  49. package/telegram-plugin/text-voice-scrub.ts +68 -18
  50. package/telegram-plugin/tool-activity-summary.ts +20 -108
  51. package/telegram-plugin/tts-normalize.ts +377 -0
  52. package/telegram-plugin/uat/driver.ts +472 -22
  53. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  54. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  56. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  57. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  58. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  59. package/telegram-plugin/voice-ondemand.ts +161 -10
  60. package/telegram-plugin/voice-presynth.ts +242 -0
  61. 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(100 /* ttl */, 500, () => now)
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 /* cap */)
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