switchroom 0.16.28 → 0.16.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -15,6 +15,7 @@
15
15
  import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
+ buildVaultGrantApprovedCardText,
18
19
  buildVaultGrantDeniedInbound,
19
20
  buildVaultSaveCompletedInbound,
20
21
  buildVaultSaveFailedInbound,
@@ -22,6 +23,7 @@ import {
22
23
  type VaultGrantInboundContext,
23
24
  type VaultSaveInboundContext,
24
25
  } from '../gateway/vault-grant-inbound-builders.js'
26
+ import { richMessage } from '../rich-send.js'
25
27
 
26
28
  const FIXED_NOW = 1_700_000_000_000
27
29
 
@@ -40,6 +42,58 @@ const CTX_WRITE: VaultGrantInboundContext = {
40
42
  ttl_seconds: 7 * 86400,
41
43
  }
42
44
 
45
+ describe('buildVaultGrantApprovedCardText — the [object Object] regression guard', () => {
46
+ const BASE = {
47
+ agentEscaped: 'gymbro',
48
+ scope: 'read' as const,
49
+ key: 'fatsecret/credentials',
50
+ days: 30,
51
+ grantId: 'vg_a1b2c3',
52
+ }
53
+
54
+ it('renders the full confirmation as ONE markdown string wrapped once — no [object Object]', () => {
55
+ // This is the whole point of the helper: the card text is built as a
56
+ // single string and passed to a single richMessage(...) call. The bug
57
+ // (fixed here) was `"prefix " + richMessage("suffix")`, which coerces
58
+ // the {markdown} object and prints "[object Object]".
59
+ const text = buildVaultGrantApprovedCardText(BASE)
60
+ const rich = richMessage(text)
61
+ expect(rich.markdown).toBe(text)
62
+ expect(rich.markdown).not.toContain('[object Object]')
63
+ expect(rich.markdown).toContain('vg_a1b2c3') // grant id present
64
+ })
65
+
66
+ it('includes agent, scope, key, days, and the grant id', () => {
67
+ const text = buildVaultGrantApprovedCardText(BASE)
68
+ expect(text).toContain('**gymbro**')
69
+ expect(text).toContain('read access')
70
+ expect(text).toContain('`fatsecret/credentials`')
71
+ expect(text).toContain('for 30d')
72
+ expect(text).toContain('(grant `vg_a1b2c3`)')
73
+ })
74
+
75
+ it('appends the footer when provided, omits it otherwise', () => {
76
+ const withFooter = buildVaultGrantApprovedCardText({
77
+ ...BASE,
78
+ footer: '\n_Approver verified by Telegram identity._',
79
+ })
80
+ expect(withFooter).toContain('_Approver verified by Telegram identity._')
81
+ // still one clean string
82
+ expect(withFooter).not.toContain('[object Object]')
83
+
84
+ const noFooter = buildVaultGrantApprovedCardText(BASE)
85
+ expect(noFooter.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
86
+ })
87
+
88
+ it('DOCUMENTS the original bug: bare-string + richMessage() coerces to [object Object]', () => {
89
+ // Demonstrates why the fix matters — if a future edit reintroduces the
90
+ // "concatenate a string onto the richMessage object" pattern, the result
91
+ // contains the literal "[object Object]". The helper above avoids it.
92
+ const buggy = '✅ Granted access. ' + (richMessage('(grant `vg_x`)') as unknown as string)
93
+ expect(buggy).toContain('[object Object]')
94
+ })
95
+ })
96
+
43
97
  describe('buildVaultGrantApprovedInbound', () => {
44
98
  it('emits the canonical envelope (type, chat_id, user, userId, ts, messageId)', () => {
45
99
  const msg = buildVaultGrantApprovedInbound({
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Render-safety pin for the agent-initiated vault-access approval card.
3
+ *
4
+ * Bug (card-ux fix 2): the vault key was interpolated into a `code span`
5
+ * WITHOUT defusing backtick / without leaking backslashes. A key with a
6
+ * backtick would prematurely close the span; the following `_why_` and
7
+ * `_footer_` markdown then paired against stray delimiters and the card
8
+ * rendered on top of itself. Keys with `_` (e.g. `openai/OPENAI_API_KEY`)
9
+ * must render literally inside the span (no visible `\_`).
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import { renderVaultRequestAccessCard } from '../gateway/vault-request-access-card.js'
14
+
15
+ // Zero-width space codeSpanSafe inserts after a backtick to defuse it.
16
+ const ZWSP = '​'
17
+
18
+ /** Count top-level (outside code spans) markdown emphasis delimiters. */
19
+ function unbalancedOutsideCodeSpans(text: string): {
20
+ underscores: number
21
+ asterisks: number
22
+ } {
23
+ let inSpan = false
24
+ let underscores = 0
25
+ let asterisks = 0
26
+ for (const ch of text) {
27
+ if (ch === '`') {
28
+ inSpan = !inSpan
29
+ continue
30
+ }
31
+ if (inSpan) continue
32
+ if (ch === '_') underscores += 1
33
+ if (ch === '*') asterisks += 1
34
+ }
35
+ return { underscores, asterisks }
36
+ }
37
+
38
+ describe('renderVaultRequestAccessCard', () => {
39
+ it('renders an underscore-laden key literally inside the code span (no backslash leak)', () => {
40
+ const text = renderVaultRequestAccessCard({
41
+ agent: 'overlord',
42
+ key: 'openai/OPENAI_API_KEY',
43
+ scope: 'read',
44
+ reason: 'call the completions endpoint',
45
+ ttl_seconds: 7 * 86400,
46
+ })
47
+ // The key rides in a code span verbatim — underscores present, NOT escaped.
48
+ expect(text).toContain('`openai/OPENAI_API_KEY`')
49
+ // No visible backslash-escaping leaked into the span.
50
+ expect(text).not.toContain('OPENAI\\_API\\_KEY')
51
+ })
52
+
53
+ it('does not leave unbalanced markdown delimiters outside code spans', () => {
54
+ const text = renderVaultRequestAccessCard({
55
+ agent: 'overlord',
56
+ key: 'openai/OPENAI_API_KEY',
57
+ scope: 'write',
58
+ reason: 'rotate the key',
59
+ ttl_seconds: 3600,
60
+ })
61
+ const { underscores, asterisks } = unbalancedOutsideCodeSpans(text)
62
+ // The `_why_` + `_footer_` italic pairs and `**agent**` bold pair must
63
+ // each be even outside the spans — an underscore-key leaking out (the bug)
64
+ // makes this odd.
65
+ expect(underscores % 2).toBe(0)
66
+ expect(asterisks % 2).toBe(0)
67
+ })
68
+
69
+ it('defuses a backtick in the key so it cannot close the span early', () => {
70
+ const text = renderVaultRequestAccessCard({
71
+ agent: 'overlord',
72
+ key: 'svc/we`ird',
73
+ scope: 'read',
74
+ ttl_seconds: 86400,
75
+ })
76
+ // A raw backtick would have closed the span; codeSpanSafe splits it with a
77
+ // zero-width space so the parser sees `` `we` `` (empty-ish) then literal
78
+ // text, never an unterminated span breaking the following lines.
79
+ expect(text).toContain(`we\`${ZWSP}ird`)
80
+ // Every intentional code-span backtick is immediately followed by
81
+ // non-backtick content or a ZWSP — no bare `\`\`` that would swallow text.
82
+ expect(text).not.toContain('``')
83
+ })
84
+
85
+ it('renders "not provided" when reason omitted', () => {
86
+ const text = renderVaultRequestAccessCard({
87
+ agent: 'overlord',
88
+ key: 'svc/key',
89
+ scope: 'read',
90
+ ttl_seconds: 86400,
91
+ })
92
+ expect(text).toContain('why: _not provided_')
93
+ })
94
+ })
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Unit tests for normalizeForSpeech — the speech-normalization pass applied
3
+ * to a reply BEFORE it is synthesized to a voice note. Covers every case the
4
+ * operator reported (tilde, asterisks, backticks, headings, links, code
5
+ * fences) plus the documented behavioural choices.
6
+ */
7
+
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { normalizeForSpeech } from '../voice-normalize-text.js'
10
+
11
+ describe('normalizeForSpeech — reported markdown/symbol cases', () => {
12
+ it('drops stray tildes (never spoken as "tilde")', () => {
13
+ expect(normalizeForSpeech('It costs ~5 dollars')).toBe('It costs 5 dollars')
14
+ expect(normalizeForSpeech('done ~ ok')).toBe('done ok')
15
+ })
16
+
17
+ it('strips bold/italic asterisks and underscores, keeping the words', () => {
18
+ expect(normalizeForSpeech('This is **bold** text')).toBe('This is bold text')
19
+ expect(normalizeForSpeech('This is *italic* text')).toBe('This is italic text')
20
+ expect(normalizeForSpeech('This is _emphasis_ text')).toBe('This is emphasis text')
21
+ expect(normalizeForSpeech('***all*** three')).toBe('all three')
22
+ })
23
+
24
+ it('strips strikethrough ~~text~~ to plain text', () => {
25
+ expect(normalizeForSpeech('~~gone~~ kept')).toBe('gone kept')
26
+ })
27
+
28
+ it('removes backticks from inline code but keeps the content', () => {
29
+ expect(normalizeForSpeech('run `npm test` now')).toBe('run npm test now')
30
+ })
31
+
32
+ it('strips headings markers', () => {
33
+ expect(normalizeForSpeech('# Summary\nAll good')).toBe('Summary All good')
34
+ expect(normalizeForSpeech('### Deep heading')).toBe('Deep heading')
35
+ })
36
+
37
+ it('speaks link text and drops the URL', () => {
38
+ expect(normalizeForSpeech('see [the docs](https://example.com/x) here')).toBe(
39
+ 'see the docs here',
40
+ )
41
+ })
42
+
43
+ it('drops a fenced code block and leaves a spoken placeholder', () => {
44
+ const input = 'Here is code:\n```js\nconst x = 1\nconsole.log(x)\n```\nDone.'
45
+ const out = normalizeForSpeech(input)
46
+ expect(out).toContain('code block omitted')
47
+ expect(out).not.toContain('const x')
48
+ expect(out).not.toContain('```')
49
+ expect(out).toContain('Done.')
50
+ })
51
+ })
52
+
53
+ describe('normalizeForSpeech — additional structures', () => {
54
+ it('converts list bullets and numbers to prose pauses', () => {
55
+ const out = normalizeForSpeech('Steps:\n- first\n- second\n1. third')
56
+ expect(out).not.toMatch(/[-*+]\s/)
57
+ expect(out).not.toMatch(/^\d+\./)
58
+ expect(out).toContain('first')
59
+ expect(out).toContain('second')
60
+ expect(out).toContain('third')
61
+ })
62
+
63
+ it('strips blockquote markers', () => {
64
+ expect(normalizeForSpeech('> quoted line')).toBe('quoted line')
65
+ })
66
+
67
+ it('turns tables into prose (no pipes or separator rows)', () => {
68
+ const table = '| A | B |\n| --- | --- |\n| 1 | 2 |'
69
+ const out = normalizeForSpeech(table)
70
+ expect(out).not.toContain('|')
71
+ expect(out).not.toContain('---')
72
+ expect(out).toContain('A')
73
+ expect(out).toContain('B')
74
+ })
75
+
76
+ it('converts arrows to the word "to"', () => {
77
+ expect(normalizeForSpeech('build -> deploy')).toBe('build to deploy')
78
+ expect(normalizeForSpeech('a => b')).toBe('a to b')
79
+ expect(normalizeForSpeech('x → y')).toBe('x to y')
80
+ })
81
+
82
+ it('replaces bare and autolink URLs with "a link"', () => {
83
+ expect(normalizeForSpeech('go to https://example.com now')).toBe('go to a link now')
84
+ expect(normalizeForSpeech('go to <https://example.com>')).toBe('go to a link')
85
+ })
86
+
87
+ it('drops images to their alt text', () => {
88
+ expect(normalizeForSpeech('![a cat](x.png) sits')).toBe('a cat sits')
89
+ })
90
+
91
+ it('collapses newlines and whitespace into sentence flow', () => {
92
+ expect(normalizeForSpeech('Line one.\n\nLine two.')).toBe('Line one. Line two.')
93
+ expect(normalizeForSpeech('a b\nc')).toBe('a b c')
94
+ })
95
+
96
+ it('expands a minimal, safe set of abbreviations', () => {
97
+ expect(normalizeForSpeech('use it, e.g. here')).toBe('use it, for example, here')
98
+ expect(normalizeForSpeech('the tool, i.e. the interface')).toBe(
99
+ 'the tool, that is, the interface',
100
+ )
101
+ expect(normalizeForSpeech('cats, dogs, etc.')).toBe('cats, dogs, and so on')
102
+ })
103
+
104
+ it('collapses excessive punctuation', () => {
105
+ expect(normalizeForSpeech('really?!?!')).toBe('really!')
106
+ expect(normalizeForSpeech('wow!!!')).toBe('wow!')
107
+ })
108
+ })
109
+
110
+ describe('normalizeForSpeech — emoji & pictographs', () => {
111
+ it('strips emoji and collapses the resulting whitespace', () => {
112
+ expect(normalizeForSpeech('done 🚀 shipping')).toBe('done shipping')
113
+ expect(normalizeForSpeech('great 👍🏽 job')).toBe('great job')
114
+ expect(normalizeForSpeech('🎉')).toBe('')
115
+ })
116
+
117
+ it('drops :shortcode: emoji forms', () => {
118
+ expect(normalizeForSpeech('nice :rocket: work')).toBe('nice work')
119
+ })
120
+
121
+ it('leaves ordinary colon usage alone', () => {
122
+ expect(normalizeForSpeech('note: this is fine')).toBe('note: this is fine')
123
+ })
124
+ })
125
+
126
+ describe('normalizeForSpeech — numbers, units & symbols', () => {
127
+ it('speaks percent', () => {
128
+ expect(normalizeForSpeech('up 20% today')).toBe('up 20 percent today')
129
+ })
130
+
131
+ it('speaks currency amounts', () => {
132
+ expect(normalizeForSpeech('costs $5')).toBe('costs five dollars')
133
+ expect(normalizeForSpeech('costs $5.50')).toBe('costs five dollars fifty')
134
+ expect(normalizeForSpeech('just $1')).toBe('just one dollar')
135
+ })
136
+
137
+ it('speaks standalone & as "and"', () => {
138
+ expect(normalizeForSpeech('cats & dogs')).toBe('cats and dogs')
139
+ expect(normalizeForSpeech('R&D team')).toBe('R and D team')
140
+ })
141
+
142
+ it('speaks + between words as "plus" and = as "equals"', () => {
143
+ expect(normalizeForSpeech('speed + power')).toBe('speed plus power')
144
+ expect(normalizeForSpeech('x = y')).toBe('x equals y')
145
+ })
146
+
147
+ it('speaks × / x multipliers as "times"', () => {
148
+ expect(normalizeForSpeech('12× faster')).toBe('twelve times faster')
149
+ expect(normalizeForSpeech('a 12x speedup')).toBe('a twelve times speedup')
150
+ })
151
+
152
+ it('speaks temperature degrees', () => {
153
+ expect(normalizeForSpeech('it is 30°C outside')).toBe(
154
+ 'it is 30 degrees outside',
155
+ )
156
+ expect(normalizeForSpeech('72°F')).toBe('72 degrees')
157
+ })
158
+
159
+ it('expands number + time/size unit suffixes', () => {
160
+ expect(normalizeForSpeech('waited 2s')).toBe('waited two seconds')
161
+ expect(normalizeForSpeech('took 500ms')).toBe('took five hundred milliseconds')
162
+ expect(normalizeForSpeech('run 5m')).toBe('run five minutes')
163
+ expect(normalizeForSpeech('run 5min')).toBe('run five minutes')
164
+ expect(normalizeForSpeech('wait 2h')).toBe('wait two hours')
165
+ expect(normalizeForSpeech('wait 2hr')).toBe('wait two hours')
166
+ expect(normalizeForSpeech('in 3d')).toBe('in three days')
167
+ expect(normalizeForSpeech('10kb file')).toBe('ten kilobytes file')
168
+ expect(normalizeForSpeech('10KB file')).toBe('ten kilobytes file')
169
+ expect(normalizeForSpeech('a 5MB image')).toBe('a five megabytes image')
170
+ expect(normalizeForSpeech('a 2GB dump')).toBe('a two gigabytes dump')
171
+ expect(normalizeForSpeech('1s ping')).toBe('one second ping')
172
+ })
173
+
174
+ it('expands the "k" thousands shorthand', () => {
175
+ expect(normalizeForSpeech('100k users')).toBe('one hundred thousand users')
176
+ })
177
+
178
+ it('leaves number-glued-to-word identifiers alone', () => {
179
+ expect(normalizeForSpeech('my5thing works')).toBe('my5thing works')
180
+ expect(normalizeForSpeech('class5 room')).toBe('class5 room')
181
+ })
182
+ })
183
+
184
+ describe('normalizeForSpeech — abbreviations (phase 2)', () => {
185
+ it('expands vs / approx / w/', () => {
186
+ expect(normalizeForSpeech('cats vs dogs')).toBe('cats versus dogs')
187
+ expect(normalizeForSpeech('cats vs. dogs')).toBe('cats versus dogs')
188
+ expect(normalizeForSpeech('approx 10 items')).toBe('approximately 10 items')
189
+ expect(normalizeForSpeech('tea w/ milk')).toBe('tea with milk')
190
+ })
191
+ })
192
+
193
+ describe('normalizeForSpeech — acronyms', () => {
194
+ it('spells curated initialisms letter-by-letter', () => {
195
+ expect(normalizeForSpeech('the CI passed')).toBe('the C I passed')
196
+ expect(normalizeForSpeech('open a PR')).toBe('open a P R')
197
+ expect(normalizeForSpeech('call the API via HTTP')).toBe(
198
+ 'call the A P I via H T T P',
199
+ )
200
+ expect(normalizeForSpeech('GPU and CPU load')).toBe('G P U and C P U load')
201
+ expect(normalizeForSpeech('parse the JSON')).toBe('parse the J S O N')
202
+ })
203
+
204
+ it('leaves word-style acronyms and unknown caps alone', () => {
205
+ expect(normalizeForSpeech('NASA launch')).toBe('NASA launch')
206
+ expect(normalizeForSpeech('the FBI report')).toBe('the FBI report')
207
+ })
208
+
209
+ it('does not touch caps inside a larger word', () => {
210
+ expect(normalizeForSpeech('myAPIkey stays')).toBe('myAPIkey stays')
211
+ })
212
+ })
213
+
214
+ describe('normalizeForSpeech — times & dates', () => {
215
+ it('speaks clock times', () => {
216
+ expect(normalizeForSpeech('meet at 12:45')).toBe('meet at twelve forty-five')
217
+ expect(normalizeForSpeech('at 09:05')).toBe('at nine oh five')
218
+ expect(normalizeForSpeech('at 14:00')).toBe("at fourteen o'clock")
219
+ })
220
+
221
+ it('speaks ISO dates', () => {
222
+ expect(normalizeForSpeech('on 2026-07-01 we ship')).toBe(
223
+ 'on July first two thousand twenty-six we ship',
224
+ )
225
+ expect(normalizeForSpeech('2026-12-25')).toBe(
226
+ 'December twenty-fifth two thousand twenty-six',
227
+ )
228
+ })
229
+ })
230
+
231
+ describe('normalizeForSpeech — conservative, does not mangle real words', () => {
232
+ it('leaves plain prose untouched', () => {
233
+ expect(normalizeForSpeech('The quick brown fox jumps.')).toBe(
234
+ 'The quick brown fox jumps.',
235
+ )
236
+ })
237
+
238
+ it('leaves lowercase day/word tokens and a symbol-free sentence alone', () => {
239
+ expect(normalizeForSpeech('see you wednesday afternoon')).toBe(
240
+ 'see you wednesday afternoon',
241
+ )
242
+ expect(normalizeForSpeech('The report covers three main areas.')).toBe(
243
+ 'The report covers three main areas.',
244
+ )
245
+ })
246
+
247
+ it('preserves an underscore inside an identifier-like word', () => {
248
+ // A lone mid-word underscore is not emphasis; keep the token intact.
249
+ expect(normalizeForSpeech('the my_var name')).toBe('the my_var name')
250
+ })
251
+
252
+ it('returns empty for empty/whitespace input', () => {
253
+ expect(normalizeForSpeech('')).toBe('')
254
+ expect(normalizeForSpeech(' \n ')).toBe('')
255
+ })
256
+ })
@@ -0,0 +1,202 @@
1
+ /**
2
+ * On-demand voice-out (reply_mode='on-demand') unit tests.
3
+ *
4
+ * gateway.ts is a 25k-line module with import-time side effects, so the
5
+ * on-demand primitives live in ../voice-ondemand.ts (imported by the gateway).
6
+ * These tests exercise those real primitives + reproduce the exact gateway
7
+ * decisions (button injection gate, no-synth-at-reply-time, callback routing)
8
+ * so the observable contract is covered without importing the gateway.
9
+ */
10
+
11
+ import { describe, it, expect } from 'bun:test'
12
+ import {
13
+ VoiceOnDemandCache,
14
+ VOICE_ONDEMAND_CALLBACK_PREFIX,
15
+ mintVoiceOnDemandToken,
16
+ isVoiceOnDemandCallback,
17
+ parseVoiceOnDemandToken,
18
+ buildListenKeyboard,
19
+ mayInjectListenButton,
20
+ } from '../voice-ondemand.js'
21
+ import { parseAgentCallback } from '../inline-keyboard-callbacks.js'
22
+
23
+ describe('on-demand: token + reserved callback prefix', () => {
24
+ it('mints an 8-hex-char token', () => {
25
+ const t = mintVoiceOnDemandToken()
26
+ expect(t).toMatch(/^[0-9a-f]{8}$/)
27
+ })
28
+
29
+ it('builds a single-button Listen keyboard with a voice: callback', () => {
30
+ const kb = buildListenKeyboard('deadbeef')
31
+ expect(kb.inline_keyboard).toHaveLength(1)
32
+ expect(kb.inline_keyboard[0]).toHaveLength(1)
33
+ const btn = kb.inline_keyboard[0]![0]!
34
+ expect(btn.text).toBe('🔊 Listen')
35
+ expect(btn.callback_data).toBe('voice:deadbeef')
36
+ expect(btn.callback_data.length).toBeLessThanOrEqual(64) // Telegram cap
37
+ })
38
+
39
+ it('recognises + parses voice: callbacks, rejects others', () => {
40
+ expect(isVoiceOnDemandCallback('voice:abc123')).toBe(true)
41
+ expect(isVoiceOnDemandCallback('agent:foo')).toBe(false)
42
+ expect(parseVoiceOnDemandToken('voice:abc123')).toBe('abc123')
43
+ expect(parseVoiceOnDemandToken('voice:')).toBeNull() // empty token
44
+ expect(parseVoiceOnDemandToken('agent:foo')).toBeNull()
45
+ })
46
+ })
47
+
48
+ describe('on-demand: callback routes internally, NOT to the agent inbound path', () => {
49
+ it('a voice: callback is NOT parsed as an agent callback', () => {
50
+ // The gateway checks isVoiceOnDemandCallback() BEFORE parseAgentCallback().
51
+ const data = `${VOICE_ONDEMAND_CALLBACK_PREFIX}${mintVoiceOnDemandToken()}`
52
+ expect(isVoiceOnDemandCallback(data)).toBe(true)
53
+ // Even if it reached agent routing, the raw voice: data must not be a
54
+ // valid agent callback (which requires the agent: prefix).
55
+ expect(parseAgentCallback(data)).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe('on-demand: bounded TTL LRU cache', () => {
60
+ it('stores and retrieves a payload by token', () => {
61
+ const cache = new VoiceOnDemandCache()
62
+ cache.put('tok', { text: 'hello world', voice: 'af_bella', speed: 1.1 })
63
+ expect(cache.get('tok')).toEqual({ text: 'hello world', voice: 'af_bella', speed: 1.1 })
64
+ })
65
+
66
+ it('cache MISS returns null gracefully (no throw)', () => {
67
+ const cache = new VoiceOnDemandCache()
68
+ expect(() => cache.get('never-stored')).not.toThrow()
69
+ expect(cache.get('never-stored')).toBeNull()
70
+ })
71
+
72
+ it('expires entries past the TTL (miss → graceful null)', () => {
73
+ let now = 1_000
74
+ const cache = new VoiceOnDemandCache(100 /* ttl */, 500, () => now)
75
+ cache.put('tok', { text: 'x', speed: 1 })
76
+ now = 1_050
77
+ expect(cache.get('tok')).not.toBeNull() // still fresh
78
+ now = 1_200
79
+ expect(cache.get('tok')).toBeNull() // expired → graceful miss
80
+ })
81
+
82
+ it('evicts the oldest entry past the size cap (LRU)', () => {
83
+ const cache = new VoiceOnDemandCache(60_000, 2 /* cap */)
84
+ cache.put('a', { text: 'a', speed: 1 })
85
+ cache.put('b', { text: 'b', speed: 1 })
86
+ cache.put('c', { text: 'c', speed: 1 }) // evicts 'a'
87
+ expect(cache.size).toBe(2)
88
+ expect(cache.get('a')).toBeNull()
89
+ expect(cache.get('b')).not.toBeNull()
90
+ expect(cache.get('c')).not.toBeNull()
91
+ })
92
+ })
93
+
94
+ describe('on-demand: reply-time behaviour (gate + no synthesis)', () => {
95
+ it('reply_mode=on-demand + local engine → Listen button, no synth at reply time', () => {
96
+ // Reproduce the gateway reply path decision for reply_mode='on-demand':
97
+ // the voiceOgg synth loop is guarded to SKIP when replyMode==='on-demand',
98
+ // and a Listen button is injected instead.
99
+ const replyMode = 'on-demand' as 'voice+text' | 'voice-only' | 'on-demand'
100
+
101
+ let synthCalls = 0
102
+ const fakeSynth = () => {
103
+ synthCalls++
104
+ }
105
+ // gateway guard: `if (plan != null && plan.replyMode !== 'on-demand')`
106
+ if (replyMode !== 'on-demand') fakeSynth()
107
+ expect(synthCalls).toBe(0) // ZERO GPU/sidecar work at reply time
108
+
109
+ // No agent buttons → the Listen button is injected.
110
+ const rawKeyboard: unknown[][] | undefined = undefined
111
+ expect(mayInjectListenButton(rawKeyboard)).toBe(true)
112
+ const kb = buildListenKeyboard(mintVoiceOnDemandToken())
113
+ expect(kb.inline_keyboard[0]![0]!.callback_data.startsWith('voice:')).toBe(true)
114
+ })
115
+
116
+ it('ENGINE GATE: openai + on-demand does NOT inject a Listen button (falls back to immediate synth)', () => {
117
+ // The gateway computes:
118
+ // useOnDemandButton = plan != null && replyMode==='on-demand' && engine==='kokoro'
119
+ // and both the reply-time synth-skip AND the button injection key on it.
120
+ // So an openai on-demand config must (a) NOT defer synthesis and (b) NOT
121
+ // mint a Listen button — its taps would dead-end on the local sidecar.
122
+ const useOnDemandButton = (
123
+ engine: 'kokoro' | 'openai',
124
+ replyMode: 'voice+text' | 'voice-only' | 'on-demand',
125
+ ) => replyMode === 'on-demand' && engine === 'kokoro'
126
+
127
+ // openai + on-demand: no button, immediate synth path runs.
128
+ expect(useOnDemandButton('openai', 'on-demand')).toBe(false)
129
+ // kokoro + on-demand: button injected, synth deferred.
130
+ expect(useOnDemandButton('kokoro', 'on-demand')).toBe(true)
131
+ // openai non-on-demand modes are unaffected (normal synth).
132
+ expect(useOnDemandButton('openai', 'voice+text')).toBe(false)
133
+ expect(useOnDemandButton('openai', 'voice-only')).toBe(false)
134
+
135
+ // Reproduce the reply-time synth guard: `if (plan != null && !useOnDemandButton)`.
136
+ // For openai on-demand it MUST enter the synth loop (fall back), unlike
137
+ // kokoro on-demand which skips it.
138
+ let openaiSynthRan = false
139
+ if (!useOnDemandButton('openai', 'on-demand')) openaiSynthRan = true
140
+ expect(openaiSynthRan).toBe(true)
141
+
142
+ let kokoroSynthRan = false
143
+ if (!useOnDemandButton('kokoro', 'on-demand')) kokoroSynthRan = true
144
+ expect(kokoroSynthRan).toBe(false)
145
+ })
146
+
147
+ it('GATE: agent-supplied inline_keyboard present → NO voice button injected', () => {
148
+ const agentKeyboard = [[{ text: 'Approve', callback_data: 'yes' }]]
149
+ expect(mayInjectListenButton(agentKeyboard)).toBe(false)
150
+
151
+ // An empty keyboard (no rows / empty rows) does not count as agent buttons.
152
+ expect(mayInjectListenButton([])).toBe(true)
153
+ expect(mayInjectListenButton([[]])).toBe(true)
154
+ expect(mayInjectListenButton(undefined)).toBe(true)
155
+ })
156
+ })
157
+
158
+ describe('on-demand: single-use — strip the Listen keyboard on success only', () => {
159
+ // Reproduce the callback handler's control flow. Each terminal branch in the
160
+ // gateway either returns EARLY (leaving the button intact so the user can
161
+ // retry) or falls through to the successful sendVoice + keyboard strip.
162
+ //
163
+ // expired cache entry → answerCallbackQuery + return (no strip)
164
+ // sidecar unavailable → answerCallbackQuery + return (no strip)
165
+ // synth failure (!result.ok) → answerCallbackQuery + return (no strip)
166
+ // sendVoice success → strip keyboard
167
+ //
168
+ // We model that as a pure decision so the observable contract — strip iff the
169
+ // audio was actually delivered — is pinned without importing the gateway.
170
+ type Outcome = 'expired' | 'sidecar-unavailable' | 'synth-failed' | 'sent'
171
+ const shouldStripListenKeyboard = (outcome: Outcome, cbMessageId: number | undefined): boolean =>
172
+ outcome === 'sent' && cbMessageId != null
173
+
174
+ it('strips the keyboard after a successful sendVoice', () => {
175
+ expect(shouldStripListenKeyboard('sent', 42)).toBe(true)
176
+ })
177
+
178
+ it('does NOT strip when the cache entry expired (user can retry)', () => {
179
+ expect(shouldStripListenKeyboard('expired', 42)).toBe(false)
180
+ })
181
+
182
+ it('does NOT strip when the voice sidecar is unavailable (user can retry)', () => {
183
+ expect(shouldStripListenKeyboard('sidecar-unavailable', 42)).toBe(false)
184
+ })
185
+
186
+ it('does NOT strip when synthesis failed (user can retry)', () => {
187
+ expect(shouldStripListenKeyboard('synth-failed', 42)).toBe(false)
188
+ })
189
+
190
+ it('cannot strip without a callback message id even on success', () => {
191
+ // The gateway guards the strip on `cbMessageId != null` (no message → no
192
+ // keyboard to edit).
193
+ expect(shouldStripListenKeyboard('sent', undefined)).toBe(false)
194
+ })
195
+
196
+ it('strips by clearing the inline keyboard (empty rows)', () => {
197
+ // The strip payload the handler sends: reply_markup with an empty
198
+ // inline_keyboard — the same shape the agent-button single_use strip uses.
199
+ const stripMarkup = { reply_markup: { inline_keyboard: [] as unknown[][] } }
200
+ expect(stripMarkup.reply_markup.inline_keyboard).toHaveLength(0)
201
+ })
202
+ })