switchroom 0.16.38 → 0.16.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +88 -82
- package/dist/auth-broker/index.js +87 -81
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +89 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1621 -737
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +271 -239
- package/dist/vault/approvals/kernel-server.js +90 -84
- package/dist/vault/broker/server.js +91 -85
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
- package/telegram-plugin/dist/server.js +180 -162
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +262 -63
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -1
|
@@ -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
|
-
|
|
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('
|
|
154
|
-
// A single fenced block larger than maxLen has no safe interior cut
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
-
|
|
160
|
-
expect(
|
|
161
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
155
|
-
'Result: empty for Saturday
|
|
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
|
|
286
|
+
'Running `git status --short`. Looks clean. ' +
|
|
168
287
|
'```\nM file.ts — modified\n```\n' +
|
|
169
|
-
'Ready to commit
|
|
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
|
|
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
|
|
304
|
-
|
|
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(`**→ ${
|
|
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
|
+
})
|