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.
- package/dist/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- 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 +5 -3
- 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/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- 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 +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- 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/outbound-send-path.test.ts +2 -0
- 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/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- 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 +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- 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(
|
|
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 ===).
|