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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +175 -220
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/gateway.ts +40 -17
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/turn-flush-safety.ts +4 -3
|
@@ -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
|
-
//
|
|
434
|
+
// Paragraph gap spacing — plain \n\n, no NBSP spacer (#2669 follow-up).
|
|
437
435
|
//
|
|
438
|
-
// The
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
// `\n\n`
|
|
443
|
-
//
|
|
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('
|
|
447
|
-
const
|
|
446
|
+
describe('paragraph gap spacing — single blank line, no NBSP', () => {
|
|
447
|
+
const NBSP = String.fromCharCode(0xa0)
|
|
448
448
|
|
|
449
|
-
test('
|
|
450
|
-
|
|
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('
|
|
454
|
-
|
|
455
|
-
|
|
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('
|
|
478
|
-
|
|
479
|
-
|
|
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('
|
|
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 =
|
|
535
|
-
|
|
536
|
-
expect(out).toContain(
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
581
|
-
*
|
|
582
|
-
*
|
|
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('
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
expect(
|
|
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('
|
|
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
|
-
|
|
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\
|
|
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('
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
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
|
|
201
|
-
const chunks = splitMarkdownChunks(
|
|
197
|
+
const text = `${A}.\n\n${B}.`
|
|
198
|
+
const chunks = splitMarkdownChunks(text, 33)
|
|
202
199
|
expect(chunks.length).toBeGreaterThan(1)
|
|
203
|
-
const
|
|
200
|
+
const blankOnly = /^[ \t]*$/
|
|
204
201
|
for (const c of chunks) {
|
|
205
202
|
const lines = c.split('\n')
|
|
206
|
-
expect(
|
|
207
|
-
expect(
|
|
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
|
|
215
|
-
const chunks = splitMarkdownChunks(
|
|
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('
|
|
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
|
|
224
|
-
const
|
|
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(
|
|
223
|
+
const chunks = splitMarkdownChunks(text, cap)
|
|
227
224
|
for (const c of chunks) {
|
|
228
225
|
const lines = c.split('\n')
|
|
229
|
-
expect(
|
|
230
|
-
expect(
|
|
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
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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
|
-
// ->
|
|
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
|
-
|
|
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
|
|
171
|
-
//
|
|
172
|
-
expect(out).toContain(
|
|
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
|
-
//
|
|
175
|
-
|
|
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'
|
|
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
|
|
187
|
-
const
|
|
188
|
-
expect(
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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 /
|