switchroom 0.18.18 → 0.18.20

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 (45) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1131 -285
  7. package/telegram-plugin/dist/server.js +6 -0
  8. package/telegram-plugin/format.ts +208 -125
  9. package/telegram-plugin/gateway/cron-session.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +800 -107
  11. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  12. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  13. package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
  14. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  15. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  16. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  17. package/telegram-plugin/llm-error-present.ts +68 -30
  18. package/telegram-plugin/narrative-flush.ts +181 -0
  19. package/telegram-plugin/pending-work-progress.ts +65 -1
  20. package/telegram-plugin/session-tail.ts +6 -1
  21. package/telegram-plugin/silent-end.ts +182 -0
  22. package/telegram-plugin/subagent-watcher.ts +244 -81
  23. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  24. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  25. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  26. package/telegram-plugin/tests/format-consistency.test.ts +39 -4
  27. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
  30. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  31. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  32. package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +72 -4
  39. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  40. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  41. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  42. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  43. package/telegram-plugin/tool-activity-summary.ts +78 -16
  44. package/telegram-plugin/turn-flush-safety.ts +2 -1
  45. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -0,0 +1,213 @@
1
+ /**
2
+ * narrative-flush.test.ts — the time-boxed narrative early-paint kernel.
3
+ *
4
+ * Pins the deterministic behaviour of NarrativeFlushController (narrative-flush.ts):
5
+ * the timer half of the JSONL-text-narrative primitive. Drives the REAL kernel with
6
+ * a fake scheduler + effect spies (no real timers, no gateway I/O) so every
7
+ * assertion is an outcome, not a code-path.
8
+ *
9
+ * Coverage maps 1:1 to the fix's correctness requirements:
10
+ * 1. Early paint — a parked block SHOWs when the timer fires and NO lookahead
11
+ * followed (RED on pre-fix: pre-fix never armed a timer, so `show` only ever
12
+ * fired on the next event — here it fires with no lookahead at all).
13
+ * 2. Fast tool — a tool_use lookahead before the timer fires SHOWs exactly once
14
+ * and cancels the timer (no double-paint).
15
+ * 3. Anti-double-print, deferred path — a parked block that drafts the reply is
16
+ * SUPPRESSED (never shown) when the reply lands before the timer.
17
+ * 4. Anti-double-print, TIMER path — a block the timer already painted that
18
+ * later proves to draft the reply is RETRACTED (the guarantee holds even when
19
+ * the timer paints ahead of the reply).
20
+ * 5. Leak safety — turn_end and teardown disarm the timer; a stale fire is inert.
21
+ */
22
+
23
+ import { describe, it, expect, vi } from 'vitest'
24
+ import { NarrativeFlushController } from '../narrative-flush.js'
25
+
26
+ const FLUSH_MS = 250
27
+
28
+ /** Deterministic stand-in for the real `unref`'d setTimeout wiring. */
29
+ class FakeScheduler {
30
+ private fn: (() => void) | null = null
31
+ armCount = 0
32
+ disarmCount = 0
33
+ lastMs: number | null = null
34
+
35
+ arm(fn: () => void, ms: number): void {
36
+ // Real scheduler cancels any prior armed callback first (at-most-one).
37
+ this.fn = fn
38
+ this.lastMs = ms
39
+ this.armCount++
40
+ }
41
+ disarm(): void {
42
+ if (this.fn != null) this.disarmCount++
43
+ this.fn = null
44
+ }
45
+ /** Simulate the real timer elapsing. No-op once disarmed (mirrors clearTimeout). */
46
+ fire(): void {
47
+ const fn = this.fn
48
+ this.fn = null // a real one-shot timer is spent after firing
49
+ fn?.()
50
+ }
51
+ get isArmed(): boolean {
52
+ return this.fn != null
53
+ }
54
+ }
55
+
56
+ function makeController() {
57
+ const show = vi.fn<[string], void>()
58
+ const retractShown = vi.fn<[string], void>()
59
+ const scheduler = new FakeScheduler()
60
+ const ctrl = new NarrativeFlushController({ show, retractShown }, scheduler, FLUSH_MS)
61
+ return { ctrl, show, retractShown, scheduler }
62
+ }
63
+
64
+ describe('early paint — parked narration surfaces on the timer with no lookahead', () => {
65
+ it('does NOT paint on stage alone, then paints exactly once when the timer fires', () => {
66
+ const { ctrl, show, scheduler } = makeController()
67
+ ctrl.stage('On it, pulling the logs…')
68
+
69
+ // Pre-fix behaviour: nothing shown yet (deferred one lookahead step).
70
+ expect(show).not.toHaveBeenCalled()
71
+ // The fix ARMS a timer for exactly this case.
72
+ expect(scheduler.isArmed).toBe(true)
73
+ expect(scheduler.lastMs).toBe(FLUSH_MS)
74
+
75
+ scheduler.fire() // the agent thought past the window before its first tool
76
+ expect(show).toHaveBeenCalledTimes(1)
77
+ expect(show).toHaveBeenCalledWith('On it, pulling the logs…')
78
+ })
79
+ })
80
+
81
+ describe('fast tool — a lookahead before the window cancels the timer, paints once', () => {
82
+ it('SHOWs the working preamble once and disarms; a later stale fire is inert', () => {
83
+ const { ctrl, show, scheduler } = makeController()
84
+ ctrl.stage('Let me check the build…')
85
+ expect(scheduler.isArmed).toBe(true)
86
+
87
+ ctrl.resolveOnTool('Bash', { command: 'npm test' }) // real tool → SHOW
88
+ expect(show).toHaveBeenCalledTimes(1)
89
+ expect(show).toHaveBeenCalledWith('Let me check the build…')
90
+ expect(scheduler.isArmed).toBe(false) // timer cancelled
91
+ expect(scheduler.disarmCount).toBeGreaterThan(0)
92
+
93
+ scheduler.fire() // stale — must not double-paint
94
+ expect(show).toHaveBeenCalledTimes(1)
95
+ })
96
+ })
97
+
98
+ describe('anti-double-print (deferred path) — a draft-then-send reply is suppressed', () => {
99
+ it('never SHOWs a parked block that drafts the reply landing before the timer', () => {
100
+ const { ctrl, show, retractShown, scheduler } = makeController()
101
+ const answer = 'The build is green — all 412 tests pass.'
102
+ ctrl.stage(answer) // the model drafting its answer just before reply()
103
+
104
+ ctrl.resolveOnTool('reply', { text: answer }) // draft-then-send → SUPPRESS
105
+ expect(show).not.toHaveBeenCalled()
106
+ expect(retractShown).not.toHaveBeenCalled() // nothing was shown to retract
107
+ expect(scheduler.isArmed).toBe(false)
108
+ })
109
+
110
+ it('SHOWs a working preamble whose text DIFFERS from the reply', () => {
111
+ const { ctrl, show } = makeController()
112
+ ctrl.stage('Looking into it…')
113
+ ctrl.resolveOnTool('reply', { text: 'The answer is 42.' })
114
+ expect(show).toHaveBeenCalledTimes(1)
115
+ expect(show).toHaveBeenCalledWith('Looking into it…')
116
+ })
117
+ })
118
+
119
+ describe('anti-double-print (TIMER path) — a timer-painted draft is retracted', () => {
120
+ it('RETRACTS a block the timer already painted when the reply proves it a draft', () => {
121
+ const { ctrl, show, retractShown, scheduler } = makeController()
122
+ const answer = 'Done — deployed v0.16.51 to production, health checks green.'
123
+ ctrl.stage(answer)
124
+
125
+ scheduler.fire() // timer paints it EARLY (the reply hadn't arrived yet)
126
+ expect(show).toHaveBeenCalledTimes(1)
127
+ expect(show).toHaveBeenCalledWith(answer)
128
+
129
+ // The reply finally lands and IS that block → must be retracted, not left
130
+ // on the card to double-print against the canonical reply.
131
+ ctrl.resolveOnTool('stream_reply', { text: answer })
132
+ expect(retractShown).toHaveBeenCalledTimes(1)
133
+ expect(retractShown).toHaveBeenCalledWith(answer)
134
+ expect(show).toHaveBeenCalledTimes(1) // not re-shown
135
+ })
136
+
137
+ it('does NOT retract a timer-painted block when the reply differs (genuine narration)', () => {
138
+ const { ctrl, show, retractShown, scheduler } = makeController()
139
+ ctrl.stage('Still working — compiling the worker…')
140
+ scheduler.fire()
141
+ ctrl.resolveOnTool('reply', { text: 'Here are your results: …' })
142
+ expect(show).toHaveBeenCalledTimes(1)
143
+ expect(retractShown).not.toHaveBeenCalled()
144
+ })
145
+
146
+ it('retracts a timer-painted draft even when the reply only lands at turn_end', () => {
147
+ const { ctrl, show, retractShown, scheduler } = makeController()
148
+ const answer = 'All set — the migration ran cleanly.'
149
+ ctrl.stage(answer)
150
+ scheduler.fire()
151
+ ctrl.flushAtTurnEnd(answer) // reply delivered; trailing block is its draft
152
+ expect(retractShown).toHaveBeenCalledTimes(1)
153
+ expect(retractShown).toHaveBeenCalledWith(answer)
154
+ expect(show).toHaveBeenCalledTimes(1)
155
+ })
156
+ })
157
+
158
+ describe('turn_end — trailing narration is shown or suppressed, timer disarmed', () => {
159
+ it('SHOWs genuine trailing narration and disarms the timer', () => {
160
+ const { ctrl, show, scheduler } = makeController()
161
+ ctrl.stage('Done — all green.')
162
+ ctrl.flushAtTurnEnd('') // no reply delivered → genuine trailing narration
163
+ expect(show).toHaveBeenCalledTimes(1)
164
+ expect(show).toHaveBeenCalledWith('Done — all green.')
165
+ expect(scheduler.isArmed).toBe(false)
166
+ })
167
+
168
+ it('SUPPRESSes a trailing block that drafts the delivered answer', () => {
169
+ const { ctrl, show } = makeController()
170
+ const answer = 'The total came to $1,240.50 across the three invoices.'
171
+ ctrl.stage(answer)
172
+ ctrl.flushAtTurnEnd(answer)
173
+ expect(show).not.toHaveBeenCalled()
174
+ })
175
+ })
176
+
177
+ describe('leak safety — teardown and turn_end can never leave a live timer', () => {
178
+ it('teardown disarms; a stale fire after teardown is inert', () => {
179
+ const { ctrl, show, scheduler } = makeController()
180
+ ctrl.stage('Working…')
181
+ expect(scheduler.isArmed).toBe(true)
182
+
183
+ ctrl.teardown()
184
+ expect(scheduler.isArmed).toBe(false)
185
+ expect(scheduler.disarmCount).toBeGreaterThan(0)
186
+
187
+ scheduler.fire() // must not paint against a torn-down turn
188
+ expect(show).not.toHaveBeenCalled()
189
+ })
190
+
191
+ it('flushAtTurnEnd disarms the timer so it cannot fire post-turn', () => {
192
+ const { ctrl, show, scheduler } = makeController()
193
+ ctrl.stage('Half-done…')
194
+ ctrl.flushAtTurnEnd('') // shows it, and disarms
195
+ show.mockClear()
196
+ scheduler.fire() // stale
197
+ expect(show).not.toHaveBeenCalled()
198
+ })
199
+
200
+ it('re-staging cancels the prior block’s timer (at-most-one armed)', () => {
201
+ const { ctrl, show, scheduler } = makeController()
202
+ ctrl.stage('first line…')
203
+ ctrl.stage('second line…') // the new block is the lookahead for the first
204
+ // The first (pure narration) is shown immediately by the stage lookahead.
205
+ expect(show).toHaveBeenCalledTimes(1)
206
+ expect(show).toHaveBeenCalledWith('first line…')
207
+ // Exactly one live timer remains (for the second block).
208
+ expect(scheduler.isArmed).toBe(true)
209
+ scheduler.fire()
210
+ expect(show).toHaveBeenCalledTimes(2)
211
+ expect(show).toHaveBeenLastCalledWith('second line…')
212
+ })
213
+ })
@@ -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
+ })
@@ -56,6 +56,8 @@ function referenceNormalize(rawText: string): { text: string; voiceReplaced: num
56
56
  }
57
57
 
58
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.
59
61
  return literalText ? text : addParagraphSpacers(text)
60
62
  }
61
63
 
@@ -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 ===).