switchroom 0.16.29 → 0.16.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -0,0 +1,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
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -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(' 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,299 @@
|
|
|
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 { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
import { tmpdir } from 'os'
|
|
15
|
+
import {
|
|
16
|
+
VoiceOnDemandCache,
|
|
17
|
+
VOICE_ONDEMAND_CALLBACK_PREFIX,
|
|
18
|
+
mintVoiceOnDemandToken,
|
|
19
|
+
isVoiceOnDemandCallback,
|
|
20
|
+
parseVoiceOnDemandToken,
|
|
21
|
+
buildListenKeyboard,
|
|
22
|
+
mayInjectListenButton,
|
|
23
|
+
} from '../voice-ondemand.js'
|
|
24
|
+
import { parseAgentCallback } from '../inline-keyboard-callbacks.js'
|
|
25
|
+
|
|
26
|
+
describe('on-demand: token + reserved callback prefix', () => {
|
|
27
|
+
it('mints an 8-hex-char token', () => {
|
|
28
|
+
const t = mintVoiceOnDemandToken()
|
|
29
|
+
expect(t).toMatch(/^[0-9a-f]{8}$/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('builds a single-button Listen keyboard with a voice: callback', () => {
|
|
33
|
+
const kb = buildListenKeyboard('deadbeef')
|
|
34
|
+
expect(kb.inline_keyboard).toHaveLength(1)
|
|
35
|
+
expect(kb.inline_keyboard[0]).toHaveLength(1)
|
|
36
|
+
const btn = kb.inline_keyboard[0]![0]!
|
|
37
|
+
expect(btn.text).toBe('🔊 Listen')
|
|
38
|
+
expect(btn.callback_data).toBe('voice:deadbeef')
|
|
39
|
+
expect(btn.callback_data.length).toBeLessThanOrEqual(64) // Telegram cap
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('recognises + parses voice: callbacks, rejects others', () => {
|
|
43
|
+
expect(isVoiceOnDemandCallback('voice:abc123')).toBe(true)
|
|
44
|
+
expect(isVoiceOnDemandCallback('agent:foo')).toBe(false)
|
|
45
|
+
expect(parseVoiceOnDemandToken('voice:abc123')).toBe('abc123')
|
|
46
|
+
expect(parseVoiceOnDemandToken('voice:')).toBeNull() // empty token
|
|
47
|
+
expect(parseVoiceOnDemandToken('agent:foo')).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('on-demand: callback routes internally, NOT to the agent inbound path', () => {
|
|
52
|
+
it('a voice: callback is NOT parsed as an agent callback', () => {
|
|
53
|
+
// The gateway checks isVoiceOnDemandCallback() BEFORE parseAgentCallback().
|
|
54
|
+
const data = `${VOICE_ONDEMAND_CALLBACK_PREFIX}${mintVoiceOnDemandToken()}`
|
|
55
|
+
expect(isVoiceOnDemandCallback(data)).toBe(true)
|
|
56
|
+
// Even if it reached agent routing, the raw voice: data must not be a
|
|
57
|
+
// valid agent callback (which requires the agent: prefix).
|
|
58
|
+
expect(parseAgentCallback(data)).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('on-demand: bounded TTL LRU cache', () => {
|
|
63
|
+
it('stores and retrieves a payload by token', () => {
|
|
64
|
+
const cache = new VoiceOnDemandCache()
|
|
65
|
+
cache.put('tok', { text: 'hello world', voice: 'af_bella', speed: 1.1 })
|
|
66
|
+
expect(cache.get('tok')).toEqual({ text: 'hello world', voice: 'af_bella', speed: 1.1 })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('cache MISS returns null gracefully (no throw)', () => {
|
|
70
|
+
const cache = new VoiceOnDemandCache()
|
|
71
|
+
expect(() => cache.get('never-stored')).not.toThrow()
|
|
72
|
+
expect(cache.get('never-stored')).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('expires entries past the TTL (miss → graceful null)', () => {
|
|
76
|
+
let now = 1_000
|
|
77
|
+
const cache = new VoiceOnDemandCache({ ttlMs: 100, maxEntries: 500, now: () => now })
|
|
78
|
+
cache.put('tok', { text: 'x', speed: 1 })
|
|
79
|
+
now = 1_050
|
|
80
|
+
expect(cache.get('tok')).not.toBeNull() // still fresh
|
|
81
|
+
now = 1_200
|
|
82
|
+
expect(cache.get('tok')).toBeNull() // expired → graceful miss
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('evicts the oldest entry past the size cap (LRU)', () => {
|
|
86
|
+
const cache = new VoiceOnDemandCache({ ttlMs: 60_000, maxEntries: 2 })
|
|
87
|
+
cache.put('a', { text: 'a', speed: 1 })
|
|
88
|
+
cache.put('b', { text: 'b', speed: 1 })
|
|
89
|
+
cache.put('c', { text: 'c', speed: 1 }) // evicts 'a'
|
|
90
|
+
expect(cache.size).toBe(2)
|
|
91
|
+
expect(cache.get('a')).toBeNull()
|
|
92
|
+
expect(cache.get('b')).not.toBeNull()
|
|
93
|
+
expect(cache.get('c')).not.toBeNull()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
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
|
+
|
|
191
|
+
describe('on-demand: reply-time behaviour (gate + no synthesis)', () => {
|
|
192
|
+
it('reply_mode=on-demand + local engine → Listen button, no synth at reply time', () => {
|
|
193
|
+
// Reproduce the gateway reply path decision for reply_mode='on-demand':
|
|
194
|
+
// the voiceOgg synth loop is guarded to SKIP when replyMode==='on-demand',
|
|
195
|
+
// and a Listen button is injected instead.
|
|
196
|
+
const replyMode = 'on-demand' as 'voice+text' | 'voice-only' | 'on-demand'
|
|
197
|
+
|
|
198
|
+
let synthCalls = 0
|
|
199
|
+
const fakeSynth = () => {
|
|
200
|
+
synthCalls++
|
|
201
|
+
}
|
|
202
|
+
// gateway guard: `if (plan != null && plan.replyMode !== 'on-demand')`
|
|
203
|
+
if (replyMode !== 'on-demand') fakeSynth()
|
|
204
|
+
expect(synthCalls).toBe(0) // ZERO GPU/sidecar work at reply time
|
|
205
|
+
|
|
206
|
+
// No agent buttons → the Listen button is injected.
|
|
207
|
+
const rawKeyboard: unknown[][] | undefined = undefined
|
|
208
|
+
expect(mayInjectListenButton(rawKeyboard)).toBe(true)
|
|
209
|
+
const kb = buildListenKeyboard(mintVoiceOnDemandToken())
|
|
210
|
+
expect(kb.inline_keyboard[0]![0]!.callback_data.startsWith('voice:')).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('ENGINE GATE: openai + on-demand does NOT inject a Listen button (falls back to immediate synth)', () => {
|
|
214
|
+
// The gateway computes:
|
|
215
|
+
// useOnDemandButton = plan != null && replyMode==='on-demand' && engine==='kokoro'
|
|
216
|
+
// and both the reply-time synth-skip AND the button injection key on it.
|
|
217
|
+
// So an openai on-demand config must (a) NOT defer synthesis and (b) NOT
|
|
218
|
+
// mint a Listen button — its taps would dead-end on the local sidecar.
|
|
219
|
+
const useOnDemandButton = (
|
|
220
|
+
engine: 'kokoro' | 'openai',
|
|
221
|
+
replyMode: 'voice+text' | 'voice-only' | 'on-demand',
|
|
222
|
+
) => replyMode === 'on-demand' && engine === 'kokoro'
|
|
223
|
+
|
|
224
|
+
// openai + on-demand: no button, immediate synth path runs.
|
|
225
|
+
expect(useOnDemandButton('openai', 'on-demand')).toBe(false)
|
|
226
|
+
// kokoro + on-demand: button injected, synth deferred.
|
|
227
|
+
expect(useOnDemandButton('kokoro', 'on-demand')).toBe(true)
|
|
228
|
+
// openai non-on-demand modes are unaffected (normal synth).
|
|
229
|
+
expect(useOnDemandButton('openai', 'voice+text')).toBe(false)
|
|
230
|
+
expect(useOnDemandButton('openai', 'voice-only')).toBe(false)
|
|
231
|
+
|
|
232
|
+
// Reproduce the reply-time synth guard: `if (plan != null && !useOnDemandButton)`.
|
|
233
|
+
// For openai on-demand it MUST enter the synth loop (fall back), unlike
|
|
234
|
+
// kokoro on-demand which skips it.
|
|
235
|
+
let openaiSynthRan = false
|
|
236
|
+
if (!useOnDemandButton('openai', 'on-demand')) openaiSynthRan = true
|
|
237
|
+
expect(openaiSynthRan).toBe(true)
|
|
238
|
+
|
|
239
|
+
let kokoroSynthRan = false
|
|
240
|
+
if (!useOnDemandButton('kokoro', 'on-demand')) kokoroSynthRan = true
|
|
241
|
+
expect(kokoroSynthRan).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('GATE: agent-supplied inline_keyboard present → NO voice button injected', () => {
|
|
245
|
+
const agentKeyboard = [[{ text: 'Approve', callback_data: 'yes' }]]
|
|
246
|
+
expect(mayInjectListenButton(agentKeyboard)).toBe(false)
|
|
247
|
+
|
|
248
|
+
// An empty keyboard (no rows / empty rows) does not count as agent buttons.
|
|
249
|
+
expect(mayInjectListenButton([])).toBe(true)
|
|
250
|
+
expect(mayInjectListenButton([[]])).toBe(true)
|
|
251
|
+
expect(mayInjectListenButton(undefined)).toBe(true)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('on-demand: single-use — strip the Listen keyboard on success only', () => {
|
|
256
|
+
// Reproduce the callback handler's control flow. Each terminal branch in the
|
|
257
|
+
// gateway either returns EARLY (leaving the button intact so the user can
|
|
258
|
+
// retry) or falls through to the successful sendVoice + keyboard strip.
|
|
259
|
+
//
|
|
260
|
+
// expired cache entry → answerCallbackQuery + return (no strip)
|
|
261
|
+
// sidecar unavailable → answerCallbackQuery + return (no strip)
|
|
262
|
+
// synth failure (!result.ok) → answerCallbackQuery + return (no strip)
|
|
263
|
+
// sendVoice success → strip keyboard
|
|
264
|
+
//
|
|
265
|
+
// We model that as a pure decision so the observable contract — strip iff the
|
|
266
|
+
// audio was actually delivered — is pinned without importing the gateway.
|
|
267
|
+
type Outcome = 'expired' | 'sidecar-unavailable' | 'synth-failed' | 'sent'
|
|
268
|
+
const shouldStripListenKeyboard = (outcome: Outcome, cbMessageId: number | undefined): boolean =>
|
|
269
|
+
outcome === 'sent' && cbMessageId != null
|
|
270
|
+
|
|
271
|
+
it('strips the keyboard after a successful sendVoice', () => {
|
|
272
|
+
expect(shouldStripListenKeyboard('sent', 42)).toBe(true)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('does NOT strip when the cache entry expired (user can retry)', () => {
|
|
276
|
+
expect(shouldStripListenKeyboard('expired', 42)).toBe(false)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('does NOT strip when the voice sidecar is unavailable (user can retry)', () => {
|
|
280
|
+
expect(shouldStripListenKeyboard('sidecar-unavailable', 42)).toBe(false)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('does NOT strip when synthesis failed (user can retry)', () => {
|
|
284
|
+
expect(shouldStripListenKeyboard('synth-failed', 42)).toBe(false)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('cannot strip without a callback message id even on success', () => {
|
|
288
|
+
// The gateway guards the strip on `cbMessageId != null` (no message → no
|
|
289
|
+
// keyboard to edit).
|
|
290
|
+
expect(shouldStripListenKeyboard('sent', undefined)).toBe(false)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('strips by clearing the inline keyboard (empty rows)', () => {
|
|
294
|
+
// The strip payload the handler sends: reply_markup with an empty
|
|
295
|
+
// inline_keyboard — the same shape the agent-button single_use strip uses.
|
|
296
|
+
const stripMarkup = { reply_markup: { inline_keyboard: [] as unknown[][] } }
|
|
297
|
+
expect(stripMarkup.reply_markup.inline_keyboard).toHaveLength(0)
|
|
298
|
+
})
|
|
299
|
+
})
|