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.
- package/dist/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- 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 +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- 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/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- 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 +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- 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/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- 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/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- 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 +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- 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)
|
|
@@ -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
|
+
})
|