switchroom 0.18.18 → 0.18.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
54
|
-
|
|
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.
|
|
86
|
+
this.tracker = new IdleTracker(startedAt)
|
|
65
87
|
}
|
|
66
88
|
|
|
67
|
-
/** A claude session-stream event
|
|
89
|
+
/** A claude session-stream event → the gateway's handleSessionEvent stamp. */
|
|
68
90
|
sessionEvent(kind: string, now: number, durationMs?: number): void {
|
|
69
|
-
|
|
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.
|
|
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 } =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
173
|
-
|
|
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
|
|