switchroom 0.18.19 → 0.18.21

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 (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -0,0 +1,167 @@
1
+ /**
2
+ * narrative-splice-before-finalize.test.ts — the gateway-level anti-double-print
3
+ * guarantee for the TIMER early-paint path.
4
+ *
5
+ * ## What this proves
6
+ * The kernel unit tests (narrative-flush.test.ts) prove the controller EMITS a
7
+ * RETRACT effect. They do NOT prove the load-bearing gateway wiring: that the
8
+ * RETRACT effect splices `mirrorLines` synchronously BEFORE the finalize render
9
+ * (`clearActivitySummary` → `composeTurnActivity(final)`) reads that same array,
10
+ * so a narration the timer painted early and that turned out to draft the reply
11
+ * appears ZERO times in the finalized card.
12
+ *
13
+ * This test reconstructs the gateway's OWN effect wiring 1:1 with
14
+ * `makeNarrativeGate` (gateway.ts): a shared `mirrorLines` array, a `show`
15
+ * effect that mirrors `showNarrativeStep`'s core
16
+ * (`appendActivityLabel(mirrorLines, clipNarrative(text))`), and a `retractShown`
17
+ * effect that mirrors `retractNarrativeLine`'s core (splice the
18
+ * `lastIndexOf(clipNarrative(text))` entry). The finalize render reads the SAME
19
+ * live array. It drives the REAL `NarrativeFlushController`, the REAL render
20
+ * helpers, and a REAL `setTimeout`-based scheduler under fake timers — every
21
+ * assertion is an outcome.
22
+ *
23
+ * ## Red-on-regression
24
+ * - If the RETRACT effect were removed / made a no-op, the timer-painted line
25
+ * stays in `mirrorLines` and the finalize render contains it → RED.
26
+ * - If the finalize render were reordered to read a snapshot taken BEFORE the
27
+ * retract splice (the "splice-after-finalize" reordering), the snapshot still
28
+ * holds the line → RED. (Modeled here by taking the finalize snapshot AFTER
29
+ * `resolveOnTool`, exactly as the gateway sequences retract-then-finalize.)
30
+ *
31
+ * ## Honest limits
32
+ * gateway.ts does NOT export `showNarrativeStep` / `retractNarrativeLine` /
33
+ * `composeTurnActivity` / `clearActivitySummary`, so this test cannot invoke
34
+ * those private functions directly — it reconstructs their effect bodies against
35
+ * the real shared helpers. It therefore locks the CONTRACT (splice-before-read,
36
+ * clipped-line matching, retract-empties-cleanly) but would not catch a future
37
+ * edit that diverges the gateway's private effect bodies from this reconstruction
38
+ * without also updating this test. The kernel test covers the controller; this
39
+ * test covers the mirrorLines splice/finalize contract they compose into.
40
+ */
41
+
42
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
43
+ import { NarrativeFlushController } from '../narrative-flush.js'
44
+ import {
45
+ clipNarrative,
46
+ appendActivityLabel,
47
+ renderActivityFeedWithNested,
48
+ } from '../tool-activity-summary.js'
49
+
50
+ const FLUSH_MS = 250
51
+
52
+ /** Count non-overlapping occurrences of `needle` in `haystack`. */
53
+ function occurrences(haystack: string, needle: string): number {
54
+ if (needle.length === 0) return 0
55
+ let count = 0
56
+ let from = 0
57
+ for (;;) {
58
+ const at = haystack.indexOf(needle, from)
59
+ if (at === -1) return count
60
+ count++
61
+ from = at + needle.length
62
+ }
63
+ }
64
+
65
+ describe('gateway wiring: retract splices mirrorLines BEFORE finalize reads it', () => {
66
+ beforeEach(() => vi.useFakeTimers())
67
+ afterEach(() => vi.useRealTimers())
68
+
69
+ /**
70
+ * Build the SAME effect wiring `makeNarrativeGate` builds, over a shared
71
+ * `mirrorLines` array, with a real setTimeout scheduler (mirrors the gateway's
72
+ * `unref`'d setTimeout wiring).
73
+ */
74
+ function wireGate() {
75
+ const mirrorLines: string[] = []
76
+ let handle: ReturnType<typeof setTimeout> | null = null
77
+ const controller = new NarrativeFlushController(
78
+ {
79
+ // showNarrativeStep core: append the clipped narrative as a feed line.
80
+ show: (text) => {
81
+ appendActivityLabel(mirrorLines, clipNarrative(text))
82
+ },
83
+ // retractNarrativeLine core: splice the clipped line out of mirrorLines.
84
+ retractShown: (text) => {
85
+ const clipped = clipNarrative(text)
86
+ const idx = mirrorLines.lastIndexOf(clipped)
87
+ if (idx === -1) return
88
+ mirrorLines.splice(idx, 1)
89
+ },
90
+ },
91
+ {
92
+ arm: (fn, ms) => {
93
+ if (handle != null) clearTimeout(handle)
94
+ handle = setTimeout(fn, ms)
95
+ handle.unref?.()
96
+ },
97
+ disarm: () => {
98
+ if (handle != null) {
99
+ clearTimeout(handle)
100
+ handle = null
101
+ }
102
+ },
103
+ },
104
+ FLUSH_MS,
105
+ )
106
+ // composeTurnActivity(final) core: render the SAME live mirrorLines array.
107
+ const finalize = () => renderActivityFeedWithNested(mirrorLines, [], true)
108
+ return { controller, mirrorLines, finalize }
109
+ }
110
+
111
+ it('early-painted narration that drafts the reply appears ZERO times in the finalized card', () => {
112
+ const { controller, mirrorLines, finalize } = wireGate()
113
+ const narration = 'The migration completed and all 40 rows were backfilled cleanly.'
114
+ const reply = 'The migration completed and all 40 rows were backfilled cleanly.'
115
+ const clipped = clipNarrative(narration)
116
+
117
+ // 1. Stage the opening narration (parked, timer armed).
118
+ controller.stage(narration)
119
+
120
+ // 2. Fire the early-paint timer → SHOW paints the line into mirrorLines.
121
+ vi.advanceTimersByTime(FLUSH_MS)
122
+ expect(mirrorLines).toContain(clipped)
123
+ // Sanity: before retract, a finalize WOULD have shown the line (proves the
124
+ // assertion below is meaningful, not vacuously passing on an empty feed).
125
+ expect(occurrences(finalize() ?? '', clipped)).toBe(1)
126
+
127
+ // 3. Deliver the matching reply (draft-then-send) → RETRACT splices the line.
128
+ controller.resolveOnTool('reply', { text: reply })
129
+
130
+ // 4. Finalize reads the SAME (now-spliced) array → line appears ZERO times.
131
+ const finalRender = finalize()
132
+ expect(occurrences(finalRender ?? '', clipped)).toBe(0)
133
+ })
134
+
135
+ it('a genuinely different early-painted narration survives finalize exactly once (no over-retract)', () => {
136
+ const { controller, mirrorLines, finalize } = wireGate()
137
+ const narration = 'Running the integration suite against staging'
138
+ const reply = 'All checks passed — deployed to production.'
139
+ const clipped = clipNarrative(narration)
140
+
141
+ controller.stage(narration)
142
+ vi.advanceTimersByTime(FLUSH_MS)
143
+ controller.resolveOnTool('reply', { text: reply })
144
+
145
+ // Not a draft of the reply → NOT retracted → present exactly once.
146
+ expect(mirrorLines).toContain(clipped)
147
+ expect(occurrences(finalize() ?? '', clipped)).toBe(1)
148
+ })
149
+
150
+ it('retract that empties the feed collapses composeTurnActivity to null (interim-ack anti-blank guard input)', () => {
151
+ // Documents the LOW cosmetic case: when the ONLY feed line is retracted, the
152
+ // live re-render collapses to null and the guard leaves the stale line up
153
+ // until the next event. Here we assert the *finalize* is clean (no double
154
+ // print) even though the interim live render would be null.
155
+ const { controller, mirrorLines, finalize } = wireGate()
156
+ const narration = 'Drafting the summary of the deploy'
157
+ const reply = 'Drafting the summary of the deploy'
158
+ controller.stage(narration)
159
+ vi.advanceTimersByTime(FLUSH_MS)
160
+ controller.resolveOnTool('reply', { text: reply })
161
+ expect(mirrorLines).toHaveLength(0)
162
+ // Empty feed → renderActivityFeedWithNested returns null (the anti-blank
163
+ // guard's null input). The finalized card carries the narration ZERO times.
164
+ const finalRender = finalize()
165
+ expect(finalRender == null || occurrences(finalRender, clipNarrative(narration)) === 0).toBe(true)
166
+ })
167
+ })
@@ -267,6 +267,26 @@ describe('nested (depth-2+) worker — end-to-end visibility harness', () => {
267
267
  expect(lastChild.text).not.toContain('starting…')
268
268
  expect(lastChild.text).toContain('index.ts')
269
269
 
270
+ // #3233 — DELIBERATE orchestrator-suppression behaviour. The depth-1
271
+ // 'depth-1 orchestrator' parent ran ZERO tools of its own: it only
272
+ // DISPATCHED the nested child. The skeleton first-paint cue (#3231) fires
273
+ // on every no-growth poll, so a naive implementation gives that pure
274
+ // orchestrator its OWN persistent "starting…" worker-feed row — redundant
275
+ // clutter, because the child's row already carries the liveness. The
276
+ // watcher suppresses the skeleton cue for any entry that has dispatched a
277
+ // child (parent_agent_id linkage), so the orchestrator NEVER earns a
278
+ // "starting…" row while its child provides the live signal. This is NOT a
279
+ // "0 own tools" rule (that would re-break the 205s-blackout incident this
280
+ // PR fixes — a leaf that blocks on its FIRST tool also has 0 completed
281
+ // tools and MUST still paint); it keys strictly on the parent/child link.
282
+ const allFeed = [...h.bot.sent, ...h.bot.edits]
283
+ const orchestratorStartingRows = allFeed.filter(
284
+ (m) => m.text.includes('depth-1 orchestrator') && m.text.includes('starting…'),
285
+ )
286
+ expect(orchestratorStartingRows.length).toBe(0)
287
+ // …while the child (a genuine leaf) DID surface real, live tool activity.
288
+ expect(childMsgs.some((m) => m.text.includes('index.ts'))).toBe(true)
289
+
270
290
  // More tool activity → climbing tool count, still live.
271
291
  h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'ls -la /repo' }))
272
292
  h.advance(1000)
@@ -4,6 +4,7 @@ import {
4
4
  normalizeParagraphBreaks,
5
5
  normalizePunctuation,
6
6
  stripExcessBold,
7
+ addParagraphSpacers,
7
8
  splitMarkdownChunks,
8
9
  hardSliceToCap,
9
10
  RICH_MESSAGE_MAX_CHARS,
@@ -54,10 +55,10 @@ function referenceNormalize(rawText: string): { text: string; voiceReplaced: num
54
55
  return { text, voiceReplaced }
55
56
  }
56
57
 
57
- function referenceEffectiveText(text: string, _literalText: boolean): string {
58
- // The NBSP paragraph-spacer pass was removed in the #2669 follow-up; both
59
- // paths now pass the normalized text through unchanged.
60
- return text
58
+ function referenceEffectiveText(text: string, literalText: boolean): string {
59
+ // Rich path injects the idempotent U+00A0 paragraph spacer (#2692, restored
60
+ // after the #3208 F1 misfire); the literal path stays byte-exact.
61
+ return literalText ? text : addParagraphSpacers(text)
61
62
  }
62
63
 
63
64
  function referenceChunks(
@@ -12,6 +12,8 @@ import { describe, test, expect } from 'vitest'
12
12
  import {
13
13
  normalizeParagraphBreaks,
14
14
  splitCollapsedInlineBullets,
15
+ addParagraphSpacers,
16
+ PARAGRAPH_SPACER,
15
17
  } from '../format.js'
16
18
 
17
19
  describe('normalizeParagraphBreaks', () => {
@@ -431,44 +433,96 @@ describe('splitCollapsedInlineBullets', () => {
431
433
  })
432
434
 
433
435
  // ---------------------------------------------------------------------------
434
- // Paragraph gap spacing plain \n\n, no NBSP spacer (#2669 follow-up).
436
+ // addParagraphSpacers restore a VISIBLE blank line between prose paragraphs.
435
437
  //
436
- // The NBSP paragraph-spacer (addParagraphSpacers / PARAGRAPH_SPACER) was
437
- // REMOVED: its premisethat 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.
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.
444
444
  // ---------------------------------------------------------------------------
445
445
 
446
- describe('paragraph gap spacing — single blank line, no NBSP', () => {
447
- const NBSP = String.fromCharCode(0xa0)
446
+ describe('addParagraphSpacers', () => {
447
+ const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
448
448
 
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.'])
449
+ test('the spacer is a single non-breaking space (U+00A0), not an ASCII space', () => {
450
+ expect(PARAGRAPH_SPACER).toBe(' ')
455
451
  })
456
452
 
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/)
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)
463
475
  })
464
476
 
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)
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
+ )
469
484
  })
470
485
 
471
- test('composed prose+list body: one blank line per gap, no NBSP, no double gap', () => {
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', () => {
472
526
  const input = [
473
527
  'Summary of the change.',
474
528
  '',
@@ -477,11 +531,12 @@ describe('paragraph gap spacing — single blank line, no NBSP', () => {
477
531
  '- adds a normalizer',
478
532
  '- lifts the cap',
479
533
  ].join('\n')
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/)
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`)
485
540
  })
486
541
  })
487
542
 
@@ -522,12 +577,12 @@ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
522
577
  * ragged / oversized gap: CommonMark discards it so it buys no visible space,
523
578
  * but it reads as noise in the raw text. Step 1 of normalizeParagraphBreaks now
524
579
  * collapses any run of blank lines — including whitespace-only interior lines —
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).
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.
528
583
  */
529
584
  describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug 2)', () => {
530
- const NBSP = String.fromCharCode(0xa0)
585
+ const NBSP = ' '
531
586
 
532
587
  test('a lone-space blank line between paragraphs collapses to a clean `\\n\\n` (real string)', () => {
533
588
  const input =
@@ -580,11 +635,14 @@ describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug
580
635
  expect(normalizeParagraphBreaks(input)).toBe(input)
581
636
  })
582
637
 
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)
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)
588
646
  })
589
647
 
590
648
  test('idempotent: collapsing a stray gap twice is stable', () => {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Durable anchor for the restored idempotent paragraph spacer (revert of
3
+ * #3208 F1). Two guarantees the fleet depends on:
4
+ *
5
+ * 1. GOLDEN — the EXACT outbound byte string for a representative message
6
+ * (bold header + two prose paragraphs + a `- ` list + a fenced code
7
+ * block + closing prose) is pinned, BOTH after the `addParagraphSpacers`
8
+ * pass AND after the in-repo IR renderer (`render/`) re-parses and
9
+ * re-renders it. Any future change to paragraph spacing must CONSCIOUSLY
10
+ * update this fixture — it can't drift silently. The wire byte string is
11
+ * byte-identical to the spacer-pass output, proving the U+00A0 spacer
12
+ * survives the live rich-render path (`SWITCHROOM_RICH_RENDER` default-on).
13
+ *
14
+ * 2. IDEMPOTENCY — the pass inserts EXACTLY ONE spacer per prose gap and can
15
+ * never stack a second: running it twice equals running it once, and a
16
+ * gap that already carries a spacer (or an extra blank line, or an
17
+ * ASCII-space-only line) is normalised to the single canonical spacer,
18
+ * not doubled. The "naive re-add" control below shows WHY the guard is
19
+ * load-bearing: a non-canonicalising spacer pass (insert on every `\n\n`)
20
+ * double-gaps on the second run — exactly the #3208 symptom. That control
21
+ * asserts the naive output DIVERGES, so this test would fail if the real
22
+ * pass ever lost its idempotency guard.
23
+ *
24
+ * Context: #3208 F1 deleted the spacer wholesale on the false premise that the
25
+ * Bot API 10.1 rich GFM renderer shows a bare `\n\n` gap as a visible blank
26
+ * line. Live evidence says the opposite — `\n\n` renders TIGHT, only the
27
+ * U+00A0 spacer produces a visible gap — so removal reintroduced the
28
+ * "paragraphs jammed together" symptom #2692 originally fixed. The correct fix
29
+ * for F1's real-but-narrow double-gap was to make the spacer idempotent (this),
30
+ * not to delete it.
31
+ */
32
+ import { describe, test, expect } from 'vitest'
33
+ import {
34
+ addParagraphSpacers,
35
+ normalizeParagraphBreaks,
36
+ PARAGRAPH_SPACER,
37
+ } from '../format.js'
38
+ import { renderOutbound, renderOutboundChunks } from '../render/rich-render.js'
39
+
40
+ const SP = PARAGRAPH_SPACER // U+00A0
41
+
42
+ // A representative multi-construct message: a **bold** header, two prose
43
+ // paragraphs, a `- ` list, a fenced code block, and closing prose.
44
+ const REPRESENTATIVE = [
45
+ '**Status report**',
46
+ '',
47
+ 'First paragraph of prose explaining the situation in some detail.',
48
+ '',
49
+ 'Second paragraph that continues the explanation across a gap.',
50
+ '',
51
+ '- first bullet item',
52
+ '- second bullet item',
53
+ '',
54
+ '```js',
55
+ 'const x = 1',
56
+ '```',
57
+ '',
58
+ 'Closing prose after the code block.',
59
+ ].join('\n')
60
+
61
+ // The EXACT expected outbound byte string. Every DISTINCT-block boundary
62
+ // carries exactly one U+00A0 spacer line; the list interior stays tight and the
63
+ // fenced block is byte-for-byte verbatim. Update this fixture ONLY with a
64
+ // conscious decision to change paragraph spacing.
65
+ const GOLDEN =
66
+ `**Status report**\n\n${SP}\n\n` +
67
+ `First paragraph of prose explaining the situation in some detail.\n\n${SP}\n\n` +
68
+ `Second paragraph that continues the explanation across a gap.\n\n${SP}\n\n` +
69
+ `- first bullet item\n- second bullet item\n\n${SP}\n\n` +
70
+ '```js\nconst x = 1\n```' +
71
+ `\n\n${SP}\n\n` +
72
+ 'Closing prose after the code block.'
73
+
74
+ describe('paragraph spacer — golden outbound byte string', () => {
75
+ test('addParagraphSpacers output matches the golden fixture exactly', () => {
76
+ const eff = addParagraphSpacers(normalizeParagraphBreaks(REPRESENTATIVE))
77
+ expect(eff).toBe(GOLDEN)
78
+ })
79
+
80
+ test('the golden survives the single-piece IR renderer byte-for-byte', () => {
81
+ // The renderer (default-on) re-parses and re-renders; a U+00A0-only line is
82
+ // a genuine paragraph that round-trips intact, so the visible gap reaches
83
+ // Telegram. (An ASCII-space-only line, by contrast, would be collapsed.)
84
+ // This is the sub-cap single-piece case (`renderOutbound`); the live-path
85
+ // assertion below proves the same through the ACTUAL send transform.
86
+ const wire = renderOutbound(GOLDEN).text
87
+ expect(wire).toBe(GOLDEN)
88
+ })
89
+
90
+ test('the golden reaches the wire through the LIVE send path (renderOutboundChunks) byte-for-byte', () => {
91
+ // The live send path (stream-controller.ts) uses `renderOutboundChunks`, not
92
+ // the single-piece `renderOutbound`. For a sub-cap body it returns exactly
93
+ // one `markdown` piece; the joined piece text must preserve the U+00A0
94
+ // spacer byte-for-byte, so the visible paragraph gaps reach Telegram on the
95
+ // real path — not merely in the single-piece renderer. This is the load-
96
+ // bearing anchor against a third flip of the paragraph-spacing behaviour.
97
+ const pieces = renderOutboundChunks(GOLDEN)
98
+ expect(pieces).toHaveLength(1) // sub-cap → single deliverable piece
99
+ expect(pieces[0].mode).toBe('markdown')
100
+ const joined = pieces.map((p) => p.text).join('')
101
+ expect(joined).toBe(GOLDEN)
102
+ // The U+00A0 spacer is present and never doubled at any boundary.
103
+ expect(joined).toContain(`\n\n${SP}\n\n`)
104
+ expect(joined).not.toContain(`${SP}\n\n${SP}`)
105
+ })
106
+
107
+ // Multi-chunk boundary safety (a body that exceeds the cap and splits, with no
108
+ // spacer stranded or duplicated at a chunk boundary) is covered by
109
+ // telegram-format.test.ts → 'no chunk starts or ends with a bare U+00A0
110
+ // spacer line (reviewer repro)' and 'spacer-boundary strip is robust across
111
+ // several gaps and small caps'. Not duplicated here.
112
+ })
113
+
114
+ describe('paragraph spacer — idempotency guard', () => {
115
+ test('running the pass twice equals running it once', () => {
116
+ const once = addParagraphSpacers(normalizeParagraphBreaks(REPRESENTATIVE))
117
+ const twice = addParagraphSpacers(once)
118
+ expect(twice).toBe(once)
119
+ })
120
+
121
+ test('a gap that already carries a spacer is not double-spaced', () => {
122
+ const already = `Alpha.\n\n${SP}\n\nBravo.`
123
+ expect(addParagraphSpacers(already)).toBe(already)
124
+ // No gap ever grows a SECOND spacer line.
125
+ expect(addParagraphSpacers(already)).not.toContain(`${SP}\n\n${SP}`)
126
+ })
127
+
128
+ test('a stray extra blank line / ASCII-space line is normalised, never stacked', () => {
129
+ // Triple newline, and an ASCII-space-only spacer line, both canonicalise to
130
+ // the single U+00A0 spacer — not to a spacer PLUS an extra blank.
131
+ expect(addParagraphSpacers('Alpha.\n\n\nBravo.')).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
132
+ expect(addParagraphSpacers('Alpha.\n\n \n\nBravo.')).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
133
+ })
134
+
135
+ test('CONTROL: a naive non-canonicalising spacer pass double-gaps on re-run (proves the guard is load-bearing)', () => {
136
+ // A naive implementation inserts a spacer at EVERY `\n\n` with no guard.
137
+ // First run is fine; the SECOND run sees the spacer's own `\n\n` gaps and
138
+ // wedges more spacers, stacking blank lines — the #3208 double-gap. This
139
+ // control asserts the naive pass DIVERGES from idempotency, so if the real
140
+ // addParagraphSpacers ever regressed to naive behaviour the idempotency
141
+ // test above would start failing rather than silently passing.
142
+ const naive = (t: string): string => t.replace(/\n\n/g, `\n\n${SP}\n\n`)
143
+ const once = naive('Alpha.\n\nBravo.')
144
+ const twice = naive(once)
145
+ expect(twice).not.toBe(once)
146
+ // And the real pass does NOT behave like the naive one on re-run.
147
+ const realOnce = addParagraphSpacers('Alpha.\n\nBravo.')
148
+ expect(addParagraphSpacers(realOnce)).toBe(realOnce)
149
+ })
150
+ })
@@ -60,8 +60,11 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
60
60
  })
61
61
 
62
62
  it('endCurrentTurnAtomic closes the leak AT ORIGIN — keyed liveness guard + keyed delete', () => {
63
+ // Anchor on the `function ` prefix only — the signature is multi-line
64
+ // (gained an `opts?` param + `number | null` return in the send-honesty
65
+ // work), and `function ` disambiguates the definition from its call sites.
63
66
  const body = gatewaySrc
64
- .split('function endCurrentTurnAtomic(turn: CurrentTurn): void {')[1]
67
+ .split('function endCurrentTurnAtomic(')[1]
65
68
  ?.split('\n}')[0] ?? ''
66
69
  expect(body.length).toBeGreaterThan(50)
67
70
  // Guard is the keyed liveness check (NOT a bare singleton ===).