switchroom 0.19.46 → 0.19.47

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.
@@ -30,11 +30,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
30
30
  import {
31
31
  shouldArmAnswerReadyFlush,
32
32
  resolveAnswerReadyFlushMs,
33
+ resolveAnswerStageMs,
33
34
  ANSWER_READY_FLUSH_MS,
35
+ ANSWER_STAGE_MS,
34
36
  AnswerReadyFlushController,
35
37
  type AnswerReadyArmInput,
36
38
  type FlushTimerHandle,
37
39
  } from '../answer-ready-flush.js'
40
+ import { decideTurnFlush } from '../turn-flush-safety.js'
38
41
  import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
39
42
 
40
43
  const CHAT = '12345'
@@ -174,9 +177,10 @@ class GatewayWorld {
174
177
  window = WINDOW
175
178
  readonly controller: AnswerReadyFlushController<FakeTurn>
176
179
 
177
- constructor() {
180
+ constructor(stageMs = 0) {
178
181
  this.currentTurn = { capturedText: [], replyCalled: false, answerReadyFlushTimeoutId: null }
179
182
  this.controller = new AnswerReadyFlushController<FakeTurn>({
183
+ stageWindowMs: stageMs,
180
184
  getCurrentTurn: () => this.currentTurn,
181
185
  getArmInput: (turn) => ({
182
186
  flush: { chatId: CHAT, replyCalled: turn.replyCalled, capturedText: turn.capturedText, flushEnabled: true },
@@ -203,14 +207,32 @@ class GatewayWorld {
203
207
  }
204
208
 
205
209
  /** = handleSessionEvent({turn_end}). A null atom short-circuits (no second
206
- * send); otherwise deliver once and tear the turn down. */
210
+ * send); otherwise the turn-flush branch runs the REAL `decideTurnFlush`
211
+ * classifier (exactly as stream-render does at turn_end): a reply-served
212
+ * turn tears down without a flush send; a captured-answer turn delivers
213
+ * once. Then tear the turn down. */
207
214
  dispatchTurnEnd(): void {
208
215
  const turn = this.currentTurn
209
216
  if (turn == null) return // atom already gone → exactly-once
210
- this.sends++
217
+ const d = decideTurnFlush({
218
+ chatId: CHAT,
219
+ replyCalled: turn.replyCalled,
220
+ capturedText: turn.capturedText,
221
+ flushEnabled: true,
222
+ })
223
+ if (d.kind === 'flush') this.sends++
211
224
  this.endCurrentTurnAtomic(turn)
212
225
  }
213
226
 
227
+ /** = the model's `reply` tool call landing on the live turn: the canonical
228
+ * answer message is sent on the normal reply path and the turn is marked
229
+ * reply-served (turn teardown waits for the real turn_end). */
230
+ onReply(): void {
231
+ if (this.currentTurn == null) return
232
+ this.currentTurn.replyCalled = true
233
+ this.sends++ // the ONE canonical user-visible message
234
+ }
235
+
214
236
  /** = case 'text': push the chunk, then (re)arm via the REAL controller. */
215
237
  onText(chunk: string): void {
216
238
  if (this.currentTurn != null) this.currentTurn.capturedText.push(chunk)
@@ -341,3 +363,125 @@ describe('answer-ready quiescence flush — real controller integration', () =>
341
363
  expect(vi.getTimerCount()).toBe(0)
342
364
  })
343
365
  })
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Stage-don't-send (2026-08-02 live repro — klanker DM, msgs 25843/25844).
369
+ // The quiescence flush posted the composed answer at 00:22:02Z; the model's
370
+ // `reply` tool call landed 4 s later (delivered 00:22:36Z as msg 25844, a
371
+ // REWORDING — not containment-equal) while a background sub-agent was live, so
372
+ // the #4176 content-gate hold shipped it FRESH: two near-identical messages.
373
+ // With staging, the quiescence expiry HOLDS the answer on the live turn and
374
+ // only promotes when the completion window closes with no reply — the
375
+ // provisional send (and everything downstream that had to claw it back) never
376
+ // happens. These timelines assert OUTCOMES: user-visible messages sent.
377
+ // ---------------------------------------------------------------------------
378
+
379
+ const STAGE = 30_000
380
+
381
+ describe('answer-ready stage-don\'t-send — completion-window staging', () => {
382
+ beforeEach(() => vi.useFakeTimers())
383
+ afterEach(() => vi.useRealTimers())
384
+
385
+ it('LIVE REPRO (msgs 25843/25844): a reply landing inside the stage window → exactly ONE message', () => {
386
+ const w = new GatewayWorld(STAGE)
387
+ // 00:21:30–00:22:00 — the model streams its terminal narration.
388
+ w.onText('Yes, that one\'s done, not pending. …the composed terminal answer…')
389
+ // 00:22:01 — quiescence fires. Pre-fix this SENT msg 25843; staged, it must
390
+ // send NOTHING (the turn stays live — typing hold — awaiting completion).
391
+ vi.advanceTimersByTime(WINDOW)
392
+ expect(w.sends).toBe(0) // ← RED on the immediate-flush behaviour
393
+ expect(w.currentTurn).not.toBeNull() // turn NOT ended by a provisional flush
394
+ // 00:22:06 — the model's `reply` lands (the reworded canonical answer).
395
+ vi.advanceTimersByTime(5_000)
396
+ w.onReply()
397
+ expect(w.sends).toBe(1) // the one canonical message (msg 25844)
398
+ // The stage promotion timer expires → fire-time re-verify sees replyCalled
399
+ // and DISCARDS the staged text. No second message, ever.
400
+ vi.advanceTimersByTime(STAGE)
401
+ expect(w.sends).toBe(1)
402
+ // The real turn_end finally lands: reply-served → teardown, no flush.
403
+ w.dispatchTurnEnd()
404
+ expect(w.sends).toBe(1)
405
+ expect(w.records).toBe(1)
406
+ vi.advanceTimersByTime(BACKSTOP_MS)
407
+ expect(w.sends).toBe(1)
408
+ expect(vi.getTimerCount()).toBe(0)
409
+ })
410
+
411
+ it('promotes the staged answer when the completion window closes with NO reply (delivery preserved)', () => {
412
+ const w = new GatewayWorld(STAGE)
413
+ w.onText('The composed final answer.')
414
+ vi.advanceTimersByTime(WINDOW)
415
+ expect(w.sends).toBe(0) // staged, not sent
416
+ // No reply, no real turn_end (the KNOWN-UNRELIABLE turn_end case PR A was
417
+ // built for): the stage window closes → promote through the same flush path.
418
+ vi.advanceTimersByTime(STAGE - 1)
419
+ expect(w.sends).toBe(0)
420
+ vi.advanceTimersByTime(1)
421
+ expect(w.sends).toBe(1)
422
+ expect(w.records).toBe(1)
423
+ // Exactly once: a late real turn_end short-circuits on the null atom.
424
+ w.dispatchTurnEnd()
425
+ expect(w.sends).toBe(1)
426
+ })
427
+
428
+ it('a REAL turn_end during the stage window delivers via the turn-flush branch — no double-send', () => {
429
+ const w = new GatewayWorld(STAGE)
430
+ w.onText('The composed final answer.')
431
+ vi.advanceTimersByTime(WINDOW) // staged
432
+ expect(w.sends).toBe(0)
433
+ vi.advanceTimersByTime(5_000)
434
+ w.dispatchTurnEnd() // the true completion signal lands first
435
+ expect(w.sends).toBe(1) // delivered by the turn-flush branch at turn_end
436
+ // endCurrentTurnAtomic's clear() cancelled the pending promotion timer.
437
+ expect(vi.getTimerCount()).toBe(0)
438
+ vi.advanceTimersByTime(BACKSTOP_MS)
439
+ expect(w.sends).toBe(1)
440
+ })
441
+
442
+ it('new text during the stage window re-stages (model still composing) and promotes once', () => {
443
+ const w = new GatewayWorld(STAGE)
444
+ w.onText('First part of the answer. ')
445
+ vi.advanceTimersByTime(WINDOW) // staged
446
+ expect(w.sends).toBe(0)
447
+ vi.advanceTimersByTime(10_000)
448
+ w.onText('Second part — the model was still composing.') // re-arms the debounce
449
+ vi.advanceTimersByTime(WINDOW) // re-staged with a fresh window
450
+ expect(w.sends).toBe(0)
451
+ vi.advanceTimersByTime(STAGE)
452
+ expect(w.sends).toBe(1) // promoted exactly once, with the full text captured
453
+ })
454
+
455
+ it('a tool starting during the stage window discards the stage (model resumed work)', () => {
456
+ const w = new GatewayWorld(STAGE)
457
+ w.onText('Interim narration that looked terminal.')
458
+ vi.advanceTimersByTime(WINDOW) // staged
459
+ w.onToolUse('bash_1') // clear() → unstaged + promotion timer cancelled
460
+ vi.advanceTimersByTime(BACKSTOP_MS)
461
+ expect(w.sends).toBe(0)
462
+ expect(vi.getTimerCount()).toBe(0)
463
+ })
464
+
465
+ it('stageMs=0 keeps the legacy immediate flush at quiescence', () => {
466
+ const w = new GatewayWorld(0)
467
+ w.onText('The composed final answer.')
468
+ vi.advanceTimersByTime(WINDOW)
469
+ expect(w.sends).toBe(1) // legacy: delivered at the ~1 s debounce
470
+ })
471
+ })
472
+
473
+ describe('resolveAnswerStageMs', () => {
474
+ it('defaults to ANSWER_STAGE_MS when unset', () => {
475
+ expect(resolveAnswerStageMs({})).toBe(ANSWER_STAGE_MS)
476
+ })
477
+ it('parses a positive override', () => {
478
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '15000' })).toBe(15_000)
479
+ })
480
+ it('treats 0 (and negatives) as the legacy immediate flush (0)', () => {
481
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '0' })).toBe(0)
482
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '-5' })).toBe(0)
483
+ })
484
+ it('fails safe to the default on an unparseable value', () => {
485
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: 'soon' })).toBe(ANSWER_STAGE_MS)
486
+ })
487
+ })
@@ -12,6 +12,7 @@ import {
12
12
  type PreTurnCardRecord,
13
13
  } from '../gateway/handback-preturn-signal.js'
14
14
  import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
15
+ import { SessionConsumeSignal } from '../gateway/session-consume-signal.js'
15
16
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
16
17
 
17
18
  /**
@@ -471,6 +472,82 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
471
472
  expect(h.signal.pendingCount()).toBe(0)
472
473
  })
473
474
 
475
+ // ── 2026-08-02 LIVE REPRO REPLAY (klanker, turnId #1785628912223) ──
476
+ // A handback was released+delivered at T0; the session CONSUMED it and
477
+ // answered (a reply tool call from idle at T0+65s — the only observable
478
+ // trace; no adopting turn ever minted), a later real turn minted and
479
+ // completed — yet at T0+TTL the reap declared a genuine orphan and
480
+ // RE-INJECTED the already-answered report (attempt 1/2 at 00:06:53Z,
481
+ // 2/2 at 00:11:58Z, escalation 00:17:34Z). The invisible-consumption gate
482
+ // must clean up WITHOUT re-injection. This test is RED on the pre-fix seam
483
+ // (it re-injects) and GREEN with `sessionActivitySince` consulted.
484
+ it('does NOT re-inject a handback the session already consumed (reply-from-idle after delivery)', async () => {
485
+ const consume = new SessionConsumeSignal()
486
+ const h = makeHarness({
487
+ deliveredTtlMs: 300_000,
488
+ sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
489
+ })
490
+ // T0: release (= delivery) — pre-turn entry armed, card painted at debounce.
491
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatConsumed', messageId: 900 }))
492
+ await h.sched.advance(700)
493
+ const cardId = await h.openCard.mock.results[0]!.value
494
+ expect(h.records.size).toBe(1)
495
+
496
+ // T0+65s: the session answers the handback — a main-session reply tool
497
+ // call observed over IPC while the gateway shows no live turn (the
498
+ // 00:02:58Z reply). NO gateway turn ever mints, so tryAdopt never fires.
499
+ await h.sched.advance(65_000)
500
+ consume.noteMainToolCall(h.sched.now())
501
+
502
+ // T0+TTL and beyond: the reap fires (idle, delivered TTL elapsed). The
503
+ // consumption evidence must resolve the entry WITHOUT re-injection and
504
+ // WITHOUT escalation — the frozen card is deleted, typing stops, entry
505
+ // dropped. OUTCOME: zero re-injected inbounds ever reach the buffer.
506
+ await h.sched.advance(600_000)
507
+ expect(h.reinjected).toEqual([])
508
+ expect(h.escalations).toEqual([])
509
+ expect(h.deletedIds).toEqual([cardId])
510
+ expect(h.records.size).toBe(0)
511
+ expect(h.signal.pendingCount()).toBe(0)
512
+ expect(h.typing.activeCount()).toBe(0)
513
+ })
514
+
515
+ it('a later turn MINT after delivery also counts as consumption (coalesced-turn case)', async () => {
516
+ const consume = new SessionConsumeSignal()
517
+ const h = makeHarness({
518
+ deliveredTtlMs: 300_000,
519
+ sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
520
+ })
521
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatCoalesced', messageId: 901 }))
522
+ await h.sched.advance(700)
523
+ // A real user turn mints AFTER the delivery (the 00:04:13Z turn #25821):
524
+ // FIFO queue ⇒ the earlier-delivered handback entered the model context.
525
+ await h.sched.advance(140_000)
526
+ consume.noteTurnMint(h.sched.now())
527
+ await h.sched.advance(600_000)
528
+ expect(h.reinjected).toEqual([])
529
+ expect(h.escalations).toEqual([])
530
+ expect(h.signal.pendingCount()).toBe(0)
531
+ })
532
+
533
+ it('still re-injects a GENUINE orphan (no session activity since delivery)', async () => {
534
+ const consume = new SessionConsumeSignal()
535
+ const h = makeHarness({
536
+ deliveredTtlMs: 300_000,
537
+ sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
538
+ })
539
+ // Activity BEFORE the delivery must not mask a genuine orphan.
540
+ consume.noteMainToolCall(h.sched.now())
541
+ await h.sched.advance(10)
542
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatDead', messageId: 902 }))
543
+ await h.sched.advance(700)
544
+ // Nothing consumes anything; past the TTL the deterministic recovery must
545
+ // still fire exactly as before the gate (delete + re-inject, no nudge).
546
+ await h.sched.advance(600_000)
547
+ expect(h.reinjected).toHaveLength(1)
548
+ expect(h.deletedIds).toHaveLength(1)
549
+ })
550
+
474
551
  // ── deterministic-recovery retry cap: re-inject up to 2×, THEN escalate ──
475
552
  // A genuine orphan whose re-injections keep coming back orphaned must not loop
476
553
  // forever, and must NEVER fall back to a chat nudge. After `maxReinjects` it