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.
- package/dist/cli/switchroom.js +848 -826
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/dist/gateway/gateway.js +499 -322
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +67 -66
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +58 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
|
@@ -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
|
+
})
|