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
@@ -19,14 +19,17 @@
19
19
  * `vitest.config.ts`.)
20
20
  */
21
21
 
22
- import { describe, it, expect } from 'vitest'
22
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
23
23
  import {
24
24
  decideIdleClear,
25
25
  classifyIdleEvent,
26
26
  idleDurationToMs,
27
27
  DEFAULT_IDLE_CLEAR_MS,
28
+ IdleTracker,
28
29
  type IdleClearState,
29
30
  } from '../gateway/idle-clear.js'
31
+ import * as pendingProgress from '../pending-work-progress.js'
32
+ import { isCronInjectFire } from '../gateway/cron-session.js'
30
33
 
31
34
  const H = 3_600_000
32
35
  const M = 60_000
@@ -45,54 +48,63 @@ function state(p: Partial<IdleClearState>): IdleClearState {
45
48
  }
46
49
 
47
50
  /**
48
- * A minimal model of the gateway's idle bookkeeping, driven by a virtual clock.
49
- * Mirrors gateway.ts: `markIdleActivity()` on inbound / cron / ANY session
50
- * event, `markIdleTurnEnd()` on a turn ending, `decideIdleClear()` on each
51
- * IDLE_CLEAR_CHECK_MS tick, `alreadyCleared` latched on fire.
51
+ * Test harness that plays the role of the GATEWAY around the REAL `IdleTracker`
52
+ * (#3115). It holds NO idle state and NO idle logic of its own — every stamp,
53
+ * decision and latch delegates to the tracker the gateway itself holds. What it
54
+ * owns is exactly what the gateway owns and the tracker does not: the
55
+ * environment inputs (`idleClearMs`, `turnInFlight`, the background-work flag),
56
+ * the periodic tick loop, and a log of when a clear fired.
57
+ *
58
+ * This is the whole point of #3115: the old `IdleModel` re-implemented
59
+ * `markIdleActivity` / `markIdleTurnEnd` / the decide+latch in a mirror, so a
60
+ * regression in the real gateway code (e.g. deleting the per-event stamp) left
61
+ * every test green. Now a deleted stamp call fails a test, because the test
62
+ * drives the same object the gateway drives.
63
+ *
64
+ * DELIBERATE STRUCTURAL LIMIT: `tick()` re-implements the gateway's ONE-LINE
65
+ * orchestration around the tracker — the `decide → markClearFired` sequence
66
+ * (maybeIdleClear, gateway.ts) and, in the #3114 block below, the
67
+ * `!isCronInjectFire(meta)` stamp gate (onInjectInbound, gateway.ts). #3115
68
+ * moved all the STATE and idle LOGIC into the importable `IdleTracker`, so those
69
+ * are now driven directly; but gateway.ts is ~30k lines with import-time side
70
+ * effects and cannot be imported, so the thin glue that wires the tracker into
71
+ * the gateway is still asserted by re-implementing that glue here. A divergence
72
+ * between the real gateway one-liner and this harness would NOT be caught — a
73
+ * known, documented boundary, not an oversight. The residual glue is one line
74
+ * per callsite; everything of substance is the real object.
52
75
  */
53
- class IdleModel {
54
- lastActivityAt: number
55
- lastTurnEndedAt: number | null = null
56
- alreadyCleared = false
76
+ class TrackerHarness {
77
+ readonly tracker: IdleTracker
57
78
  turnInFlight = false
79
+ backgroundWorkInFlight: boolean | undefined = undefined
58
80
  clears: number[] = []
59
81
 
60
82
  constructor(
61
83
  startedAt: number,
62
84
  readonly idleClearMs = 3 * H,
63
85
  ) {
64
- this.lastActivityAt = startedAt
86
+ this.tracker = new IdleTracker(startedAt)
65
87
  }
66
88
 
67
- /** A claude session-stream event (the gateway's handleSessionEvent stamp). */
89
+ /** A claude session-stream event the gateway's handleSessionEvent stamp. */
68
90
  sessionEvent(kind: string, now: number, durationMs?: number): void {
69
- const signal = classifyIdleEvent(kind, durationMs)
70
- if (signal.activity) {
71
- this.lastActivityAt = now
72
- this.alreadyCleared = false
73
- }
74
- if (signal.turnEnded) this.lastTurnEndedAt = now
91
+ this.tracker.noteEvent(kind, now, durationMs)
75
92
  }
76
93
 
77
- /** Inbound / cron fire. */
94
+ /** Inbound / genuine cron fire → the gateway's markIdleActivity(). */
78
95
  activity(now: number): void {
79
- this.lastActivityAt = now
80
- this.alreadyCleared = false
96
+ this.tracker.noteInbound(now)
81
97
  }
82
98
 
99
+ /** One IDLE_CLEAR_CHECK_MS tick → the core of maybeIdleClear's decide+latch. */
83
100
  tick(now: number): boolean {
84
- const { clear } = decideIdleClear(
85
- {
86
- lastActivityAt: this.lastActivityAt,
87
- lastTurnEndedAt: this.lastTurnEndedAt,
88
- idleClearMs: this.idleClearMs,
89
- alreadyCleared: this.alreadyCleared,
90
- turnInFlight: this.turnInFlight,
91
- },
92
- now,
93
- )
101
+ const { clear } = this.tracker.decide(now, {
102
+ idleClearMs: this.idleClearMs,
103
+ turnInFlight: this.turnInFlight,
104
+ backgroundWorkInFlight: this.backgroundWorkInFlight,
105
+ })
94
106
  if (clear) {
95
- this.alreadyCleared = true
107
+ this.tracker.markClearFired()
96
108
  this.clears.push(now)
97
109
  }
98
110
  return clear
@@ -109,7 +121,7 @@ describe('idle-clear: a working agent is not idle', () => {
109
121
  // The overlord incident, replayed. Window 3h. Turn starts at T, runs 3h08m
110
122
  // of real work (sub-agents, tool calls), ends at T+3h08m.
111
123
  const T = 100 * H
112
- const m = new IdleModel(T)
124
+ const m = new TrackerHarness(T)
113
125
 
114
126
  m.turnInFlight = true
115
127
  m.sessionEvent('enqueue', T) // turn start
@@ -136,7 +148,7 @@ describe('idle-clear: a working agent is not idle', () => {
136
148
  // turn. `turnInFlight` was false the whole time, so only the per-event stamp
137
149
  // can save it.
138
150
  const T = 100 * H
139
- const m = new IdleModel(T)
151
+ const m = new TrackerHarness(T)
140
152
  m.sessionEvent('turn_end', T, 34_596) // last turn boundary
141
153
  m.turnInFlight = false
142
154
 
@@ -175,7 +187,7 @@ describe('idle-clear: a working agent is not idle', () => {
175
187
 
176
188
  it('a background sub-agent still emitting events after the main turn ends holds off the clear', () => {
177
189
  const T = 100 * H
178
- const m = new IdleModel(T)
190
+ const m = new TrackerHarness(T)
179
191
  m.sessionEvent('turn_end', T, 30_000)
180
192
  m.turnInFlight = false
181
193
  // Worker grinds for 4h past the main turn end.
@@ -190,14 +202,14 @@ describe('idle-clear: a working agent is not idle', () => {
190
202
  describe('idle-clear: a genuinely idle agent is still cleared', () => {
191
203
  it('no turn, no inbound, no session event for a full window → cleared exactly once', () => {
192
204
  const T = 100 * H
193
- const m = new IdleModel(T)
205
+ const m = new TrackerHarness(T)
194
206
  m.ticksThrough(T, T + 6 * H)
195
207
  expect(m.clears).toEqual([T + 3 * H])
196
208
  })
197
209
 
198
210
  it('cleared once per idle period, and re-arms on the next inbound', () => {
199
211
  const T = 100 * H
200
- const m = new IdleModel(T)
212
+ const m = new TrackerHarness(T)
201
213
  m.ticksThrough(T, T + 5 * H)
202
214
  expect(m.clears).toEqual([T + 3 * H])
203
215
 
@@ -211,7 +223,7 @@ describe('idle-clear: a genuinely idle agent is still cleared', () => {
211
223
 
212
224
  it('a turn that ends is cleared one full window after it ended (not before)', () => {
213
225
  const T = 100 * H
214
- const m = new IdleModel(T)
226
+ const m = new TrackerHarness(T)
215
227
  m.turnInFlight = true
216
228
  m.sessionEvent('enqueue', T)
217
229
  const end = T + 3 * H + 8 * M
@@ -225,6 +237,177 @@ describe('idle-clear: a genuinely idle agent is still cleared', () => {
225
237
  })
226
238
  })
227
239
 
240
+ describe('IdleTracker wiring (#3115) — the real object, not a mirror', () => {
241
+ // These are the tests the old IdleModel COULD NOT be: they assert the real
242
+ // tracker's stamp calls are load-bearing. Delete `noteEvent`'s activity
243
+ // stamp, or gateway.ts's `idleTracker.noteEvent(...)` call, and these fail —
244
+ // whereas against the IdleModel mirror the same regression stayed green.
245
+
246
+ it('noteEvent advances lastActivityAt on every genuine session event', () => {
247
+ const T = 100 * H
248
+ const t = new IdleTracker(T)
249
+ expect(t.activityAt).toBe(T)
250
+ // A stream of events, each newer than the last, must walk the clock forward.
251
+ for (const [i, kind] of [
252
+ 'enqueue', 'thinking', 'tool_use', 'tool_result', 'text',
253
+ 'sub_agent_started', 'sub_agent_tool_use',
254
+ ].entries()) {
255
+ const now = T + (i + 1) * M
256
+ t.noteEvent(kind, now)
257
+ expect(t.activityAt).toBe(now) // load-bearing: the stamp actually fired
258
+ }
259
+ })
260
+
261
+ it('noteEvent stamps the turn-end clock on a real turn_end but the synthetic one does not stamp activity', () => {
262
+ const T = 100 * H
263
+ const t = new IdleTracker(T)
264
+ t.noteEvent('turn_end', T + M, 5_000) // real turn end: activity + turn-end
265
+ expect(t.activityAt).toBe(T + M)
266
+ expect(t.turnEndedAt).toBe(T + M)
267
+ // Gateway's synthetic turn_end (durationMs === -1): ends the turn, NOT activity.
268
+ t.noteEvent('turn_end', T + 2 * M, -1)
269
+ expect(t.activityAt).toBe(T + M) // unchanged — not real activity
270
+ expect(t.turnEndedAt).toBe(T + 2 * M) // but the turn-end clock advanced
271
+ })
272
+
273
+ it('the re-entrancy guard (isDispatching) makes an overlapping tick a no-op until endDispatch', () => {
274
+ // maybeIdleClear early-returns while a /clear inject is in flight
275
+ // (gateway.ts: `if (idleTracker.isDispatching) return`). Model that gate
276
+ // against the REAL tracker: begin a dispatch, then a tick that arrives
277
+ // before the async inject settles must NOT fire a second clear, and normal
278
+ // behaviour must resume once the dispatch ends.
279
+ const T = 100 * H
280
+ const t = new IdleTracker(T)
281
+ const inputs = { idleClearMs: 3 * H, turnInFlight: false }
282
+
283
+ // The window has elapsed → first tick clears and opens a dispatch.
284
+ expect(t.isDispatching).toBe(false)
285
+ expect(t.decide(T + 3 * H, inputs).clear).toBe(true)
286
+ t.markClearFired()
287
+ t.beginDispatch()
288
+ expect(t.isDispatching).toBe(true)
289
+
290
+ // A second tick arrives mid-dispatch. The gateway's guard short-circuits
291
+ // BEFORE decide(), so no second clear fires. Assert both the guard signal
292
+ // and that a bypassing decide() would still be latched off anyway.
293
+ let secondClearFired = false
294
+ if (!t.isDispatching) {
295
+ // (unreached while dispatching — this is the gateway's guarded path)
296
+ const { clear } = t.decide(T + 4 * H, inputs)
297
+ if (clear) secondClearFired = true
298
+ }
299
+ expect(secondClearFired).toBe(false) // guard held: no double-dispatch
300
+ // Even if the guard were bypassed, the fire-once latch blocks a re-clear.
301
+ expect(t.decide(T + 4 * H, inputs).clear).toBe(false)
302
+
303
+ // Dispatch settles. The guard reopens; still latched (no activity yet).
304
+ t.endDispatch()
305
+ expect(t.isDispatching).toBe(false)
306
+ expect(t.decide(T + 5 * H, inputs).clear).toBe(false) // alreadyCleared
307
+
308
+ // Fresh activity re-arms → normal behaviour resumes, one window later.
309
+ t.noteInbound(T + 6 * H)
310
+ expect(t.decide(T + 9 * H, inputs).clear).toBe(true)
311
+ })
312
+
313
+ it('the fire-once latch survives across ticks and re-arms only on activity', () => {
314
+ const T = 100 * H
315
+ const t = new IdleTracker(T)
316
+ const inputs = { idleClearMs: 3 * H, turnInFlight: false }
317
+ // Window elapsed → clears once, then latched.
318
+ expect(t.decide(T + 3 * H, inputs).clear).toBe(true)
319
+ t.markClearFired()
320
+ expect(t.cleared).toBe(true)
321
+ expect(t.decide(T + 4 * H, inputs).clear).toBe(false) // latched
322
+ // Fresh activity re-arms.
323
+ t.noteInbound(T + 5 * H)
324
+ expect(t.cleared).toBe(false)
325
+ expect(t.decide(T + 8 * H, inputs).clear).toBe(true) // one window after re-arm
326
+ })
327
+ })
328
+
329
+ describe('IdleTracker #3116 — write-time re-eval suppresses a clear when activity arrives in the gap', () => {
330
+ // maybeIdleClear latches `alreadyCleared=true` before the async /clear inject
331
+ // (re-entrancy). The write-time precondition (`decideIgnoringLatch`) must
332
+ // therefore judge idleness on the LIVE clocks, ignoring that latch, so a new
333
+ // inbound in the check-to-send gap still aborts the buffered /clear.
334
+ const inputs = { idleClearMs: 3 * H, turnInFlight: false }
335
+
336
+ it('re-eval returns not-idle after an inbound lands in the gap (latch ignored)', () => {
337
+ const T = 100 * H
338
+ const t = new IdleTracker(T)
339
+ // Gate fires at the window; gateway latches the fire-once guard.
340
+ expect(t.decide(T + 3 * H, inputs).clear).toBe(true)
341
+ t.markClearFired()
342
+ t.beginDispatch()
343
+ // Inbound arrives in the check-to-send gap → re-arms the activity clock.
344
+ t.noteInbound(T + 3 * H + 5 * M)
345
+ // Write-time re-eval (ignoring the latch) now sees a warm clock → abort.
346
+ expect(t.decideIgnoringLatch(T + 3 * H + 6 * M, inputs).clear).toBe(false)
347
+ })
348
+
349
+ it('with no activity in the gap the write-time re-eval still says clear (latch ignored, not the gate)', () => {
350
+ const T = 100 * H
351
+ const t = new IdleTracker(T)
352
+ expect(t.decide(T + 3 * H, inputs).clear).toBe(true)
353
+ t.markClearFired()
354
+ // No inbound; the clock is still cold → the buffered /clear proceeds.
355
+ expect(t.decideIgnoringLatch(T + 3 * H + M, inputs).clear).toBe(true)
356
+ })
357
+
358
+ it('#3117 composes at write time: a background dispatch in the gap suppresses the buffered /clear', () => {
359
+ const T = 100 * H
360
+ const t = new IdleTracker(T)
361
+ expect(t.decide(T + 3 * H, { ...inputs, backgroundWorkInFlight: false }).clear).toBe(true)
362
+ t.markClearFired()
363
+ // A detached worker gets dispatched in the gap → re-eval honours it for free.
364
+ expect(
365
+ t.decideIgnoringLatch(T + 3 * H + M, { ...inputs, backgroundWorkInFlight: true }).clear,
366
+ ).toBe(false)
367
+ })
368
+ })
369
+
370
+ describe('IdleTracker #3114 — a cron fire does not warm the clock, a human inbound does', () => {
371
+ // Behavioural coverage that REPLACES 3114's source-text wiring pin: drives the
372
+ // REAL isCronInjectFire predicate + the REAL IdleTracker through the exact
373
+ // gateway rule (`if (!isCronInjectFire(meta)) tracker.noteInbound(now)`). A
374
+ // regression in EITHER the predicate or the stamp fails this — the old
375
+ // source-scrape asserted only that a string appeared in gateway.ts.
376
+
377
+ /** The gateway's onInjectInbound stamp rule, applied to the real objects. */
378
+ function injectFire(t: IdleTracker, meta: Record<string, unknown> | undefined, now: number): void {
379
+ if (!isCronInjectFire(meta)) t.noteInbound(now)
380
+ }
381
+
382
+ it('frequent cron fires (cadence < window) never re-arm the clock → idle-clear still fires', () => {
383
+ const T = 100 * H
384
+ const t = new IdleTracker(T)
385
+ const inputs = { idleClearMs: 3 * H, turnInFlight: false }
386
+ // A cron fires every 10 min for well over the window, doing NO real work
387
+ // (no session events). On main this re-armed the clock every fire → never idle.
388
+ for (let now = T; now <= T + 3 * H + 30 * M; now += 10 * M) {
389
+ injectFire(t, { session: 'cron', source: 'cron' }, now) // Tier-1 cheap cron
390
+ injectFire(t, { source: 'cron' }, now) // Tier-2 main-session cron
391
+ }
392
+ // The clock never moved off T → the tracker is genuinely idle and clears.
393
+ expect(t.activityAt).toBe(T)
394
+ expect(t.decide(T + 3 * H, inputs).clear).toBe(true)
395
+ })
396
+
397
+ it('a genuine operator inbound (reaction/vault/resume/manual) DOES warm the clock', () => {
398
+ const T = 100 * H
399
+ const t = new IdleTracker(T)
400
+ injectFire(t, { source: 'reaction' }, T + M)
401
+ expect(t.activityAt).toBe(T + M)
402
+ injectFire(t, undefined, T + 2 * M) // bare manual inject
403
+ expect(t.activityAt).toBe(T + 2 * M)
404
+ // And a warmed clock defers the clear a full window past the last inbound.
405
+ const inputs = { idleClearMs: 3 * H, turnInFlight: false }
406
+ expect(t.decide(T + 2 * M + 3 * H - 1, inputs).clear).toBe(false)
407
+ expect(t.decide(T + 2 * M + 3 * H, inputs).clear).toBe(true)
408
+ })
409
+ })
410
+
228
411
  describe('decideIdleClear (pure gate)', () => {
229
412
  it('fires once the idle window has elapsed', () => {
230
413
  expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
@@ -273,6 +456,101 @@ describe('decideIdleClear (pure gate)', () => {
273
456
  })
274
457
  })
275
458
 
459
+ describe('decideIdleClear: #3117 background-work suppressor', () => {
460
+ it('suppresses the clear while a background sub-agent is in flight, even past the window', () => {
461
+ // Window elapsed (10h >> 3h), main-turn gate open (turnInFlight:false), and
462
+ // the activity clock is cold — yet a detached worker is in flight. On main
463
+ // (no backgroundWorkInFlight branch) this would clear; the fix must not.
464
+ const s = state({ lastActivityAt: 0, backgroundWorkInFlight: true })
465
+ expect(decideIdleClear(s, 10 * H).clear).toBe(false)
466
+ })
467
+
468
+ it('allows the clear once background work is no longer in flight (TTL lapsed or dispatch cleared)', () => {
469
+ const s = state({ lastActivityAt: 0, backgroundWorkInFlight: false })
470
+ expect(decideIdleClear(s, 10 * H).clear).toBe(true)
471
+ })
472
+
473
+ it('undefined background flag preserves pre-#3117 behaviour (treated as false)', () => {
474
+ const s = state({ lastActivityAt: 0 })
475
+ delete (s as { backgroundWorkInFlight?: boolean }).backgroundWorkInFlight
476
+ expect(decideIdleClear(s, 10 * H).clear).toBe(true)
477
+ })
478
+ })
479
+
480
+ describe('#3117 end-to-end: pending dispatch TTL gates idle-clear', () => {
481
+ // Drives the REAL pending-work-progress state (the source of
482
+ // backgroundWorkInFlight) against decideIdleClear with an injected clock, so
483
+ // the wiring — noteAsyncDispatch stamps, the TTL expires, the flag clears —
484
+ // is exercised as an outcome, not mirrored.
485
+ let clock = 0
486
+ const KEY = 'chat-1:'
487
+
488
+ beforeEach(() => {
489
+ pendingProgress.__resetAllForTests()
490
+ clock = 100 * H
491
+ // Install a clock override without starting the real timer.
492
+ pendingProgress.__setDepsForTests({
493
+ editMessage: async () => {},
494
+ nowMs: () => clock,
495
+ })
496
+ })
497
+ afterEach(() => {
498
+ pendingProgress.__setDepsForTests(null)
499
+ pendingProgress.__resetAllForTests()
500
+ })
501
+
502
+ // Cold activity clock (started at 0, window long elapsed) held in the REAL
503
+ // tracker; the gateway's exact background-work input is fed at decide time.
504
+ const tracker = new IdleTracker(0)
505
+ function decideNow(now: number): boolean {
506
+ return tracker.decide(now, {
507
+ idleClearMs: 3 * H,
508
+ turnInFlight: false,
509
+ backgroundWorkInFlight: pendingProgress.anyPendingAsyncDispatchWithin(
510
+ pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS,
511
+ ),
512
+ }).clear
513
+ }
514
+
515
+ it('a pending dispatch inside the TTL suppresses the clear; past the TTL it self-heals', () => {
516
+ // No dispatch yet → idle-clear fires (control).
517
+ expect(decideNow(clock)).toBe(true)
518
+
519
+ // Background worker dispatched. Now within the TTL: suppressed.
520
+ pendingProgress.noteAsyncDispatch(KEY)
521
+ expect(pendingProgress.hasPendingAsyncDispatch(KEY)).toBe(true)
522
+ expect(decideNow(clock)).toBe(false)
523
+
524
+ // Still within TTL (just under 30m) → still suppressed.
525
+ clock += pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS - 1
526
+ expect(decideNow(clock)).toBe(false)
527
+
528
+ // TTL lapses (stuck/leaked flag) → suppression drops, idle-clear self-heals.
529
+ clock += 2
530
+ expect(pendingProgress.hasPendingAsyncDispatch(KEY)).toBe(true) // flag still set
531
+ expect(decideNow(clock)).toBe(true)
532
+ })
533
+
534
+ it('a dispatch that clears (worker returned) re-allows the clear before the TTL', () => {
535
+ pendingProgress.noteAsyncDispatch(KEY)
536
+ expect(decideNow(clock)).toBe(false)
537
+ // Worker handback / user inbound clears the pending flag well before TTL.
538
+ clock += 5 * M
539
+ pendingProgress.clearPending(KEY, 'handback')
540
+ expect(decideNow(clock)).toBe(true)
541
+ })
542
+
543
+ it('a fresh dispatch re-arms the TTL (freshest legitimate work wins)', () => {
544
+ pendingProgress.noteAsyncDispatch(KEY)
545
+ // Advance to just before expiry, then a new dispatch re-stamps.
546
+ clock += pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS - 1
547
+ pendingProgress.noteAsyncDispatch(KEY)
548
+ // Now advance past what WOULD have been the first dispatch's expiry.
549
+ clock += 2
550
+ expect(decideNow(clock)).toBe(false) // re-armed → still suppressed
551
+ })
552
+ })
553
+
276
554
  describe('idleDurationToMs', () => {
277
555
  it('parses s/m/h', () => {
278
556
  expect(idleDurationToMs('3h')).toBe(3 * H)
@@ -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
+ })