switchroom 0.18.19 → 0.18.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -0,0 +1,343 @@
1
+ /**
2
+ * PR A — answer-ready quiescence flush (late-delivery fix).
3
+ *
4
+ * The gateway delivers a toolless terminal-text answer either on claude's
5
+ * (unreliable) `turn_duration`/turn_end, or — when that never lands — on the
6
+ * orphaned-reply backstop, which the answer text itself keeps re-arming across
7
+ * a ~150 s window. PR A adds a DETERMINISTIC ~1 s quiescence flush.
8
+ *
9
+ * Two layers of coverage:
10
+ * 1. The pure predicate `shouldArmAnswerReadyFlush` (which calls the REAL
11
+ * `decideTurnFlush` classifier + a REAL `ToolFlightTracker`).
12
+ * 2. An INTEGRATION harness that drives the REAL orchestration —
13
+ * `AnswerReadyFlushController` (the exact arm / debounce / rollover-guard /
14
+ * fire-time-re-verify / disarm code the gateway runs, extracted so it is
15
+ * testable, mirroring how `turn-end-gate-backstop.test.ts` drives the real
16
+ * `withTurnEndGateBackstop`). The harness supplies a faithful gateway world
17
+ * (a mutable `currentTurn`, a real `ToolFlightTracker`, and an
18
+ * `endCurrentTurnAtomic` that — like the real one — disarms via
19
+ * `controller.clear(turn)` and NULLS the atom) plus a send-sink that models
20
+ * `handleSessionEvent({turn_end})` short-circuiting on a null atom.
21
+ *
22
+ * The oracle asserts OUTCOMES on real code: the flush fires at the ~1 s debounce
23
+ * (not ~150 s), re-arms per text chunk, and delivers EXACTLY ONCE across both
24
+ * turn_end race orderings. It is designed to go RED if the controller's
25
+ * rollover guard, fire-time re-verify, or disarm were removed (see the
26
+ * red-when-removed proofs in the handback).
27
+ */
28
+
29
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
30
+ import {
31
+ shouldArmAnswerReadyFlush,
32
+ resolveAnswerReadyFlushMs,
33
+ ANSWER_READY_FLUSH_MS,
34
+ AnswerReadyFlushController,
35
+ type AnswerReadyArmInput,
36
+ type FlushTimerHandle,
37
+ } from '../answer-ready-flush.js'
38
+ import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
39
+
40
+ const CHAT = '12345'
41
+ const WINDOW = 1000
42
+ // The orphaned-reply backstop's real dead-wait ceiling — the wall-clock the
43
+ // flush must beat. Used to prove the flush fires ~1 s, not ~150 s.
44
+ const BACKSTOP_MS = 150_000
45
+
46
+ function armInput(over: Partial<AnswerReadyArmInput> = {}): AnswerReadyArmInput {
47
+ return {
48
+ flush: {
49
+ chatId: CHAT,
50
+ replyCalled: false,
51
+ capturedText: ['Here is the composed final answer to your question.'],
52
+ flushEnabled: true,
53
+ },
54
+ inFlightToolCount: 0,
55
+ hasPendingAsyncDispatch: false,
56
+ flushWindowMs: WINDOW,
57
+ ...over,
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Pure predicate: shouldArmAnswerReadyFlush (real decideTurnFlush classifier)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('shouldArmAnswerReadyFlush (arm/fire predicate)', () => {
66
+ it('arms on a genuine composed terminal answer that is quiescent', () => {
67
+ expect(shouldArmAnswerReadyFlush(armInput())).toBe(true)
68
+ })
69
+
70
+ it('does NOT arm while a tool is in flight (not quiescent)', () => {
71
+ expect(shouldArmAnswerReadyFlush(armInput({ inFlightToolCount: 1 }))).toBe(false)
72
+ })
73
+
74
+ it('does NOT arm while a background/async dispatch is pending', () => {
75
+ expect(shouldArmAnswerReadyFlush(armInput({ hasPendingAsyncDispatch: true }))).toBe(false)
76
+ })
77
+
78
+ it('does NOT arm once the reply tool has served the turn (reply-called)', () => {
79
+ expect(
80
+ shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: true, capturedText: ['x'], flushEnabled: true } })),
81
+ ).toBe(false)
82
+ })
83
+
84
+ it('does NOT arm on a genuine NO_REPLY turn (silent marker → no spurious flush)', () => {
85
+ expect(
86
+ shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: ['NO_REPLY'], flushEnabled: true } })),
87
+ ).toBe(false)
88
+ })
89
+
90
+ it('does NOT arm on an empty terminal turn (nothing to send)', () => {
91
+ expect(
92
+ shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: [], flushEnabled: true } })),
93
+ ).toBe(false)
94
+ })
95
+
96
+ it('does NOT arm on prose that ends with a trailing silent marker', () => {
97
+ expect(
98
+ shouldArmAnswerReadyFlush(
99
+ armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: ['Some prose the model wrote.', 'NO_REPLY'], flushEnabled: true } }),
100
+ ),
101
+ ).toBe(false)
102
+ })
103
+
104
+ it('does NOT arm for a system/sub-agent turn (no inbound chat)', () => {
105
+ expect(
106
+ shouldArmAnswerReadyFlush(armInput({ flush: { chatId: null, replyCalled: false, capturedText: ['answer'], flushEnabled: true } })),
107
+ ).toBe(false)
108
+ })
109
+
110
+ it('does NOT arm when the kill-switch disables the flush (window <= 0)', () => {
111
+ expect(shouldArmAnswerReadyFlush(armInput({ flushWindowMs: 0 }))).toBe(false)
112
+ expect(shouldArmAnswerReadyFlush(armInput({ flushWindowMs: -1 }))).toBe(false)
113
+ })
114
+
115
+ it('does NOT arm when the turn-flush safety flag is off', () => {
116
+ expect(
117
+ shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: ['answer'], flushEnabled: false } })),
118
+ ).toBe(false)
119
+ })
120
+ })
121
+
122
+ describe('resolveAnswerReadyFlushMs', () => {
123
+ it('defaults to ANSWER_READY_FLUSH_MS when unset', () => {
124
+ expect(resolveAnswerReadyFlushMs({})).toBe(ANSWER_READY_FLUSH_MS)
125
+ expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '' })).toBe(ANSWER_READY_FLUSH_MS)
126
+ })
127
+
128
+ it('parses a positive override', () => {
129
+ expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '1500' })).toBe(1500)
130
+ })
131
+
132
+ it('treats 0 (and negatives) as the kill-switch → disabled (0)', () => {
133
+ expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '0' })).toBe(0)
134
+ expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '-5' })).toBe(0)
135
+ })
136
+
137
+ it('fails safe to the default on an unparseable value', () => {
138
+ expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: 'nonsense' })).toBe(ANSWER_READY_FLUSH_MS)
139
+ })
140
+
141
+ it('ANSWER_READY_FLUSH_MS is ~1 s ("immediate")', () => {
142
+ expect(ANSWER_READY_FLUSH_MS).toBe(1000)
143
+ })
144
+ })
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Integration harness — drives the REAL AnswerReadyFlushController. Only the
148
+ // gateway world (currentTurn storage, the send sink, endCurrentTurnAtomic) is
149
+ // modeled; the arm / debounce / rollover-guard / fire-time-re-verify / disarm
150
+ // is the controller's REAL code. Mirrors turn-end-gate-backstop.test.ts driving
151
+ // the real withTurnEndGateBackstop.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ interface FakeTurn {
155
+ capturedText: string[]
156
+ replyCalled: boolean
157
+ answerReadyFlushTimeoutId: FlushTimerHandle | null
158
+ }
159
+
160
+ /**
161
+ * A faithful gateway world around the REAL controller. `sends` / `records`
162
+ * count what actually reached the user / turns.jsonl. `endCurrentTurnAtomic`
163
+ * mirrors the real one: it DISARMS via `controller.clear(turn)` and NULLS the
164
+ * atom. `dispatchTurnEnd` mirrors `handleSessionEvent({turn_end})`: it
165
+ * short-circuits when the atom is already gone (the exactly-once guarantee),
166
+ * else delivers once and runs `endCurrentTurnAtomic`.
167
+ */
168
+ class GatewayWorld {
169
+ currentTurn: FakeTurn | null
170
+ flight = new ToolFlightTracker()
171
+ pendingAsync = false
172
+ sends = 0
173
+ records = 0
174
+ window = WINDOW
175
+ readonly controller: AnswerReadyFlushController<FakeTurn>
176
+
177
+ constructor() {
178
+ this.currentTurn = { capturedText: [], replyCalled: false, answerReadyFlushTimeoutId: null }
179
+ this.controller = new AnswerReadyFlushController<FakeTurn>({
180
+ getCurrentTurn: () => this.currentTurn,
181
+ getArmInput: (turn) => ({
182
+ flush: { chatId: CHAT, replyCalled: turn.replyCalled, capturedText: turn.capturedText, flushEnabled: true },
183
+ inFlightToolCount: this.flight.inFlightCount(),
184
+ hasPendingAsyncDispatch: this.pendingAsync,
185
+ flushWindowMs: this.window,
186
+ }),
187
+ getTimerHandle: (turn) => turn.answerReadyFlushTimeoutId,
188
+ setTimerHandle: (turn, handle) => {
189
+ turn.answerReadyFlushTimeoutId = handle
190
+ },
191
+ // The controller's single delivery action routes into the world's
192
+ // turn_end dispatch — exactly as the gateway's onFlush dispatches a
193
+ // synthetic turn_end into handleSessionEvent.
194
+ onFlush: () => this.dispatchTurnEnd(),
195
+ })
196
+ }
197
+
198
+ /** = endCurrentTurnAtomic: DISARM (real controller.clear) + NULL the atom. */
199
+ private endCurrentTurnAtomic(turn: FakeTurn): void {
200
+ this.controller.clear(turn) // ← the load-bearing disarm (gateway.ts:~4818)
201
+ this.records++
202
+ this.currentTurn = null // ← the load-bearing atom-null (#1067)
203
+ }
204
+
205
+ /** = handleSessionEvent({turn_end}). A null atom short-circuits (no second
206
+ * send); otherwise deliver once and tear the turn down. */
207
+ dispatchTurnEnd(): void {
208
+ const turn = this.currentTurn
209
+ if (turn == null) return // atom already gone → exactly-once
210
+ this.sends++
211
+ this.endCurrentTurnAtomic(turn)
212
+ }
213
+
214
+ /** = case 'text': push the chunk, then (re)arm via the REAL controller. */
215
+ onText(chunk: string): void {
216
+ if (this.currentTurn != null) this.currentTurn.capturedText.push(chunk)
217
+ this.controller.reset()
218
+ }
219
+
220
+ /** = case 'tool_use' / 'tool_label': disarm (real controller.clear) then track. */
221
+ onToolUse(id: string): void {
222
+ this.controller.clear(this.currentTurn)
223
+ this.flight.onEvent({ kind: 'tool_use', toolUseId: id })
224
+ }
225
+
226
+ onToolResult(id: string): void {
227
+ this.flight.onEvent({ kind: 'tool_result', toolUseId: id })
228
+ }
229
+ }
230
+
231
+ describe('answer-ready quiescence flush — real controller integration', () => {
232
+ beforeEach(() => vi.useFakeTimers())
233
+ afterEach(() => vi.useRealTimers())
234
+
235
+ it('flushes the composed answer at the ~1 s debounce, NOT at the ~150 s backstop', () => {
236
+ const w = new GatewayWorld()
237
+ w.onText('The composed final answer.')
238
+ // Nothing before the debounce elapses.
239
+ vi.advanceTimersByTime(WINDOW - 1)
240
+ expect(w.sends).toBe(0)
241
+ // Fires deterministically at the debounce.
242
+ vi.advanceTimersByTime(1)
243
+ expect(w.sends).toBe(1)
244
+ expect(w.records).toBe(1) // exactly one turns.jsonl record
245
+ // And it did NOT wait for the multi-minute backstop.
246
+ expect(vi.getTimerCount()).toBe(0)
247
+ vi.advanceTimersByTime(BACKSTOP_MS)
248
+ expect(w.sends).toBe(1) // still exactly one — proves it beat ~150 s
249
+ })
250
+
251
+ it('each text chunk re-arms the debounce (only fires 1 s after the LAST chunk)', () => {
252
+ const w = new GatewayWorld()
253
+ w.onText('Part one. ')
254
+ vi.advanceTimersByTime(500)
255
+ expect(w.sends).toBe(0)
256
+ w.onText('Part two — the rest of the answer.') // re-arms
257
+ vi.advanceTimersByTime(999)
258
+ expect(w.sends).toBe(0) // debounce reset by the 2nd chunk
259
+ vi.advanceTimersByTime(1)
260
+ expect(w.sends).toBe(1)
261
+ })
262
+
263
+ it('EXACTLY-ONCE (a): quiescence flush, THEN a real turn_end → still one send', () => {
264
+ const w = new GatewayWorld()
265
+ w.onText('Answer text.')
266
+ vi.advanceTimersByTime(WINDOW)
267
+ expect(w.sends).toBe(1) // quiescence flushed + nulled the atom
268
+ // The real turn_end finally lands — it must short-circuit on the null atom.
269
+ // (Goes red if the atom-null in endCurrentTurnAtomic were removed.)
270
+ w.dispatchTurnEnd()
271
+ expect(w.sends).toBe(1)
272
+ expect(w.records).toBe(1)
273
+ })
274
+
275
+ it('EXACTLY-ONCE (b): a real turn_end BEFORE the debounce disarms the pending flush', () => {
276
+ const w = new GatewayWorld()
277
+ w.onText('Answer text.')
278
+ vi.advanceTimersByTime(WINDOW - 100)
279
+ // turn_end arrives first → delivers once and (via controller.clear in
280
+ // endCurrentTurnAtomic) cancels the pending flush timer.
281
+ // (Goes red if controller.clear no longer cleared the timer.)
282
+ w.dispatchTurnEnd()
283
+ expect(w.sends).toBe(1)
284
+ expect(vi.getTimerCount()).toBe(0) // the pending quiescence timer was cleared
285
+ vi.advanceTimersByTime(BACKSTOP_MS)
286
+ expect(w.sends).toBe(1) // the stale timer never fired a second send
287
+ expect(w.records).toBe(1)
288
+ })
289
+
290
+ it('does NOT flush while a tool call is in flight (only on genuine quiescence)', () => {
291
+ const w = new GatewayWorld()
292
+ w.onText('Interim thought before a tool.')
293
+ // A tool starts within the window → disarm.
294
+ vi.advanceTimersByTime(400)
295
+ w.onToolUse('bash_1')
296
+ vi.advanceTimersByTime(BACKSTOP_MS)
297
+ expect(w.sends).toBe(0) // never fired while (and after) the tool was open
298
+ expect(vi.getTimerCount()).toBe(0)
299
+ })
300
+
301
+ it('re-arms and flushes once the tool completes and new answer text settles', () => {
302
+ const w = new GatewayWorld()
303
+ w.onText('Working on it.')
304
+ w.onToolUse('bash_1') // disarm
305
+ vi.advanceTimersByTime(2000)
306
+ expect(w.sends).toBe(0)
307
+ w.onToolResult('bash_1') // tool done → quiescent again
308
+ w.onText(' Here is the final composed answer.') // new text re-arms
309
+ vi.advanceTimersByTime(WINDOW)
310
+ expect(w.sends).toBe(1)
311
+ })
312
+
313
+ it('fire-time re-verify: a tool in flight at expiry cancels the flush even if disarm was missed', () => {
314
+ const w = new GatewayWorld()
315
+ w.onText('Answer text.')
316
+ // Simulate a missed explicit disarm: a tool becomes in-flight directly,
317
+ // WITHOUT going through onToolUse (which would clear the timer).
318
+ w.flight.onEvent({ kind: 'tool_use', toolUseId: 'sneaky' })
319
+ vi.advanceTimersByTime(WINDOW)
320
+ // (Goes red if the controller's fire-time re-verification were removed.)
321
+ expect(w.sends).toBe(0)
322
+ })
323
+
324
+ it('rollover guard: a superseded turn does not fire against a fresh atom', () => {
325
+ const w = new GatewayWorld()
326
+ const armed = w.currentTurn!
327
+ w.onText('Answer for turn A.') // arms A's timer
328
+ // A new turn swaps in before the debounce (turn rollover).
329
+ w.currentTurn = { capturedText: ['Turn B is still working'], replyCalled: false, answerReadyFlushTimeoutId: null }
330
+ expect(w.currentTurn).not.toBe(armed)
331
+ vi.advanceTimersByTime(WINDOW)
332
+ // (Goes red if the controller's rollover guard `live !== armedTurn` were removed.)
333
+ expect(w.sends).toBe(0)
334
+ })
335
+
336
+ it('a NO_REPLY turn never arms, so it flushes nothing (records no_reply upstream)', () => {
337
+ const w = new GatewayWorld()
338
+ w.onText('NO_REPLY')
339
+ vi.advanceTimersByTime(BACKSTOP_MS)
340
+ expect(w.sends).toBe(0)
341
+ expect(vi.getTimerCount()).toBe(0)
342
+ })
343
+ })
@@ -0,0 +1,54 @@
1
+ /**
2
+ * #3114 — a cron fire must NOT warm the MAIN session's idle-clear clock.
3
+ *
4
+ * Root cause (confirmed live): `onInjectInbound` stamped the main idle clock
5
+ * UNCONDITIONALLY at inject time. A cron cadence shorter than
6
+ * `idle_clear_after` re-armed the timer on every fire, so idle-clear was
7
+ * permanently suppressed for any agent with a frequent scheduled task —
8
+ * including cheap-cron fires routed to the derived `<agent>-cron` bridge,
9
+ * whose session events never reach the main clock at all.
10
+ *
11
+ * This covers the pure identity predicate `isCronInjectFire` — which inject
12
+ * fires warm the main clock. It did not exist on main → import fails there.
13
+ *
14
+ * The gateway WIRING (that `onInjectInbound` gates the stamp on
15
+ * `!isCronInjectFire(...)`, that a cron cadence shorter than the window no
16
+ * longer re-arms the clock, and that a human inbound still warms it) is now
17
+ * covered BEHAVIOURALLY in `idle-clear.test.ts` — the
18
+ * `IdleTracker #3114` block drives the REAL predicate + the REAL `IdleTracker`
19
+ * through the exact gateway rule. #3115 extracted the idle bookkeeping into an
20
+ * importable object, so that block replaces the brittle source-text wiring pin
21
+ * this file used to carry (a regression in the real stamp logic now fails a
22
+ * behavioural assertion, not just a string-match over gateway.ts).
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest'
26
+ import { isCronInjectFire } from '../gateway/cron-session.js'
27
+
28
+ describe('#3114 isCronInjectFire — which inject fires warm the main idle clock', () => {
29
+ it('a cheap-cron fire (routed to the derived <agent>-cron bridge) does NOT warm the clock', () => {
30
+ // Tier-1: scheduler tags meta.session='cron' → resolveInjectTarget sends it
31
+ // to `<agent>-cron`; its session events are dropped for the cron identity.
32
+ expect(isCronInjectFire({ session: 'cron', source: 'cron' })).toBe(true)
33
+ expect(isCronInjectFire({ session: 'cron' })).toBe(true)
34
+ })
35
+
36
+ it('a Tier-2 (main-session) cron fire does NOT warm the clock at inject time', () => {
37
+ // Tier-2 lands on the main bridge but is still a scheduled fire — the
38
+ // inject-time stamp is suppressed (its real turn stamps via session events;
39
+ // that is the documented residual, not the inject-time path).
40
+ expect(isCronInjectFire({ source: 'cron' })).toBe(true)
41
+ })
42
+
43
+ it('a genuine operator/session inject (reaction, vault grant, resume) DOES warm the clock', () => {
44
+ expect(isCronInjectFire({ source: 'reaction' })).toBe(false)
45
+ expect(isCronInjectFire({ source: 'vault_grant_approved' })).toBe(false)
46
+ expect(isCronInjectFire({ source: 'resume' })).toBe(false)
47
+ })
48
+
49
+ it('a bare / manual inject with no cron marker DOES warm the clock', () => {
50
+ expect(isCronInjectFire(undefined)).toBe(false)
51
+ expect(isCronInjectFire({})).toBe(false)
52
+ expect(isCronInjectFire({ prompt_key: 'x' })).toBe(false)
53
+ })
54
+ })
@@ -370,13 +370,15 @@ describe('the drain sites route through the façade with producers preserved ver
370
370
 
371
371
  it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
372
372
  const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
373
- // narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness
374
- // (Fix 2) = 7. The Phase-1 0-label climb (deterministic-turn-liveness.md)
375
- // consults the SAME guard, but its `if (deps.mayDrain())` lives in the
376
- // extracted tick body (feed-heartbeat-climb.ts) with `() => ea.mayDrain(turn)`
377
- // injected at the gateway call site pinned by
378
- // feed-heartbeat-liveness-open.test.ts + silent-turn-climb-transport.test.ts.
379
- expect(mayDrainGuards).toHaveLength(7)
373
+ // narrative SHOW + narrative RETRACT (timer-flush early-paint retract,
374
+ // narrative-flush.ts) + 2 liveness + tool + 2 sub-agent + 1 post-answer
375
+ // bg-liveness (Fix 2) = 8. The Phase-1 0-label climb
376
+ // (deterministic-turn-liveness.md) consults the SAME guard, but its
377
+ // `if (deps.mayDrain())` lives in the extracted tick body
378
+ // (feed-heartbeat-climb.ts) with `() => ea.mayDrain(turn)` injected at the
379
+ // gateway call site — pinned by feed-heartbeat-liveness-open.test.ts +
380
+ // silent-turn-climb-transport.test.ts.
381
+ expect(mayDrainGuards).toHaveLength(8)
380
382
  expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
381
383
  })
382
384
  })
@@ -498,11 +500,12 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
498
500
  })
499
501
 
500
502
  it('the card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
501
- // Option A: the 7 `if (ea.mayDrain(turn))` guards + drainActivitySummary
503
+ // Option A: the 8 `if (ea.mayDrain(turn))` guards + drainActivitySummary
502
504
  // thunks stay byte-identical, wrapped by the centralized helper.
503
- // 6 original + 1 new post-answer background-agent liveness drain (Fix 2).
505
+ // 6 original + 1 post-answer background-agent liveness drain (Fix 2) + 1
506
+ // narrative-retract drain (timer-flush early-paint retract, narrative-flush.ts).
504
507
  const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
505
- expect(wraps).toHaveLength(7)
508
+ expect(wraps).toHaveLength(8)
506
509
  // The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
507
510
  // the SAME helper via an injected thunk — its call lives in the extracted
508
511
  // tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Tests for the fleet-wide consistent-formatting bundle:
3
3
  *
4
- * 1. paragraph gap spacing — a plain `\n\n` (one blank line) at EVERY block
4
+ * 1. addParagraphSpacers — a visible U+00A0 spacer line at EVERY block
5
5
  * transition (paragraph→list, list→paragraph, heading→anything,
6
- * blockquote/table boundaries), never a double blank line and never an
7
- * NBSP spacer (the old spacer pass was removed in the #2669 follow-up).
6
+ * blockquote/table boundaries), idempotent and double-gap-proof; list/
7
+ * table interiors stay tight. Restored after the #3208 F1 misfire the
8
+ * rich GFM renderer renders a bare `\n\n` gap TIGHT, so the spacer is what
9
+ * produces the visible paragraph gap.
8
10
  * 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
9
11
  * list markers → `- `, on code-masked text, idempotent; link hrefs are
10
12
  * protected from dash rewriting.
@@ -13,13 +15,17 @@
13
15
  */
14
16
  import { describe, test, expect } from 'vitest'
15
17
  import {
18
+ addParagraphSpacers,
16
19
  normalizeParagraphBreaks,
17
20
  normalizePunctuation,
18
21
  stripExcessBold,
19
22
  splitMarkdownChunks,
20
23
  hardenCardBreaks,
24
+ PARAGRAPH_SPACER,
21
25
  } from '../format.js'
22
26
 
27
+ const SP = PARAGRAPH_SPACER // U+00A0
28
+
23
29
  describe('hardenCardBreaks — deterministic card line-break hardener', () => {
24
30
  test('promotes lone field breaks to GFM hard breaks (the blob fix)', () => {
25
31
  const out = hardenCardBreaks('Agent: assistant\nAuth: Max\nStatus: running')
@@ -98,60 +104,74 @@ describe('hardenCardBreaks — deterministic card line-break hardener', () => {
98
104
  })
99
105
  })
100
106
 
101
- describe('paragraph gap spacing plain single blank line, no NBSP', () => {
102
- const NBSP = String.fromCharCode(0xa0)
103
-
104
- test('prose→prose gap is one blank line, no NBSP', () => {
105
- const out = normalizeParagraphBreaks('Alpha.\n\nBravo.')
106
- expect(out).toBe('Alpha.\n\nBravo.')
107
- expect(out).not.toContain(NBSP)
107
+ describe('addParagraphSpacersuniform block spacing', () => {
108
+ test('still spaces prose→prose (existing behaviour)', () => {
109
+ const out = addParagraphSpacers('Alpha.\n\nBravo.')
110
+ expect(out).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
108
111
  })
109
112
 
110
- test('paragraph→list transition is a single `\\n\\n` boundary', () => {
111
- expect(normalizeParagraphBreaks('Intro.\n\n- one\n- two')).toBe('Intro.\n\n- one\n- two')
113
+ test('spaces paragraph→list transition', () => {
114
+ const out = addParagraphSpacers('Intro.\n\n- one\n- two')
115
+ expect(out).toBe(`Intro.\n\n${SP}\n\n- one\n- two`)
112
116
  })
113
117
 
114
- test('list→paragraph transition is a single `\\n\\n` boundary', () => {
115
- expect(normalizeParagraphBreaks('- one\n- two\n\nOutro.')).toBe('- one\n- two\n\nOutro.')
118
+ test('spaces list→paragraph transition', () => {
119
+ const out = addParagraphSpacers('- one\n- two\n\nOutro.')
120
+ expect(out).toBe(`- one\n- two\n\n${SP}\n\nOutro.`)
116
121
  })
117
122
 
118
- test('heading→anything is a single `\\n\\n` boundary', () => {
119
- expect(normalizeParagraphBreaks('# Title\n\nBody.')).toBe('# Title\n\nBody.')
120
- expect(normalizeParagraphBreaks('# Title\n\n- a\n- b')).toBe('# Title\n\n- a\n- b')
123
+ test('spaces heading→anything', () => {
124
+ expect(addParagraphSpacers('# Title\n\nBody.')).toBe(`# Title\n\n${SP}\n\nBody.`)
125
+ expect(addParagraphSpacers('# Title\n\n- a\n- b')).toBe(`# Title\n\n${SP}\n\n- a\n- b`)
121
126
  })
122
127
 
123
- test('blockquote and table boundaries are single `\\n\\n`', () => {
124
- expect(normalizeParagraphBreaks('> quoted\n\nProse.')).toBe('> quoted\n\nProse.')
128
+ test('spaces blockquote and table boundaries', () => {
129
+ expect(addParagraphSpacers('> quoted\n\nProse.')).toBe(`> quoted\n\n${SP}\n\nProse.`)
125
130
  const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
126
- expect(normalizeParagraphBreaks(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${table}`)
127
- expect(normalizeParagraphBreaks(`${table}\n\nProse.`)).toBe(`${table}\n\nProse.`)
131
+ expect(addParagraphSpacers(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${SP}\n\n${table}`)
132
+ expect(addParagraphSpacers(`${table}\n\nProse.`)).toBe(`${table}\n\n${SP}\n\nProse.`)
133
+ })
134
+
135
+ test('does NOT space between items of the same loose list', () => {
136
+ const input = '- one\n\n- two\n\n- three'
137
+ expect(addParagraphSpacers(input)).toBe(input)
138
+ })
139
+
140
+ test('does NOT space inside a tight list or table interior (single \\n)', () => {
141
+ const list = 'Intro.\n\n- a\n- b\n- c'
142
+ expect(addParagraphSpacers(list)).toBe(`Intro.\n\n${SP}\n\n- a\n- b\n- c`)
143
+ const table = '| a |\n| --- |\n| 1 |\n| 2 |'
144
+ expect(addParagraphSpacers(table)).toBe(table)
145
+ })
146
+
147
+ test('idempotent across every transition kind', () => {
148
+ const input = '# H\n\nProse one.\n\n- a\n- b\n\nProse two.\n\n> quote'
149
+ const once = addParagraphSpacers(input)
150
+ expect(addParagraphSpacers(once)).toBe(once)
128
151
  })
129
152
 
130
153
  test('never touches code fences', () => {
131
154
  const input = '```\nA\n\nB\n```\n\n```\nC\n```'
132
- expect(normalizeParagraphBreaks(input)).toBe(input)
155
+ expect(addParagraphSpacers(input)).toBe(input)
133
156
  })
134
157
 
135
- test('full pipeline: mixed prose+list message gets single-blank-line gaps, no NBSP, no double gap', () => {
136
- const raw = 'Summary line.\n\n- item one\n- item two\n\nClosing prose.'
137
- const out = normalizeParagraphBreaks(raw)
138
- expect(out).toBe('Summary line.\n\n- item one\n- item two\n\nClosing prose.')
139
- expect(out).not.toContain(NBSP)
140
- expect(out).not.toMatch(/\n\n\n/)
158
+ test('full pipeline: mixed prose+list+heading message gets uniform gaps', () => {
159
+ const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
160
+ const out = addParagraphSpacers(normalizeParagraphBreaks(raw))
161
+ // Every block transition carries exactly one visible spacer line.
162
+ expect(out).toBe(
163
+ `Summary line.\n\n${SP}\n\n- item one\n- item two\n\n${SP}\n\nClosing prose.`,
164
+ )
141
165
  })
142
166
 
143
- test('chunk-boundary interaction: a cut in a `\\n\\n` gap leaves clean chunks (no stray blank lines)', () => {
167
+ test('chunk-boundary interaction: a cut in a spacer gap strips the spacer', () => {
144
168
  const a = 'A'.repeat(60)
145
169
  const b = 'B'.repeat(60)
146
- const text = `${a}\n\n${b}`
170
+ const text = `${a}\n\n${SP}\n\n${b}`
147
171
  const chunks = splitMarkdownChunks(text, 80)
148
172
  expect(chunks.length).toBe(2)
149
173
  expect(chunks[0]).toBe(a)
150
174
  expect(chunks[1]).toBe(b)
151
- // Neither chunk opens or ends with a stray blank line.
152
- for (const c of chunks) {
153
- expect(c).toBe(c.replace(/^\n+|\n+$/g, ''))
154
- }
155
175
  })
156
176
  })
157
177
 
@@ -17,10 +17,9 @@
17
17
  * fixture intended (bold/italic/code/pre/link at the right inner text).
18
18
  *
19
19
  * The pipeline mirrors `telegram-plugin/gateway/gateway.ts`:
20
- * repairEscapedWhitespace -> normalizeParagraphBreaks -> splitMarkdownChunks.
21
- * (No paragraph-spacer pass the NBSP spacer was removed in the #2669
22
- * follow-up; gaps are plain `\n\n`. Card-surface fixtures also apply
23
- * `normalizeDashes` first — that is where F1's voice scrub lives.)
20
+ * repairEscapedWhitespace -> normalizeParagraphBreaks -> addParagraphSpacers
21
+ * -> splitMarkdownChunks. (Card-surface fixtures also apply `normalizeDashes`
22
+ * first that is where F1's voice scrub lives.)
24
23
  *
25
24
  * This suite is TEST-ONLY. It changes no production formatting behaviour. If a
26
25
  * fixture ever fails signal (a), it means the formatter emits markdown Telegram
@@ -31,6 +30,7 @@ import { describe, test, expect } from 'vitest'
31
30
  import {
32
31
  repairEscapedWhitespace,
33
32
  normalizeParagraphBreaks,
33
+ addParagraphSpacers,
34
34
  splitMarkdownChunks,
35
35
  RICH_MESSAGE_MAX_CHARS,
36
36
  } from '../format.js'
@@ -57,7 +57,8 @@ function transform(input: string, opts: { cardSurfaceScrub?: boolean; cap?: numb
57
57
  } {
58
58
  let text = input
59
59
  if (opts.cardSurfaceScrub) text = normalizeDashes(text)
60
- const body = normalizeParagraphBreaks(repairEscapedWhitespace(text))
60
+ text = normalizeParagraphBreaks(repairEscapedWhitespace(text))
61
+ const body = addParagraphSpacers(text)
61
62
  const chunks = splitMarkdownChunks(body, opts.cap ?? RICH_MESSAGE_MAX_CHARS)
62
63
  return { chunks, body }
63
64
  }
@@ -7,7 +7,7 @@
7
7
  * The `input` is the raw text as a model/card surface would author it — the
8
8
  * regression test runs it through the SAME outbound transform pipeline the
9
9
  * gateway uses (repairEscapedWhitespace -> normalizeParagraphBreaks ->
10
- * splitMarkdownChunks) and then asserts:
10
+ * addParagraphSpacers -> splitMarkdownChunks) and then asserts:
11
11
  *
12
12
  * (a) every emitted chunk is parse-accept valid (no rich-path 400), and
13
13
  * (b) the concatenated output parses into `expect` (entity structure).