switchroom 0.18.18 → 0.18.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1131 -285
  7. package/telegram-plugin/dist/server.js +6 -0
  8. package/telegram-plugin/format.ts +208 -125
  9. package/telegram-plugin/gateway/cron-session.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +800 -107
  11. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  12. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  13. package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
  14. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  15. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  16. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  17. package/telegram-plugin/llm-error-present.ts +68 -30
  18. package/telegram-plugin/narrative-flush.ts +181 -0
  19. package/telegram-plugin/pending-work-progress.ts +65 -1
  20. package/telegram-plugin/session-tail.ts +6 -1
  21. package/telegram-plugin/silent-end.ts +182 -0
  22. package/telegram-plugin/subagent-watcher.ts +244 -81
  23. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  24. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  25. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  26. package/telegram-plugin/tests/format-consistency.test.ts +39 -4
  27. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
  30. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  31. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  32. package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +72 -4
  39. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  40. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  41. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  42. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  43. package/telegram-plugin/tool-activity-summary.ts +78 -16
  44. package/telegram-plugin/turn-flush-safety.ts +2 -1
  45. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -0,0 +1,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,11 +1,15 @@
1
1
  /**
2
2
  * Tests for the fleet-wide consistent-formatting bundle:
3
3
  *
4
- * 1. addParagraphSpacers extension — a visible U+00A0 spacer line at EVERY
5
- * block transition (paragraph→list, list→paragraph, heading→anything,
6
- * blockquote/table boundaries), never inside a list/table interior.
4
+ * 1. addParagraphSpacers — a visible U+00A0 spacer line at EVERY block
5
+ * transition (paragraph→list, list→paragraph, heading→anything,
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.
7
10
  * 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
8
- * list markers → `- `, on code-masked text, idempotent.
11
+ * list markers → `- `, on code-masked text, idempotent; link hrefs are
12
+ * protected from dash rewriting.
9
13
  * 3. stripExcessBold — over-bold tripwire: >30% bold or fully-bolded
10
14
  * paragraphs/lists lose their bold markers; short messages exempt.
11
15
  */
@@ -210,6 +214,37 @@ describe('normalizePunctuation', () => {
210
214
  expect(normalizePunctuation(input)).toBe(input)
211
215
  })
212
216
 
217
+ test('does NOT rewrite a dash inside a markdown link href (#finding-2)', () => {
218
+ // An en-dash in a URL path must survive verbatim — rewriting it to `-`
219
+ // silently points at a different URL.
220
+ expect(normalizePunctuation('[a](https://x.com/foo–bar)')).toBe(
221
+ '[a](https://x.com/foo–bar)',
222
+ )
223
+ // An em-dash in a URL must NOT become `, ` — the injected space would
224
+ // TERMINATE the markdown link and leak the trailing text as prose.
225
+ expect(normalizePunctuation('[a](https://x.com/foo—bar)')).toBe(
226
+ '[a](https://x.com/foo—bar)',
227
+ )
228
+ // The visible LABEL still normalizes (dashes in label text are prose).
229
+ expect(normalizePunctuation('[foo—bar](https://x.com/path)')).toBe(
230
+ '[foo, bar](https://x.com/path)',
231
+ )
232
+ })
233
+
234
+ test('does NOT rewrite a dash inside a `<scheme:…>` autolink (#finding-2 sibling)', () => {
235
+ // An en-dash in an angle-bracket autolink URL must survive verbatim.
236
+ expect(normalizePunctuation('<https://x.com/foo–bar>')).toBe('<https://x.com/foo–bar>')
237
+ // An em-dash in an autolink must NOT become `, `.
238
+ expect(normalizePunctuation('<https://x.com/foo—bar>')).toBe('<https://x.com/foo—bar>')
239
+ // Autolink mid-prose: the surrounding prose still normalizes, the URL does not.
240
+ expect(normalizePunctuation('see <https://x.com/a–b> now — go')).toBe(
241
+ 'see <https://x.com/a–b> now, go',
242
+ )
243
+ // Arbitrary `<…>` prose (no scheme) is NOT treated as an autolink: a dash
244
+ // inside it still normalizes like ordinary text.
245
+ expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
246
+ })
247
+
213
248
  test('idempotent', () => {
214
249
  const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
215
250
  expect(normalizePunctuation(once)).toBe(once)
@@ -134,6 +134,32 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
134
134
  expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
135
135
  })
136
136
 
137
+ it('operator_event: scrubs event.detail at the top of emitGatewayOperatorEvent, BEFORE render/send', () => {
138
+ // #llm-error-surfacing FIX 2 — operator-event cards are sent via a raw
139
+ // bot.api.sendRichMessage that BYPASSES the normal outbound chokepoint, so a
140
+ // secret in an error `detail` (credentials-expired / credit-exhausted /
141
+ // unknown-4xx) could reach the card verbatim. The detail is redacted ONCE at
142
+ // the top of the function, through the shared redactOutboundText, and MUST
143
+ // run before the renderers escapeMarkdown it (redacting the already-escaped
144
+ // text lets url-query-param secrets slip past url-redact) and before the
145
+ // send. This is the sole regression guard for that wiring line — deleting it
146
+ // must turn this red.
147
+ const start = src.indexOf('function emitGatewayOperatorEvent(')
148
+ const redactIdx = src.indexOf(
149
+ `event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }`,
150
+ start,
151
+ )
152
+ // The two render surfaces + the wire send this must precede.
153
+ const renderOpIdx = src.indexOf('renderOperatorEvent(event)', start)
154
+ const renderLlmIdx = src.indexOf('renderLlmErrorSafe(parsed', start)
155
+ const sendIdx = src.indexOf('bot.api.sendRichMessage(chat_id, richMessage(renderedText)', start)
156
+ expect(start).toBeGreaterThan(0)
157
+ expect(redactIdx).toBeGreaterThan(start)
158
+ expect(renderOpIdx).toBeGreaterThan(redactIdx) // mask BEFORE the escapeMarkdown render
159
+ expect(renderLlmIdx).toBeGreaterThan(redactIdx) // mask BEFORE the humanized render
160
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the card hits the wire
161
+ })
162
+
137
163
  it('does not log the secret value when a mask fires', () => {
138
164
  const idx = src.indexOf('function redactOutboundText(')
139
165
  const body = src.slice(idx, idx + 400)