switchroom 0.14.55 → 0.14.56

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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Serialize-until-replied buffer-drain gate (multitopic reply-routing).
3
+ *
4
+ * Pure decision: when a turn ends and a cross-topic inbound is sitting in
5
+ * `pendingInboundBuffer`, may the gateway drain (re-deliver) that buffered
6
+ * inbound *right now*?
7
+ *
8
+ * ## The bug this closes
9
+ *
10
+ * In a forum supergroup one agent owns the whole supergroup — a single
11
+ * sequential `claude` CLI with a singleton `currentTurn`. Two questions in
12
+ * two topics (Brevo=thread 4, then Meta=thread 3) were both answered into
13
+ * Meta. Root cause #1 of two coupled defects:
14
+ *
15
+ * The buffer drain (`redeliverBufferedInbound`, fired from
16
+ * `purgeReactionTracking` and `releaseTurnBufferGate`) ran whenever
17
+ * `!turnInFlightForGate()` — i.e. on the bare turn-end signal —
18
+ * REGARDLESS of whether the turn that just ended had actually delivered
19
+ * its reply. When the Brevo turn emitted its turn-end event BEFORE its
20
+ * late reply landed (~42s later), the buffered Meta message drained
21
+ * immediately and started the Meta turn. By the time the Brevo reply
22
+ * executed, `currentTurn` had flipped to Meta and the reply routed to
23
+ * Meta's thread (defect #2, fixed separately by turn-origin routing).
24
+ *
25
+ * ## The guarantee
26
+ *
27
+ * A buffered cross-topic inbound drains only after the just-ended turn
28
+ * delivered its reply to its OWN thread. Concretely: Brevo's buffered-Meta
29
+ * drain waits until Brevo's reply lands in thread 4. Because the canonical
30
+ * reply path (`executeReply`) sets `finalAnswerDelivered = true` and then
31
+ * calls `releaseTurnBufferGate`, the drain now happens FROM the reply, not
32
+ * from the bare turn_end.
33
+ *
34
+ * ## Liveness — the no-reply case is NOT handled here
35
+ *
36
+ * A turn that legitimately ends with NO reply (greeting already handled,
37
+ * handback ack, NO_REPLY / HEARTBEAT_OK marker, silent-end) has
38
+ * `finalAnswerDelivered === false` and would block the drain FOREVER under
39
+ * this predicate. That is intentional: liveness for the no-reply case is
40
+ * restored by a *separate* bounded escape-hatch timer in
41
+ * `endCurrentTurnAtomic` (`SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS`), plus
42
+ * the existing 300s silence-poke unwedge fallback as the long-stop. This
43
+ * predicate stays pure and total — it never reasons about timers.
44
+ *
45
+ * ## Kill switch
46
+ *
47
+ * `SWITCHROOM_SERIALIZE_UNTIL_REPLIED=0` reverts to legacy behaviour
48
+ * (drain on bare turn-end). The kill switch is read by the CALLER, which
49
+ * passes `enabled` here; when disabled the predicate degenerates to the
50
+ * old `!turnInFlight` check.
51
+ */
52
+
53
+ export interface DrainGateInput {
54
+ /** A turn is in flight RIGHT NOW (`turnInFlightForGate()`), evaluated at
55
+ * the drain site. When true, never drain — claude is busy. */
56
+ turnInFlight: boolean
57
+ /** Whether the ending turn delivered its final answer. Read from the
58
+ * ending turn's `finalAnswerDelivered`. `undefined` / `null` means
59
+ * there is no ending turn handle at this drain site (sibling-key purge,
60
+ * restart-init cleanup, idle sweep) — treated as "no turn to wait on",
61
+ * so the gate doesn't block. */
62
+ endingTurnFinalAnswerDelivered?: boolean | null
63
+ /** Kill-switch state. When false the serialize-until-replied behaviour
64
+ * is OFF and the gate reduces to `!turnInFlight` (legacy). */
65
+ enabled: boolean
66
+ }
67
+
68
+ /**
69
+ * Pure. Returns true when the buffered inbound may be drained now.
70
+ *
71
+ * - turnInFlight → always false (claude is busy; never drain mid-turn).
72
+ * - !enabled (kill switch off) → drain whenever idle (legacy behaviour).
73
+ * - no ending-turn handle (undefined/null) → drain (nothing to wait on).
74
+ * - ending turn delivered its final answer → drain.
75
+ * - ending turn ended WITHOUT a final answer → do NOT drain here; the
76
+ * bounded no-reply escape hatch (separate timer) releases it.
77
+ */
78
+ export function mayDrainBufferedInbound(input: DrainGateInput): boolean {
79
+ if (input.turnInFlight) return false
80
+ if (!input.enabled) return true
81
+ const delivered = input.endingTurnFinalAnswerDelivered
82
+ if (delivered == null) return true
83
+ return delivered === true
84
+ }
85
+
86
+ export interface NoReplyDrainArmInput {
87
+ /** Kill-switch state. Off → never arm (legacy behaviour drains on the
88
+ * bare turn-end, so there is nothing to rescue). */
89
+ enabled: boolean
90
+ /** Whether the just-ended turn delivered its final answer. A delivered
91
+ * turn already drained via the serialize gate — no rescue needed. */
92
+ finalAnswerDelivered: boolean
93
+ /** Number of inbounds sitting in the pending-inbound buffer for this
94
+ * agent. Only arm when something is actually waiting. */
95
+ bufferedDepth: number
96
+ }
97
+
98
+ /**
99
+ * Component 2 — should the bounded no-reply drain timer be armed when a
100
+ * turn ends? Pure mirror of the guard inside `armNoReplyDrainTimer`. THIS
101
+ * is the liveness guarantee in predicate form: a turn that ends with NO
102
+ * reply (finalAnswerDelivered=false) AND a buffered inbound waiting must
103
+ * arm the bounded force-drain so the queue can never wedge. A delivered
104
+ * turn (already drained), an empty buffer (nothing to rescue), or the
105
+ * feature being disabled → do not arm.
106
+ */
107
+ export function shouldArmNoReplyDrain(input: NoReplyDrainArmInput): boolean {
108
+ if (!input.enabled) return false
109
+ if (input.finalAnswerDelivered === true) return false
110
+ return input.bufferedDepth > 0
111
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveAnswerThreadId } from '../gateway/answer-thread-resolve.js'
4
+
5
+ /**
6
+ * Component 3 (multitopic reply-routing) — turn-origin answer thread.
7
+ *
8
+ * The Brevo answer landed ~42s after Brevo's turn-end; by then currentTurn
9
+ * had flipped to the Meta turn. executeReply captured `turn = currentTurn`
10
+ * and, when the model omitted message_thread_id, routed to the LIVE (Meta)
11
+ * turn's thread — so Brevo's answer landed in Meta. The fix routes by the
12
+ * ORIGIN turn (matched by origin_turn_id), authoritative even after the
13
+ * flip, and never falls through to the chat last-seen heuristic for answers.
14
+ */
15
+ describe('resolveAnswerThreadId', () => {
16
+ const BREVO = 4
17
+ const META = 3
18
+
19
+ it('explicit model thread wins outright', () => {
20
+ expect(
21
+ resolveAnswerThreadId({
22
+ explicitThreadId: BREVO,
23
+ originResolved: true,
24
+ originThreadId: META, // even if origin says otherwise
25
+ liveThreadId: META,
26
+ }),
27
+ ).toBe(BREVO)
28
+ })
29
+
30
+ it('routes to the ORIGIN turn thread even when currentTurn has flipped (the headline fix)', () => {
31
+ // Brevo reply executing late: origin=Brevo(4), live currentTurn=Meta(3).
32
+ // Must land in Brevo, NOT the live Meta thread.
33
+ expect(
34
+ resolveAnswerThreadId({
35
+ explicitThreadId: undefined,
36
+ originResolved: true,
37
+ originThreadId: BREVO,
38
+ liveThreadId: META,
39
+ }),
40
+ ).toBe(BREVO)
41
+ })
42
+
43
+ it('origin thread is authoritative even when it equals the live thread (no flip)', () => {
44
+ expect(
45
+ resolveAnswerThreadId({
46
+ explicitThreadId: undefined,
47
+ originResolved: true,
48
+ originThreadId: BREVO,
49
+ liveThreadId: BREVO,
50
+ }),
51
+ ).toBe(BREVO)
52
+ })
53
+
54
+ it('a DM origin turn yields undefined (no thread), not the live thread', () => {
55
+ expect(
56
+ resolveAnswerThreadId({
57
+ explicitThreadId: undefined,
58
+ originResolved: true,
59
+ originThreadId: undefined, // DM origin
60
+ liveThreadId: META,
61
+ }),
62
+ ).toBeUndefined()
63
+ })
64
+
65
+ it('falls back to the LIVE turn thread only when no origin is resolvable (legacy #1664)', () => {
66
+ // Model omitted origin_turn_id, or the origin turn was evicted from the
67
+ // bounded registry: preserve the existing turn-pinned behaviour.
68
+ expect(
69
+ resolveAnswerThreadId({
70
+ explicitThreadId: undefined,
71
+ originResolved: false,
72
+ originThreadId: undefined,
73
+ liveThreadId: META,
74
+ }),
75
+ ).toBe(META)
76
+ })
77
+
78
+ it('DM (no thread anywhere) → undefined', () => {
79
+ expect(
80
+ resolveAnswerThreadId({
81
+ explicitThreadId: undefined,
82
+ originResolved: false,
83
+ originThreadId: undefined,
84
+ liveThreadId: undefined,
85
+ }),
86
+ ).toBeUndefined()
87
+ })
88
+
89
+ it('precedence: explicit > origin > live (never chatThreadMap — not an input)', () => {
90
+ // The chat last-seen thread is deliberately NOT a parameter: answer
91
+ // paths can never reach it, which is what closes the wrong-topic bug.
92
+ // explicit beats both:
93
+ expect(
94
+ resolveAnswerThreadId({
95
+ explicitThreadId: 9,
96
+ originResolved: true,
97
+ originThreadId: BREVO,
98
+ liveThreadId: META,
99
+ }),
100
+ ).toBe(9)
101
+ // no explicit, origin resolved → origin (not live):
102
+ expect(
103
+ resolveAnswerThreadId({
104
+ explicitThreadId: undefined,
105
+ originResolved: true,
106
+ originThreadId: BREVO,
107
+ liveThreadId: META,
108
+ }),
109
+ ).toBe(BREVO)
110
+ })
111
+ })
@@ -68,7 +68,12 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
68
68
  }
69
69
 
70
70
  it('declares a narrow `releaseTurnBufferGate` helper (not the full purgeReactionTracking)', () => {
71
- expect(gatewaySrc).toMatch(/function releaseTurnBufferGate\(key: string\): void/)
71
+ // Multitopic component 1: the signature now also accepts an optional
72
+ // `endingTurn` so the serialize-until-replied drain gate can read its
73
+ // finalAnswerDelivered flag. The narrow-helper contract is unchanged.
74
+ expect(gatewaySrc).toMatch(
75
+ /function releaseTurnBufferGate\(key: string, endingTurn\?: CurrentTurn\): void/,
76
+ )
72
77
  // The helper docstring must explain WHY split from
73
78
  // purgeReactionTracking — future readers need to know.
74
79
  const doc = fnDocstring()
@@ -79,7 +84,10 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
79
84
  it('releaseTurnBufferGate ONLY clears activeTurnStartedAt + flushes; does NOT touch activeStatusReactions', () => {
80
85
  const body = fnBody()
81
86
  expect(body).toMatch(/activeTurnStartedAt\.delete\(key\)/)
82
- expect(body).toMatch(/pendingInboundBuffer/)
87
+ // The drain is now routed through the shared `drainBufferedIfAllowed`
88
+ // helper (multitopic component 1), which owns the pendingInboundBuffer
89
+ // flush + the serialize-until-replied gate.
90
+ expect(body).toMatch(/drainBufferedIfAllowed\(/)
83
91
  // Critical regression guard: the helper must NOT touch the
84
92
  // reaction controller, else #1713's bidirectional ladder
85
93
  // collapses to 👍 mid-turn.
@@ -100,11 +108,12 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
100
108
  // emits the 👍 reaction on the final-answer happy path).
101
109
  expect(slice).toMatch(/isFinalAnswerReply\(/)
102
110
  expect(slice).toMatch(/finalizeStatusReaction\(/)
103
- // The new unconditional buffer-gate release must ALSO be
104
- // present and must be OUTSIDE the isFinalAnswerReply branch
105
- // (so trivial-prompt non-notification replies still release
106
- // the gate).
107
- expect(slice).toMatch(/releaseTurnBufferGate\(statusKey\(chat_id, threadId\)\)/)
111
+ // The unconditional buffer-gate release must ALSO be present and
112
+ // must be OUTSIDE the isFinalAnswerReply branch (so trivial-prompt
113
+ // non-notification replies still release the gate). Multitopic
114
+ // component 1: the call now passes the turn so the serialize gate
115
+ // can read finalAnswerDelivered.
116
+ expect(slice).toMatch(/releaseTurnBufferGate\(statusKey\(chat_id, threadId\), turn \?\? undefined\)/)
108
117
  // Structural check: the release must appear AFTER the
109
118
  // isFinalAnswerReply block's closing brace but BEFORE the
110
119
  // post-send block ends. Easiest pin: it must NOT be inside the
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Multitopic reply-routing — gateway/bridge wiring guards.
3
+ *
4
+ * The gateway IIFE is too entangled to instantiate in-process, so these
5
+ * are source-level assertions (the same pattern buffer-gate-broadened.test
6
+ * and reply-terminal-reaction.test use). They pin the load-bearing wiring
7
+ * of components 3 (turn-origin routing), 4 (topic framing), and 5 (queued-
8
+ * status UX) so a future refactor that drops a hook trips here.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import { readFileSync } from 'node:fs'
13
+ import { resolve } from 'node:path'
14
+
15
+ const gatewaySrc = readFileSync(
16
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
17
+ 'utf-8',
18
+ )
19
+ const bridgeSrc = readFileSync(
20
+ resolve(__dirname, '..', 'bridge', 'bridge.ts'),
21
+ 'utf-8',
22
+ )
23
+
24
+ describe('component 3 — turn-origin reply routing', () => {
25
+ it('CurrentTurn carries a turnId, and the enqueue handler initialises it', () => {
26
+ expect(gatewaySrc).toMatch(/turnId: string/)
27
+ expect(gatewaySrc).toMatch(/const turnId\s*=\s*\n?\s*deriveTurnId\(/)
28
+ expect(gatewaySrc).toMatch(/rememberRecentTurn\(next\)/)
29
+ })
30
+
31
+ it('the inbound meta stamps origin_turn_id derived from chat/thread/messageId', () => {
32
+ expect(gatewaySrc).toMatch(/const originTurnId = deriveTurnId\(chat_id, messageThreadId \?\? null, msgId\)/)
33
+ expect(gatewaySrc).toMatch(/origin_turn_id: originTurnId/)
34
+ })
35
+
36
+ it('deriveTurnId is stable across inbound-build and enqueue (message-id based)', () => {
37
+ // The id must be derivable identically at both sites — keyed on
38
+ // chat/thread/messageId, NOT the not-yet-known startedAt.
39
+ const fn = gatewaySrc.split('function deriveTurnId')[1]?.split('\nfunction ')[0] ?? ''
40
+ expect(fn).toMatch(/chatKey\(chatId, threadId \?\? null\)/)
41
+ expect(fn).toMatch(/messageId/)
42
+ })
43
+
44
+ it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
45
+ const fn = gatewaySrc.split('async function executeReply')[1]?.split('\nasync function ')[0] ?? ''
46
+ expect(fn).toMatch(/TURN_ORIGIN_ROUTING_ENABLED/)
47
+ expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
48
+ expect(fn).toMatch(/resolveAnswerThreadId\(/)
49
+ })
50
+
51
+ it('executeStreamReply resolves the answer thread via the origin turn too', () => {
52
+ const fn = gatewaySrc.split('async function executeStreamReply')[1]?.split('\nasync function ')[0] ?? ''
53
+ expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
54
+ expect(fn).toMatch(/resolveAnswerThreadId\(/)
55
+ })
56
+
57
+ it('the reply + stream_reply tool schemas expose origin_turn_id to the model', () => {
58
+ const occurrences = bridgeSrc.match(/origin_turn_id: \{ type: 'string'/g) ?? []
59
+ expect(occurrences.length).toBe(2) // reply + stream_reply
60
+ })
61
+
62
+ it('recentTurnsById is a BOUNDED registry (cannot grow unbounded)', () => {
63
+ expect(gatewaySrc).toMatch(/RECENT_TURNS_MAX/)
64
+ expect(gatewaySrc).toMatch(/recentTurnsById\.delete\(oldest\)/)
65
+ })
66
+ })
67
+
68
+ describe('component 4 — per-turn topic framing', () => {
69
+ it('the gateway stamps a topic_scope directive for forum-topic inbounds (kill-switched)', () => {
70
+ expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
71
+ expect(gatewaySrc).toMatch(/topic_scope: topicScope/)
72
+ // Only for topic inbounds — DMs get nothing.
73
+ expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED && messageThreadId != null/)
74
+ })
75
+
76
+ it('the bridge instructions frame each channel message as the current topic', () => {
77
+ expect(bridgeSrc).toMatch(/answer ONLY this message/)
78
+ expect(bridgeSrc).toMatch(/do not also answer a pending message from another topic/i)
79
+ })
80
+ })
81
+
82
+ describe('component 5 — queued-status UX (delete-on-answer)', () => {
83
+ it('a queuedStatusMsgIds map tracks the placeholder per buffered topic', () => {
84
+ expect(gatewaySrc).toMatch(/const queuedStatusMsgIds = new Map/)
85
+ })
86
+
87
+ it('Hook A posts a queued status into the buffered message own topic (cross-topic only, kill-switched)', () => {
88
+ expect(gatewaySrc).toMatch(/postQueuedStatus\(chat_id, messageThreadId, inFlightThread\)/)
89
+ // Suppressed for DMs and same-topic.
90
+ expect(gatewaySrc).toMatch(/!isDmChatId\(chat_id\) &&/)
91
+ expect(gatewaySrc).toMatch(/messageThreadId !== inFlightThread/)
92
+ })
93
+
94
+ it('Hook B promotes the placeholder to "On it" when the buffered turn starts', () => {
95
+ expect(gatewaySrc).toMatch(/promoteQueuedStatus\(ev\.chatId, enqThreadIdNum\)/)
96
+ })
97
+
98
+ it('Hook C reaps the placeholder on the answer (executeReply / stream)', () => {
99
+ const reapCalls = gatewaySrc.match(/reapQueuedStatus\(/g) ?? []
100
+ // definition + executeReply + executeStreamReply + purge cleanup (2 branches)
101
+ expect(reapCalls.length).toBeGreaterThanOrEqual(4)
102
+ })
103
+
104
+ it('purgeReactionTracking reaps the placeholder on abnormal turn-end (defense-in-depth)', () => {
105
+ const fn = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
106
+ expect(fn).toMatch(/reapQueuedStatus\(/)
107
+ // Reap sits alongside the activeReactionMsgIds cleanup.
108
+ expect(fn).toMatch(/activeReactionMsgIds\.delete\(key\)/)
109
+ })
110
+
111
+ it('all queued-status sends/edits/deletes go through the swallowing wrapper (carry thread)', () => {
112
+ for (const helper of ['postQueuedStatus', 'promoteQueuedStatus', 'reapQueuedStatus']) {
113
+ const fn = gatewaySrc.split(`function ${helper}`)[1]?.split('\nfunction ')[0] ?? ''
114
+ expect(fn).toMatch(/swallowingApiCall\(/)
115
+ }
116
+ })
117
+ })
118
+
119
+ describe('kill switches — all five default ON, each independently disableable', () => {
120
+ it('declares the three named kill switches + the two component switches', () => {
121
+ expect(gatewaySrc).toMatch(/SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'/)
122
+ expect(gatewaySrc).toMatch(/SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS/)
123
+ expect(gatewaySrc).toMatch(/SWITCHROOM_QUEUED_STATUS_UX !== '0'/)
124
+ expect(gatewaySrc).toMatch(/SWITCHROOM_TURN_ORIGIN_ROUTING !== '0'/)
125
+ expect(gatewaySrc).toMatch(/SWITCHROOM_TOPIC_FRAMING !== '0'/)
126
+ })
127
+
128
+ it('the no-reply drain ms default is 2500 and clamped positive', () => {
129
+ expect(gatewaySrc).toMatch(/SERIALIZE_NOREPLY_DRAIN_MS\s*=\s*\n?\s*Number\.isFinite\([^)]*\)\s*&&[^?]*\?\s*[^:]*:\s*2_500/)
130
+ })
131
+ })
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
5
+ import { shouldArmNoReplyDrain } from '../gateway/serialize-drain-gate.js'
6
+
7
+ /**
8
+ * Component 2 (multitopic reply-routing) — bounded no-reply escape hatch.
9
+ *
10
+ * THE liveness guarantee. Component 1's serialize gate blocks the
11
+ * cross-topic buffer drain until the ending turn delivers its reply. A turn
12
+ * that legitimately ends with NO reply (handback ack, NO_REPLY /
13
+ * HEARTBEAT_OK marker, silent-end) sets finalAnswerDelivered=false and
14
+ * would otherwise block the drain FOREVER — and the 300s silence-poke is
15
+ * disarmed for those turns. The bounded timer rescues it: it force-drains
16
+ * within SERIALIZE_NOREPLY_DRAIN_MS so the queue can NEVER wedge.
17
+ *
18
+ * This file pins (a) the arming predicate `shouldArmNoReplyDrain` shared by
19
+ * the gateway's `armNoReplyDrainTimer`, (b) the liveness behaviour via a
20
+ * fake-timer simulation of the exact timer pattern, and (c) source-level
21
+ * guards that the gateway wires the timer + force-drain on the no-reply
22
+ * turn-end path.
23
+ */
24
+ describe('shouldArmNoReplyDrain (the arming predicate)', () => {
25
+ it('ARMS when a no-reply turn ends with a buffered inbound waiting (the rescue)', () => {
26
+ expect(
27
+ shouldArmNoReplyDrain({ enabled: true, finalAnswerDelivered: false, bufferedDepth: 1 }),
28
+ ).toBe(true)
29
+ })
30
+
31
+ it('does NOT arm when the turn delivered its final answer (serialize gate already drained)', () => {
32
+ expect(
33
+ shouldArmNoReplyDrain({ enabled: true, finalAnswerDelivered: true, bufferedDepth: 1 }),
34
+ ).toBe(false)
35
+ })
36
+
37
+ it('does NOT arm when nothing is buffered (no rescue needed)', () => {
38
+ expect(
39
+ shouldArmNoReplyDrain({ enabled: true, finalAnswerDelivered: false, bufferedDepth: 0 }),
40
+ ).toBe(false)
41
+ })
42
+
43
+ it('does NOT arm when the serialize feature is off (legacy drains on bare turn-end)', () => {
44
+ expect(
45
+ shouldArmNoReplyDrain({ enabled: false, finalAnswerDelivered: false, bufferedDepth: 3 }),
46
+ ).toBe(false)
47
+ })
48
+
49
+ it('is total over the input space', () => {
50
+ for (const enabled of [true, false]) {
51
+ for (const finalAnswerDelivered of [true, false]) {
52
+ for (const bufferedDepth of [0, 1, 5]) {
53
+ const got = shouldArmNoReplyDrain({ enabled, finalAnswerDelivered, bufferedDepth })
54
+ const want = enabled && !finalAnswerDelivered && bufferedDepth > 0
55
+ expect(got).toBe(want)
56
+ }
57
+ }
58
+ }
59
+ })
60
+ })
61
+
62
+ describe('no-reply bounded drain — liveness (the queue cannot wedge)', () => {
63
+ beforeEach(() => vi.useFakeTimers())
64
+ afterEach(() => vi.useRealTimers())
65
+
66
+ // Mirror of the gateway's armNoReplyDrainTimer timer pattern: when the
67
+ // predicate arms, a setTimeout(forceDrain, NOREPLY_DRAIN_MS) is set; the
68
+ // force-drain releases the buffer regardless of the serialize gate.
69
+ function simulateNoReplyTurnEnd(opts: {
70
+ drainMs: number
71
+ finalAnswerDelivered: boolean
72
+ bufferDepthAtEnd: number
73
+ forceDrain: () => void
74
+ }): void {
75
+ if (
76
+ shouldArmNoReplyDrain({
77
+ enabled: true,
78
+ finalAnswerDelivered: opts.finalAnswerDelivered,
79
+ bufferedDepth: opts.bufferDepthAtEnd,
80
+ })
81
+ ) {
82
+ const t = setTimeout(() => opts.forceDrain(), opts.drainMs)
83
+ t.unref?.()
84
+ }
85
+ }
86
+
87
+ it('a no-reply turn followed by a queued cross-topic message releases within ~2.5s', () => {
88
+ const NOREPLY_DRAIN_MS = 2_500
89
+ let buffered = 1 // one cross-topic inbound queued behind the no-reply turn
90
+ const forceDrain = vi.fn(() => { buffered = 0 })
91
+
92
+ simulateNoReplyTurnEnd({
93
+ drainMs: NOREPLY_DRAIN_MS,
94
+ finalAnswerDelivered: false,
95
+ bufferDepthAtEnd: buffered,
96
+ forceDrain,
97
+ })
98
+
99
+ // Before the bound: still queued (serialize gate held it, no reply came).
100
+ vi.advanceTimersByTime(NOREPLY_DRAIN_MS - 1)
101
+ expect(forceDrain).not.toHaveBeenCalled()
102
+ expect(buffered).toBe(1)
103
+
104
+ // At the bound: the escape hatch fires and drains. No wedge.
105
+ vi.advanceTimersByTime(1)
106
+ expect(forceDrain).toHaveBeenCalledTimes(1)
107
+ expect(buffered).toBe(0)
108
+ })
109
+
110
+ it('a DELIVERED turn does not arm the timer (the reply already drained)', () => {
111
+ const forceDrain = vi.fn()
112
+ simulateNoReplyTurnEnd({
113
+ drainMs: 2_500,
114
+ finalAnswerDelivered: true,
115
+ bufferDepthAtEnd: 1,
116
+ forceDrain,
117
+ })
118
+ vi.advanceTimersByTime(10_000)
119
+ expect(forceDrain).not.toHaveBeenCalled()
120
+ })
121
+ })
122
+
123
+ describe('gateway wiring (source-level guards) — the gateway IIFE is too entangled to instantiate', () => {
124
+ const gatewaySrc = readFileSync(
125
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
126
+ 'utf-8',
127
+ )
128
+
129
+ it('endCurrentTurnAtomic arms the no-reply drain timer after the serialize-gated purge', () => {
130
+ const fn = gatewaySrc.split('function endCurrentTurnAtomic')[1]?.split('\nfunction ')[0] ?? ''
131
+ expect(fn).toMatch(/purgeReactionTracking\(/)
132
+ expect(fn).toMatch(/armNoReplyDrainTimer\(turn\)/)
133
+ // The arm must come AFTER the purge (the purge attempts the gated
134
+ // drain first; the timer is the fallback for the no-reply case).
135
+ expect(fn.indexOf('armNoReplyDrainTimer')).toBeGreaterThan(fn.indexOf('purgeReactionTracking'))
136
+ })
137
+
138
+ it('armNoReplyDrainTimer uses the shared predicate + a bounded setTimeout that force-drains', () => {
139
+ const fn = gatewaySrc.split('function armNoReplyDrainTimer')[1]?.split('\nfunction ')[0] ?? ''
140
+ expect(fn).toMatch(/shouldArmNoReplyDrain\(/)
141
+ expect(fn).toMatch(/setTimeout\(/)
142
+ expect(fn).toMatch(/SERIALIZE_NOREPLY_DRAIN_MS/)
143
+ // It must force-drain (bypassing the serialize delivered-check), not
144
+ // route back through the gated drain — else the no-reply turn would
145
+ // re-block itself.
146
+ expect(fn).toMatch(/performBufferDrain\(/)
147
+ expect(fn).not.toMatch(/drainBufferedIfAllowed\(/)
148
+ })
149
+
150
+ it('the 300s silence-poke fallback remains an independent long-stop (direct redeliver)', () => {
151
+ // The unwedge fallback calls redeliverBufferedInbound directly — it
152
+ // must NOT be gated by the serialize predicate, or a stuck no-reply
153
+ // turn whose bounded timer somehow failed would never recover.
154
+ expect(gatewaySrc).toMatch(/silence-poke framework-fallback ended wedged turn/)
155
+ })
156
+ })
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { mayDrainBufferedInbound } from '../gateway/serialize-drain-gate.js'
4
+
5
+ /**
6
+ * Component 1 (multitopic reply-routing) — deliver-before-drain gate.
7
+ *
8
+ * In a forum supergroup one sequential claude CLI owns every topic with a
9
+ * singleton currentTurn. The buffer drain used to fire on the bare turn-end
10
+ * signal regardless of whether the ending turn had delivered its reply, so
11
+ * a buffered cross-topic message drained the instant the prior turn's
12
+ * turn-end event landed — even when its reply was still ~42s out. By the
13
+ * time the prior reply executed, currentTurn had flipped and the reply
14
+ * routed to the wrong topic.
15
+ *
16
+ * The predicate gates the drain: drain only when claude is idle AND (the
17
+ * kill switch is off, OR there is no ending-turn handle, OR the ending turn
18
+ * delivered its final answer). The no-reply case (finalAnswerDelivered
19
+ * false) deliberately blocks here — liveness is restored by the bounded
20
+ * escape-hatch timer, NOT by this predicate.
21
+ */
22
+ describe('mayDrainBufferedInbound', () => {
23
+ it('drains when idle AND the ending turn delivered its final answer', () => {
24
+ expect(
25
+ mayDrainBufferedInbound({
26
+ turnInFlight: false,
27
+ endingTurnFinalAnswerDelivered: true,
28
+ enabled: true,
29
+ }),
30
+ ).toBe(true)
31
+ })
32
+
33
+ it('does NOT drain when the ending turn ended WITHOUT a reply (the wedge fix)', () => {
34
+ // The Brevo turn-end fired before its late reply landed; the buffered
35
+ // Meta message must NOT drain here.
36
+ expect(
37
+ mayDrainBufferedInbound({
38
+ turnInFlight: false,
39
+ endingTurnFinalAnswerDelivered: false,
40
+ enabled: true,
41
+ }),
42
+ ).toBe(false)
43
+ })
44
+
45
+ it('drains when there is no ending-turn handle (sibling purge / idle sweep)', () => {
46
+ expect(
47
+ mayDrainBufferedInbound({
48
+ turnInFlight: false,
49
+ endingTurnFinalAnswerDelivered: null,
50
+ enabled: true,
51
+ }),
52
+ ).toBe(true)
53
+ expect(
54
+ mayDrainBufferedInbound({
55
+ turnInFlight: false,
56
+ endingTurnFinalAnswerDelivered: undefined,
57
+ enabled: true,
58
+ }),
59
+ ).toBe(true)
60
+ })
61
+
62
+ it('NEVER drains while a turn is in flight, regardless of other inputs', () => {
63
+ for (const delivered of [true, false, null, undefined] as const) {
64
+ for (const enabled of [true, false]) {
65
+ expect(
66
+ mayDrainBufferedInbound({
67
+ turnInFlight: true,
68
+ endingTurnFinalAnswerDelivered: delivered,
69
+ enabled,
70
+ }),
71
+ ).toBe(false)
72
+ }
73
+ }
74
+ })
75
+
76
+ it('kill switch off → legacy behaviour: drain whenever idle (delivered ignored)', () => {
77
+ expect(
78
+ mayDrainBufferedInbound({
79
+ turnInFlight: false,
80
+ endingTurnFinalAnswerDelivered: false,
81
+ enabled: false,
82
+ }),
83
+ ).toBe(true)
84
+ expect(
85
+ mayDrainBufferedInbound({
86
+ turnInFlight: false,
87
+ endingTurnFinalAnswerDelivered: true,
88
+ enabled: false,
89
+ }),
90
+ ).toBe(true)
91
+ })
92
+
93
+ it('is total over the input space', () => {
94
+ for (const turnInFlight of [true, false]) {
95
+ for (const delivered of [true, false, null, undefined] as const) {
96
+ for (const enabled of [true, false]) {
97
+ const got = mayDrainBufferedInbound({
98
+ turnInFlight,
99
+ endingTurnFinalAnswerDelivered: delivered,
100
+ enabled,
101
+ })
102
+ let want: boolean
103
+ if (turnInFlight) want = false
104
+ else if (!enabled) want = true
105
+ else if (delivered == null) want = true
106
+ else want = delivered === true
107
+ expect(got).toBe(want)
108
+ }
109
+ }
110
+ }
111
+ })
112
+ })