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
@@ -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)
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach } from 'vitest'
14
14
  import {
15
15
  parseLlmError,
16
16
  renderLlmError,
17
+ renderLlmErrorSafe,
17
18
  stripRawErrorBytes,
18
19
  extractRequestId,
19
20
  formatResetLocal,
@@ -27,6 +28,7 @@ import {
27
28
  import { truncateDetailPreservingRequestId } from '../raw-error-scrub.js'
28
29
  import { projectTranscriptLine, detectErrorInTranscriptLine } from '../session-tail.js'
29
30
  import { renderOperatorEvent, type OperatorEvent } from '../operator-events.js'
31
+ import { redact } from '../secret-detect/redact.js'
30
32
 
31
33
  // A raw byte-blob every surface must scrub.
32
34
  const RAW_BYTES = `b'{"type":"error","error":{"type":"rate_limit_error","message":"rate limit"},"request_id":"req_abc123"}'`
@@ -161,16 +163,115 @@ describe('renderLlmError / formatResetLocal — local-time rendering', () => {
161
163
  expect(text).toContain('AEST')
162
164
  })
163
165
 
164
- it('auth + quota_wall cards carry action buttons', () => {
166
+ // FIX 1 (Ken, CPO, 2026-07): the dead auth/quota action buttons are GONE
167
+ // renderLlmError never returns an inline_keyboard; the actionable classes
168
+ // carry a plain-text recommendation line instead.
169
+ it('auth card recommends re-authentication in text, with NO action buttons', () => {
165
170
  const auth = renderLlmError(parseLlmError('authentication_error: token expired'), 'a', tz, now)
166
- expect(auth.keyboard?.inline_keyboard.flat().some((b) => b.callback_data?.includes('reauth'))).toBe(true)
167
- const quota = renderLlmError(
168
- parseLlmError("You've hit your limit · resets 5pm"),
169
- 'a',
170
- tz,
171
- now,
172
- )
173
- expect(quota.keyboard?.inline_keyboard.flat().length).toBeGreaterThan(0)
171
+ expect((auth as { keyboard?: unknown }).keyboard).toBeUndefined()
172
+ expect(auth.text.toLowerCase()).toContain('re-authenticate')
173
+ })
174
+
175
+ it('quota_wall card recommends switch/wait in text (naming the reset), NO buttons', () => {
176
+ const quota = renderLlmError(parseLlmError("You've hit your limit · resets 5pm"), 'a', tz, now)
177
+ expect((quota as { keyboard?: unknown }).keyboard).toBeUndefined()
178
+ const lower = quota.text.toLowerCase()
179
+ expect(lower).toContain('switch to another account')
180
+ expect(lower).toContain('wait for the quota to reset')
181
+ // The reset instant is named in the recommendation line.
182
+ expect(quota.text).toContain('AEST')
183
+ })
184
+
185
+ it('transient (rate_limit) card has neither buttons nor an action recommendation', () => {
186
+ const rl = renderLlmError(parseLlmError('rate_limit_error: slow down'), 'a', tz, now)
187
+ expect((rl as { keyboard?: unknown }).keyboard).toBeUndefined()
188
+ expect(rl.text.toLowerCase()).not.toContain('re-authenticate')
189
+ expect(rl.text).not.toContain('→')
190
+ })
191
+
192
+ // FIX 3 (crash guard): an invalid IANA timezone throws a RangeError out of the
193
+ // raw renderer (local-time.ts's "never throws" claim is false for tz
194
+ // construction). renderLlmErrorSafe MUST swallow it and degrade — asserting the
195
+ // OUTCOME (no throw + a usable message), not just that the branch ran.
196
+ it('renderLlmError DOES throw on an invalid tz with a reset present (documents the hazard)', () => {
197
+ const parsed = parseLlmError("You've hit your limit · resets 5pm")
198
+ expect(parsed.resetAt).toBeDefined()
199
+ expect(() => renderLlmError(parsed, 'gymbro', 'Not/AZone', now)).toThrow()
200
+ })
201
+
202
+ it('renderLlmErrorSafe does NOT throw on an invalid tz and returns a usable line', () => {
203
+ const parsed = parseLlmError("You've hit your limit · resets 5pm")
204
+ let out: { text: string } | undefined
205
+ expect(() => {
206
+ out = renderLlmErrorSafe(parsed, 'gymbro', 'Not/AZone', now)
207
+ }).not.toThrow()
208
+ expect(out?.text).toContain('gymbro')
209
+ assertNoRawBytes(out!.text)
210
+ })
211
+ })
212
+
213
+ // ─── FIX 2: operator-card text is scrubbed by the REAL redactor ───────────────
214
+ //
215
+ // The gateway sends operator-event cards via a raw bot.api call that bypasses
216
+ // the normal outbound redact chokepoint; it now routes the rendered text through
217
+ // the same redact() the reply path uses. These tests assert the OUTCOME: a
218
+ // synthetic bearer token / sk- key / url-embedded credential planted in an error
219
+ // detail does NOT survive into the redacted card text. stripRawErrorBytes alone
220
+ // (a JSON-shape scrub) does NOT catch these — redact() is required.
221
+ describe('operator-card secret redaction (FIX 2)', () => {
222
+ const now = new Date('2026-07-13T06:14:00Z')
223
+ // Runtime-assembled so no contiguous Anthropic-token literal lands in source
224
+ // (check-no-pii-secrets discipline). Resolves to a real sk-ant-shaped key that
225
+ // redact()'s anthropic_api_key pattern masks.
226
+ const BEARER = ['sk', 'ant', 'api03-ABCDEF1234567890abcdefGHIJKLMN'].join('-')
227
+ const URL_SECRET = 'https://user:hunter2pass@api.anthropic.com/v1/x?api_key=abc123secretval456'
228
+
229
+ const mkEvent = (kind: OperatorEvent['kind'], detail: string): OperatorEvent => ({
230
+ agent: 'gymbro',
231
+ kind,
232
+ detail,
233
+ suggestedActions: [],
234
+ firstSeenAt: now,
235
+ })
236
+
237
+ // Mirrors emitGatewayOperatorEvent's transform: redact the DETAIL first (via
238
+ // the real redact()), THEN render — so the scrub happens BEFORE the renderer's
239
+ // escapeMarkdown, exactly as production now does. Redacting the already-escaped
240
+ // final text would let url-query-param secrets (`api_key=…`) slip past.
241
+ const renderCardAsSent = (ev: OperatorEvent): string =>
242
+ renderOperatorEvent({ ...ev, detail: redact(ev.detail) }).text
243
+
244
+ it('stripRawErrorBytes alone LEAKS a bearer/api-key (proves redact is needed)', () => {
245
+ // Guard test: the shape-scrub inside the renderer is JSON-shape-only. If this
246
+ // ever stops leaking, the shape-scrub grew secret awareness.
247
+ expect(stripRawErrorBytes(`auth failed: Bearer ${BEARER}`)).toContain(BEARER)
248
+ })
249
+
250
+ it('pre-fix render (no redact) LEAKS the bearer; the production transform scrubs it', () => {
251
+ const ev = mkEvent('credentials-expired', `token rejected: Bearer ${BEARER}`)
252
+ // Renderer alone (pre-fix path): secret survives.
253
+ expect(renderOperatorEvent(ev).text).toContain(BEARER)
254
+ // Production transform (redact detail → render): secret is gone.
255
+ expect(renderCardAsSent(ev)).not.toContain(BEARER)
256
+ })
257
+
258
+ it('masks a url-embedded credential in a credit-exhausted card', () => {
259
+ const ev = mkEvent('credit-exhausted', `billing check: ${URL_SECRET}`)
260
+ const sent = renderCardAsSent(ev)
261
+ expect(sent).not.toContain('hunter2pass')
262
+ expect(sent).not.toContain('abc123secretval456')
263
+ })
264
+
265
+ it('masks a bearer key in an unknown-4xx card', () => {
266
+ const ev = mkEvent('unknown-4xx', `API Error: 400 · x-api-key ${BEARER}`)
267
+ expect(renderCardAsSent(ev)).not.toContain(BEARER)
268
+ })
269
+
270
+ it('the humanized (renderLlmError) card never relays raw detail — no secret to leak', () => {
271
+ // The humanized card's coreText is a per-kind TEMPLATE, never the raw detail,
272
+ // so a secret in the detail cannot reach it even before redaction.
273
+ const parsed = parseLlmError(`rate_limit_error · Bearer ${BEARER}`)
274
+ expect(renderLlmError(parsed, 'gymbro', 'Australia/Melbourne', now).text).not.toContain(BEARER)
174
275
  })
175
276
  })
176
277