switchroom 0.16.23 → 0.16.27

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 (138) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  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 +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -1,639 +1,151 @@
1
1
  /**
2
- * Tests for Telegram formatting utilities: markdownToHtml, splitHtmlChunks,
3
- * file reference wrapping, and message coalescing.
2
+ * Tests for the post-#2669 Telegram formatting utilities.
3
+ *
4
+ * The markdown→HTML engine, the HTML sanitizer, and the MarkdownV2 escaper
5
+ * are gone — every outbound message ships raw GFM markdown via
6
+ * `sendRichMessage` / `editMessageText({ markdown })`. What survives in
7
+ * `format.ts` and is covered here:
8
+ *
9
+ * - repairEscapedWhitespace — LLM-side JSON-escape repair (format-agnostic).
10
+ * - escapeMarkdown — inline-special escaper for dynamic card content.
11
+ * - splitMarkdownChunks — code-fence / table-row aware chunker.
12
+ * - RICH_MESSAGE_MAX_CHARS — the 32768 rich-message wire cap.
4
13
  */
5
14
  import { describe, test, expect } from 'vitest'
6
15
 
7
16
  // Import from the side-effect-free format module so tests don't trigger
8
17
  // server.ts's startup (env load, token check, grammy init).
9
- import { markdownToHtml, splitHtmlChunks, isLikelyTelegramHtml, repairEscapedWhitespace, sanitizeForTelegram, telegramHtmlToPlainText } from '../format.js'
18
+ import {
19
+ repairEscapedWhitespace,
20
+ escapeMarkdown,
21
+ splitMarkdownChunks,
22
+ RICH_MESSAGE_MAX_CHARS,
23
+ } from '../format.js'
10
24
 
11
25
  // ---------------------------------------------------------------------------
12
- // markdownToHtml
26
+ // escapeMarkdown — narrowed inline-special escaper (#2669)
13
27
  // ---------------------------------------------------------------------------
14
28
 
15
- describe('markdownToHtml', () => {
16
- test('converts bold **text** to <b>text</b>', () => {
17
- expect(markdownToHtml('Hello **world**')).toContain('<b>world</b>')
18
- })
19
-
20
- test('converts italic *text* to <i>text</i>', () => {
21
- expect(markdownToHtml('Hello *world*')).toContain('<i>world</i>')
22
- })
23
-
24
- test('does not confuse bold and italic', () => {
25
- const result = markdownToHtml('**bold** and *italic*')
26
- expect(result).toContain('<b>bold</b>')
27
- expect(result).toContain('<i>italic</i>')
28
- })
29
-
30
- // _..._ italic (underscore form) 8 cases
31
- test('converts _text_ to <i>text</i> (plain underscore italic)', () => {
32
- expect(markdownToHtml('Hello _world_')).toContain('<i>world</i>')
33
- })
34
-
35
- test('converts emoji-leading _📥 Queued as a new task_', () => {
36
- expect(markdownToHtml('_📥 Queued as a new task_')).toContain('<i>📥 Queued as a new task</i>')
37
- })
38
-
39
- test('converts emoji-trailing _steer on the prior task 🔁_', () => {
40
- expect(markdownToHtml('_steer on the prior task 🔁_')).toContain('<i>steer on the prior task 🔁</i>')
41
- })
42
-
43
- test('converts both-ends emoji _🔥 hot take 🔥_', () => {
44
- expect(markdownToHtml('_🔥 hot take 🔥_')).toContain('<i>🔥 hot take 🔥</i>')
45
- })
46
-
47
- test('does NOT convert snake_case to italic', () => {
48
- const result = markdownToHtml('my_snake_case_var')
49
- expect(result).not.toContain('<i>')
50
- expect(result).toContain('my_snake_case_var')
51
- })
52
-
53
- test('does NOT convert __double__ underscore to italic', () => {
54
- const result = markdownToHtml('__double__')
55
- expect(result).not.toContain('<i>')
56
- })
57
-
58
- test('does NOT convert word-internal underscores', () => {
59
- const result = markdownToHtml('foo_bar')
60
- expect(result).not.toContain('<i>')
61
- expect(result).toContain('foo_bar')
62
- })
63
-
64
- test('_..._ and *...* italics coexist correctly', () => {
65
- const result = markdownToHtml('*asterisk* and _underscore_')
66
- expect(result).toContain('<i>asterisk</i>')
67
- expect(result).toContain('<i>underscore</i>')
68
- })
69
-
70
- test('converts inline `code` to <code>code</code>', () => {
71
- expect(markdownToHtml('Use `console.log`')).toContain('<code>console.log</code>')
72
- })
73
-
74
- test('converts code blocks with language', () => {
75
- const input = '```typescript\nconst x = 1\n```'
76
- const result = markdownToHtml(input)
77
- expect(result).toContain('<pre><code class="language-typescript">')
78
- expect(result).toContain('const x = 1')
79
- expect(result).toContain('</code></pre>')
80
- })
81
-
82
- test('converts code blocks without language', () => {
83
- const input = '```\nplain code\n```'
84
- const result = markdownToHtml(input)
85
- expect(result).toContain('<pre><code>')
86
- expect(result).toContain('plain code')
87
- })
88
-
89
- test('converts strikethrough ~~text~~ to <s>text</s>', () => {
90
- expect(markdownToHtml('~~deleted~~')).toContain('<s>deleted</s>')
91
- })
92
-
93
- test('converts [text](url) to <a href="url">text</a>', () => {
94
- const result = markdownToHtml('Click [here](https://example.com)')
95
- expect(result).toContain('<a href="https://example.com">here</a>')
96
- })
97
-
98
- test('escapes HTML entities in plain text', () => {
99
- const result = markdownToHtml('x < y & z > w')
100
- expect(result).toContain('&lt;')
101
- expect(result).toContain('&amp;')
102
- expect(result).toContain('&gt;')
103
- })
104
-
105
- test('does not escape HTML inside code blocks', () => {
106
- const input = '```html\n<div>test</div>\n```'
107
- const result = markdownToHtml(input)
108
- expect(result).toContain('&lt;div&gt;test&lt;/div&gt;')
109
- })
110
-
111
- test('does not escape HTML inside inline code', () => {
112
- const result = markdownToHtml('Use `<div>` element')
113
- expect(result).toContain('<code>&lt;div&gt;</code>')
114
- })
115
-
116
- test('wraps file references in code tags', () => {
117
- const result = markdownToHtml('Edit server.ts and package.json')
118
- expect(result).toContain('<code>server.ts</code>')
119
- expect(result).toContain('<code>package.json</code>')
120
- })
121
-
122
- test('does not double-wrap file references already in code', () => {
123
- const result = markdownToHtml('Edit `server.ts` now')
124
- // Should have exactly one <code>server.ts</code>, not nested
125
- const matches = result.match(/<code>server\.ts<\/code>/g)
126
- expect(matches).not.toBeNull()
127
- expect(matches!.length).toBe(1)
128
- // And crucially: NO nested <code><code>...</code></code>
129
- expect(result).not.toContain('<code><code>')
130
- expect(result).not.toContain('</code></code>')
131
- })
132
-
133
- test('inline code containing asterisks does not get re-matched by italic regex (#415)', () => {
134
- // Regression for #415: inline-code spans containing `*` (e.g. C
135
- // pointer syntax) used to be restored from their placeholder BEFORE
136
- // the italic pass, so the italic regex would see `<code>size_t *p</code>`
137
- // and try to wrap `p</code>...` in <i>...</i>, producing invalid HTML
138
- // that Telegram rejected with 400 Bad Request, sending the caller into
139
- // a `format: text` fallback for the rest of the chunk.
140
- const result = markdownToHtml('Use `size_t *p` to declare a pointer.')
141
- expect(result).toContain('<code>size_t *p</code>')
142
- // No stray <i> wrapping the asterisk — pre-fix the buggy output was
143
- // `<code>size_t <i>p</code> to declare a pointer.</i>`.
144
- expect(result).not.toMatch(/<i>[^<]*<\/code>/)
145
- expect(result).not.toMatch(/<code>[^<]*<i>/)
146
- })
147
-
148
- test('inline code containing double-asterisks does not get re-matched by bold regex (#415)', () => {
149
- const result = markdownToHtml('Pattern is `**glob**` not regex.')
150
- expect(result).toContain('<code>**glob**</code>')
151
- // The bold regex must not have wrapped the literal asterisks inside <code>.
152
- expect(result).not.toMatch(/<b>[^<]*<\/code>/)
153
- expect(result).not.toMatch(/<code>[^<]*<b>/)
154
- })
155
-
156
- test('code block containing asterisks does not get re-matched by italic regex (#415)', () => {
157
- const input = '```c\nsize_t *p = NULL;\n```'
158
- const result = markdownToHtml(input)
159
- expect(result).toContain('size_t *p = NULL;')
160
- expect(result).not.toMatch(/<i>[^<]*<\/code>/)
161
- expect(result).not.toMatch(/<i>[^<]*<\/pre>/)
162
- })
163
-
164
- test('does not double-wrap when inline code sits alongside prose with file refs', () => {
165
- // Regression for the user-observed bug: messages that mixed inline code
166
- // spans (backticks around filenames) with prose produced
167
- // `<code><code>settings.json</code></code>` in the stored history. The
168
- // file-reference regex ran AFTER inline-code placeholder restoration and
169
- // re-wrapped the filename inside the just-restored <code> tag because
170
- // its negative lookbehind did not exclude `>`.
171
- const result = markdownToHtml(
172
- 'I mixed raw `<a href="...">` HTML into messages whose `format` defaults ' +
173
- 'to `html` — but the plugin runs a markdown→HTML converter which escapes ' +
174
- 'literal `<` and `>`, so raw tags render as visible text in the rendered ' +
175
- '`settings.json` output.'
176
- )
177
- expect(result).not.toContain('<code><code>')
178
- expect(result).not.toContain('</code></code>')
179
- // settings.json, format, html should each appear inside exactly one
180
- // <code> tag — either from the backtick wrapping or the file-ref regex,
181
- // but never both.
182
- const settingsMatches = result.match(/<code>settings\.json<\/code>/g)
183
- expect(settingsMatches).not.toBeNull()
184
- expect(settingsMatches!.length).toBe(1)
185
- })
186
-
187
- test('file-reference wrap still runs on bare filenames in prose', () => {
188
- // Confirm the fix doesn't break the normal case: bare filenames in
189
- // plain prose still get auto-wrapped in <code> tags.
190
- const result = markdownToHtml('Edit server.ts and then run tsc --noEmit')
191
- expect(result).toContain('<code>server.ts</code>')
192
- })
193
-
194
- test('file-reference wrap does not match filenames adjacent to > (inside tag markup)', () => {
195
- // A filename that sits right after a `>` (tag close) should not be
196
- // re-wrapped — it's already inside some structured context.
197
- const input = '<b>foo.ts</b>'
198
- const result = markdownToHtml(input)
199
- // Passes through as Telegram HTML (smart pass-through) — filename is
200
- // not wrapped in <code> because it's inside a <b>.
201
- expect(result).toBe(input)
202
- })
203
-
204
- test('handles nested bold and italic', () => {
205
- const result = markdownToHtml('**bold *and italic* text**')
206
- expect(result).toContain('<b>')
207
- expect(result).toContain('</b>')
208
- })
209
-
210
- test('handles plain text with no formatting', () => {
211
- const result = markdownToHtml('Just a plain message')
212
- expect(result).toBe('Just a plain message')
213
- })
214
-
215
- test('handles empty string', () => {
216
- expect(markdownToHtml('')).toBe('')
217
- })
218
-
219
- test('preserves multiple paragraphs', () => {
220
- const result = markdownToHtml('First paragraph\n\nSecond paragraph')
221
- expect(result).toContain('First paragraph')
222
- expect(result).toContain('Second paragraph')
223
- })
224
-
225
- test('converts ## headings to bold (Telegram has no <h1>)', () => {
226
- const result = markdownToHtml('## My Heading\n\nbody text')
227
- expect(result).toContain('<b>My Heading</b>')
228
- expect(result).not.toContain('## ')
229
- })
230
-
231
- test('converts # headings to bold', () => {
232
- const result = markdownToHtml('# Top heading\n\nbody')
233
- expect(result).toContain('<b>Top heading</b>')
234
- expect(result).not.toMatch(/^# /m)
235
- })
236
-
237
- test('converts deep ### #### headings to bold without losing content', () => {
238
- const result = markdownToHtml('### Section\n#### Subsection\nbody')
239
- expect(result).toContain('<b>Section</b>')
240
- expect(result).toContain('<b>Subsection</b>')
241
- expect(result).not.toContain('###')
242
- expect(result).not.toContain('####')
243
- })
244
-
245
- test('does not convert # inside code blocks', () => {
246
- const input = '```bash\n# this is a comment\n```'
247
- const result = markdownToHtml(input)
248
- expect(result).toContain('# this is a comment')
249
- expect(result).not.toContain('<b># this is a comment</b>')
250
- })
251
-
252
- // ─── HTML pass-through (the bug that made <b> tags render as text) ─────
253
-
254
- test('passes through already-rendered Telegram HTML untouched', () => {
255
- const input = '<b>Bold heading</b>\n<i>italic body</i>'
256
- expect(markdownToHtml(input)).toBe(input)
257
- })
258
-
259
- test('passes through Telegram HTML with <code> blocks', () => {
260
- const input = '<b>commit</b> <code>abc123</code>'
261
- expect(markdownToHtml(input)).toBe(input)
262
- })
263
-
264
- test('passes through Telegram HTML with mixed tags and text', () => {
265
- const input = '<b>What you should see</b>\n👀 immediately, then 🤔 after 2s'
266
- expect(markdownToHtml(input)).toBe(input)
267
- })
268
-
269
- test('escapes when input has unsupported HTML tags (e.g. <div>)', () => {
270
- const input = '<div>not telegram html</div>'
271
- const out = markdownToHtml(input)
272
- // Falls into the markdown path → escapes the angle brackets
273
- expect(out).toContain('&lt;div&gt;')
274
- })
275
-
276
- test('escapes when input is plain markdown without HTML', () => {
277
- const input = '**bold** text'
278
- const out = markdownToHtml(input)
279
- expect(out).toContain('<b>bold</b>')
280
- })
281
- })
282
-
283
- describe('isLikelyTelegramHtml', () => {
284
- test('returns true for simple <b>', () => {
285
- expect(isLikelyTelegramHtml('<b>hello</b>')).toBe(true)
286
- })
287
-
288
- test('returns true for <code>', () => {
289
- expect(isLikelyTelegramHtml('use <code>git status</code>')).toBe(true)
290
- })
291
-
292
- test('returns true for nested supported tags', () => {
293
- expect(isLikelyTelegramHtml('<b><i>bold italic</i></b>')).toBe(true)
294
- })
295
-
296
- test('returns true for <a href>', () => {
297
- expect(isLikelyTelegramHtml('see <a href="https://x.com">x</a>')).toBe(true)
298
- })
299
-
300
- test('returns false when ANY tag is unsupported', () => {
301
- expect(isLikelyTelegramHtml('<b>fine</b> but <div>not</div>')).toBe(false)
302
- })
303
-
304
- test('returns false for plain text with no tags', () => {
305
- expect(isLikelyTelegramHtml('just words here')).toBe(false)
306
- })
307
-
308
- test('returns false for plain markdown', () => {
309
- expect(isLikelyTelegramHtml('**bold** and *italic*')).toBe(false)
310
- })
311
-
312
- test('returns false for code with angle brackets', () => {
313
- expect(isLikelyTelegramHtml('the operator <-> means something')).toBe(false)
314
- })
315
-
316
- // ─── The bug: HTML tags inside markdown inline code spans ─────────────
317
-
318
- test('ignores HTML tags inside backtick inline code', () => {
319
- // The model writes `<b>tag</b>` (showing literal HTML in inline code).
320
- // The text is markdown, NOT raw HTML — must return false.
321
- expect(isLikelyTelegramHtml('Use `<b>tag</b>` to make text bold.')).toBe(false)
322
- })
323
-
324
- test('ignores HTML tags inside fenced code blocks', () => {
325
- const input = 'Example:\n```html\n<div>hi</div>\n```\nThat\'s it.'
326
- expect(isLikelyTelegramHtml(input)).toBe(false)
327
- })
328
-
329
- test('returns false when text mixes markdown bold with HTML examples in code', () => {
330
- // The exact bug pattern from the user-facing screenshot regression
331
- const input = '**1. Raw HTML rendering** — replies showed `<b>tag</b>` text instead of bold.'
332
- expect(isLikelyTelegramHtml(input)).toBe(false)
333
- })
334
-
335
- test('returns false when text has markdown links', () => {
336
- expect(isLikelyTelegramHtml('See [docs](https://example.com)')).toBe(false)
337
- })
338
-
339
- test('returns false when text has markdown headings', () => {
340
- expect(isLikelyTelegramHtml('## Section\n\nbody')).toBe(false)
341
- })
342
-
343
- test('still returns true for pure HTML even with code spans', () => {
344
- // Code spans can coexist with real HTML — as long as there are NO
345
- // markdown bold/link/heading patterns and the tags outside code are
346
- // all valid Telegram HTML, trust it.
347
- expect(isLikelyTelegramHtml('<b>commit</b> <code>abc123</code>')).toBe(true)
348
- })
349
- })
350
-
351
- describe('markdownToHtml regression: mixed markdown + raw Telegram HTML', () => {
352
- // The exact bug pattern from the user-facing screenshot regression: model
353
- // emits markdown bold AND raw <b>/<a> tags in the same message. The
354
- // markdown path used to escape every `<` to `&lt;`, so the raw tags
355
- // rendered as literal text. Now the converter preserves whitelisted
356
- // Telegram HTML tags through the escape pass.
357
-
358
- test('preserves embedded <b> when text also has markdown bold', () => {
359
- const input = '**Pattern worth stealing:** the <b>verification subagent</b> is a validator.'
360
- const out = markdownToHtml(input)
361
- expect(out).toContain('<b>Pattern worth stealing:</b>')
362
- expect(out).toContain('<b>verification subagent</b>')
363
- expect(out).not.toContain('&lt;b&gt;')
364
- })
365
-
366
- test('preserves embedded <a href> when text also has markdown bold', () => {
367
- const input = '**Sources:** see <a href="https://example.com/x">Example</a> for details.'
368
- const out = markdownToHtml(input)
369
- expect(out).toContain('<b>Sources:</b>')
370
- expect(out).toContain('<a href="https://example.com/x">Example</a>')
371
- expect(out).not.toContain('&lt;a ')
372
- })
373
-
374
- test('preserves embedded <i> when text also has markdown bold', () => {
375
- const input = '**Rule:** group work by <i>what context it needs</i>.'
376
- const out = markdownToHtml(input)
377
- expect(out).toContain('<b>Rule:</b>')
378
- expect(out).toContain('<i>what context it needs</i>')
379
- expect(out).not.toContain('&lt;i&gt;')
380
- })
381
-
382
- test('preserves multiple embedded tags in one message', () => {
383
- const input = '**Header**\n- <b>Context</b> matters\n- <i>Speed</i> too\n- See <a href="https://x.com">x</a>'
384
- const out = markdownToHtml(input)
385
- expect(out).toContain('<b>Header</b>')
386
- expect(out).toContain('<b>Context</b>')
387
- expect(out).toContain('<i>Speed</i>')
388
- expect(out).toContain('<a href="https://x.com">x</a>')
389
- })
390
-
391
- test('still escapes unsupported tags even when whitelisted ones are present', () => {
392
- const input = '**hi** <b>ok</b> and <div>bad</div>'
393
- const out = markdownToHtml(input)
394
- expect(out).toContain('<b>hi</b>')
395
- expect(out).toContain('<b>ok</b>')
396
- // <div> is not in the whitelist → escaped
397
- expect(out).toContain('&lt;div&gt;')
398
- })
399
-
400
- test('preserves embedded <code> spans alongside markdown', () => {
401
- const input = '**Run:** <code>git status</code> first.'
402
- const out = markdownToHtml(input)
403
- expect(out).toContain('<b>Run:</b>')
404
- expect(out).toContain('<code>git status</code>')
29
+ describe('escapeMarkdown', () => {
30
+ test('escapes the inline-formatting specials: \\ ` * _ ~ = [ ] |', () => {
31
+ expect(escapeMarkdown('\\')).toBe('\\\\')
32
+ expect(escapeMarkdown('`')).toBe('\\`')
33
+ expect(escapeMarkdown('*')).toBe('\\*')
34
+ expect(escapeMarkdown('_')).toBe('\\_')
35
+ expect(escapeMarkdown('~')).toBe('\\~')
36
+ expect(escapeMarkdown('=')).toBe('\\=')
37
+ expect(escapeMarkdown('[')).toBe('\\[')
38
+ expect(escapeMarkdown(']')).toBe('\\]')
39
+ expect(escapeMarkdown('|')).toBe('\\|')
40
+ })
41
+
42
+ test('does NOT escape line-start-only / structural chars: . - + # ( ) { } ! >', () => {
43
+ // Escaping these mid-word would litter filenames/versions/URLs with
44
+ // visible backslashes. They are only meaningful at line-start or in
45
+ // link/structure context.
46
+ for (const ch of ['.', '-', '+', '#', '(', ')', '{', '}', '!', '>']) {
47
+ expect(escapeMarkdown(ch)).toBe(ch)
48
+ }
405
49
  })
406
50
 
407
- test('preserves <a> with query-string href containing markdown-link-like text', () => {
408
- const input = 'See <a href="https://example.com/path">the docs</a>.'
409
- const out = markdownToHtml(input)
410
- expect(out).toContain('<a href="https://example.com/path">the docs</a>')
51
+ test('leaves a plain filename untouched', () => {
52
+ expect(escapeMarkdown('foo.ts')).toBe('foo.ts')
53
+ expect(escapeMarkdown('v1.2-rc')).toBe('v1.2-rc')
54
+ expect(escapeMarkdown('src/cli/index.ts')).toBe('src/cli/index.ts')
411
55
  })
412
- })
413
56
 
414
- describe('markdownToHtml regression: HTML in code spans', () => {
415
- test('renders **bold** correctly when text also contains `<b>` in inline code', () => {
416
- const input = '**1. Raw HTML rendering** — replies showed `<b>tag</b>` text instead of bold.'
417
- const out = markdownToHtml(input)
418
- expect(out).toContain('<b>1. Raw HTML rendering</b>')
419
- expect(out).toContain('<code>&lt;b&gt;tag&lt;/b&gt;</code>')
420
- expect(out).not.toContain('**1. Raw HTML rendering**')
57
+ test('escapes emphasis markers inside dynamic text so they render literally', () => {
58
+ expect(escapeMarkdown('a_b_c')).toBe('a\\_b\\_c')
59
+ expect(escapeMarkdown('**not bold**')).toBe('\\*\\*not bold\\*\\*')
421
60
  })
422
61
 
423
- test('renders fenced code blocks even when they contain HTML examples', () => {
424
- const input = 'Example:\n```html\n<div>hi</div>\n```'
425
- const out = markdownToHtml(input)
426
- expect(out).toContain('<pre><code class="language-html">')
427
- expect(out).toContain('&lt;div&gt;hi&lt;/div&gt;')
62
+ test('escapes the backslash first so it never double-escapes a following special', () => {
63
+ // `\*` in the input is backslash + star → both get escaped exactly once.
64
+ expect(escapeMarkdown('\\*')).toBe('\\\\\\*')
428
65
  })
429
66
  })
430
67
 
431
68
  // ---------------------------------------------------------------------------
432
- // splitHtmlChunks
69
+ // splitMarkdownChunks — never bisects a code fence or a table row
433
70
  // ---------------------------------------------------------------------------
434
71
 
435
- describe('splitHtmlChunks', () => {
436
- test('returns single chunk for short text', () => {
437
- const result = splitHtmlChunks('Hello world', 4000)
438
- expect(result).toEqual(['Hello world'])
439
- })
440
-
441
- test('splits long text into multiple chunks', () => {
442
- const longText = 'a'.repeat(5000)
443
- const chunks = splitHtmlChunks(longText, 2000)
444
- expect(chunks.length).toBeGreaterThan(1)
445
- // All chunks should be <= maxLen (plus possible closing tags)
446
- for (const c of chunks) {
447
- expect(c.length).toBeLessThanOrEqual(2100) // small margin for closing tags
448
- }
449
- })
450
-
451
- test('preserves open tags across chunk boundaries', () => {
452
- const html = '<b>' + 'x'.repeat(5000) + '</b>'
453
- const chunks = splitHtmlChunks(html, 2000)
454
- expect(chunks.length).toBeGreaterThan(1)
455
- // First chunk should have closing </b>
456
- expect(chunks[0]).toContain('</b>')
457
- // Second chunk should reopen <b>
458
- expect(chunks[1]).toMatch(/^<b>/)
459
- })
460
-
461
- test('prefers splitting at paragraph boundaries', () => {
462
- const html = 'First paragraph content here' + '\n\n' + 'Second paragraph content here'
463
- // Set maxLen so it would split somewhere in the middle
464
- const chunks = splitHtmlChunks(html, 35)
465
- expect(chunks.length).toBe(2)
466
- expect(chunks[0]).toContain('First paragraph')
467
- expect(chunks[1]).toContain('Second paragraph')
468
- })
469
-
470
- test('handles nested tags', () => {
471
- const html = '<b><i>' + 'x'.repeat(5000) + '</i></b>'
472
- const chunks = splitHtmlChunks(html, 2000)
473
- expect(chunks.length).toBeGreaterThan(1)
474
- // First chunk should close both tags
475
- expect(chunks[0]).toMatch(/<\/i><\/b>$/)
476
- // Second chunk should reopen both tags
477
- expect(chunks[1]).toMatch(/^<b><i>/)
478
- })
479
-
480
- test('handles empty string', () => {
481
- expect(splitHtmlChunks('')).toEqual([''])
482
- })
483
-
484
- test('respects custom maxLen', () => {
485
- const text = 'a'.repeat(100)
486
- const chunks = splitHtmlChunks(text, 30)
487
- expect(chunks.length).toBeGreaterThanOrEqual(3)
488
- })
489
-
490
- test('defaults to 4000 maxLen', () => {
491
- const text = 'a'.repeat(3999)
492
- const chunks = splitHtmlChunks(text)
493
- expect(chunks).toEqual([text])
494
- })
495
-
496
- test('does not split inside an HTML entity (&amp;)', () => {
497
- // Construct text where the natural cut would land inside &amp;
498
- // Position the entity so that maxLen falls between & and ;
499
- const filler = 'x'.repeat(20)
500
- // Cut would be at position 22, mid-entity
501
- const html = filler + ' &amp; more text after the entity'
502
- const chunks = splitHtmlChunks(html, 22)
503
- // The entity should not be broken — we should see the full &amp; in
504
- // some chunk, never &am or amp;.
505
- for (const c of chunks) {
506
- expect(c).not.toMatch(/&am$/)
507
- expect(c).not.toMatch(/^p;/)
508
- expect(c).not.toMatch(/^amp;/)
509
- }
510
- // Recombined text should equal original (allowing for the chunker's
511
- // tag-rebalancing trim of leading newlines)
512
- expect(chunks.join('')).toContain('&amp;')
513
- })
514
-
515
- test('does not split inside a numeric HTML entity (&#x1F4A9;)', () => {
516
- const filler = 'a'.repeat(15)
517
- const html = filler + ' &#x1F4A9; more'
518
- const chunks = splitHtmlChunks(html, 20)
519
- for (const c of chunks) {
520
- expect(c).not.toMatch(/&#x1F$/)
521
- expect(c).not.toMatch(/^4A9;/)
522
- }
72
+ describe('splitMarkdownChunks', () => {
73
+ test('returns a single chunk when the body fits', () => {
74
+ const text = 'short message'
75
+ expect(splitMarkdownChunks(text, 100)).toEqual([text])
523
76
  })
524
77
 
525
- // ─── Regression: tag-name parsing must allow `-` so `tg-spoiler` and
526
- // `tg-emoji` survive chunk boundaries instead of being truncated to `tg`.
527
- test('preserves <tg-spoiler> across chunk boundaries', () => {
528
- const html = '<tg-spoiler>' + 'x'.repeat(5000) + '</tg-spoiler>'
529
- const chunks = splitHtmlChunks(html, 2000)
530
- expect(chunks.length).toBeGreaterThan(1)
531
- // Chunk0 must close with the FULL tag name, not a truncated `</tg>`
532
- expect(chunks[0]).toMatch(/<\/tg-spoiler>$/)
533
- expect(chunks[0]).not.toMatch(/<\/tg>$/)
534
- // Chunk1 must reopen with the full tag name
535
- expect(chunks[1]).toMatch(/^<tg-spoiler>/)
536
- expect(chunks[1]).not.toMatch(/^<tg>/)
78
+ test('defaults the cap to RICH_MESSAGE_MAX_CHARS', () => {
79
+ const text = 'x'.repeat(RICH_MESSAGE_MAX_CHARS)
80
+ expect(splitMarkdownChunks(text)).toEqual([text])
81
+ expect(splitMarkdownChunks(text + 'y')).toHaveLength(2)
537
82
  })
538
83
 
539
- test('preserves <tg-emoji> across chunk boundaries', () => {
540
- const html = '<tg-emoji emoji-id="5368324170671202286">' + 'y'.repeat(5000) + '</tg-emoji>'
541
- const chunks = splitHtmlChunks(html, 2000)
84
+ test('every chunk fits within maxLen', () => {
85
+ const text = Array.from({ length: 50 }, (_, i) => `paragraph ${i} ${'word '.repeat(20)}`).join('\n\n')
86
+ const chunks = splitMarkdownChunks(text, 200)
542
87
  expect(chunks.length).toBeGreaterThan(1)
543
- expect(chunks[0]).toMatch(/<\/tg-emoji>$/)
544
- expect(chunks[1]).toMatch(/^<tg-emoji/)
88
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(200)
545
89
  })
546
90
 
547
- // ─── Regression: reopening `<a href="...">` in the next chunk must
548
- // preserve the href attribute. Previously the splitter emitted bare
549
- // `<a>` which Telegram rejects.
550
- test('preserves <a href="..."> attributes across chunk boundaries', () => {
551
- const href = 'https://example.com/some/deep/path?x=1'
552
- // Put a natural split point well into the link text so paragraph/space
553
- // breaks don't land inside the opening tag itself.
554
- const html = `<a href="${href}">` + 'word '.repeat(1000) + '</a>'
555
- const chunks = splitHtmlChunks(html, 2000)
556
- expect(chunks.length).toBeGreaterThan(1)
557
- // First chunk must close the anchor
558
- expect(chunks[0]).toMatch(/<\/a>$/)
559
- // Second chunk must reopen with the FULL href attribute, not bare `<a>`
560
- expect(chunks[1]).toMatch(new RegExp(`^<a href="${href.replace(/[.?/]/g, '\\$&')}">`))
561
- expect(chunks[1]).not.toMatch(/^<a>/)
91
+ test('rejoining the chunks reproduces the original content (modulo trimmed boundary newlines)', () => {
92
+ const text = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n')
93
+ const chunks = splitMarkdownChunks(text, 50)
94
+ // The chunker strips leading newlines from each subsequent chunk, so
95
+ // join with '\n' and compare the whitespace-collapsed forms.
96
+ const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
97
+ expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
562
98
  })
563
99
 
564
- test('preserves <code class="language-ts"> attributes across boundaries', () => {
565
- const html = '<pre><code class="language-ts">' + 'z '.repeat(2000) + '</code></pre>'
566
- const chunks = splitHtmlChunks(html, 2000)
567
- expect(chunks.length).toBeGreaterThan(1)
568
- // Reopened chunk should carry the class attribute
569
- expect(chunks[1]).toContain('<code class="language-ts">')
570
- })
571
-
572
- // ─── Regression: splitter must not cut INSIDE an open tag. Previously,
573
- // `<a href="..."` followed by a long run of non-space text made the
574
- // space-fallback pick position 2 (the space inside `<a href=`) and emit
575
- // a chunk consisting of just `<a`, which Telegram rejects.
576
- test('does not cut inside an open tag when tag contains the only nearby space', () => {
577
- const html = '<a href="https://example.com/very/long/url">' + 'y'.repeat(5000) + '</a>'
578
- const chunks = splitHtmlChunks(html, 2000)
579
- // No chunk should end mid-tag (e.g. `<a` or `<a href="..`)
100
+ test('never leaves a chunk with an unbalanced ``` fence', () => {
101
+ // A long body with a fenced code block straddling the cap. The chunker
102
+ // must move the whole fence to a single chunk so no chunk has an odd
103
+ // number of fence delimiters (which would swallow the next chunk).
104
+ const fence = '```\n' + 'code line\n'.repeat(20) + '```'
105
+ const text = 'intro paragraph '.repeat(15) + '\n\n' + fence + '\n\noutro paragraph'
106
+ const chunks = splitMarkdownChunks(text, 180)
580
107
  for (const c of chunks) {
581
- // A chunk ending with `<` or `<tagname` with no closing `>` is malformed.
582
- // Quick check: count unclosed `<`s by stripping complete tags.
583
- const withoutTags = c.replace(/<[^>]*>/g, '')
584
- expect(withoutTags).not.toContain('<')
108
+ const fences = (c.match(/^```/gm) ?? []).length
109
+ expect(fences % 2).toBe(0)
585
110
  }
586
111
  })
587
112
 
588
- test('backs off when the cut lands between < and > of an opening tag', () => {
589
- // Construct a case where `cut` would naturally land inside `<b attr="...">`
590
- const filler = 'a '.repeat(1000) // lots of spaces so splitter has choices
591
- const html = filler + '<b class="very-long-classname-that-pushes-the-tag-past-cut">' + 'x'.repeat(5000) + '</b>'
592
- const chunks = splitHtmlChunks(html, 2000)
593
- // None of the chunks should contain a stray `<` without a matching `>`.
113
+ test('never bisects a markdown table row', () => {
114
+ const header = '| col a | col b | col c |\n| --- | --- | --- |\n'
115
+ const rows = Array.from({ length: 30 }, (_, i) => `| r${i}a | r${i}b | r${i}c |`).join('\n')
116
+ const text = 'preamble '.repeat(10) + '\n\n' + header + rows
117
+ const chunks = splitMarkdownChunks(text, 160)
118
+ // Every line containing a `|` must be a complete row i.e. start and
119
+ // end with `|` once trimmed (the chunker cuts at line boundaries).
594
120
  for (const c of chunks) {
595
- const withoutTags = c.replace(/<[^>]*>/g, '')
596
- expect(withoutTags).not.toContain('<')
597
- expect(withoutTags).not.toContain('>')
121
+ for (const line of c.split('\n')) {
122
+ if (!line.includes('|')) continue
123
+ const t = line.trim()
124
+ if (t.length === 0) continue
125
+ expect(t.startsWith('|')).toBe(true)
126
+ expect(t.endsWith('|')).toBe(true)
127
+ }
598
128
  }
599
129
  })
600
- })
601
130
 
602
- // ---------------------------------------------------------------------------
603
- // File reference wrapping
604
- // ---------------------------------------------------------------------------
605
-
606
- describe('file reference wrapping', () => {
607
- test('wraps .ts files', () => {
608
- expect(markdownToHtml('Look at server.ts')).toContain('<code>server.ts</code>')
609
- })
610
-
611
- test('wraps .json files', () => {
612
- expect(markdownToHtml('Check package.json')).toContain('<code>package.json</code>')
613
- })
614
-
615
- test('wraps .py files', () => {
616
- expect(markdownToHtml('Run main.py')).toContain('<code>main.py</code>')
617
- })
618
-
619
- test('wraps complex filenames', () => {
620
- expect(markdownToHtml('Edit my-component.tsx')).toContain('<code>my-component.tsx</code>')
621
- })
622
-
623
- test('does not wrap non-file extensions', () => {
624
- const result = markdownToHtml('This is sentence.ending with a period')
625
- // "sentence.ending" shouldn't be wrapped since "ending" is not in the ext list
626
- expect(result).not.toContain('<code>sentence.ending</code>')
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).
135
+ const giant = '```\n' + 'x'.repeat(500) + '\n```'
136
+ 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)
627
140
  })
628
141
  })
629
142
 
630
143
  // ---------------------------------------------------------------------------
631
- // Coalescing (unit-level: test the buffer/flush logic)
144
+ // coalescing logic (pure helpers unchanged by #2669)
632
145
  // ---------------------------------------------------------------------------
633
146
 
634
147
  describe('coalescing logic', () => {
635
148
  test('coalesceKey produces unique keys per chat+user', () => {
636
- // We test the key format directly — the coalescing behavior is integration-level
637
149
  const key1 = `chat1:user1`
638
150
  const key2 = `chat1:user2`
639
151
  const key3 = `chat2:user1`
@@ -642,7 +154,6 @@ describe('coalescing logic', () => {
642
154
  })
643
155
 
644
156
  test('messages combine with newline separator', () => {
645
- // Simulate what the coalescing logic does: join texts with \n
646
157
  const messages = ['Hello', 'How are you?', 'One more thing']
647
158
  const combined = messages.join('\n')
648
159
  expect(combined).toBe('Hello\nHow are you?\nOne more thing')
@@ -650,26 +161,24 @@ describe('coalescing logic', () => {
650
161
 
651
162
  test('single message passes through unchanged', () => {
652
163
  const messages = ['Hello']
653
- const combined = messages.join('\n')
654
- expect(combined).toBe('Hello')
164
+ expect(messages.join('\n')).toBe('Hello')
655
165
  })
656
166
 
657
167
  test('empty messages produce empty combined text', () => {
658
168
  const messages: string[] = []
659
- const combined = messages.join('\n')
660
- expect(combined).toBe('')
169
+ expect(messages.join('\n')).toBe('')
661
170
  })
662
171
 
663
172
  test('messages with newlines preserve internal structure', () => {
664
173
  const messages = ['Line 1\nLine 2', 'Line 3']
665
- const combined = messages.join('\n')
666
- expect(combined).toBe('Line 1\nLine 2\nLine 3')
174
+ expect(messages.join('\n')).toBe('Line 1\nLine 2\nLine 3')
667
175
  })
668
176
  })
669
177
 
670
178
  // ---------------------------------------------------------------------------
671
179
  // repairEscapedWhitespace — defends against LLM-side JSON escape bungles
672
180
  // where real newlines come through as the literal two-char sequence `\n`.
181
+ // Format-agnostic: runs on the raw text BEFORE it reaches the rich path.
673
182
  // ---------------------------------------------------------------------------
674
183
 
675
184
  describe('repairEscapedWhitespace', () => {
@@ -683,68 +192,50 @@ describe('repairEscapedWhitespace', () => {
683
192
  expect(repairEscapedWhitespace(input)).toBe('Paragraph one.\n\nParagraph two.')
684
193
  })
685
194
 
686
- test('handles the exact observed bug: html tags mixed with literal \\n', () => {
195
+ test('handles the exact observed bug: markdown mixed with literal \\n', () => {
687
196
  // Reproduces the actual stream_reply failure: a model produced a message
688
- // with <b>/<code> tags and literal `\n` escape sequences instead of real
689
- // newlines, and Telegram rendered the `\n` as visible characters.
690
- const input = 'Audit done:\\n\\n<b>README.md</b>\\n• Missing <code>switchroom update</code>\\n• Missing <code>switchroom agent grant</code>'
197
+ // with markdown and literal `\n` escape sequences instead of real
198
+ // newlines, and Telegram rendered the `\n` as visible characters. The
199
+ // repair runs on raw markdown now (no HTML pass).
200
+ const input =
201
+ 'Audit done:\\n\\n**README.md**\\n• Missing `switchroom update`\\n• Missing `switchroom agent grant`'
691
202
  const repaired = repairEscapedWhitespace(input)
692
- expect(repaired).toBe('Audit done:\n\n<b>README.md</b>\n• Missing <code>switchroom update</code>\n• Missing <code>switchroom agent grant</code>')
693
- // And the repaired text should still be recognized as Telegram HTML
694
- // so the markdownToHtml pass-through works correctly.
695
- expect(isLikelyTelegramHtml(repaired)).toBe(true)
203
+ expect(repaired).toBe(
204
+ 'Audit done:\n\n**README.md**\n• Missing `switchroom update`\n• Missing `switchroom agent grant`',
205
+ )
696
206
  })
697
207
 
698
208
  test('unescapes literal \\n in prose even when real newlines are present (mixed-message fix, #2456)', () => {
699
- // The old implementation bailed entirely when any real newline was present.
700
- // The new implementation unescapes literal \\n outside code spans regardless,
701
- // so a mixed message (real newlines + stray literal \\n in prose) is repaired.
702
209
  const input = 'Real newline here\nand a literal \\n escape in prose'
703
210
  const out = repairEscapedWhitespace(input)
704
- // The literal \n in prose becomes a real newline; the existing real newline is preserved.
705
211
  expect(out).toBe('Real newline here\nand a literal \n escape in prose')
706
212
  })
707
213
 
708
214
  test('preserves literal \\n inside inline code span when mixed with real newlines (#2456)', () => {
709
- // The structurally safe version: a \\n inside backticks must stay verbatim,
710
- // even in a message that also has real newlines and literal \\n in prose.
711
215
  const input = 'First line\nUse `grep -P \\n` for newlines\nand prose \\n here'
712
216
  const out = repairEscapedWhitespace(input)
713
- // Inline code span preserved verbatim (\\n inside backticks untouched).
714
217
  expect(out).toContain('`grep -P \\n`')
715
- // The literal \\n in prose is unescaped to a real newline.
716
218
  expect(out).toContain('and prose \n here')
717
- // The original real newlines are preserved.
718
219
  expect(out).toContain('First line\n')
719
220
  })
720
221
 
721
222
  test('preserves literal \\n inside fenced code block (#2456)', () => {
722
- // A fenced code block containing a literal \\n (e.g. a regex or shell snippet)
723
- // must not be unescaped.
724
223
  const input = 'Prose \\n here\n```bash\necho "line1\\nline2"\n```\nMore \\n prose'
725
224
  const out = repairEscapedWhitespace(input)
726
- // The \\n inside the fenced block stays verbatim.
727
225
  expect(out).toContain('```bash\necho "line1\\nline2"\n```')
728
- // The \\n in prose is unescaped.
729
226
  expect(out).toContain('Prose \n here')
730
227
  expect(out).toContain('More \n prose')
731
228
  })
732
229
 
733
230
  test('pure no-real-newline case still unescapes (backward compat)', () => {
734
- // Original pre-#2456 case: message with NO real newlines and literal \\n.
735
231
  const input = 'Line one\\nLine two\\nLine three'
736
232
  expect(repairEscapedWhitespace(input)).toBe('Line one\nLine two\nLine three')
737
233
  })
738
234
 
739
235
  test('genuine escaped backslash (\\\\n) stays literal even in mixed-newline message (#2456)', () => {
740
- // \\\\n in the source is the two-char sequence \\ then n — the user typed
741
- // a backslash followed by the letter n, NOT a newline. The protect-\\
742
- // phase must still work inside mixed messages.
743
236
  const input = 'Windows path: C:\\\\temp\\\\file.txt\\nnext line\nreal newline too'
744
237
  const out = repairEscapedWhitespace(input)
745
- // The \\n becomes a real newline.
746
238
  expect(out).toContain('C:\\temp\\file.txt\nnext line')
747
- // The real newline is preserved.
748
239
  expect(out).toContain('real newline too')
749
240
  })
750
241
 
@@ -764,41 +255,27 @@ describe('repairEscapedWhitespace', () => {
764
255
  })
765
256
 
766
257
  test('preserves literal backslash sequences via \\\\', () => {
767
- // `\\n` in the source is `\\` followed by `n`, which means the user
768
- // literally wanted a backslash followed by the letter n, NOT a newline.
769
- // Our order-aware unescape must protect `\\` before touching `\n`.
770
258
  const input = 'Windows path: C:\\\\temp\\\\file.txt\\nnext line'
771
259
  const out = repairEscapedWhitespace(input)
772
260
  expect(out).toBe('Windows path: C:\\temp\\file.txt\nnext line')
773
261
  })
774
262
 
775
- test('end-to-end with markdownToHtml: repaired text renders correctly', () => {
776
- // Full pipeline: broken input repair markdownToHtml Telegram HTML.
263
+ test('end-to-end: repaired raw markdown is ready for the rich path', () => {
264
+ // Full pipeline now ends at raw GFM markdown no HTML conversion.
777
265
  const broken = '**Bold line**\\n\\n- bullet one\\n- bullet two'
778
266
  const repaired = repairEscapedWhitespace(broken)
779
- const html = markdownToHtml(repaired)
780
- expect(html).toContain('<b>Bold line</b>')
781
- // Real newlines should be present in the HTML output (Telegram renders
782
- // them as actual line breaks in HTML parse mode).
783
- expect(html).toContain('\n\n')
784
- expect(html).toContain('- bullet one')
785
- // Literal \n must not survive anywhere.
786
- expect(html).not.toContain('\\n')
267
+ expect(repaired).toBe('**Bold line**\n\n- bullet one\n- bullet two')
268
+ // Real newlines present; no literal \n survives.
269
+ expect(repaired).toContain('\n\n')
270
+ expect(repaired).not.toContain('\\n')
787
271
  })
788
272
 
789
273
  // ── Sentinel-collision safety (reviewer blocker) ─────────────────────────
790
274
 
791
275
  test('does not produce "undefined" when input contains NUL-byte sequences (#2456)', () => {
792
- // A per-call random nonce makes sentinel collision statistically impossible.
793
- // Confirm that a message containing NUL bytes (which could match a hardcoded
794
- // sentinel) is passed through safely rather than emitting literal "undefined".
795
- // We craft a string that would have matched the OLD hardcoded sentinel \x00REPMASK0\x00
796
- // to prove it no longer causes corruption.
797
276
  const dangerous = 'hello \x00REPMASK0\x00 world \\n end'
798
277
  const out = repairEscapedWhitespace(dangerous)
799
- // Must not produce the string "undefined" in output.
800
278
  expect(out).not.toContain('undefined')
801
- // The \\n in prose must be unescaped.
802
279
  expect(out).toContain(' end')
803
280
  expect(out).not.toContain('\\n')
804
281
  })
@@ -806,15 +283,9 @@ describe('repairEscapedWhitespace', () => {
806
283
  // ── Unclosed fenced block (reviewer major) ────────────────────────────────
807
284
 
808
285
  test('handles unclosed fenced block gracefully: \\n in trailing content is still unescaped', () => {
809
- // An unclosed ``` is not matched by the fenced-block regex (it requires a
810
- // closing ```). Content after the unmatched opening is treated as prose, so
811
- // literal \\n there gets unescaped — which is the least-surprising outcome
812
- // for malformed input (the alternative would be silently swallowing content).
813
286
  const input = 'Prose \\n here\n```bash\necho "line1\\nline2"\n'
814
287
  const out = repairEscapedWhitespace(input)
815
- // \\n in prose before the unclosed fence is unescaped.
816
288
  expect(out).toContain('Prose \n here')
817
- // The function must not throw and must return a string.
818
289
  expect(typeof out).toBe('string')
819
290
  })
820
291
 
@@ -829,443 +300,9 @@ describe('repairEscapedWhitespace', () => {
829
300
  })
830
301
 
831
302
  test('unescapes \\r in prose when real newlines are also present (#2456)', () => {
832
- // A message that mixes real newlines and a literal \\r escape in prose.
833
303
  const input = 'First line\nsome \\r carriage return in prose'
834
304
  const out = repairEscapedWhitespace(input)
835
305
  expect(out).toContain('some \r carriage return in prose')
836
306
  expect(out).toContain('First line\n')
837
307
  })
838
308
  })
839
-
840
- // ---------------------------------------------------------------------------
841
- // sanitizeForTelegram — output invariants enforced pre-send
842
- // ---------------------------------------------------------------------------
843
-
844
- describe('sanitizeForTelegram', () => {
845
- // ── Rule 1: strip ## headings ────────────────────────────────────────────
846
-
847
- test('strips ## heading and converts to bold', () => {
848
- const result = sanitizeForTelegram('## My Heading\n\nbody text')
849
- expect(result).toContain('<b>My Heading</b>')
850
- expect(result).not.toContain('## ')
851
- })
852
-
853
- test('strips ### heading and converts to bold', () => {
854
- const result = sanitizeForTelegram('### Section\n\nbody')
855
- expect(result).toContain('<b>Section</b>')
856
- expect(result).not.toContain('### ')
857
- })
858
-
859
- test('strips #### heading and converts to bold', () => {
860
- const result = sanitizeForTelegram('#### Sub\n\nbody')
861
- expect(result).toContain('<b>Sub</b>')
862
- expect(result).not.toContain('#### ')
863
- })
864
-
865
- test('strips # (h1) heading and converts to bold', () => {
866
- const result = sanitizeForTelegram('# Title\n\nbody')
867
- expect(result).toContain('<b>Title</b>')
868
- expect(result).not.toContain('# Title')
869
- })
870
-
871
- // ── Rule 2: flatten nested bullets ──────────────────────────────────────
872
-
873
- test('flattens 2-space-indented bullets', () => {
874
- const result = sanitizeForTelegram('- top\n - sub')
875
- expect(result).toContain('· sub')
876
- expect(result).not.toContain(' - sub')
877
- })
878
-
879
- test('flattens 4-space-indented bullets', () => {
880
- const result = sanitizeForTelegram('- top\n - deeply nested')
881
- expect(result).toContain('· deeply nested')
882
- expect(result).not.toContain(' - deeply nested')
883
- })
884
-
885
- test('flattens tab-indented bullets', () => {
886
- const result = sanitizeForTelegram('- top\n\t- tabbed sub')
887
- expect(result).toContain('· tabbed sub')
888
- expect(result).not.toContain('\t- tabbed sub')
889
- })
890
-
891
- test('preserves unindented bullets unchanged', () => {
892
- const result = sanitizeForTelegram('- item one\n- item two')
893
- expect(result).toContain('- item one')
894
- expect(result).toContain('- item two')
895
- // No middle-dot substitution on top-level bullets
896
- expect(result).not.toContain('· item one')
897
- })
898
-
899
- // ── Rule 3: collapse blank lines ────────────────────────────────────────
900
-
901
- test('collapses 4 blank lines to 2', () => {
902
- const result = sanitizeForTelegram('before\n\n\n\nafter')
903
- expect(result).toBe('before\n\nafter')
904
- })
905
-
906
- test('collapses 3 blank lines to 2', () => {
907
- const result = sanitizeForTelegram('a\n\n\nb')
908
- expect(result).toBe('a\n\nb')
909
- })
910
-
911
- test('leaves exactly 2 blank lines alone', () => {
912
- const result = sanitizeForTelegram('a\n\nb')
913
- expect(result).toBe('a\n\nb')
914
- })
915
-
916
- // ── Rule 4: trailing whitespace ──────────────────────────────────────────
917
-
918
- test('strips trailing spaces from lines', () => {
919
- const result = sanitizeForTelegram('hello \nworld ')
920
- expect(result).toBe('hello\nworld')
921
- })
922
-
923
- test('strips trailing tabs from lines', () => {
924
- const result = sanitizeForTelegram('hello\t\t\nworld')
925
- expect(result).toBe('hello\nworld')
926
- })
927
-
928
- // ── Rule 5: HTML escape inside code/pre ─────────────────────────────────
929
-
930
- test('HTML-escapes bare < and > inside <code> block', () => {
931
- const result = sanitizeForTelegram('<code>a < b && c > d</code>')
932
- expect(result).toContain('<code>a &lt; b')
933
- expect(result).toContain('&gt; d</code>')
934
- })
935
-
936
- test('HTML-escapes bare & inside <code> block', () => {
937
- const result = sanitizeForTelegram('<code>foo & bar</code>')
938
- expect(result).toContain('<code>foo &amp; bar</code>')
939
- })
940
-
941
- test('HTML-escapes bare < and > inside <pre> block', () => {
942
- const result = sanitizeForTelegram('<pre><code>if a < b</code></pre>')
943
- expect(result).toContain('&lt; b')
944
- })
945
-
946
- test('does not double-escape already-escaped &amp; in <code>', () => {
947
- const result = sanitizeForTelegram('<code>a &amp; b</code>')
948
- // Must remain single-escaped, not become &amp;amp;
949
- expect(result).toContain('<code>a &amp; b</code>')
950
- expect(result).not.toContain('&amp;amp;')
951
- })
952
-
953
- test('does not double-escape &lt; in <code>', () => {
954
- const result = sanitizeForTelegram('<code>&lt;div&gt;</code>')
955
- expect(result).toContain('<code>&lt;div&gt;</code>')
956
- expect(result).not.toContain('&amp;lt;')
957
- })
958
-
959
- test('does not double-escape &#123; numeric entity in <code>', () => {
960
- const result = sanitizeForTelegram('<code>&#123; x &#125;</code>')
961
- expect(result).toContain('&#123;')
962
- expect(result).not.toContain('&amp;#123;')
963
- })
964
-
965
- // ── Code block exclusion from structural rules ───────────────────────────
966
-
967
- test('does not strip ## heading inside <code> block', () => {
968
- const result = sanitizeForTelegram('<code>## not a heading</code>')
969
- // The ## stays; only < > & are touched inside code
970
- expect(result).toContain('## not a heading')
971
- })
972
-
973
- test('does not flatten bullets inside <code> block', () => {
974
- const result = sanitizeForTelegram('<code> - not flattened</code>')
975
- // The indented bullet stays verbatim inside code
976
- expect(result).toContain(' - not flattened')
977
- })
978
-
979
- test('does not strip ## heading inside <pre> block', () => {
980
- const result = sanitizeForTelegram('<pre><code class="language-bash"># comment\n## heading\n</code></pre>')
981
- expect(result).toContain('## heading')
982
- })
983
-
984
- // ── Idempotency ──────────────────────────────────────────────────────────
985
-
986
- test('is idempotent for heading conversion', () => {
987
- const once = sanitizeForTelegram('## Heading\n\nbody')
988
- const twice = sanitizeForTelegram(once)
989
- expect(twice).toBe(once)
990
- })
991
-
992
- test('is idempotent for bullet flattening', () => {
993
- const once = sanitizeForTelegram('- top\n - sub\n - deep')
994
- const twice = sanitizeForTelegram(once)
995
- expect(twice).toBe(once)
996
- })
997
-
998
- test('is idempotent for blank-line collapse', () => {
999
- const once = sanitizeForTelegram('a\n\n\n\nb')
1000
- const twice = sanitizeForTelegram(once)
1001
- expect(twice).toBe(once)
1002
- })
1003
-
1004
- test('is idempotent for code-block escaping', () => {
1005
- const once = sanitizeForTelegram('<code>a < b & c > d</code>')
1006
- const twice = sanitizeForTelegram(once)
1007
- expect(twice).toBe(once)
1008
- })
1009
-
1010
- test('is idempotent for a combined realistic message', () => {
1011
- const input = [
1012
- '## Status Report',
1013
- '',
1014
- '- top item',
1015
- ' - sub item one',
1016
- ' - sub item two',
1017
- '',
1018
- '',
1019
- '',
1020
- 'Here is some <code>a < b</code> inline code.',
1021
- ].join('\n')
1022
- const once = sanitizeForTelegram(input)
1023
- const twice = sanitizeForTelegram(once)
1024
- expect(twice).toBe(once)
1025
- })
1026
- })
1027
-
1028
- // ---------------------------------------------------------------------------
1029
- // Markdown table rendering
1030
- // ---------------------------------------------------------------------------
1031
-
1032
- describe('markdownToHtml — markdown table rendering', () => {
1033
- // 2-col 3-row → bullet list
1034
- test('2-col 3-row renders as bullet list', () => {
1035
- const input = [
1036
- '| Name | Value |',
1037
- '| --- | --- |',
1038
- '| Alpha | 1 |',
1039
- '| Beta | 2 |',
1040
- '| Gamma | 3 |',
1041
- ].join('\n')
1042
- const result = markdownToHtml(input)
1043
- // Header line present
1044
- expect(result).toContain('Name / Value')
1045
- // Each row is a bullet with <b> first column
1046
- expect(result).toContain('• <b>Alpha</b>')
1047
- expect(result).toContain('• <b>Beta</b>')
1048
- expect(result).toContain('• <b>Gamma</b>')
1049
- // Values appended after dash
1050
- expect(result).toContain('— 1')
1051
- expect(result).toContain('— 2')
1052
- expect(result).toContain('— 3')
1053
- // Must NOT contain any raw table markdown pipes
1054
- expect(result).not.toContain('| --- |')
1055
- expect(result).not.toContain('<table>')
1056
- })
1057
-
1058
- // 3-col 4-row → bullet list (still within ≤3 cols AND ≤6 rows)
1059
- test('3-col 4-row renders as bullet list', () => {
1060
- const input = [
1061
- '| Tool | Status | Notes |',
1062
- '| ---- | ------ | ----- |',
1063
- '| bun | ok | fast |',
1064
- '| tsc | ok | strict |',
1065
- '| eslint | warn | fixable |',
1066
- '| vitest | skip | optional |',
1067
- ].join('\n')
1068
- const result = markdownToHtml(input)
1069
- expect(result).toContain('• <b>bun</b>')
1070
- expect(result).toContain('• <b>tsc</b>')
1071
- // Third column appended too
1072
- expect(result).toContain('fast')
1073
- expect(result).toContain('strict')
1074
- expect(result).not.toContain('| ---- |')
1075
- })
1076
-
1077
- // 4-col 3-row → <pre> block (4 cols exceeds limit)
1078
- test('4-col 3-row renders as <pre> block', () => {
1079
- const input = [
1080
- '| A | B | C | D |',
1081
- '| - | - | - | - |',
1082
- '| 1 | 2 | 3 | 4 |',
1083
- '| 5 | 6 | 7 | 8 |',
1084
- '| 9 | 0 | 1 | 2 |',
1085
- ].join('\n')
1086
- const result = markdownToHtml(input)
1087
- expect(result).toContain('<pre>')
1088
- expect(result).toContain('</pre>')
1089
- // Column headers should appear in the pre block
1090
- expect(result).toContain('A')
1091
- expect(result).toContain('B')
1092
- // Must not produce a bullet list
1093
- expect(result).not.toContain('• <b>')
1094
- })
1095
-
1096
- // 3-col 8-row → <pre> block (8 rows exceeds ≤6 limit)
1097
- test('3-col 8-row renders as <pre> block', () => {
1098
- const rows = Array.from({ length: 8 }, (_, i) => `| Row${i + 1} | X${i} | Y${i} |`)
1099
- const input = [
1100
- '| Name | ColX | ColY |',
1101
- '| ---- | ---- | ---- |',
1102
- ...rows,
1103
- ].join('\n')
1104
- const result = markdownToHtml(input)
1105
- expect(result).toContain('<pre>')
1106
- expect(result).toContain('</pre>')
1107
- expect(result).not.toContain('• <b>')
1108
- })
1109
-
1110
- // Pipe in plain prose is NOT a table
1111
- test('plain prose with a pipe is not converted to a table', () => {
1112
- const input = 'Run echo foo | bar to see output'
1113
- const result = markdownToHtml(input)
1114
- expect(result).toContain('echo foo | bar')
1115
- expect(result).not.toContain('• <b>')
1116
- expect(result).not.toContain('<pre>')
1117
- })
1118
-
1119
- // Pipe in code block is not a table
1120
- test('pipe inside fenced code block is left verbatim', () => {
1121
- const input = [
1122
- '```bash',
1123
- '| Name | Value |',
1124
- '| --- | --- |',
1125
- '| foo | bar |',
1126
- '```',
1127
- ].join('\n')
1128
- const result = markdownToHtml(input)
1129
- // Should be inside <pre><code>, not a rendered table
1130
- expect(result).toContain('<pre>')
1131
- expect(result).toContain('| Name | Value |')
1132
- expect(result).not.toContain('• <b>')
1133
- })
1134
-
1135
- // Table with empty cells
1136
- test('table with empty cells is handled gracefully', () => {
1137
- const input = [
1138
- '| Key | Value |',
1139
- '| --- | ----- |',
1140
- '| present | |',
1141
- '| | orphan |',
1142
- ].join('\n')
1143
- const result = markdownToHtml(input)
1144
- // Should produce output without crashing; empty cells rendered as empty/—
1145
- expect(result).toContain('• <b>present</b>')
1146
- // No raw markdown pipes in output
1147
- expect(result).not.toContain('| --- |')
1148
- })
1149
-
1150
- // Table preceded and followed by paragraph text — only the table transforms
1151
- test('table inside paragraph text: only the table block transforms', () => {
1152
- const input = [
1153
- 'Before paragraph.',
1154
- '',
1155
- '| Name | Score |',
1156
- '| ---- | ----- |',
1157
- '| Alice | 95 |',
1158
- '| Bob | 87 |',
1159
- '',
1160
- 'After paragraph.',
1161
- ].join('\n')
1162
- const result = markdownToHtml(input)
1163
- // Prose preserved
1164
- expect(result).toContain('Before paragraph.')
1165
- expect(result).toContain('After paragraph.')
1166
- // Table converted
1167
- expect(result).toContain('• <b>Alice</b>')
1168
- expect(result).toContain('• <b>Bob</b>')
1169
- // No raw table markdown remains
1170
- expect(result).not.toContain('| ---- |')
1171
- })
1172
-
1173
- // HTML entities in cell content are properly escaped
1174
- test('cell content with ampersand is safely escaped', () => {
1175
- const input = [
1176
- '| Operator | Meaning |',
1177
- '| -------- | ------- |',
1178
- '| AND | a & b |',
1179
- '| OR | x & y |',
1180
- ].join('\n')
1181
- const result = markdownToHtml(input)
1182
- // & in cell content must be entity-escaped
1183
- expect(result).toContain('&amp;')
1184
- // Output is still a bullet list
1185
- expect(result).toContain('• <b>AND</b>')
1186
- expect(result).toContain('• <b>OR</b>')
1187
- })
1188
- })
1189
-
1190
- describe('telegramHtmlToPlainText (HTML parse-reject fallback)', () => {
1191
- test('strips supported formatting tags, keeps the text', () => {
1192
- const out = telegramHtmlToPlainText('<b>Bold</b> and <i>italic</i> and <code>x=1</code>')
1193
- expect(out).toBe('Bold and italic and x=1')
1194
- })
1195
-
1196
- test('anchors become "label (href)"', () => {
1197
- const out = telegramHtmlToPlainText('see <a href="https://example.com/x">the docs</a> now')
1198
- expect(out).toBe('see the docs (https://example.com/x) now')
1199
- })
1200
-
1201
- test('anchor with label equal to href collapses to the bare url', () => {
1202
- const out = telegramHtmlToPlainText('<a href="https://example.com">https://example.com</a>')
1203
- expect(out).toBe('https://example.com')
1204
- })
1205
-
1206
- test('anchor with empty label yields just the href', () => {
1207
- expect(telegramHtmlToPlainText('<a href="https://e.com"></a>')).toBe('https://e.com')
1208
- })
1209
-
1210
- test('single-quoted and unquoted href forms are handled', () => {
1211
- expect(telegramHtmlToPlainText("<a href='https://a.co'>A</a>")).toBe('A (https://a.co)')
1212
- expect(telegramHtmlToPlainText('<a href=https://b.co>B</a>')).toBe('B (https://b.co)')
1213
- })
1214
-
1215
- test('decodes the standard HTML entities (no double-decode of the result)', () => {
1216
- const out = telegramHtmlToPlainText('a &amp; b &lt;tag&gt; &quot;q&quot; &#39;s&#39; 5 &nbsp;€')
1217
- expect(out).toBe('a & b <tag> "q" \'s\' 5 €')
1218
- })
1219
-
1220
- test('numeric + hex char references decode', () => {
1221
- expect(telegramHtmlToPlainText('&#8594; &#x2192;')).toBe('→ →')
1222
- })
1223
-
1224
- test('out-of-range / malformed char refs are left literal', () => {
1225
- expect(telegramHtmlToPlainText('&#0; &#1114112; &#xZZ;')).toBe('&#0; &#1114112; &#xZZ;')
1226
- })
1227
-
1228
- test('block/break boundaries become newlines', () => {
1229
- const out = telegramHtmlToPlainText('one<br>two<br/>three</p>four</blockquote>five')
1230
- expect(out).toBe('one\ntwo\nthree\nfour\nfive')
1231
- })
1232
-
1233
- test('unsupported / malformed tags (the actual reject cause) are stripped, not escaped', () => {
1234
- // A markdown→HTML slip that emitted an unsupported tag is exactly
1235
- // what triggers Telegram's 400; the fallback must yield clean text.
1236
- const out = telegramHtmlToPlainText('<h2>Title</h2><span class=x>body </span><unknowntag>tail')
1237
- expect(out).toBe('Title\nbody tail')
1238
- })
1239
-
1240
- test('result is literal (parse_mode unset) — no re-escaping of < > &', () => {
1241
- // We resend with parse_mode UNSET, so the output must be the raw
1242
- // characters, not HTML entities.
1243
- const out = telegramHtmlToPlainText('a &lt; b &amp;&amp; c &gt; d')
1244
- expect(out).toBe('a < b && c > d')
1245
- expect(out).not.toContain('&lt;')
1246
- expect(out).not.toContain('&amp;')
1247
- })
1248
-
1249
- test('collapses 3+ blank lines and trims trailing line whitespace', () => {
1250
- const out = telegramHtmlToPlainText('a \n\n\n\n\nb')
1251
- expect(out).toBe('a\n\nb')
1252
- })
1253
-
1254
- test('nested formatting inside an anchor label is flattened', () => {
1255
- const out = telegramHtmlToPlainText('<a href="https://x.io"><b>Big</b> link</a>')
1256
- expect(out).toBe('Big link (https://x.io)')
1257
- })
1258
-
1259
- test('empty / whitespace input is safe', () => {
1260
- expect(telegramHtmlToPlainText('')).toBe('')
1261
- expect(telegramHtmlToPlainText(' \n ')).toBe('')
1262
- })
1263
-
1264
- test('pure-markup chunk collapses to empty (gateway substitutes a placeholder)', () => {
1265
- // Documents the trigger for the empty-string guard in
1266
- // gateway.ts:sendChunkPlainText — a chunk with no text content
1267
- // strips to '', so the send path must substitute rather than
1268
- // post an empty message (Telegram 400 "message text is empty").
1269
- expect(telegramHtmlToPlainText('<b></b><i></i><br><span></span>')).toBe('')
1270
- })
1271
- })