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
@@ -123,7 +123,10 @@ describe('splitMarkdownChunks', () => {
123
123
  // A long body with a fenced code block straddling the cap. The chunker
124
124
  // must move the whole fence to a single chunk so no chunk has an odd
125
125
  // number of fence delimiters (which would swallow the next chunk).
126
- const fence = '```\n' + 'code line\n'.repeat(20) + '```'
126
+ // The fence FITS within the cap, so the chunker can move it whole to a
127
+ // single chunk and keep every chunk's fence count balanced. (An oversized
128
+ // fence that cannot fit is hard-sliced instead — see the F7 tests below.)
129
+ const fence = '```\n' + 'code line\n'.repeat(8) + '```'
127
130
  const text = 'intro paragraph '.repeat(15) + '\n\n' + fence + '\n\noutro paragraph'
128
131
  const chunks = splitMarkdownChunks(text, 180)
129
132
  for (const c of chunks) {
@@ -150,15 +153,37 @@ describe('splitMarkdownChunks', () => {
150
153
  }
151
154
  })
152
155
 
153
- test('emits an oversized indivisible block whole rather than looping forever', () => {
154
- // A single fenced block larger than maxLen has no safe interior cut
155
- // the chunker emits it whole (a louder, debuggable Telegram reject
156
- // beats an infinite loop).
156
+ test('hard-slices an indivisible block that exceeds the cap (F7 — no oversized chunk)', () => {
157
+ // A single fenced block larger than maxLen has no safe interior cut. The
158
+ // OLD behaviour emitted it whole, which Telegram rejects with
159
+ // RICH_MESSAGE_TEXT_TOO_LONG and drops the entire answer. F7 delegates to
160
+ // hardSliceToCap so the region comes out as multiple <= cap character
161
+ // slices instead — degraded-but-delivered beats a hard reject.
157
162
  const giant = '```\n' + 'x'.repeat(500) + '\n```'
158
163
  const chunks = splitMarkdownChunks(giant, 100)
159
- // It comes out as one (oversized) chunk, not split mid-fence.
160
- expect(chunks.length).toBe(1)
161
- expect(chunks[0]).toBe(giant)
164
+ expect(chunks.length).toBeGreaterThan(1)
165
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100)
166
+ // No content is dropped — the slices concatenate back to the original.
167
+ expect(chunks.join('')).toBe(giant)
168
+ })
169
+
170
+ test('hard-slices a single unbreakable >maxLen token run into <= cap chunks (F7)', () => {
171
+ // One giant pipe-bearing line with no space/newline boundary below the
172
+ // cap: neither the blank-line, newline, nor space heuristic can find a
173
+ // cut, and the table-row back-off cannot help either. This is the exact
174
+ // "cut <= 0" path — it must hard-slice, not emit an oversized chunk.
175
+ const runaway = 'a|b|'.repeat(200) // 800 chars, no spaces/newlines
176
+ const chunks = splitMarkdownChunks(runaway, 100)
177
+ expect(chunks.length).toBeGreaterThan(1)
178
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100)
179
+ expect(chunks.join('')).toBe(runaway)
180
+ })
181
+
182
+ test('an indivisible region larger than the real 32768 cap still slices to <= cap (F7)', () => {
183
+ const giant = 'z'.repeat(RICH_MESSAGE_MAX_CHARS + 5000)
184
+ const chunks = splitMarkdownChunks(giant) // default cap = RICH_MESSAGE_MAX_CHARS
185
+ expect(chunks.length).toBeGreaterThan(1)
186
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
162
187
  })
163
188
 
164
189
  // -------------------------------------------------------------------------
@@ -10,7 +10,95 @@
10
10
 
11
11
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
12
12
 
13
- import { scrubVoice } from '../text-voice-scrub.js'
13
+ import { scrubVoice, normalizeDashes } from '../text-voice-scrub.js'
14
+
15
+ /**
16
+ * Regression guard for the em-dash comma-splice bug seen in a real reply
17
+ * (the #2737-era voice scrub). The original #1683 rule degraded a
18
+ * clause-joining em-dash to a comma when the next word was lowercase, which
19
+ * joined two independent clauses with a comma — a comma splice ("voice came
20
+ * back, three PRs stacked"). The fix degrades to a full stop and recapitalizes
21
+ * the following word, so the output is never a splice. These tests use the
22
+ * verbatim failure strings from the stored bad message.
23
+ */
24
+ describe('scrubVoice — em-dash between clauses never produces a comma splice', () => {
25
+ beforeEach(() => {
26
+ delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
27
+ delete process.env.SWITCHROOM_VOICE_STRIP_OPENERS
28
+ })
29
+
30
+ it('lowercase-continued independent clause becomes two sentences, not a splice (real string 1)', () => {
31
+ const r = scrubVoice('**Why voice came back** — three PRs stacked')
32
+ // Was: "**Why voice came back**, three PRs stacked" (comma splice).
33
+ expect(r.scrubbed).toBe('**Why voice came back**. Three PRs stacked')
34
+ expect(r.scrubbed).not.toContain('**, three')
35
+ expect(r.replaced).toBe(1)
36
+ })
37
+
38
+ it('lowercase-continued independent clause becomes two sentences, not a splice (real string 2)', () => {
39
+ const r = scrubVoice(
40
+ 'never reached the agents — this is what unblocked everything today.',
41
+ )
42
+ // Was: "...the agents, this is what unblocked..." (comma splice).
43
+ expect(r.scrubbed).toBe(
44
+ 'never reached the agents. This is what unblocked everything today.',
45
+ )
46
+ expect(r.scrubbed).not.toContain('agents, this')
47
+ expect(r.replaced).toBe(1)
48
+ })
49
+
50
+ it('the scrubbed output contains no ", <lowercase clause>" splice for any spaced dash', () => {
51
+ const r = scrubVoice(
52
+ 'first thing — second independent clause and — third one here',
53
+ )
54
+ // No comma directly joining the two independent clauses.
55
+ expect(r.scrubbed).toBe(
56
+ 'first thing. Second independent clause and. Third one here',
57
+ )
58
+ })
59
+
60
+ it('normalizeDashes (card / worker-narration path) also avoids the splice', () => {
61
+ // The non-reply surfaces reuse normalizeDashes; same splice-free rule.
62
+ expect(normalizeDashes('came back — three PRs stacked')).toBe(
63
+ 'came back. Three PRs stacked',
64
+ )
65
+ })
66
+
67
+ it('a dash before a NON-letter (digit / paren) is not wrongly recapitalized', () => {
68
+ // capFirst only touches an ASCII lowercase letter; a digit / punctuation
69
+ // start is left as-is after the full stop.
70
+ expect(normalizeDashes('the count — 3 items remain')).toBe(
71
+ 'the count. 3 items remain',
72
+ )
73
+ expect(normalizeDashes('the note — (see appendix)')).toBe(
74
+ 'the note. (see appendix)',
75
+ )
76
+ })
77
+
78
+ it('dashes inside code spans, fenced blocks, and URLs are still never touched', () => {
79
+ const input =
80
+ 'prose — one\n' +
81
+ 'inline `a — b — c` stays\n' +
82
+ '```\nfenced x — y — z stays\n```\n' +
83
+ 'link https://ex.io/a—b—c stays too — end'
84
+ const r = scrubVoice(input)
85
+ expect(r.scrubbed).toContain('`a — b — c`')
86
+ expect(r.scrubbed).toContain('fenced x — y — z stays')
87
+ expect(r.scrubbed).toContain('https://ex.io/a—b—c')
88
+ // Only the two prose dashes were scrubbed (to full stops).
89
+ expect(r.scrubbed).toContain('prose. One')
90
+ expect(r.scrubbed).toContain('stays too. End')
91
+ expect(r.replaced).toBe(2)
92
+ })
93
+
94
+ it('a double-space inside inline code / a URL is left untouched by the scrub', () => {
95
+ // The scrub must not normalize whitespace inside protected regions.
96
+ const r = scrubVoice('keep `foo bar` and http://h/a b intact — done')
97
+ expect(r.scrubbed).toContain('`foo bar`')
98
+ expect(r.scrubbed).toContain('http://h/a b')
99
+ expect(r.scrubbed).toContain('intact. Done')
100
+ })
101
+ })
14
102
 
15
103
  describe('scrubVoice — em / en dash replacement', () => {
16
104
  beforeEach(() => {
@@ -21,9 +109,13 @@ describe('scrubVoice — em / en dash replacement', () => {
21
109
  })
22
110
 
23
111
  describe('mechanical rewrite of spaced dashes', () => {
24
- it('replaces a spaced em-dash before lowercase with a comma', () => {
112
+ it('replaces a spaced em-dash before lowercase with a full stop (no comma splice)', () => {
113
+ // #1683 originally emitted a comma here, which produced a comma splice
114
+ // between two independent clauses ("on it, checking the calendar"). The
115
+ // splice-free rule degrades the clause-joining dash to a period and
116
+ // recapitalizes the following word.
25
117
  const r = scrubVoice('on it — checking the calendar')
26
- expect(r.scrubbed).toBe('on it, checking the calendar')
118
+ expect(r.scrubbed).toBe('on it. Checking the calendar')
27
119
  expect(r.replaced).toBe(1)
28
120
  })
29
121
 
@@ -34,22 +126,23 @@ describe('scrubVoice — em / en dash replacement', () => {
34
126
  expect(r.replaced).toBe(1)
35
127
  })
36
128
 
37
- it('handles multiple em-dashes in one sentence', () => {
129
+ it('handles multiple em-dashes in one sentence (each becomes a full stop)', () => {
38
130
  const r = scrubVoice('one — two — three — done')
39
- expect(r.scrubbed).toBe('one, two, three, done')
131
+ expect(r.scrubbed).toBe('one. Two. Three. Done')
40
132
  expect(r.replaced).toBe(3)
41
133
  })
42
134
 
43
135
  it('treats en-dash (–) identically to em-dash', () => {
44
136
  const r = scrubVoice('on it – checking the calendar')
45
- expect(r.scrubbed).toBe('on it, checking the calendar')
137
+ expect(r.scrubbed).toBe('on it. Checking the calendar')
46
138
  expect(r.replaced).toBe(1)
47
139
  })
48
140
 
49
- it('replaces unspaced word-dash-word as a comma', () => {
50
- // Less common but seen in tightly-typed prose.
141
+ it('replaces unspaced word-dash-word with a full stop (no comma splice)', () => {
142
+ // Less common but seen in tightly-typed prose. Same splice-free rule as
143
+ // the spaced form: a period plus a recapitalized following word.
51
144
  const r = scrubVoice('flag—on or flag—off')
52
- expect(r.scrubbed).toBe('flag, on or flag, off')
145
+ expect(r.scrubbed).toBe('flag. On or flag. Off')
53
146
  expect(r.replaced).toBe(2)
54
147
  })
55
148
 
@@ -66,6 +159,32 @@ describe('scrubVoice — em / en dash replacement', () => {
66
159
  expect(r.scrubbed).toBe('- note: ship it')
67
160
  expect(r.replaced).toBe(1)
68
161
  })
162
+
163
+ it('does not recapitalize a mixed-case brand token after a dash', () => {
164
+ // capFirst must leave a lowercase-initial mixed-case token (iOS, eBay,
165
+ // iPhone, macOS) alone — recapitalizing "iOS" to "IOS" corrupts the
166
+ // brand. A normal lowercase word still gets recapitalized.
167
+ const r = scrubVoice('foo—iOS build')
168
+ expect(r.scrubbed).toBe('foo. iOS build')
169
+ expect(r.replaced).toBe(1)
170
+
171
+ const spaced = scrubVoice('shipped — iPhone ready')
172
+ expect(spaced.scrubbed).toBe('shipped. iPhone ready')
173
+
174
+ const normal = scrubVoice('foo—done here')
175
+ expect(normal.scrubbed).toBe('foo. Done here')
176
+ })
177
+
178
+ it('preserves numeric ranges as ASCII hyphens, not full stops', () => {
179
+ // A digit-flanked dash is a range, not a clause break. It falls through
180
+ // to the catch-all rule and becomes an ASCII hyphen.
181
+ const unspaced = scrubVoice('10–20')
182
+ expect(unspaced.scrubbed).toBe('10-20')
183
+ expect(unspaced.replaced).toBe(1)
184
+
185
+ const tight = scrubVoice('scored 3–2')
186
+ expect(tight.scrubbed).toBe('scored 3-2')
187
+ })
69
188
  })
70
189
 
71
190
  describe('protected regions are left alone', () => {
@@ -73,33 +192,33 @@ describe('scrubVoice — em / en dash replacement', () => {
73
192
  const input = 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose — done'
74
193
  const r = scrubVoice(input)
75
194
  expect(r.scrubbed).toBe(
76
- 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose, done',
195
+ 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose. Done',
77
196
  )
78
197
  expect(r.replaced).toBe(1)
79
198
  })
80
199
 
81
200
  it('preserves dashes inside inline code', () => {
82
201
  const r = scrubVoice('the flag `--really — keep` matters — yes')
83
- expect(r.scrubbed).toBe('the flag `--really — keep` matters, yes')
202
+ expect(r.scrubbed).toBe('the flag `--really — keep` matters. Yes')
84
203
  expect(r.replaced).toBe(1)
85
204
  })
86
205
 
87
206
  it('preserves dashes inside <code> HTML tags', () => {
88
207
  const r = scrubVoice('see <code>x — y</code> and note — ok')
89
- expect(r.scrubbed).toBe('see <code>x — y</code> and note, ok')
208
+ expect(r.scrubbed).toBe('see <code>x — y</code> and note. Ok')
90
209
  expect(r.replaced).toBe(1)
91
210
  })
92
211
 
93
212
  it('preserves dashes inside <pre> HTML tags', () => {
94
213
  const r = scrubVoice('block:\n<pre>x — y\nz — w</pre>\nend — ok')
95
- expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend, ok')
214
+ expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend. Ok')
96
215
  expect(r.replaced).toBe(1)
97
216
  })
98
217
 
99
218
  it('preserves dashes inside URLs', () => {
100
219
  const r = scrubVoice('see https://example.com/a—b for context — ok')
101
220
  expect(r.scrubbed).toBe(
102
- 'see https://example.com/a—b for context, ok',
221
+ 'see https://example.com/a—b for context. Ok',
103
222
  )
104
223
  expect(r.replaced).toBe(1)
105
224
  })
@@ -110,7 +229,7 @@ describe('scrubVoice — em / en dash replacement', () => {
110
229
  const fence =
111
230
  '```\n# heading — title\nfunction f() {}\n```'
112
231
  const r = scrubVoice(fence + '\ntrailing — yes')
113
- expect(r.scrubbed).toBe(fence + '\ntrailing, yes')
232
+ expect(r.scrubbed).toBe(fence + '\ntrailing. Yes')
114
233
  expect(r.replaced).toBe(1)
115
234
  })
116
235
  })
@@ -151,8 +270,8 @@ describe('scrubVoice — em / en dash replacement', () => {
151
270
  'Result: empty for Saturday — nothing scheduled. Anything else?'
152
271
  const r = scrubVoice(input)
153
272
  expect(r.scrubbed).toBe(
154
- "I'll check the calendar, should take a few seconds. " +
155
- 'Result: empty for Saturday, nothing scheduled. Anything else?',
273
+ "I'll check the calendar. Should take a few seconds. " +
274
+ 'Result: empty for Saturday. Nothing scheduled. Anything else?',
156
275
  )
157
276
  expect(r.replaced).toBe(2)
158
277
  })
@@ -164,9 +283,9 @@ describe('scrubVoice — em / en dash replacement', () => {
164
283
  'Ready to commit — go?'
165
284
  const r = scrubVoice(input)
166
285
  expect(r.scrubbed).toBe(
167
- 'Running `git status --short`, looks clean. ' +
286
+ 'Running `git status --short`. Looks clean. ' +
168
287
  '```\nM file.ts — modified\n```\n' +
169
- 'Ready to commit, go?',
288
+ 'Ready to commit. Go?',
170
289
  )
171
290
  expect(r.replaced).toBe(2)
172
291
  })
@@ -293,15 +412,16 @@ describe('scrubVoice — opener strip is OFF by default (prompt carries tone)',
293
412
 
294
413
  it('STILL normalizes em-dashes by default (punctuation, no content removed)', () => {
295
414
  const r = scrubVoice('on it — checking the calendar')
296
- expect(r.scrubbed).toBe('on it, checking the calendar')
415
+ expect(r.scrubbed).toBe('on it. Checking the calendar')
297
416
  expect(r.replaced).toBe(1)
298
417
  expect(r.openersStripped).toBe(0)
299
418
  })
300
419
 
301
420
  it('an affirmation opener with an em-dash keeps the words, fixes only the dash', () => {
302
421
  const r = scrubVoice('Exactly right — the token had expired.')
303
- // Opener preserved; the em-dash after it becomes a comma.
304
- expect(r.scrubbed).toBe('Exactly right, the token had expired.')
422
+ // Opener preserved (strip is OFF); the em-dash between two independent
423
+ // clauses becomes a full stop, not a comma (no splice).
424
+ expect(r.scrubbed).toBe('Exactly right. The token had expired.')
305
425
  expect(r.openersStripped).toBe(0)
306
426
  expect(r.replaced).toBe(1)
307
427
  })
@@ -241,10 +241,15 @@ describe("clipNarrative — narrative-step clip (JSONL-text-narrative primitive)
241
241
  const narrativeLines: string[] = [];
242
242
  const labelLines: string[] = [];
243
243
  const text = "Important find: no main branch yet — branching off origin.";
244
+ // F1: the feed renders through renderStatusCard → stripMarkdown, which now
245
+ // normalizes em/en-dashes on this card surface. A narrative and a label go
246
+ // through the exact same path, so the render is still byte-identical — the
247
+ // dash is scrubbed to a full stop (new sentence, recapitalized) in BOTH.
248
+ const scrubbed = "Important find: no main branch yet. Branching off origin.";
244
249
  const narrativeRender = appendActivityLabel(narrativeLines, clipNarrative(text));
245
250
  const labelRender = appendActivityLabel(labelLines, clipNarrative(text));
246
251
  expect(narrativeRender).toBe(labelRender);
247
- expect(narrativeRender).toBe(`**→ ${text}**`);
252
+ expect(narrativeRender).toBe(`**→ ${scrubbed}**`);
248
253
  });
249
254
 
250
255
  it("empty string yields an empty clip (appendActivityLabel then drops it)", () => {
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for tts-normalize.ts — deterministic L1 TTS normalization
3
+ * (issue #2760, Phase 1). Bun test (this dir uses bun test, not vitest).
4
+ */
5
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
6
+ import { normalizeForTts, ttsNormalizeEnabled } from '../tts-normalize.js'
7
+
8
+ const KILL = 'SWITCHROOM_DISABLE_TTS_NORMALIZE'
9
+
10
+ let savedKill: string | undefined
11
+
12
+ beforeEach(() => {
13
+ savedKill = process.env[KILL]
14
+ delete process.env[KILL]
15
+ })
16
+
17
+ afterEach(() => {
18
+ if (savedKill === undefined) delete process.env[KILL]
19
+ else process.env[KILL] = savedKill
20
+ })
21
+
22
+ describe('gating', () => {
23
+ test('ON by default — normalizes with no env vars set', () => {
24
+ expect(ttsNormalizeEnabled()).toBe(true)
25
+ expect(normalizeForTts('**bold** $5.20')).toBe('bold five dollars twenty')
26
+ })
27
+
28
+ test('kill switch → byte-identical pass-through', () => {
29
+ process.env[KILL] = '1'
30
+ const input = '**bold** and $5.20 🚀 https://github.com/x'
31
+ expect(normalizeForTts(input)).toBe(input)
32
+ expect(ttsNormalizeEnabled()).toBe(false)
33
+ })
34
+
35
+ test('kill switch accepts "true"', () => {
36
+ process.env[KILL] = 'true'
37
+ expect(ttsNormalizeEnabled()).toBe(false)
38
+ })
39
+
40
+ test('empty input stays empty', () => {
41
+ expect(normalizeForTts('')).toBe('')
42
+ })
43
+ })
44
+
45
+ describe('markdown', () => {
46
+ test('bold/italic/strike markers removed', () => {
47
+ expect(normalizeForTts('**bold** and *italic* and ~~gone~~')).toBe(
48
+ 'bold and italic and gone',
49
+ )
50
+ })
51
+
52
+ test('headings become plain text', () => {
53
+ expect(normalizeForTts('## Status report\nAll good')).toBe(
54
+ 'Status report All good',
55
+ )
56
+ })
57
+
58
+ test('links become link text', () => {
59
+ expect(normalizeForTts('see [the docs](https://example.com/a/b) now')).toBe(
60
+ 'see the docs now',
61
+ )
62
+ })
63
+
64
+ test('list markers dropped', () => {
65
+ expect(normalizeForTts('- first\n- second')).toBe('first second')
66
+ })
67
+
68
+ test('blockquote markers dropped', () => {
69
+ expect(normalizeForTts('> quoted line')).toBe('quoted line')
70
+ })
71
+
72
+ test('code fences become spoken placeholder', () => {
73
+ const out = normalizeForTts('before\n```ts\nconst x = 1\n```\nafter')
74
+ expect(out).toContain('code block omitted')
75
+ expect(out).not.toContain('const x')
76
+ })
77
+
78
+ test('inline code read as-is without backticks, content untouched', () => {
79
+ expect(normalizeForTts('run `deploy_5km_run` now')).toBe(
80
+ 'run deploy_5km_run now',
81
+ )
82
+ })
83
+
84
+ test('unterminated fence swallows to end-of-input (deliberate)', () => {
85
+ // An opening ``` with no close drops everything after it — reading
86
+ // half a code block aloud is worse than omitting it. Pinned as the
87
+ // intended trade-off.
88
+ const out = normalizeForTts('before\n```ts\nconst secret = 1\nmore code')
89
+ expect(out).toBe('before code block omitted.')
90
+ })
91
+
92
+ test('tables summarized as table omitted', () => {
93
+ const out = normalizeForTts('| a | b |\n|---|---|\n| 1 | 2 |\n')
94
+ expect(out).toContain('table omitted')
95
+ expect(out).not.toContain('|')
96
+ })
97
+ })
98
+
99
+ describe('numbers', () => {
100
+ test('currency $5.20 → five dollars twenty', () => {
101
+ expect(normalizeForTts('that costs $5.20 today')).toBe(
102
+ 'that costs five dollars twenty today',
103
+ )
104
+ })
105
+
106
+ test('whole-dollar and singular', () => {
107
+ expect(normalizeForTts('$1 fee')).toBe('one dollar fee')
108
+ expect(normalizeForTts('$300 total')).toBe('three hundred dollars total')
109
+ })
110
+
111
+ test('percent 12% → twelve percent', () => {
112
+ expect(normalizeForTts('up 12% overnight')).toBe('up twelve percent overnight')
113
+ })
114
+
115
+ test('ordinal 3rd → third', () => {
116
+ expect(normalizeForTts('the 3rd item and the 21st day')).toBe(
117
+ 'the third item and the twenty-first day',
118
+ )
119
+ })
120
+
121
+ test('time 14:30 spoken', () => {
122
+ expect(normalizeForTts('meet at 14:30 sharp')).toBe(
123
+ 'meet at fourteen thirty sharp',
124
+ )
125
+ })
126
+
127
+ test('ISO date spoken', () => {
128
+ expect(normalizeForTts('due 2026-07-04 ok')).toBe(
129
+ 'due July fourth two thousand twenty-six ok',
130
+ )
131
+ })
132
+
133
+ test('phone-like digit run read as digits', () => {
134
+ expect(normalizeForTts('call 0412 345 678 now')).toBe(
135
+ 'call zero four one two three four five six seven eight now',
136
+ )
137
+ })
138
+
139
+ test('international +-prefixed number read as digits', () => {
140
+ expect(normalizeForTts('call +61 412 345 678')).toBe(
141
+ 'call plus six one four one two three four five six seven eight',
142
+ )
143
+ })
144
+
145
+ test('ordinary space-separated numbers stay untouched', () => {
146
+ expect(normalizeForTts('scores were 1024 2048 4096 today')).toBe(
147
+ 'scores were 1024 2048 4096 today',
148
+ )
149
+ })
150
+
151
+ test('short digit groups without phone prefix stay untouched', () => {
152
+ // The unit pass may expand a trailing unit, but the digit groups
153
+ // themselves must never be read digit-by-digit.
154
+ const out = normalizeForTts('12 34 56 78 items')
155
+ expect(out).toBe('12 34 56 78 items')
156
+ })
157
+
158
+ test('currency with thousands separator: $1,000 → one thousand dollars', () => {
159
+ expect(normalizeForTts('paid $1,000 up front')).toBe(
160
+ 'paid one thousand dollars up front',
161
+ )
162
+ expect(normalizeForTts('$12,345.67 total')).toBe(
163
+ 'twelve thousand three hundred forty-five dollars sixty-seven total',
164
+ )
165
+ })
166
+
167
+ test('odd cents ($5.203) left unchanged rather than half-read', () => {
168
+ expect(normalizeForTts('value $5.203 today')).toBe('value $5.203 today')
169
+ })
170
+
171
+ test('HH:MM:SS left unchanged (no dangling seconds)', () => {
172
+ expect(normalizeForTts('at 12:34:56 sharp')).toBe('at 12:34:56 sharp')
173
+ })
174
+
175
+ test('units expand: 5km, 10GB, 500ms', () => {
176
+ expect(normalizeForTts('ran 5km')).toBe('ran five kilometres')
177
+ expect(normalizeForTts('used 10GB')).toBe('used ten gigabytes')
178
+ expect(normalizeForTts('took 500ms')).toBe('took five hundred milliseconds')
179
+ })
180
+
181
+ test('identifiers with digits are never touched', () => {
182
+ expect(normalizeForTts('rename class5 and my5thing')).toBe(
183
+ 'rename class5 and my5thing',
184
+ )
185
+ })
186
+ })
187
+
188
+ describe('URLs', () => {
189
+ test('bare URL → spoken domain', () => {
190
+ expect(normalizeForTts('see https://github.com/switchroom/switchroom for code')).toBe(
191
+ 'see github dot com link for code',
192
+ )
193
+ })
194
+
195
+ test('www prefix dropped from spoken domain', () => {
196
+ expect(normalizeForTts('at https://www.example.org/x')).toBe(
197
+ 'at example dot org link',
198
+ )
199
+ })
200
+
201
+ test('IP-host URL degrades to a link', () => {
202
+ expect(normalizeForTts('at http://127.0.0.1:8080/x ok')).toBe('at a link ok')
203
+ })
204
+ })
205
+
206
+ describe('emoji', () => {
207
+ test('common emoji spoken via map', () => {
208
+ expect(normalizeForTts('great work 👍')).toBe('great work thumbs up')
209
+ })
210
+
211
+ test('other emoji stripped', () => {
212
+ expect(normalizeForTts('launch 🚀 now 🎉')).toBe('launch now')
213
+ })
214
+ })
215
+
216
+ describe('symbols', () => {
217
+ test('& → and, @ → at, # → hash', () => {
218
+ expect(normalizeForTts('cats & dogs')).toBe('cats and dogs')
219
+ expect(normalizeForTts('ping @ken')).toBe('ping at ken')
220
+ expect(normalizeForTts('see #general')).toBe('see hash general')
221
+ })
222
+
223
+ test('prose slash spoken', () => {
224
+ expect(normalizeForTts('yes/no answer')).toBe('yes slash no answer')
225
+ })
226
+
227
+ test('~ before a number → about', () => {
228
+ expect(normalizeForTts('~5 minutes left')).toBe('about 5 minutes left')
229
+ })
230
+ })
231
+
232
+ describe('conservatism', () => {
233
+ test('plain prose passes through unchanged', () => {
234
+ const input = 'The build finished and everything looks good.'
235
+ expect(normalizeForTts(input)).toBe(input)
236
+ })
237
+
238
+ test('deterministic: same input, same output', () => {
239
+ const input = '## Hi\n**bold** $5.20 at 14:30 👍 https://github.com/a'
240
+ expect(normalizeForTts(input)).toBe(normalizeForTts(input))
241
+ })
242
+ })
@@ -163,3 +163,27 @@ describe('Fix B: vault_request_access standing-ACL-aware (#1487 follow-up)', ()
163
163
  expect(approveBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
164
164
  })
165
165
  })
166
+
167
+ describe('vault_request_access — `why` alias for `reason`', () => {
168
+ // The sibling tool vault_request_save uses `why`; agents cross-
169
+ // contaminate the two schemas. Without the alias the rationale
170
+ // silently drops off the approval card ("why: not provided") and
171
+ // operators tend to Deny.
172
+ it('gateway accepts args.why as a fallback when args.reason is absent', () => {
173
+ expect(gatewaySrc).toMatch(
174
+ /const reason =\s*\n?\s*typeof args\.reason === 'string' \? args\.reason : typeof args\.why === 'string' \? args\.why : undefined/,
175
+ )
176
+ })
177
+
178
+ it('bridge schema documents the alias on both fields', () => {
179
+ const block = bridgeSrc.split("name: 'vault_request_access'")[1]?.split("name: '")[0] ?? ''
180
+ expect(block).toMatch(/why:\s*{\s*type:\s*'string'/)
181
+ expect(block).toContain('Alias for `reason`')
182
+ expect(block).toContain('`why` is accepted as an alias')
183
+ })
184
+
185
+ it('reason wins when both are supplied (ordering: reason checked first)', () => {
186
+ const idx = gatewaySrc.indexOf("typeof args.reason === 'string' ? args.reason : typeof args.why === 'string'")
187
+ expect(idx).toBeGreaterThan(-1)
188
+ })
189
+ })