switchroom 0.18.18 → 0.18.19

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.
@@ -12,8 +12,6 @@ import { describe, test, expect } from 'vitest'
12
12
  import {
13
13
  normalizeParagraphBreaks,
14
14
  splitCollapsedInlineBullets,
15
- addParagraphSpacers,
16
- PARAGRAPH_SPACER,
17
15
  } from '../format.js'
18
16
 
19
17
  describe('normalizeParagraphBreaks', () => {
@@ -433,96 +431,44 @@ describe('splitCollapsedInlineBullets', () => {
433
431
  })
434
432
 
435
433
  // ---------------------------------------------------------------------------
436
- // addParagraphSpacersrestore a VISIBLE blank line between prose paragraphs.
434
+ // Paragraph gap spacing plain \n\n, no NBSP spacer (#2669 follow-up).
437
435
  //
438
- // The rich GFM renderer (post-#2669) collapses a `\n\n` paragraph gap TIGHT, so
439
- // multi-paragraph replies render jammed together the operator-confirmed
440
- // regression vs the old HTML path (`parse_mode:"HTML"`, `\n\n` → real blank
441
- // line). addParagraphSpacers wedges a U+00A0 spacer paragraph into each prose
442
- // `\n\n` gap so the rich renderer shows a visible empty line. Conservative:
443
- // only between two prose blocks, never adjacent to list/table/code/quote/heading.
436
+ // The NBSP paragraph-spacer (addParagraphSpacers / PARAGRAPH_SPACER) was
437
+ // REMOVED: its premise that the Bot API 10.1 rich GFM renderer collapses a
438
+ // `\n\n` gap TIGHT — is false for the live renderer, which shows a `\n\n` gap
439
+ // as one normal blank line. The spacer therefore injected a spurious SECOND
440
+ // blank line (`\n\n \n\n`) between every paragraph fleet-wide. Paragraph
441
+ // spacing now relies on the plain `\n\n` that normalizeParagraphBreaks already
442
+ // guarantees. These tests pin: multi-paragraph output has exactly one blank
443
+ // line per gap, and no U+00A0 anywhere.
444
444
  // ---------------------------------------------------------------------------
445
445
 
446
- describe('addParagraphSpacers', () => {
447
- const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
446
+ describe('paragraph gap spacing — single blank line, no NBSP', () => {
447
+ const NBSP = String.fromCharCode(0xa0)
448
448
 
449
- test('the spacer is a single non-breaking space (U+00A0), not an ASCII space', () => {
450
- expect(PARAGRAPH_SPACER).toBe(' ')
449
+ test('a two-paragraph prose body has exactly one blank-line gap and no NBSP', () => {
450
+ const out = normalizeParagraphBreaks('Paragraph one.\n\nParagraph two.')
451
+ expect(out).toBe('Paragraph one.\n\nParagraph two.')
452
+ expect(out).not.toContain(NBSP)
453
+ // Exactly one empty line between the two content lines.
454
+ expect(out.split('\n')).toEqual(['Paragraph one.', '', 'Paragraph two.'])
451
455
  })
452
456
 
453
- test('inserts a visible spacer paragraph into a prose `\\n\\n` gap', () => {
454
- expect(addParagraphSpacers('Paragraph one.\n\nParagraph two.')).toBe(
455
- `Paragraph one.${gap}Paragraph two.`,
456
- )
457
- })
458
-
459
- test('spaces every gap in a three-paragraph body', () => {
460
- expect(addParagraphSpacers('One.\n\nTwo.\n\nThree.')).toBe(
461
- `One.${gap}Two.${gap}Three.`,
462
- )
463
- })
464
-
465
- test('is idempotent — a spaced gap is not doubled on a second pass', () => {
466
- const once = addParagraphSpacers('Alpha.\n\nBravo.')
467
- expect(addParagraphSpacers(once)).toBe(once)
468
- })
469
-
470
- test('leaves a single-`\\n` separation alone (only `\\n\\n` gaps are spaced)', () => {
471
- // normalizeParagraphBreaks owns lone-break promotion; this pass only adds a
472
- // visible spacer where a genuine blank-line gap already exists.
473
- const input = 'Line one.\nLine two.'
474
- expect(addParagraphSpacers(input)).toBe(input)
457
+ test('a three-paragraph body keeps single-blank-line gaps, no NBSP', () => {
458
+ const out = normalizeParagraphBreaks('One.\n\nTwo.\n\nThree.')
459
+ expect(out).toBe('One.\n\nTwo.\n\nThree.')
460
+ expect(out).not.toContain(NBSP)
461
+ // No gap is a DOUBLE blank line (`\n\n\n`), which the old spacer produced.
462
+ expect(out).not.toMatch(/\n\n\n/)
475
463
  })
476
464
 
477
- test('spaces proselist transitions (uniform block spacing), list interior tight', () => {
478
- expect(addParagraphSpacers('Here are the steps.\n\n- first\n- second')).toBe(
479
- `Here are the steps.${gap}- first\n- second`,
480
- )
481
- expect(addParagraphSpacers('- first\n- second\n\nClosing prose after the list.')).toBe(
482
- `- first\n- second${gap}Closing prose after the list.`,
483
- )
465
+ test('prose->list transition is a single `\n\n` boundary, list interior tight', () => {
466
+ const out = normalizeParagraphBreaks('Here are the steps.\n\n- first\n- second')
467
+ expect(out).toBe('Here are the steps.\n\n- first\n- second')
468
+ expect(out).not.toContain(NBSP)
484
469
  })
485
470
 
486
- test('spaces a prose→table boundary; table rows stay contiguous', () => {
487
- const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
488
- expect(addParagraphSpacers(`Intro.\n\n${table}`)).toBe(`Intro.${gap}${table}`)
489
- })
490
-
491
- test('spaces prose↔fence boundaries; fence interior untouched', () => {
492
- const input = 'Look here.\n\n```js\nconst a = 1;\n```\n\nDone.'
493
- expect(addParagraphSpacers(input)).toBe(
494
- `Look here.${gap}\`\`\`js\nconst a = 1;\n\`\`\`${gap}Done.`,
495
- )
496
- })
497
-
498
- test('spaces heading→anything and blockquote boundaries', () => {
499
- expect(addParagraphSpacers('Intro prose.\n\n## Section\n\nBody prose.')).toBe(
500
- `Intro prose.${gap}## Section${gap}Body prose.`,
501
- )
502
- expect(addParagraphSpacers('Said.\n\n> a quote\n\nAfter.')).toBe(
503
- `Said.${gap}> a quote${gap}After.`,
504
- )
505
- })
506
-
507
- test('does NOT space between items of the same loose list / same-kind blocks', () => {
508
- const looseList = '- first\n\n- second\n\n- third'
509
- expect(addParagraphSpacers(looseList)).toBe(looseList)
510
- const quotes = '> one\n\n> two'
511
- expect(addParagraphSpacers(quotes)).toBe(quotes)
512
- })
513
-
514
- test('never reaches inside a fenced block (interior blank line untouched)', () => {
515
- const input = 'Before.\n\n```\nline 1\n\nline 2\n```\n\nAfter.'
516
- const out = addParagraphSpacers(input)
517
- // The fence interior — including its own blank line — is byte-for-byte intact.
518
- expect(out).toContain('```\nline 1\n\nline 2\n```')
519
- })
520
-
521
- test('a body with no paragraph gap is returned unchanged', () => {
522
- expect(addParagraphSpacers('just one paragraph, no gap')).toBe('just one paragraph, no gap')
523
- })
524
-
525
- test('composes after normalizeParagraphBreaks: prose gap spaced, list left tight', () => {
471
+ test('composed prose+list body: one blank line per gap, no NBSP, no double gap', () => {
526
472
  const input = [
527
473
  'Summary of the change.',
528
474
  '',
@@ -531,12 +477,11 @@ describe('addParagraphSpacers', () => {
531
477
  '- adds a normalizer',
532
478
  '- lifts the cap',
533
479
  ].join('\n')
534
- const out = addParagraphSpacers(normalizeParagraphBreaks(input))
535
- // The two prose paragraphs gain a visible spacer between them.
536
- expect(out).toContain(`Summary of the change.${gap}It does two things.`)
537
- // The prose→list transition gains a spacer too (uniform block spacing),
538
- // but the list INTERIOR stays tight.
539
- expect(out).toContain(`It does two things.${gap}- adds a normalizer\n- lifts the cap`)
480
+ const out = normalizeParagraphBreaks(input)
481
+ expect(out).toContain('Summary of the change.\n\nIt does two things.')
482
+ expect(out).toContain('It does two things.\n\n- adds a normalizer\n- lifts the cap')
483
+ expect(out).not.toContain(NBSP)
484
+ expect(out).not.toMatch(/\n\n\n/)
540
485
  })
541
486
  })
542
487
 
@@ -577,12 +522,12 @@ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
577
522
  * ragged / oversized gap: CommonMark discards it so it buys no visible space,
578
523
  * but it reads as noise in the raw text. Step 1 of normalizeParagraphBreaks now
579
524
  * collapses any run of blank lines — including whitespace-only interior lines —
580
- * to exactly one clean `\n\n`, WITHOUT ever eating the deliberate U+00A0
581
- * paragraph spacer that addParagraphSpacers (#2692) adds later for a visible
582
- * gap on the rich-message path.
525
+ * to exactly one clean `\n\n`. The collapse is deliberately ASCII-only, so a
526
+ * genuine user-typed U+00A0 line survives (the conservative choice; there is no
527
+ * longer an NBSP paragraph spacer to protect — that pass was removed).
583
528
  */
584
529
  describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug 2)', () => {
585
- const NBSP = ' '
530
+ const NBSP = String.fromCharCode(0xa0)
586
531
 
587
532
  test('a lone-space blank line between paragraphs collapses to a clean `\\n\\n` (real string)', () => {
588
533
  const input =
@@ -635,14 +580,11 @@ describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug
635
580
  expect(normalizeParagraphBreaks(input)).toBe(input)
636
581
  })
637
582
 
638
- test('the deliberate U+00A0 spacer from addParagraphSpacers is preserved (no #2692 regression)', () => {
639
- const normalized = normalizeParagraphBreaks('Alpha para.\n\nBravo para.')
640
- const spaced = addParagraphSpacers(normalized)
641
- // addParagraphSpacers wedges a U+00A0-only line to force a visible gap.
642
- expect(spaced).toContain('\n\n' + PARAGRAPH_SPACER + '\n\n')
643
- expect(spaced.split('\n')).toContain(NBSP)
644
- // And re-running the normalizer must NOT eat that intentional spacer.
645
- expect(normalizeParagraphBreaks(spaced)).toBe(spaced)
583
+ test('a genuine user-typed U+00A0-only line survives (ASCII-only collapse)', () => {
584
+ // The blank-line collapse is deliberately ASCII-only, so a non-breaking
585
+ // space a user actually typed on its own line is not silently eaten.
586
+ const input = 'Alpha para.\n' + NBSP + '\nBravo para.'
587
+ expect(normalizeParagraphBreaks(input)).toBe(input)
646
588
  })
647
589
 
648
590
  test('idempotent: collapsing a stray gap twice is stable', () => {
@@ -120,13 +120,12 @@ describe('handleStreamReply', () => {
120
120
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
121
121
  })
122
122
 
123
- it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
123
+ it('rich path sends the multi-paragraph `\\n\\n` gap byte-exact (no NBSP spacer)', async () => {
124
+ // The NBSP paragraph-spacer pass was removed in the #2669 follow-up — the
125
+ // rich renderer shows a plain `\n\n` gap as one blank line, so the handler
126
+ // passes the text through unchanged.
124
127
  const state = makeState()
125
- // Spacer dep replaces every `\n\n` gap with a visible marker so we can
126
- // assert the rich path ran it (mirrors the real gateway wiring).
127
- const deps = makeDeps(bot, {
128
- addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
129
- })
128
+ const deps = makeDeps(bot)
130
129
 
131
130
  const pending = handleStreamReply(
132
131
  { chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
@@ -137,14 +136,13 @@ describe('handleStreamReply', () => {
137
136
  await pending
138
137
 
139
138
  expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
140
- expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
139
+ expect(richSendMarkdown(bot)).toBe('Para one.\n\nPara two.')
140
+ expect(richSendMarkdown(bot)).not.toContain(String.fromCharCode(0xa0))
141
141
  })
142
142
 
143
- it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
143
+ it('literal format=text path is byte-exact too', async () => {
144
144
  const state = makeState()
145
- const deps = makeDeps(bot, {
146
- addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
147
- })
145
+ const deps = makeDeps(bot)
148
146
 
149
147
  const pending = handleStreamReply(
150
148
  { chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
@@ -155,7 +153,6 @@ describe('handleStreamReply', () => {
155
153
  await pending
156
154
 
157
155
  expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
158
- // Literal path is byte-exact — no spacer injected.
159
156
  expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
160
157
  })
161
158
 
@@ -19,8 +19,6 @@ import {
19
19
  repairEscapedWhitespace,
20
20
  escapeMarkdown,
21
21
  splitMarkdownChunks,
22
- addParagraphSpacers,
23
- PARAGRAPH_SPACER,
24
22
  RICH_MESSAGE_MAX_CHARS,
25
23
  } from '../format.js'
26
24
 
@@ -187,62 +185,119 @@ describe('splitMarkdownChunks', () => {
187
185
  })
188
186
 
189
187
  // -------------------------------------------------------------------------
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).
188
+ // Chunk-boundary blank-line hygiene. Paragraph gaps are now plain `\n\n`
189
+ // (the NBSP spacer was removed in the #2669 follow-up). When a cut lands in
190
+ // a gap, the boundary must not leave a chunk that opens or ends with a bare
191
+ // blank line, and no visible content may be dropped.
194
192
  // -------------------------------------------------------------------------
195
193
 
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.
194
+ test('no chunk starts or ends with a bare blank line at a `\\n\\n` gap cut', () => {
198
195
  const A = 'Alpha sentence one'
199
196
  const B = 'Bravo sentence two'
200
- const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
201
- const chunks = splitMarkdownChunks(spaced, 33)
197
+ const text = `${A}.\n\n${B}.`
198
+ const chunks = splitMarkdownChunks(text, 33)
202
199
  expect(chunks.length).toBeGreaterThan(1)
203
- const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
200
+ const blankOnly = /^[ \t]*$/
204
201
  for (const c of chunks) {
205
202
  const lines = c.split('\n')
206
- expect(spacerOnly.test(lines[0])).toBe(false)
207
- expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
203
+ expect(blankOnly.test(lines[0])).toBe(false)
204
+ expect(blankOnly.test(lines[lines.length - 1])).toBe(false)
208
205
  }
209
206
  })
210
207
 
211
208
  test('visible paragraph content survives the boundary (no text dropped)', () => {
212
209
  const A = 'Alpha sentence one'
213
210
  const B = 'Bravo sentence two'
214
- const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
215
- const chunks = splitMarkdownChunks(spaced, 33)
211
+ const text = `${A}.\n\n${B}.`
212
+ const chunks = splitMarkdownChunks(text, 33)
216
213
  const rejoined = chunks.join('\n')
217
214
  expect(rejoined).toContain(`${A}.`)
218
215
  expect(rejoined).toContain(`${B}.`)
219
216
  })
220
217
 
221
- test('spacer-boundary strip is robust across several gaps and small caps', () => {
218
+ test('blank-line-boundary strip is robust across several gaps and small caps', () => {
222
219
  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]*$`)
220
+ const text = paras.join('\n\n')
221
+ const blankOnly = /^[ \t]*$/
225
222
  for (const cap of [20, 31, 40, 64]) {
226
- const chunks = splitMarkdownChunks(spaced, cap)
223
+ const chunks = splitMarkdownChunks(text, cap)
227
224
  for (const c of chunks) {
228
225
  const lines = c.split('\n')
229
- expect(spacerOnly.test(lines[0])).toBe(false)
230
- expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
226
+ expect(blankOnly.test(lines[0])).toBe(false)
227
+ expect(blankOnly.test(lines[lines.length - 1])).toBe(false)
231
228
  }
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.)
229
+ // No visible word is dropped: concatenating the chunks' non-blank tokens
230
+ // reproduces the original word sequence. (Word-level, not line-level,
231
+ // because a small cap may split mid-word — the chunker's normal
232
+ // space-boundary behaviour.)
233
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
242
234
  expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
243
235
  }
244
236
  })
245
237
 
238
+ // -------------------------------------------------------------------------
239
+ // Entity-aware chunking (#finding-3): a cut must never bisect an inline
240
+ // span (`**bold**`, `` `code` ``, `_italic_`, `[label](href)`), which would
241
+ // strand an unclosed delimiter in the emitted chunk.
242
+ // -------------------------------------------------------------------------
243
+
244
+ test('a bold/code/link span straddling the cap is not bisected (balanced delimiters)', () => {
245
+ // Build a body where each inline span sits right around a small cap so the
246
+ // naive space/newline cut would land inside it. Every span is shorter than
247
+ // the smallest cap tried (the longest is the 34-char link), so a straddling
248
+ // span can always be kept whole — the entity-aware back-off must do so.
249
+ const filler = 'x'.repeat(30)
250
+ const body =
251
+ `${filler} **bold span here** ` +
252
+ `${filler} \`code span here\` ` +
253
+ `${filler} [label here](https://ex.com/a-b-c) ` +
254
+ `${filler} _italic span here_ ${filler}`
255
+ for (const cap of [40, 50, 60, 70, 80]) {
256
+ const chunks = splitMarkdownChunks(body, cap)
257
+ for (const c of chunks) {
258
+ // Balanced `**` and single-backtick delimiters in every chunk.
259
+ expect((c.match(/\*\*/g) ?? []).length % 2).toBe(0)
260
+ expect((c.match(/`/g) ?? []).length % 2).toBe(0)
261
+ // No chunk ends mid-link (an open `](` with no closing `)`), and no
262
+ // chunk starts with an orphan link tail.
263
+ const opens = (c.match(/\]\(/g) ?? []).length
264
+ const closesAfterOpen = (c.match(/\]\([^)\n]*\)/g) ?? []).length
265
+ expect(opens).toBe(closesAfterOpen)
266
+ }
267
+ // Nothing is dropped.
268
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
269
+ expect(words(chunks.join(' '))).toEqual(words(body))
270
+ }
271
+ })
272
+
273
+ test('a `***bold-italic***` / `___…___` span straddling the cap keeps SINGLE-marker balance', () => {
274
+ // Regression for the triple-marker case: without the `***…***` pattern, the
275
+ // bold pattern matches only the inner `**…**`, so a cut inside `***x***`
276
+ // strands the lone outer `*` (odd asterisk count → the italic is lost). A
277
+ // `**`-PAIR balance check misses that, so assert SINGLE-`*` / SINGLE-`_`
278
+ // balance in every chunk.
279
+ const filler = 'x'.repeat(30)
280
+ const body =
281
+ `${filler} ***bold italic here*** ` +
282
+ `${filler} ___under bold here___ ${filler}`
283
+ for (const cap of [40, 50, 60, 70]) {
284
+ const chunks = splitMarkdownChunks(body, cap)
285
+ for (const c of chunks) {
286
+ // SINGLE-marker balance: an even count of `*` and of `_` in every chunk
287
+ // (a stranded lone `*`/`_` from a bisected triple span makes it odd).
288
+ expect((c.match(/\*/g) ?? []).length % 2).toBe(0)
289
+ expect((c.match(/_/g) ?? []).length % 2).toBe(0)
290
+ }
291
+ // Each triple span survives intact in exactly one chunk.
292
+ const rejoined = chunks.join('\n')
293
+ expect(rejoined).toContain('***bold italic here***')
294
+ expect(rejoined).toContain('___under bold here___')
295
+ // Nothing dropped.
296
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
297
+ expect(words(chunks.join(' '))).toEqual(words(body))
298
+ }
299
+ })
300
+
246
301
  test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
247
302
  const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
248
303
  const chunks = splitMarkdownChunks(text, 40)
@@ -31,9 +31,7 @@ import {
31
31
  normalizeParagraphBreaks,
32
32
  normalizePunctuation,
33
33
  stripExcessBold,
34
- addParagraphSpacers,
35
34
  splitMarkdownChunks,
36
- PARAGRAPH_SPACER,
37
35
  RICH_MESSAGE_MAX_CHARS,
38
36
  } from '../format.js'
39
37
 
@@ -130,7 +128,9 @@ describe('decideTurnFlush — prose+trailing-sentinel is suppressed, not leaked
130
128
  // the real gateway turn-flush render pipeline (post-#2669 rich-markdown path):
131
129
  // decideTurnFlush -> join('\n\n')
132
130
  // -> repairEscapedWhitespace -> normalizeParagraphBreaks
133
- // -> addParagraphSpacers -> splitMarkdownChunks -> sendRichMessage
131
+ // -> splitMarkdownChunks -> sendRichMessage
132
+ // (no paragraph-spacer pass — the NBSP spacer was removed in the #2669
133
+ // follow-up; gaps are plain `\n\n`, one visible blank line)
134
134
  // so it pins the end-to-end fix, not just the pure decision. The corpus is a
135
135
  // REAL captured-transcript shape (three separate content[i].text blocks, one
136
136
  // stored UNTRIMMED with a trailing '\n' exactly as session-tail.ts pushes
@@ -156,8 +156,7 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
156
156
  const d = decideTurnFlush({ chatId: '12345', replyCalled: false, capturedText: blocks })
157
157
  expect(d.kind).toBe('flush')
158
158
  const joined = (d as { kind: 'flush'; text: string }).text
159
- const normalized = normalizeParagraphBreaks(repairEscapedWhitespace(joined))
160
- return addParagraphSpacers(normalized)
159
+ return normalizeParagraphBreaks(repairEscapedWhitespace(joined))
161
160
  }
162
161
 
163
162
  it('separates whole blocks with a visible paragraph gap, not a wall-of-text', () => {
@@ -167,32 +166,29 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
167
166
  expect(out).toContain('The `auth` handler looks correct')
168
167
  expect(out).toContain('Want me to open a PR')
169
168
  // The wall-of-text failure mode glues two blocks one '\n' apart. Assert the
170
- // boundary carries a real paragraph break with the injected visible spacer
171
- // line (#2692 rich-path spacer), and NOT a single-newline join.
172
- expect(out).toContain(`\n\n${PARAGRAPH_SPACER}\n\n`)
169
+ // boundary carries a real paragraph break (plain `\n\n`, one blank line —
170
+ // no NBSP spacer), and NOT a single-newline join.
171
+ expect(out).toContain('flagged.\n\nThe `auth`')
173
172
  expect(out).not.toContain('flagged.\nThe `auth`')
174
- // A spacer sits specifically between block 1 and block 2.
175
- const b1 = out.indexOf('flagged.')
176
- const b2 = out.indexOf('The `auth` handler')
177
- expect(out.slice(b1, b2)).toContain(PARAGRAPH_SPACER)
173
+ // No U+00A0 anywhere.
174
+ expect(out).not.toContain(String.fromCharCode(0xa0))
178
175
  })
179
176
 
180
177
  it('collapses the untrimmed-trailing-newline stack — no 3+ newline run reaches the wire', () => {
181
178
  const out = renderLikeTurnFlush(realBlocks)
182
179
  // Block 2's trailing '\n' + the '\n\n' join = 3 newlines; normalize
183
- // collapses 3+ runs to '\n\n' and addParagraphSpacers wedges exactly one
184
- // spacer, so no doubled/stacked blank run survives.
180
+ // collapses 3+ runs to '\n\n', so no doubled/stacked blank run survives.
185
181
  expect(out).not.toMatch(/\n{3,}/)
186
- // One spacer per block transition: 3 blocks → 2 gaps → 2 spacers.
187
- const spacerCount = out.split(PARAGRAPH_SPACER).length - 1
188
- expect(spacerCount).toBe(2)
182
+ // One blank-line gap per block transition: 3 blocks → 2 gaps.
183
+ const gapCount = (out.match(/\n\n/g) ?? []).length
184
+ expect(gapCount).toBe(2)
189
185
  })
190
186
 
191
187
  it('the whole separated answer stays in one rich chunk here (well under 32768)', () => {
192
188
  const out = renderLikeTurnFlush(realBlocks)
193
189
  const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
194
190
  expect(chunks.length).toBe(1)
195
- expect(chunks[0]).toContain(PARAGRAPH_SPACER)
191
+ expect(chunks[0]).toContain('\n\n')
196
192
  })
197
193
 
198
194
  it('still SUPPRESSES a real transcript that deliberately terminates with a bare NO_REPLY (#2053 guard intact)', () => {
@@ -228,9 +224,9 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
228
224
  // runs (gateway executeReply):
229
225
  // repairEscapedWhitespace -> normalizeParagraphBreaks -> redactOutboundText
230
226
  // -> stripExcessBold(normalizePunctuation) -> scrubVoice
231
- // -> addParagraphSpacers (send side)
227
+ // (no send-side paragraph-spacer pass — removed in the #2669 follow-up).
232
228
  // The original #2798 change gave turn-flush the paragraph steps + redact +
233
- // scrub + spacers but OMITTED `stripExcessBold(normalizePunctuation(...))`.
229
+ // scrub but OMITTED `stripExcessBold(normalizePunctuation(...))`.
234
230
  // This suite reconstructs the deterministic format chain of BOTH paths (the
235
231
  // runtime-only redact + voice-scrub steps are literally the same calls on both
236
232
  // paths and are out of scope here) and pins that turn-flush now matches reply
@@ -312,7 +308,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
312
308
  function formatChain(text: string): string {
313
309
  let t = normalizeParagraphBreaks(repairEscapedWhitespace(text))
314
310
  t = stripExcessBold(normalizePunctuation(t))
315
- return addParagraphSpacers(t)
311
+ return t
316
312
  }
317
313
 
318
314
  const input =
@@ -197,9 +197,10 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
197
197
  // lone `\n` collapses adjacent blocks into one run — on the Bot API 10.1
198
198
  // rich-markdown path (#2669) a single newline is a soft break, so the blocks
199
199
  // render as an undifferentiated wall-of-text. `\n\n` is the GFM paragraph
200
- // separator; the gateway send path then wedges visible spacers into those
201
- // gaps via addParagraphSpacers (mirroring the reply path, #2692) so the
202
- // paragraphs render with real separation.
200
+ // separator; the Bot API 10.1 rich renderer shows it as one visible blank
201
+ // line, so the paragraphs render with real separation on their own (the
202
+ // former NBSP spacer pass was removed in the #2669 follow-up — it added a
203
+ // spurious second blank line).
203
204
  //
204
205
  // The silent-marker guards below are unaffected by this change:
205
206
  // isSilentFlushMarker length-guards the whole joined string; the composite /