switchroom 0.19.44 → 0.19.46

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.
@@ -25,6 +25,8 @@ import { tmpdir } from 'node:os'
25
25
  import {
26
26
  handleSessionEvent,
27
27
  __resetParkedTurnStartsForTest,
28
+ flushCompletionTracker,
29
+ closeFlushCompletionWindows,
28
30
  type StreamRenderDeps,
29
31
  } from '../gateway/stream-render.js'
30
32
  import {
@@ -33,7 +35,7 @@ import {
33
35
  type SendReplyRequest,
34
36
  } from '../gateway/outbound-send-path.js'
35
37
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
36
- import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
38
+ import { FlushedTurnSupersedeRegistry, SUPERSEDE_COMPLETED_GRACE_MS } from '../flushed-turn-supersede.js'
37
39
  import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
38
40
  import { redact } from '../secret-detect/redact.js'
39
41
  import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
@@ -279,6 +281,9 @@ function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedT
279
281
  streamKey: key,
280
282
  resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
281
283
  getLastSubagentHandbackAt: () => null,
284
+ // #4176 — no sub-agent live (these suites drive the parent session's own
285
+ // flush→reply collapse). The gate is exercised in send-reply-golden.test.ts.
286
+ subagentReplyAuthority: { subagentCouldOwnReply: () => false },
282
287
  findTurnByOriginId: () => null,
283
288
  findTurnByQuotedMessageId: () => null,
284
289
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
@@ -698,3 +703,201 @@ describe('#3544 — turn-start typing is unconditional at the enqueue seam', ()
698
703
  rig.emitter.reset()
699
704
  })
700
705
  })
706
+
707
+
708
+ // ── #4173 — the turn-completion window wiring, driven through the REAL paths ──
709
+ //
710
+ // The pure three-phase rule is pinned in flushed-turn-supersede.test.ts /
711
+ // reply-owner-resolve.test.ts; THESE tests pin the stream-render WIRING — the
712
+ // quiescence flush OPENING the window and the real turn_end CLOSING it — which
713
+ // nothing else covers (deleting either hook leaves every pure test green).
714
+ describe('#4173 — quiescence flush opens the completion window; the real turn_end closes it', () => {
715
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
716
+ const ANSWER =
717
+ 'The composed terminal answer that the model never sent via reply, long enough to be a ' +
718
+ 'genuine substantive flush delivery for the completion-window wiring tests.'
719
+
720
+ beforeEach(() => {
721
+ // drain module-scope tracker state left by other suites
722
+ flushCompletionTracker.closeAll(Date.now())
723
+ })
724
+
725
+ it('END-TO-END: the synthetic quiescence turn_end OPENS the window (atom + record ' +
726
+ 'claimable far past any fixed TTL); the REAL turn_end then CLOSES both to the ' +
727
+ 'replay grace', async () => {
728
+ const supersede = new FlushedTurnSupersedeRegistry()
729
+ // Pre-stamp a bogus value so the OPEN assertion proves the flush branch
730
+ // actively nulled it (not that it was merely never written).
731
+ const turn = makeTurn({ capturedText: [ANSWER], realEndObservedAt: 111 } as Partial<CurrentTurn>)
732
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
733
+
734
+ // The answer-ready quiescence flush: a SYNTHETIC turn_end.
735
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' })
736
+ await settleFlush()
737
+ expect(sh.delivered).toContain(ANSWER)
738
+
739
+ // OPEN: the atom's window was re-opened at fire time…
740
+ expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).toBeNull()
741
+ // …and the record is claimable 20 minutes out (a /compact longer than any
742
+ // tuned TTL still collapses to one bubble — the #4166 unbounded case).
743
+ // Removing the flush branch's `flushCompletionTracker.open(turn)` leaves
744
+ // the record born-completed via turn-end stamping in prod; in this harness
745
+ // the observable red is the atom assertion above.
746
+ expect(
747
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + 20 * 60_000 }).reason,
748
+ ).toBe('supersede')
749
+
750
+ // The session's REAL turn_end lands later — the completion signal.
751
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
752
+ await settleFlush()
753
+
754
+ // CLOSED: the atom is stamped…
755
+ const stamped = (turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt
756
+ expect(stamped).not.toBeNull()
757
+ // …the record still honours the bounded tool-call-replay grace…
758
+ expect(
759
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + 1_000 }).reason,
760
+ ).toBe('supersede')
761
+ // …and EXPIRES past it: a decoupled same-bridge reply (the Task-sub-agent-
762
+ // after-parent-turn-end shape) can no longer edit over the flushed answer.
763
+ // Removing the close hook (`closeFlushCompletionWindows` in case 'turn_end')
764
+ // turns THIS red — the record would stay open for 30 minutes.
765
+ expect(
766
+ supersede.peek(CHAT, undefined, {
767
+ liveTurnId: turn.turnId,
768
+ now: Date.now() + SUPERSEDE_COMPLETED_GRACE_MS + 5_000,
769
+ }).reason,
770
+ ).toBe('expired')
771
+ })
772
+
773
+ it('the REAL-turn_end backstop variant does NOT re-open the window ' +
774
+ '(the `durationMs === -1` conditional is load-bearing)', async () => {
775
+ const supersede = new FlushedTurnSupersedeRegistry()
776
+ const turn = makeTurn({ capturedText: [ANSWER], realEndObservedAt: 999 } as Partial<CurrentTurn>)
777
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
778
+
779
+ // The turn-end BACKSTOP: the flush fires ON a real turn_end. The session
780
+ // already stopped, so the window must stay closed (born completed).
781
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
782
+ await settleFlush()
783
+ expect(sh.delivered).toContain(ANSWER)
784
+
785
+ // Unconditionally opening in the flush branch (dropping the -1 check)
786
+ // nulls this and turns the test red.
787
+ expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).not.toBeNull()
788
+ // The record was born completed (inherited from the atom), so it expires
789
+ // on the grace, never the 30-minute open cap.
790
+ expect(
791
+ supersede.peek(CHAT, undefined, {
792
+ liveTurnId: turn.turnId,
793
+ now: Date.now() + SUPERSEDE_COMPLETED_GRACE_MS + 5_000,
794
+ }).reason,
795
+ ).toBe('expired')
796
+ })
797
+ })
798
+
799
+ // ── LOW #2 (#4167 review) — the two CLOSE PROXIES, which nothing covered ──────
800
+ //
801
+ // `closeFlushCompletionWindows` has three call sites: the real turn_end (pinned
802
+ // by the #4173 block above) and two PROXIES — a new turn minting on the serial
803
+ // session (`case 'enqueue'`), and the main bridge dying (gateway.ts). Both were
804
+ // untested: deleting the `beginTurn` call left all 18 stream-render tests green,
805
+ // and the bridge-death sweep had no coverage at all. A silently-dropped proxy
806
+ // leaves a flushed turn's window OPEN for the full 30-minute crash cap, which is
807
+ // exactly the tail that makes an edit-over of a delivered answer possible.
808
+ describe('#4173 close proxies — a new turn mint and bridge death both close open windows', () => {
809
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
810
+ const ANSWER =
811
+ 'The composed terminal answer that the model never sent via reply, long enough to be a ' +
812
+ 'genuine substantive flush delivery for the close-proxy wiring tests.'
813
+ const CLOSED_BY_GRACE = SUPERSEDE_COMPLETED_GRACE_MS + 5_000
814
+ // Well inside the 30-minute OPEN cap: if the window did NOT close, the record
815
+ // is still claimable here — which is what makes these assertions bite.
816
+ const STILL_OPEN_AT = 10 * 60_000
817
+
818
+ beforeEach(() => {
819
+ __resetParkedTurnStartsForTest()
820
+ flushCompletionTracker.closeAll(Date.now())
821
+ })
822
+
823
+ async function flushWithOpenWindow(supersede: FlushedTurnSupersedeRegistry) {
824
+ const turn = makeTurn({ capturedText: [ANSWER], realEndObservedAt: 111 } as Partial<CurrentTurn>)
825
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
826
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' })
827
+ await settleFlush()
828
+ expect(sh.delivered).toContain(ANSWER)
829
+ // Precondition: the window is genuinely OPEN (claimable 10 minutes out).
830
+ expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).toBeNull()
831
+ expect(
832
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + STILL_OPEN_AT }).reason,
833
+ ).toBe('supersede')
834
+ return { turn, sh }
835
+ }
836
+
837
+ it('PROXY A: a NEW TURN minting (case `enqueue`) closes the prior flush window — ' +
838
+ 'the record then expires on the replay grace, not the 30-minute open cap', async () => {
839
+ const supersede = new FlushedTurnSupersedeRegistry()
840
+ const { turn, sh } = await flushWithOpenWindow(supersede)
841
+
842
+ // The serial session moved on: the CLI enqueues the next turn. The harness's
843
+ // fake `endCurrentTurnAtomic` does not null the turn, so clear it here — an
844
+ // idle session is what makes `enqueue` MINT rather than park.
845
+ ;(sh.deps as unknown as { setCurrentTurn: (t: CurrentTurn | null) => void }).setCurrentTurn(null)
846
+ handleSessionEvent(sh.deps, {
847
+ kind: 'enqueue',
848
+ chatId: CHAT,
849
+ messageId: null,
850
+ threadId: null,
851
+ rawContent: 'the next user message',
852
+ })
853
+
854
+ // The atom is stamped…
855
+ expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).not.toBeNull()
856
+ // …the bounded replay grace still honours the flushed turn's own late reply…
857
+ expect(
858
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + 1_000 }).reason,
859
+ ).toBe('supersede')
860
+ // …and past the grace it is EXPIRED. Deleting `closeFlushCompletionWindows`
861
+ // from `beginTurn` turns BOTH of the assertions below red (the record would
862
+ // still read 'supersede' out to the 30-minute cap).
863
+ expect(
864
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + CLOSED_BY_GRACE }).reason,
865
+ ).toBe('expired')
866
+ expect(
867
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + STILL_OPEN_AT }).reason,
868
+ ).toBe('expired')
869
+ })
870
+
871
+ it('PROXY B: the bridge-death sweep (`closeFlushCompletionWindows`) closes an open ' +
872
+ 'window to the grace and reports how many atoms it stamped', async () => {
873
+ const supersede = new FlushedTurnSupersedeRegistry()
874
+ const { turn } = await flushWithOpenWindow(supersede)
875
+
876
+ // The main claude bridge disconnected: the exact call gateway.ts makes.
877
+ const closed = closeFlushCompletionWindows(supersede, Date.now())
878
+ expect(closed).toBe(1) // the open atom was stamped, not silently skipped
879
+
880
+ expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).not.toBeNull()
881
+ expect(
882
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + CLOSED_BY_GRACE }).reason,
883
+ ).toBe('expired')
884
+ // Idempotent: a second disconnect sweep stamps nothing further.
885
+ expect(closeFlushCompletionWindows(supersede, Date.now())).toBe(0)
886
+ })
887
+
888
+ it('PROXY B wiring: gateway.ts calls the sweep on MAIN-bridge disconnect and the ' +
889
+ '#4176 sub-agent liveness reset alongside it (gateway.ts is not importable)', () => {
890
+ const src = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
891
+ const call = 'const closedWindows = closeFlushCompletionWindows(flushedTurnSupersede, Date.now())'
892
+ expect(src).toContain(call)
893
+ // The sweep must sit under the main-bridge guard: a CRON bridge dying says
894
+ // nothing about the main session's flush windows, so closing there would
895
+ // shorten a live window on an unrelated session.
896
+ const idx = src.indexOf(call)
897
+ expect(idx).toBeGreaterThan(0)
898
+ const before = src.slice(Math.max(0, idx - 1200), idx)
899
+ expect(before).toContain('isCronIdentity')
900
+ // #4176 — the sub-agents die with the session; the reset is co-located.
901
+ expect(before).toContain('subagentReplyAuthority.reset()')
902
+ })
903
+ })