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.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +3 -1
- package/telegram-plugin/dist/bridge/bridge.js +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +218 -40
- package/telegram-plugin/dist/server.js +3 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +501 -90
- package/telegram-plugin/gateway/serialize-drain-gate.ts +111 -0
- package/telegram-plugin/tests/answer-thread-resolve.test.ts +111 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -7
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +131 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +156 -0
- package/telegram-plugin/tests/serialize-drain-gate.test.ts +112 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// the gate
|
|
107
|
-
|
|
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
|
+
})
|