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.
- package/dist/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- 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,
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
-
//
|
|
436
|
+
// addParagraphSpacers — restore a VISIBLE blank line between prose paragraphs.
|
|
435
437
|
//
|
|
436
|
-
// The
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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('
|
|
447
|
-
const
|
|
446
|
+
describe('addParagraphSpacers', () => {
|
|
447
|
+
const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
|
|
448
448
|
|
|
449
|
-
test('
|
|
450
|
-
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
477
|
+
test('spaces prose↔list 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('
|
|
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
|
-
|
|
482
|
-
expect(out).toContain(
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
526
|
-
*
|
|
527
|
-
*
|
|
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 =
|
|
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('
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
expect(
|
|
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(
|
|
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 ===).
|