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.
Files changed (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { describeToolUse } from '../tool-activity-summary.js'
3
+ import { computeLabel } from '../hooks/tool-label-pretool.mjs'
4
+
5
+ /**
6
+ * SINGLE STATUS-VOCABULARY GUARANTEE (know-what-my-agent-is-doing).
7
+ *
8
+ * Every activity surface renders per-tool status wording from ONE composer:
9
+ * `computeLabel` (hooks/tool-label-pretool.mjs). The consumers:
10
+ *
11
+ * - real-time live feed — the PreToolUse sidecar runs computeLabel at
12
+ * tool-call time (`tool_label` events → appendActivityLabel);
13
+ * - flush-time feed — appendActivityLine → describeToolUse;
14
+ * - nested sub-agent / worker-card steps — subagent-watcher →
15
+ * describeToolUse (whose lines the 🛠 Worker card renders via
16
+ * renderStatusCard).
17
+ *
18
+ * `describeToolUse` is a thin delegating wrapper over computeLabel. Before
19
+ * the delegation the two tables had drifted (same action, different copy on
20
+ * different surfaces — the wording drift the spec bars). This test is the
21
+ * anti-drift lock: it sweeps a corpus of every tool shape both composers
22
+ * handle and asserts they emit IDENTICAL labels. If someone re-forks the
23
+ * vocabulary — a second wording table in describeToolUse, a hook-only
24
+ * rewording — this fails CI.
25
+ */
26
+
27
+ // Corpus: (toolName, input) pairs spanning every branch of the composer.
28
+ const CORPUS: Array<[string, Record<string, unknown>]> = [
29
+ ['Bash', { command: 'ls -la', description: 'List repo files' }],
30
+ ['Bash', { command: 'grep -r foo .' }],
31
+ ['BashOutput', {}],
32
+ ['KillShell', {}],
33
+ ['Read', { file_path: '/home/u/code/gateway.ts' }],
34
+ ['Read', {}],
35
+ ['Edit', { file_path: '/a/b/CLAUDE.md' }],
36
+ ['MultiEdit', { file_path: '/a/b/x.ts' }],
37
+ ['Edit', {}],
38
+ ['Write', { file_path: 'notes.txt' }],
39
+ ['Write', {}],
40
+ ['NotebookEdit', { notebook_path: '/n/analysis.ipynb' }],
41
+ ['NotebookEdit', {}],
42
+ ['Grep', { pattern: 'TODO' }],
43
+ ['Grep', { pattern: 'TODO', path: 'src/' }],
44
+ ['Grep', {}],
45
+ ['Glob', { pattern: '**/*.ts' }],
46
+ ['Glob', {}],
47
+ ['WebFetch', { url: 'https://www.example.com/path?q=1' }],
48
+ ['WebFetch', {}],
49
+ ['WebSearch', { query: 'best running shoes' }],
50
+ ['WebSearch', {}],
51
+ ['Task', { description: 'Review the migration' }],
52
+ ['Agent', {}],
53
+ ['TodoWrite', {}],
54
+ ['TaskCreate', {}],
55
+ ['TaskUpdate', {}],
56
+ ['TaskList', {}],
57
+ ['ToolSearch', {}],
58
+ ['Skill', { skill: 'switchroom' }],
59
+ ['SomeFutureBuiltin', {}],
60
+ ['mcp__hindsight__recall', { query: 'x' }],
61
+ ['mcp__hindsight__reflect', { query: 'x' }],
62
+ ['mcp__hindsight__retain', {}],
63
+ ['mcp__hindsight__update_memory', {}],
64
+ ['mcp__hindsight__list_memories', {}],
65
+ ['mcp__claude_ai_Google_Calendar__list_events', {}],
66
+ ['mcp__claude_ai_Gmail__search', {}],
67
+ ['mcp__claude_ai_Google_Drive__search_files', {}],
68
+ ['mcp__claude_ai_Notion__notion-search', {}],
69
+ ['mcp__perplexity__perplexity_search', { query: 'weather sydney' }],
70
+ ['mcp__webkite__read', { url: 'https://example.org/docs' }],
71
+ ['mcp__acme__do_thing', { description: 'Fetched the report' }],
72
+ ['mcp__acme__do_thing', {}],
73
+ ['mcp__switchroom-telegram__get_recent_messages', {}],
74
+ ]
75
+
76
+ // Suppressed-on-both corpus: surfaces must agree these render NOTHING.
77
+ const SUPPRESSED: Array<[string, Record<string, unknown>]> = [
78
+ ['mcp__switchroom-telegram__reply', { text: 'hi' }],
79
+ ['mcp__switchroom-telegram__stream_reply', {}],
80
+ ['mcp__switchroom-telegram__edit_message', {}],
81
+ ['mcp__switchroom-telegram__react', {}],
82
+ ['mcp__clerk-telegram__reply', {}],
83
+ ['mcp__hindsight__sync_retain', {}],
84
+ ['Skill', {}], // empty-slug Skill stays suppressed (#2111 sidecar contract)
85
+ ]
86
+
87
+ describe('single status-vocabulary composer (drift fails CI)', () => {
88
+ it('describeToolUse and computeLabel emit identical labels across the corpus', () => {
89
+ for (const [tool, input] of CORPUS) {
90
+ expect(describeToolUse(tool, input), `label drift for ${tool}`).toBe(
91
+ computeLabel(tool, input),
92
+ )
93
+ }
94
+ })
95
+
96
+ it('both composers agree on suppression (never a surfaced reply/control tool)', () => {
97
+ for (const [tool, input] of SUPPRESSED) {
98
+ expect(describeToolUse(tool, input), `describeToolUse must suppress ${tool}`).toBeNull()
99
+ expect(computeLabel(tool, input), `computeLabel must suppress ${tool}`).toBeNull()
100
+ }
101
+ })
102
+
103
+ it('the previously-drifted wordings are unified (the Ken-observed drift)', () => {
104
+ // These exact pairs rendered DIFFERENT copy per surface before the
105
+ // unification. Pin the unified form on both composers.
106
+ expect(computeLabel('Grep', { pattern: 'TODO' })).toBe('Searching for TODO')
107
+ expect(describeToolUse('Grep', { pattern: 'TODO' })).toBe('Searching for TODO')
108
+ expect(computeLabel('WebFetch', { url: 'https://www.example.com/path?q=1' })).toBe(
109
+ 'Reading example.com',
110
+ )
111
+ expect(describeToolUse('WebFetch', { url: 'https://www.example.com/path?q=1' })).toBe(
112
+ 'Reading example.com',
113
+ )
114
+ expect(computeLabel('mcp__hindsight__retain', {})).toBe('Saving to memory')
115
+ expect(describeToolUse('mcp__hindsight__retain', {})).toBe('Saving to memory')
116
+ })
117
+
118
+ it('never emits raw shell/query syntax from either composer', () => {
119
+ for (const [tool, input] of CORPUS) {
120
+ const label = computeLabel(tool, input)
121
+ if (label == null) continue
122
+ expect(label, `raw syntax leaked for ${tool}`).not.toMatch(/grep -r|ls -la/)
123
+ }
124
+ })
125
+ })
@@ -117,6 +117,45 @@ describe('handleStreamReply', () => {
117
117
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
118
118
  })
119
119
 
120
+ it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
121
+ const state = makeState()
122
+ // Spacer dep replaces every `\n\n` gap with a visible marker so we can
123
+ // assert the rich path ran it (mirrors the real gateway wiring).
124
+ const deps = makeDeps(bot, {
125
+ addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
126
+ })
127
+
128
+ const pending = handleStreamReply(
129
+ { chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
130
+ state,
131
+ deps,
132
+ )
133
+ await microtaskFlush()
134
+ await pending
135
+
136
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
137
+ expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
138
+ })
139
+
140
+ it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
141
+ const state = makeState()
142
+ const deps = makeDeps(bot, {
143
+ addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
144
+ })
145
+
146
+ const pending = handleStreamReply(
147
+ { chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
148
+ state,
149
+ deps,
150
+ )
151
+ await microtaskFlush()
152
+ await pending
153
+
154
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
155
+ // Literal path is byte-exact — no spacer injected.
156
+ expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
157
+ })
158
+
120
159
  it('throws when text exceeds the rich-message cap (no silent id:pending)', async () => {
121
160
  const state = makeState()
122
161
  const deps = makeDeps(bot)
@@ -19,6 +19,8 @@ import {
19
19
  repairEscapedWhitespace,
20
20
  escapeMarkdown,
21
21
  splitMarkdownChunks,
22
+ addParagraphSpacers,
23
+ PARAGRAPH_SPACER,
22
24
  RICH_MESSAGE_MAX_CHARS,
23
25
  } from '../format.js'
24
26
 
@@ -63,6 +65,26 @@ describe('escapeMarkdown', () => {
63
65
  // `\*` in the input is backslash + star → both get escaped exactly once.
64
66
  expect(escapeMarkdown('\\*')).toBe('\\\\\\*')
65
67
  })
68
+
69
+ // The de-dup pass (#2695) replaced ~12 local escaper copies with this one
70
+ // canonical function. Every consolidated caller now depends on this exact
71
+ // contract: the FULL metacharacter set in a single string, each escaped.
72
+ test('escapes the full metacharacter set in one combined string', () => {
73
+ // Backslash placed last so it doesn't re-escape the chars after it.
74
+ expect(escapeMarkdown('`*_~=[]|\\')).toBe('\\`\\*\\_\\~\\=\\[\\]\\|\\\\')
75
+ })
76
+
77
+ test('already-safe text passes through unchanged', () => {
78
+ const safe = 'Switched fleet to alice (5h window 29% resets in 2h)'
79
+ expect(escapeMarkdown(safe)).toBe(safe)
80
+ })
81
+
82
+ // card-format.ts re-exports this same symbol; the consolidated callers
83
+ // import from one or the other. Lock that they are the identical function.
84
+ test('card-format re-exports the identical canonical escaper', async () => {
85
+ const cardFormat = await import('../card-format.js')
86
+ expect(cardFormat.escapeMarkdown).toBe(escapeMarkdown)
87
+ })
66
88
  })
67
89
 
68
90
  // ---------------------------------------------------------------------------
@@ -101,7 +123,10 @@ describe('splitMarkdownChunks', () => {
101
123
  // A long body with a fenced code block straddling the cap. The chunker
102
124
  // must move the whole fence to a single chunk so no chunk has an odd
103
125
  // number of fence delimiters (which would swallow the next chunk).
104
- const fence = '```\n' + 'code line\n'.repeat(20) + '```'
126
+ // The fence FITS within the cap, so the chunker can move it whole to a
127
+ // single chunk and keep every chunk's fence count balanced. (An oversized
128
+ // fence that cannot fit is hard-sliced instead — see the F7 tests below.)
129
+ const fence = '```\n' + 'code line\n'.repeat(8) + '```'
105
130
  const text = 'intro paragraph '.repeat(15) + '\n\n' + fence + '\n\noutro paragraph'
106
131
  const chunks = splitMarkdownChunks(text, 180)
107
132
  for (const c of chunks) {
@@ -128,15 +153,103 @@ describe('splitMarkdownChunks', () => {
128
153
  }
129
154
  })
130
155
 
131
- test('emits an oversized indivisible block whole rather than looping forever', () => {
132
- // A single fenced block larger than maxLen has no safe interior cut
133
- // the chunker emits it whole (a louder, debuggable Telegram reject
134
- // beats an infinite loop).
156
+ test('hard-slices an indivisible block that exceeds the cap (F7 — no oversized chunk)', () => {
157
+ // A single fenced block larger than maxLen has no safe interior cut. The
158
+ // OLD behaviour emitted it whole, which Telegram rejects with
159
+ // RICH_MESSAGE_TEXT_TOO_LONG and drops the entire answer. F7 delegates to
160
+ // hardSliceToCap so the region comes out as multiple <= cap character
161
+ // slices instead — degraded-but-delivered beats a hard reject.
135
162
  const giant = '```\n' + 'x'.repeat(500) + '\n```'
136
163
  const chunks = splitMarkdownChunks(giant, 100)
137
- // It comes out as one (oversized) chunk, not split mid-fence.
138
- expect(chunks.length).toBe(1)
139
- expect(chunks[0]).toBe(giant)
164
+ expect(chunks.length).toBeGreaterThan(1)
165
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100)
166
+ // No content is dropped — the slices concatenate back to the original.
167
+ expect(chunks.join('')).toBe(giant)
168
+ })
169
+
170
+ test('hard-slices a single unbreakable >maxLen token run into <= cap chunks (F7)', () => {
171
+ // One giant pipe-bearing line with no space/newline boundary below the
172
+ // cap: neither the blank-line, newline, nor space heuristic can find a
173
+ // cut, and the table-row back-off cannot help either. This is the exact
174
+ // "cut <= 0" path — it must hard-slice, not emit an oversized chunk.
175
+ const runaway = 'a|b|'.repeat(200) // 800 chars, no spaces/newlines
176
+ const chunks = splitMarkdownChunks(runaway, 100)
177
+ expect(chunks.length).toBeGreaterThan(1)
178
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100)
179
+ expect(chunks.join('')).toBe(runaway)
180
+ })
181
+
182
+ test('an indivisible region larger than the real 32768 cap still slices to <= cap (F7)', () => {
183
+ const giant = 'z'.repeat(RICH_MESSAGE_MAX_CHARS + 5000)
184
+ const chunks = splitMarkdownChunks(giant) // default cap = RICH_MESSAGE_MAX_CHARS
185
+ expect(chunks.length).toBeGreaterThan(1)
186
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
187
+ })
188
+
189
+ // -------------------------------------------------------------------------
190
+ // Chunk-boundary spacer hygiene. addParagraphSpacers injects a
191
+ // `\n\n${PARAGRAPH_SPACER}\n\n` gap between prose paragraphs. When a cut
192
+ // lands inside that gap, the boundary must not leave a chunk that opens or
193
+ // ends with a bare U+00A0 spacer line (a stray blank bubble line).
194
+ // -------------------------------------------------------------------------
195
+
196
+ test('no chunk starts or ends with a bare U+00A0 spacer line (reviewer repro)', () => {
197
+ // The exact reviewer repro: a spacer gap straddling a small cap.
198
+ const A = 'Alpha sentence one'
199
+ const B = 'Bravo sentence two'
200
+ const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
201
+ const chunks = splitMarkdownChunks(spaced, 33)
202
+ expect(chunks.length).toBeGreaterThan(1)
203
+ const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
204
+ for (const c of chunks) {
205
+ const lines = c.split('\n')
206
+ expect(spacerOnly.test(lines[0])).toBe(false)
207
+ expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
208
+ }
209
+ })
210
+
211
+ test('visible paragraph content survives the boundary (no text dropped)', () => {
212
+ const A = 'Alpha sentence one'
213
+ const B = 'Bravo sentence two'
214
+ const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
215
+ const chunks = splitMarkdownChunks(spaced, 33)
216
+ const rejoined = chunks.join('\n')
217
+ expect(rejoined).toContain(`${A}.`)
218
+ expect(rejoined).toContain(`${B}.`)
219
+ })
220
+
221
+ test('spacer-boundary strip is robust across several gaps and small caps', () => {
222
+ const paras = Array.from({ length: 6 }, (_, i) => `Paragraph ${i} body text here.`)
223
+ const spaced = addParagraphSpacers(paras.join('\n\n'))
224
+ const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
225
+ for (const cap of [20, 31, 40, 64]) {
226
+ const chunks = splitMarkdownChunks(spaced, cap)
227
+ for (const c of chunks) {
228
+ const lines = c.split('\n')
229
+ expect(spacerOnly.test(lines[0])).toBe(false)
230
+ expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
231
+ }
232
+ // No visible word is dropped: concatenating the chunks' non-blank,
233
+ // non-spacer tokens reproduces the original word sequence. (Word-level,
234
+ // not line-level, because a small cap may split mid-word — that's the
235
+ // chunker's normal space-boundary behaviour, orthogonal to spacers.)
236
+ const words = (s: string): string[] =>
237
+ s.split(/\s+/).filter((w) => w.length > 0 && w !== PARAGRAPH_SPACER)
238
+ // Join chunks with a space — each chunk is a separate Telegram message,
239
+ // so inter-chunk whitespace is irrelevant; what matters is no word is
240
+ // lost or fused. (A small cap may end a chunk mid-sentence at a space
241
+ // boundary, e.g. "...here." | "Paragraph 1...", which is normal.)
242
+ expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
243
+ }
244
+ })
245
+
246
+ test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
247
+ const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
248
+ const chunks = splitMarkdownChunks(text, 40)
249
+ // No spacer was ever present, so no chunk gains/loses anything beyond the
250
+ // normal leading-newline strip; content is preserved.
251
+ const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
252
+ expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
140
253
  })
141
254
  })
142
255
 
@@ -10,7 +10,95 @@
10
10
 
11
11
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
12
12
 
13
- import { scrubVoice } from '../text-voice-scrub.js'
13
+ import { scrubVoice, normalizeDashes } from '../text-voice-scrub.js'
14
+
15
+ /**
16
+ * Regression guard for the em-dash comma-splice bug seen in a real reply
17
+ * (the #2737-era voice scrub). The original #1683 rule degraded a
18
+ * clause-joining em-dash to a comma when the next word was lowercase, which
19
+ * joined two independent clauses with a comma — a comma splice ("voice came
20
+ * back, three PRs stacked"). The fix degrades to a full stop and recapitalizes
21
+ * the following word, so the output is never a splice. These tests use the
22
+ * verbatim failure strings from the stored bad message.
23
+ */
24
+ describe('scrubVoice — em-dash between clauses never produces a comma splice', () => {
25
+ beforeEach(() => {
26
+ delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
27
+ delete process.env.SWITCHROOM_VOICE_STRIP_OPENERS
28
+ })
29
+
30
+ it('lowercase-continued independent clause becomes two sentences, not a splice (real string 1)', () => {
31
+ const r = scrubVoice('**Why voice came back** — three PRs stacked')
32
+ // Was: "**Why voice came back**, three PRs stacked" (comma splice).
33
+ expect(r.scrubbed).toBe('**Why voice came back**. Three PRs stacked')
34
+ expect(r.scrubbed).not.toContain('**, three')
35
+ expect(r.replaced).toBe(1)
36
+ })
37
+
38
+ it('lowercase-continued independent clause becomes two sentences, not a splice (real string 2)', () => {
39
+ const r = scrubVoice(
40
+ 'never reached the agents — this is what unblocked everything today.',
41
+ )
42
+ // Was: "...the agents, this is what unblocked..." (comma splice).
43
+ expect(r.scrubbed).toBe(
44
+ 'never reached the agents. This is what unblocked everything today.',
45
+ )
46
+ expect(r.scrubbed).not.toContain('agents, this')
47
+ expect(r.replaced).toBe(1)
48
+ })
49
+
50
+ it('the scrubbed output contains no ", <lowercase clause>" splice for any spaced dash', () => {
51
+ const r = scrubVoice(
52
+ 'first thing — second independent clause and — third one here',
53
+ )
54
+ // No comma directly joining the two independent clauses.
55
+ expect(r.scrubbed).toBe(
56
+ 'first thing. Second independent clause and. Third one here',
57
+ )
58
+ })
59
+
60
+ it('normalizeDashes (card / worker-narration path) also avoids the splice', () => {
61
+ // The non-reply surfaces reuse normalizeDashes; same splice-free rule.
62
+ expect(normalizeDashes('came back — three PRs stacked')).toBe(
63
+ 'came back. Three PRs stacked',
64
+ )
65
+ })
66
+
67
+ it('a dash before a NON-letter (digit / paren) is not wrongly recapitalized', () => {
68
+ // capFirst only touches an ASCII lowercase letter; a digit / punctuation
69
+ // start is left as-is after the full stop.
70
+ expect(normalizeDashes('the count — 3 items remain')).toBe(
71
+ 'the count. 3 items remain',
72
+ )
73
+ expect(normalizeDashes('the note — (see appendix)')).toBe(
74
+ 'the note. (see appendix)',
75
+ )
76
+ })
77
+
78
+ it('dashes inside code spans, fenced blocks, and URLs are still never touched', () => {
79
+ const input =
80
+ 'prose — one\n' +
81
+ 'inline `a — b — c` stays\n' +
82
+ '```\nfenced x — y — z stays\n```\n' +
83
+ 'link https://ex.io/a—b—c stays too — end'
84
+ const r = scrubVoice(input)
85
+ expect(r.scrubbed).toContain('`a — b — c`')
86
+ expect(r.scrubbed).toContain('fenced x — y — z stays')
87
+ expect(r.scrubbed).toContain('https://ex.io/a—b—c')
88
+ // Only the two prose dashes were scrubbed (to full stops).
89
+ expect(r.scrubbed).toContain('prose. One')
90
+ expect(r.scrubbed).toContain('stays too. End')
91
+ expect(r.replaced).toBe(2)
92
+ })
93
+
94
+ it('a double-space inside inline code / a URL is left untouched by the scrub', () => {
95
+ // The scrub must not normalize whitespace inside protected regions.
96
+ const r = scrubVoice('keep `foo bar` and http://h/a b intact — done')
97
+ expect(r.scrubbed).toContain('`foo bar`')
98
+ expect(r.scrubbed).toContain('http://h/a b')
99
+ expect(r.scrubbed).toContain('intact. Done')
100
+ })
101
+ })
14
102
 
15
103
  describe('scrubVoice — em / en dash replacement', () => {
16
104
  beforeEach(() => {
@@ -21,9 +109,13 @@ describe('scrubVoice — em / en dash replacement', () => {
21
109
  })
22
110
 
23
111
  describe('mechanical rewrite of spaced dashes', () => {
24
- it('replaces a spaced em-dash before lowercase with a comma', () => {
112
+ it('replaces a spaced em-dash before lowercase with a full stop (no comma splice)', () => {
113
+ // #1683 originally emitted a comma here, which produced a comma splice
114
+ // between two independent clauses ("on it, checking the calendar"). The
115
+ // splice-free rule degrades the clause-joining dash to a period and
116
+ // recapitalizes the following word.
25
117
  const r = scrubVoice('on it — checking the calendar')
26
- expect(r.scrubbed).toBe('on it, checking the calendar')
118
+ expect(r.scrubbed).toBe('on it. Checking the calendar')
27
119
  expect(r.replaced).toBe(1)
28
120
  })
29
121
 
@@ -34,22 +126,23 @@ describe('scrubVoice — em / en dash replacement', () => {
34
126
  expect(r.replaced).toBe(1)
35
127
  })
36
128
 
37
- it('handles multiple em-dashes in one sentence', () => {
129
+ it('handles multiple em-dashes in one sentence (each becomes a full stop)', () => {
38
130
  const r = scrubVoice('one — two — three — done')
39
- expect(r.scrubbed).toBe('one, two, three, done')
131
+ expect(r.scrubbed).toBe('one. Two. Three. Done')
40
132
  expect(r.replaced).toBe(3)
41
133
  })
42
134
 
43
135
  it('treats en-dash (–) identically to em-dash', () => {
44
136
  const r = scrubVoice('on it – checking the calendar')
45
- expect(r.scrubbed).toBe('on it, checking the calendar')
137
+ expect(r.scrubbed).toBe('on it. Checking the calendar')
46
138
  expect(r.replaced).toBe(1)
47
139
  })
48
140
 
49
- it('replaces unspaced word-dash-word as a comma', () => {
50
- // Less common but seen in tightly-typed prose.
141
+ it('replaces unspaced word-dash-word with a full stop (no comma splice)', () => {
142
+ // Less common but seen in tightly-typed prose. Same splice-free rule as
143
+ // the spaced form: a period plus a recapitalized following word.
51
144
  const r = scrubVoice('flag—on or flag—off')
52
- expect(r.scrubbed).toBe('flag, on or flag, off')
145
+ expect(r.scrubbed).toBe('flag. On or flag. Off')
53
146
  expect(r.replaced).toBe(2)
54
147
  })
55
148
 
@@ -66,6 +159,32 @@ describe('scrubVoice — em / en dash replacement', () => {
66
159
  expect(r.scrubbed).toBe('- note: ship it')
67
160
  expect(r.replaced).toBe(1)
68
161
  })
162
+
163
+ it('does not recapitalize a mixed-case brand token after a dash', () => {
164
+ // capFirst must leave a lowercase-initial mixed-case token (iOS, eBay,
165
+ // iPhone, macOS) alone — recapitalizing "iOS" to "IOS" corrupts the
166
+ // brand. A normal lowercase word still gets recapitalized.
167
+ const r = scrubVoice('foo—iOS build')
168
+ expect(r.scrubbed).toBe('foo. iOS build')
169
+ expect(r.replaced).toBe(1)
170
+
171
+ const spaced = scrubVoice('shipped — iPhone ready')
172
+ expect(spaced.scrubbed).toBe('shipped. iPhone ready')
173
+
174
+ const normal = scrubVoice('foo—done here')
175
+ expect(normal.scrubbed).toBe('foo. Done here')
176
+ })
177
+
178
+ it('preserves numeric ranges as ASCII hyphens, not full stops', () => {
179
+ // A digit-flanked dash is a range, not a clause break. It falls through
180
+ // to the catch-all rule and becomes an ASCII hyphen.
181
+ const unspaced = scrubVoice('10–20')
182
+ expect(unspaced.scrubbed).toBe('10-20')
183
+ expect(unspaced.replaced).toBe(1)
184
+
185
+ const tight = scrubVoice('scored 3–2')
186
+ expect(tight.scrubbed).toBe('scored 3-2')
187
+ })
69
188
  })
70
189
 
71
190
  describe('protected regions are left alone', () => {
@@ -73,33 +192,33 @@ describe('scrubVoice — em / en dash replacement', () => {
73
192
  const input = 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose — done'
74
193
  const r = scrubVoice(input)
75
194
  expect(r.scrubbed).toBe(
76
- 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose, done',
195
+ 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose. Done',
77
196
  )
78
197
  expect(r.replaced).toBe(1)
79
198
  })
80
199
 
81
200
  it('preserves dashes inside inline code', () => {
82
201
  const r = scrubVoice('the flag `--really — keep` matters — yes')
83
- expect(r.scrubbed).toBe('the flag `--really — keep` matters, yes')
202
+ expect(r.scrubbed).toBe('the flag `--really — keep` matters. Yes')
84
203
  expect(r.replaced).toBe(1)
85
204
  })
86
205
 
87
206
  it('preserves dashes inside <code> HTML tags', () => {
88
207
  const r = scrubVoice('see <code>x — y</code> and note — ok')
89
- expect(r.scrubbed).toBe('see <code>x — y</code> and note, ok')
208
+ expect(r.scrubbed).toBe('see <code>x — y</code> and note. Ok')
90
209
  expect(r.replaced).toBe(1)
91
210
  })
92
211
 
93
212
  it('preserves dashes inside <pre> HTML tags', () => {
94
213
  const r = scrubVoice('block:\n<pre>x — y\nz — w</pre>\nend — ok')
95
- expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend, ok')
214
+ expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend. Ok')
96
215
  expect(r.replaced).toBe(1)
97
216
  })
98
217
 
99
218
  it('preserves dashes inside URLs', () => {
100
219
  const r = scrubVoice('see https://example.com/a—b for context — ok')
101
220
  expect(r.scrubbed).toBe(
102
- 'see https://example.com/a—b for context, ok',
221
+ 'see https://example.com/a—b for context. Ok',
103
222
  )
104
223
  expect(r.replaced).toBe(1)
105
224
  })
@@ -110,7 +229,7 @@ describe('scrubVoice — em / en dash replacement', () => {
110
229
  const fence =
111
230
  '```\n# heading — title\nfunction f() {}\n```'
112
231
  const r = scrubVoice(fence + '\ntrailing — yes')
113
- expect(r.scrubbed).toBe(fence + '\ntrailing, yes')
232
+ expect(r.scrubbed).toBe(fence + '\ntrailing. Yes')
114
233
  expect(r.replaced).toBe(1)
115
234
  })
116
235
  })
@@ -151,8 +270,8 @@ describe('scrubVoice — em / en dash replacement', () => {
151
270
  'Result: empty for Saturday — nothing scheduled. Anything else?'
152
271
  const r = scrubVoice(input)
153
272
  expect(r.scrubbed).toBe(
154
- "I'll check the calendar, should take a few seconds. " +
155
- 'Result: empty for Saturday, nothing scheduled. Anything else?',
273
+ "I'll check the calendar. Should take a few seconds. " +
274
+ 'Result: empty for Saturday. Nothing scheduled. Anything else?',
156
275
  )
157
276
  expect(r.replaced).toBe(2)
158
277
  })
@@ -164,9 +283,9 @@ describe('scrubVoice — em / en dash replacement', () => {
164
283
  'Ready to commit — go?'
165
284
  const r = scrubVoice(input)
166
285
  expect(r.scrubbed).toBe(
167
- 'Running `git status --short`, looks clean. ' +
286
+ 'Running `git status --short`. Looks clean. ' +
168
287
  '```\nM file.ts — modified\n```\n' +
169
- 'Ready to commit, go?',
288
+ 'Ready to commit. Go?',
170
289
  )
171
290
  expect(r.replaced).toBe(2)
172
291
  })
@@ -293,15 +412,16 @@ describe('scrubVoice — opener strip is OFF by default (prompt carries tone)',
293
412
 
294
413
  it('STILL normalizes em-dashes by default (punctuation, no content removed)', () => {
295
414
  const r = scrubVoice('on it — checking the calendar')
296
- expect(r.scrubbed).toBe('on it, checking the calendar')
415
+ expect(r.scrubbed).toBe('on it. Checking the calendar')
297
416
  expect(r.replaced).toBe(1)
298
417
  expect(r.openersStripped).toBe(0)
299
418
  })
300
419
 
301
420
  it('an affirmation opener with an em-dash keeps the words, fixes only the dash', () => {
302
421
  const r = scrubVoice('Exactly right — the token had expired.')
303
- // Opener preserved; the em-dash after it becomes a comma.
304
- expect(r.scrubbed).toBe('Exactly right, the token had expired.')
422
+ // Opener preserved (strip is OFF); the em-dash between two independent
423
+ // clauses becomes a full stop, not a comma (no splice).
424
+ expect(r.scrubbed).toBe('Exactly right. The token had expired.')
305
425
  expect(r.openersStripped).toBe(0)
306
426
  expect(r.replaced).toBe(1)
307
427
  })