switchroom 0.14.67 → 0.14.69
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 +105 -2
- package/dist/cli/ui/index.html +103 -38
- package/package.json +1 -1
- package/telegram-plugin/answer-stream-flag.ts +19 -0
- package/telegram-plugin/dist/gateway/gateway.js +62 -17
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +135 -1
- package/telegram-plugin/gateway/gateway.ts +151 -11
- package/telegram-plugin/tests/answer-stream-flag.test.ts +19 -1
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +52 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +45 -0
- package/telegram-plugin/uat/scenarios/fuzz-cross-surface-ordering-channel.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/fuzz-multitopic-routing-channel.test.ts +184 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
2
|
+
import { resolveAnswerThreadId, type AnswerThreadInput } from './answer-thread-resolve.js'
|
|
3
3
|
|
|
4
4
|
describe('resolveAnswerThreadId — precedence', () => {
|
|
5
5
|
it('(1) explicit model thread wins over everything', () => {
|
|
@@ -83,3 +83,137 @@ describe('resolveAnswerThreadId — precedence', () => {
|
|
|
83
83
|
expect(resolveAnswerThreadId({ originResolved: false })).toBeUndefined()
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
// ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// The operator standard (memory feedback_prove_finite_fsm_not_sample): a
|
|
90
|
+
// passing sample is not a proof. `resolveAnswerThreadId` is a PURE decision
|
|
91
|
+
// function over a FINITE input space — so we can prove its determinism by
|
|
92
|
+
// CONSTRUCTION: enumerate every reachable input and assert totality,
|
|
93
|
+
// determinism, no-fabrication, and the precedence the doc-comment promises.
|
|
94
|
+
// Any future edit that perturbs the decision table on ANY of the 64 inputs
|
|
95
|
+
// fails here — this block is the regression guard, the 9 examples above are
|
|
96
|
+
// the human-readable map.
|
|
97
|
+
//
|
|
98
|
+
// Distinct symbolic thread ids so an output's provenance is unambiguous (no
|
|
99
|
+
// two tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
|
|
100
|
+
const T = 70 // explicit (tier 1)
|
|
101
|
+
const O = 50 // origin (tier 2)
|
|
102
|
+
const L = 30 // live (tier 3)
|
|
103
|
+
const E = 90 // lastEnded(tier 4)
|
|
104
|
+
|
|
105
|
+
function allInputs(): AnswerThreadInput[] {
|
|
106
|
+
const rows: AnswerThreadInput[] = []
|
|
107
|
+
for (const explicitThreadId of [undefined, T])
|
|
108
|
+
for (const originResolved of [false, true])
|
|
109
|
+
for (const originThreadId of [undefined, O])
|
|
110
|
+
for (const liveThreadId of [undefined, L])
|
|
111
|
+
for (const lastEndedResolvedForChat of [false, true])
|
|
112
|
+
for (const lastEndedThreadIdForChat of [undefined, E])
|
|
113
|
+
rows.push({
|
|
114
|
+
explicitThreadId,
|
|
115
|
+
originResolved,
|
|
116
|
+
originThreadId,
|
|
117
|
+
lastEndedResolvedForChat,
|
|
118
|
+
lastEndedThreadIdForChat,
|
|
119
|
+
liveThreadId,
|
|
120
|
+
})
|
|
121
|
+
return rows
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Independent reference encoding the documented precedence (the SPEC), kept
|
|
125
|
+
// deliberately separate from the implementation so a regression in either
|
|
126
|
+
// surfaces as a divergence rather than a silently-shared bug.
|
|
127
|
+
function specExpected(i: AnswerThreadInput): number | undefined {
|
|
128
|
+
if (i.explicitThreadId != null) return i.explicitThreadId // tier 1
|
|
129
|
+
if (i.originResolved) return i.originThreadId // tier 2 (may be undefined: DM origin)
|
|
130
|
+
if (i.liveThreadId != null) return i.liveThreadId // tier 3
|
|
131
|
+
if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat // tier 4
|
|
132
|
+
return i.liveThreadId // catch-all (undefined here)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('resolveAnswerThreadId — total-enumeration determinism proof (all 64 inputs)', () => {
|
|
136
|
+
const ROWS = allInputs()
|
|
137
|
+
|
|
138
|
+
it('the input space is exactly 64 rows (2^6)', () => {
|
|
139
|
+
expect(ROWS.length).toBe(64)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('TOTAL: every input returns without throwing', () => {
|
|
143
|
+
for (const i of ROWS) {
|
|
144
|
+
expect(() => resolveAnswerThreadId(i)).not.toThrow()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('DETERMINISTIC: each input maps to exactly one output (idempotent across repeated calls)', () => {
|
|
149
|
+
for (const i of ROWS) {
|
|
150
|
+
const a = resolveAnswerThreadId(i)
|
|
151
|
+
const b = resolveAnswerThreadId({ ...i })
|
|
152
|
+
expect(b).toBe(a)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('NO FABRICATION: every output is undefined or one of the four input thread fields', () => {
|
|
157
|
+
for (const i of ROWS) {
|
|
158
|
+
const out = resolveAnswerThreadId(i)
|
|
159
|
+
const provenance = new Set([
|
|
160
|
+
undefined,
|
|
161
|
+
i.explicitThreadId,
|
|
162
|
+
i.originThreadId,
|
|
163
|
+
i.liveThreadId,
|
|
164
|
+
i.lastEndedThreadIdForChat,
|
|
165
|
+
])
|
|
166
|
+
expect(provenance.has(out)).toBe(true)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('PRECEDENCE: matches the documented spec on all 64 inputs', () => {
|
|
171
|
+
for (const i of ROWS) {
|
|
172
|
+
expect(resolveAnswerThreadId(i)).toBe(specExpected(i))
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ── By-construction invariants: the output depends ONLY on the highest
|
|
177
|
+
// RESOLVED tier, so no lower-tier input (notably a flipped live turn)
|
|
178
|
+
// can perturb a higher tier's decision. These are the routing guarantees
|
|
179
|
+
// the resolver exists to provide. ─────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
it('INV-1 explicit DOMINANCE: explicit set ⇒ output === explicit, independent of all other fields', () => {
|
|
182
|
+
for (const i of ROWS) {
|
|
183
|
+
if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('INV-2 origin FLIP-IMMUNITY: no explicit + originResolved ⇒ output === originThreadId, for EVERY liveThreadId/lastEnded combo (the Brevo→Meta fix: a currentTurn flip cannot steal a resolved origin)', () => {
|
|
188
|
+
for (const i of ROWS) {
|
|
189
|
+
if (i.explicitThreadId == null && i.originResolved) {
|
|
190
|
+
expect(resolveAnswerThreadId(i)).toBe(i.originThreadId)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('INV-3 recovery REACHABILITY: tier-4 (lastEnded) result occurs ONLY when no explicit, no origin, no live turn', () => {
|
|
196
|
+
for (const i of ROWS) {
|
|
197
|
+
const out = resolveAnswerThreadId(i)
|
|
198
|
+
// If the result came from the lastEnded field (and that field is the
|
|
199
|
+
// only one carrying that distinct value E), the three higher tiers must
|
|
200
|
+
// all be absent.
|
|
201
|
+
if (out === E) {
|
|
202
|
+
expect(i.explicitThreadId).toBeUndefined()
|
|
203
|
+
expect(i.originResolved).toBe(false)
|
|
204
|
+
expect(i.liveThreadId).toBeUndefined()
|
|
205
|
+
expect(i.lastEndedResolvedForChat).toBe(true)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('INV-4 live-tier REACHABILITY: tier-3 (live) result occurs ONLY when no explicit and no resolved origin', () => {
|
|
211
|
+
for (const i of ROWS) {
|
|
212
|
+
const out = resolveAnswerThreadId(i)
|
|
213
|
+
if (out === L) {
|
|
214
|
+
expect(i.explicitThreadId).toBeUndefined()
|
|
215
|
+
expect(i.originResolved).toBe(false)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -98,7 +98,7 @@ import * as pendingProgress from '../pending-work-progress.js'
|
|
|
98
98
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
99
99
|
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
100
100
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
101
|
-
import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
|
|
101
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
102
102
|
import { type SessionEvent } from '../session-tail.js'
|
|
103
103
|
import {
|
|
104
104
|
shouldSuppressToolActivity,
|
|
@@ -678,6 +678,14 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
|
|
|
678
678
|
const bot = new Bot(TOKEN)
|
|
679
679
|
installTgPostLogger(bot)
|
|
680
680
|
|
|
681
|
+
// Draft-answer-lane retirement (2026-06-05): default RETIRED so the live answer
|
|
682
|
+
// lane uses a real, mtcute-observable message instead of the invisible
|
|
683
|
+
// compose-box draft. Declared HERE (above the boot-probe block) because
|
|
684
|
+
// `sendMessageDraftFn` below reads it — keep it above its first use to avoid a
|
|
685
|
+
// temporal-dead-zone ReferenceError at boot. Kill switch
|
|
686
|
+
// SWITCHROOM_DRAFT_ANSWER_LANE=0 restores the legacy draft.
|
|
687
|
+
const DRAFT_ANSWER_LANE_RETIRED = parseDraftLaneRetiredEnabled(process.env.SWITCHROOM_DRAFT_ANSWER_LANE)
|
|
688
|
+
|
|
681
689
|
// ─── sendMessageDraft boot probe ──────────────────────────────────────────
|
|
682
690
|
// grammY 1.x exposes all Telegram Bot API methods through bot.api.raw.
|
|
683
691
|
// bot.api.sendMessageDraft (the typed wrapper) takes chat_id as number, but
|
|
@@ -695,7 +703,11 @@ const GRAMMY_VERSION: string = (() => {
|
|
|
695
703
|
const sendMessageDraftFn: (
|
|
696
704
|
(chatId: string, draftId: number, text: string, params?: { message_thread_id?: number; parse_mode?: 'HTML' }) => Promise<unknown>
|
|
697
705
|
) | undefined =
|
|
698
|
-
|
|
706
|
+
// When the draft lane is retired (default), force this undefined so BOTH
|
|
707
|
+
// consumers (the answer-stream config + the stream_reply handler) drop the
|
|
708
|
+
// draft transport and fall back to visible message transport — the single
|
|
709
|
+
// chokepoint for the retirement.
|
|
710
|
+
!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'
|
|
699
711
|
? (chatId, draftId, text, params) =>
|
|
700
712
|
(_rawSendMessageDraft as (args: Record<string, unknown>) => Promise<unknown>)({
|
|
701
713
|
chat_id: Number(chatId),
|
|
@@ -1537,6 +1549,18 @@ const SERIALIZE_NOREPLY_DRAIN_MS =
|
|
|
1537
1549
|
// behaviour (#1664: thread from the live currentTurn capture).
|
|
1538
1550
|
const TURN_ORIGIN_ROUTING_ENABLED =
|
|
1539
1551
|
process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== '0'
|
|
1552
|
+
// Framework-owned origin recovery (2026-06-05 determinism pass). The origin
|
|
1553
|
+
// signal that survives a currentTurn flip (tier 2) travels through the MODEL
|
|
1554
|
+
// (it echoes `origin_turn_id`). When the model OMITS it, routing falls to the
|
|
1555
|
+
// live turn — the wrong topic if currentTurn flipped (HOLE a). The model rarely
|
|
1556
|
+
// passes `reply_to` explicitly, but WHEN it quotes a specific earlier message
|
|
1557
|
+
// that message_id is a FRAMEWORK-owned anchor: reverse-index it to the turn
|
|
1558
|
+
// that owns it and recover the origin without the model echo. Strictly
|
|
1559
|
+
// additive — it only fires when the echo is absent AND a quote is present, and
|
|
1560
|
+
// resolves to the ACTUAL origin turn (never the live successor), so it cannot
|
|
1561
|
+
// mask a misroute. Kill switch off (=0) → echo-only origin (today's behaviour).
|
|
1562
|
+
const FRAMEWORK_ORIGIN_ROUTING_ENABLED =
|
|
1563
|
+
process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'
|
|
1540
1564
|
// Component 4 (per-turn topic framing). Add a one-line directive to the
|
|
1541
1565
|
// channel meta + bridge instructions telling the model to answer ONLY the
|
|
1542
1566
|
// current message's topic. Kill switch off (=0) → no framing field.
|
|
@@ -1846,14 +1870,57 @@ let currentTurn: CurrentTurn | null = null
|
|
|
1846
1870
|
// a long-lived supergroup session.
|
|
1847
1871
|
const RECENT_TURNS_MAX = 32
|
|
1848
1872
|
const recentTurnsById = new Map<string, CurrentTurn>()
|
|
1873
|
+
// Framework-owned origin recovery: reverse-index from an inbound's source
|
|
1874
|
+
// message_id to the turnId that owns it, so a reply that QUOTES a specific
|
|
1875
|
+
// message (args.reply_to) resolves its origin turn deterministically — a
|
|
1876
|
+
// real message_id the framework stamped, never a model-asserted thread.
|
|
1877
|
+
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
1878
|
+
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
1849
1879
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
1850
1880
|
recentTurnsById.set(turn.turnId, turn)
|
|
1881
|
+
if (turn.sourceMessageId != null) {
|
|
1882
|
+
recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId)
|
|
1883
|
+
}
|
|
1851
1884
|
while (recentTurnsById.size > RECENT_TURNS_MAX) {
|
|
1852
1885
|
const oldest = recentTurnsById.keys().next().value
|
|
1853
1886
|
if (oldest === undefined) break
|
|
1887
|
+
const evicted = recentTurnsById.get(oldest)
|
|
1854
1888
|
recentTurnsById.delete(oldest)
|
|
1889
|
+
// Drop the reverse-index entry for the evicted turn (only when it still
|
|
1890
|
+
// points at THIS turn — a newer turn may have reused the same message id).
|
|
1891
|
+
if (evicted?.sourceMessageId != null &&
|
|
1892
|
+
recentTurnIdBySourceMessageId.get(evicted.sourceMessageId) === oldest) {
|
|
1893
|
+
recentTurnIdBySourceMessageId.delete(evicted.sourceMessageId)
|
|
1894
|
+
}
|
|
1855
1895
|
}
|
|
1856
1896
|
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Framework-owned origin recovery (kill switch SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING).
|
|
1900
|
+
* Resolve the turn that owns a reply from the message_id it QUOTES (args.reply_to),
|
|
1901
|
+
* via the source-message reverse index. Returns null when disabled, when reply_to
|
|
1902
|
+
* is absent/non-numeric, or when no turn owns that message id (evicted / unknown).
|
|
1903
|
+
* Deterministic: a real framework-stamped message_id → its turn, with no model
|
|
1904
|
+
* thread assertion and no dependence on the live currentTurn.
|
|
1905
|
+
*
|
|
1906
|
+
* SCOPED to `chatId`: Telegram numbers message ids PER CHAT, so the same numeric
|
|
1907
|
+
* id exists in a DM and a supergroup. The reverse index is keyed by id alone, so
|
|
1908
|
+
* the resolved turn MUST be confirmed to belong to this reply's chat — otherwise
|
|
1909
|
+
* a reply quoting its own id could inherit another chat's thread (a cross-chat
|
|
1910
|
+
* leak). On a chat mismatch we return null (decline to recover → fall through to
|
|
1911
|
+
* the live-turn behaviour), never a wrong-chat thread.
|
|
1912
|
+
*/
|
|
1913
|
+
function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null {
|
|
1914
|
+
if (!FRAMEWORK_ORIGIN_ROUTING_ENABLED) return null
|
|
1915
|
+
if (replyTo == null) return null
|
|
1916
|
+
const mid = Number(replyTo)
|
|
1917
|
+
if (!Number.isFinite(mid)) return null
|
|
1918
|
+
const owner = recentTurnIdBySourceMessageId.get(mid)
|
|
1919
|
+
if (owner == null) return null
|
|
1920
|
+
const turn = recentTurnsById.get(owner) ?? null
|
|
1921
|
+
if (turn == null || turn.sessionChatId !== chatId) return null
|
|
1922
|
+
return turn
|
|
1923
|
+
}
|
|
1857
1924
|
/**
|
|
1858
1925
|
* Component 3 — derive the stable per-turn identity from the chat, thread,
|
|
1859
1926
|
* and originating message id. Stamped into the inbound meta at build time
|
|
@@ -1920,13 +1987,26 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
1920
1987
|
* timestamps. This wrapper logs, per reply: which precedence tier won (`via`),
|
|
1921
1988
|
* the resolved thread, the origin turn + its thread, and whether the reply was
|
|
1922
1989
|
* late (turn already ended). `via=recovered` marks a late reply this fix saved
|
|
1923
|
-
* from General; `
|
|
1924
|
-
*
|
|
1990
|
+
* from General; `via=quoted` marks an origin recovered from the framework-owned
|
|
1991
|
+
* quoted message_id (no model echo); `UNROUTED` flags a supergroup reply that
|
|
1992
|
+
* resolved to no topic with NO owner turn to attribute it to (genuinely lost).
|
|
1993
|
+
* `MISROUTE_RISK` flags the irreducible determinism residual: a no-echo,
|
|
1994
|
+
* no-quote reply that fell to the LIVE turn while a DIFFERENT topic recently had
|
|
1995
|
+
* a turn — the framework cannot tell which topic the bare reply answers, so the
|
|
1996
|
+
* routing MIGHT be wrong (HOLE a). It is observability only (the reply still
|
|
1997
|
+
* routes to the live turn) — it makes the one case that is genuinely
|
|
1998
|
+
* model-dependent visible instead of silently mis-routed. A General-topic turn
|
|
1999
|
+
* legitimately has no thread, so its replies are NOT UNROUTED-flagged.
|
|
2000
|
+
*
|
|
2001
|
+
* `originVia` distinguishes how the origin turn was resolved: 'echo' (model
|
|
2002
|
+
* echoed origin_turn_id), 'quoted' (framework recovered it from args.reply_to),
|
|
2003
|
+
* or null (no origin turn). It only affects the `via` label, never the routing.
|
|
1925
2004
|
*/
|
|
1926
2005
|
function resolveAnswerThreadWithLog(
|
|
1927
2006
|
chatId: string,
|
|
1928
2007
|
explicitThreadId: number | undefined,
|
|
1929
2008
|
originTurn: CurrentTurn | null,
|
|
2009
|
+
originVia: 'echo' | 'quoted' | null,
|
|
1930
2010
|
liveTurn: CurrentTurn | null,
|
|
1931
2011
|
surface: 'reply' | 'stream_reply',
|
|
1932
2012
|
): number | undefined {
|
|
@@ -1953,24 +2033,60 @@ function resolveAnswerThreadWithLog(
|
|
|
1953
2033
|
})
|
|
1954
2034
|
const via =
|
|
1955
2035
|
explicitThreadId != null ? 'explicit'
|
|
1956
|
-
: originTurn != null ? 'origin'
|
|
2036
|
+
: originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
1957
2037
|
: liveTurn?.sessionThreadId != null ? 'live'
|
|
1958
2038
|
: recovered != null ? 'recovered'
|
|
1959
2039
|
: 'none'
|
|
1960
2040
|
const ownerTurn = originTurn ?? recovered ?? liveTurn
|
|
1961
2041
|
const isSupergroup = chatId.startsWith('-100')
|
|
1962
|
-
|
|
2042
|
+
// UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
|
|
2043
|
+
// to attribute it to (genuinely lost). A General-topic turn legitimately has
|
|
2044
|
+
// no thread, so a reply owned by it resolving to `-` is CORRECT, not lost —
|
|
2045
|
+
// gate on `ownerTurn == null` so General replies don't false-alarm (found by
|
|
2046
|
+
// the multi-topic UAT stress, 2026-06-05).
|
|
2047
|
+
const unrouted = isSupergroup && threadId == null && ownerTurn == null
|
|
2048
|
+
// MISROUTE_RISK = the irreducible determinism residual (HOLE a). A no-echo,
|
|
2049
|
+
// no-quote reply fell to the LIVE turn (via=live), but a DIFFERENT topic
|
|
2050
|
+
// recently had a turn for this chat — so this bare reply MIGHT belong to that
|
|
2051
|
+
// other topic and we cannot tell without the model's echo. Observability only;
|
|
2052
|
+
// routing is unchanged. This is the one case framework state cannot
|
|
2053
|
+
// disambiguate, surfaced instead of silently mis-routed.
|
|
2054
|
+
const misrouteRisk =
|
|
2055
|
+
isSupergroup &&
|
|
2056
|
+
via === 'live' &&
|
|
2057
|
+
hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId)
|
|
1963
2058
|
process.stderr.write(
|
|
1964
2059
|
`telegram gateway: reply-route surface=${surface} chat=${chatId} ` +
|
|
1965
2060
|
`resolved_thread=${threadId ?? '-'} via=${via} late=${liveTurn == null} ` +
|
|
1966
2061
|
`originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
|
|
1967
2062
|
(via === 'recovered' ? ' RECOVERED' : '') +
|
|
2063
|
+
(via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
|
|
1968
2064
|
(unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
|
|
2065
|
+
(misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
|
|
1969
2066
|
'\n',
|
|
1970
2067
|
)
|
|
1971
2068
|
return threadId
|
|
1972
2069
|
}
|
|
1973
2070
|
|
|
2071
|
+
/**
|
|
2072
|
+
* Determinism-residual detector (HOLE a observability). True when a DIFFERENT
|
|
2073
|
+
* forum topic recently had a turn for this chat than the live turn's thread —
|
|
2074
|
+
* i.e. a currentTurn flip plausibly happened, so a no-echo / no-quote reply
|
|
2075
|
+
* routed to the live turn MIGHT belong to the other topic. Scans the bounded
|
|
2076
|
+
* recently-ended registry; cheap (≤32 entries). Used only to ALARM, never to
|
|
2077
|
+
* route.
|
|
2078
|
+
*/
|
|
2079
|
+
function hasDifferentThreadedRecentTurn(
|
|
2080
|
+
chatId: string,
|
|
2081
|
+
liveThreadId: number | undefined,
|
|
2082
|
+
): boolean {
|
|
2083
|
+
const live = liveThreadId ?? null
|
|
2084
|
+
for (const t of recentTurnsById.values()) {
|
|
2085
|
+
if (t.sessionChatId === chatId && (t.sessionThreadId ?? null) !== live) return true
|
|
2086
|
+
}
|
|
2087
|
+
return false
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1974
2090
|
/**
|
|
1975
2091
|
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
1976
2092
|
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
@@ -6619,11 +6735,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6619
6735
|
let threadId: number | undefined
|
|
6620
6736
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
6621
6737
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
6622
|
-
|
|
6738
|
+
// Origin precedence: model echo first (authoritative), then the
|
|
6739
|
+
// framework-owned quoted message_id (deterministic, no model thread
|
|
6740
|
+
// assertion) as a fallback when the model omitted the echo.
|
|
6741
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
6742
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
|
|
6743
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
6623
6744
|
threadId = resolveAnswerThreadWithLog(
|
|
6624
6745
|
chat_id,
|
|
6625
6746
|
Number.isFinite(explicit as number) ? (explicit as number) : undefined,
|
|
6626
6747
|
originTurn,
|
|
6748
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
6627
6749
|
turn,
|
|
6628
6750
|
'reply',
|
|
6629
6751
|
)
|
|
@@ -7276,11 +7398,17 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7276
7398
|
if (args.message_thread_id == null) {
|
|
7277
7399
|
let injected: number | undefined
|
|
7278
7400
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7279
|
-
|
|
7401
|
+
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7402
|
+
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7403
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7404
|
+
const quotedTurn =
|
|
7405
|
+
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7406
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
7280
7407
|
injected = resolveAnswerThreadWithLog(
|
|
7281
7408
|
String(args.chat_id),
|
|
7282
7409
|
undefined,
|
|
7283
7410
|
originTurn,
|
|
7411
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
7284
7412
|
turn,
|
|
7285
7413
|
'stream_reply',
|
|
7286
7414
|
)
|
|
@@ -9545,7 +9673,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9545
9673
|
// General). With the gate unreachable the only posted message is
|
|
9546
9674
|
// the canonical reply. (The gate is bypassed for DM draft
|
|
9547
9675
|
// transport, so DM draft streaming is unaffected.)
|
|
9548
|
-
|
|
9676
|
+
// Draft retired (default) OR visible explicitly on → a real
|
|
9677
|
+
// edit-in-place message (minInitialChars:1, no draft): observable by
|
|
9678
|
+
// the UAT and the onMetric silence-liveness reset fires on visible
|
|
9679
|
+
// sends in DMs AND supergroups. Legacy draft only when the kill
|
|
9680
|
+
// switch re-enables it (DRAFT_ANSWER_LANE_RETIRED=false), which also
|
|
9681
|
+
// restores sendMessageDraftFn above.
|
|
9682
|
+
...(ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED
|
|
9549
9683
|
? { minInitialChars: 1 }
|
|
9550
9684
|
: { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER }),
|
|
9551
9685
|
// #1075: route through robustApiCall so flood-wait,
|
|
@@ -9835,7 +9969,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9835
9969
|
const streamedMsgId = stream.messageId()
|
|
9836
9970
|
const streamedFinalText = turn.capturedText.join('').trim()
|
|
9837
9971
|
if (
|
|
9838
|
-
|
|
9972
|
+
// Broadened for draft retirement: a text-only no-reply turn that
|
|
9973
|
+
// streamed a VISIBLE preview must materialize a pinged final answer +
|
|
9974
|
+
// delete the preview. Without this, the retired-default path would
|
|
9975
|
+
// fall into the else-branch retract() and delete the user's only copy
|
|
9976
|
+
// of the answer (a lost-answer bug). The reply-tool branch still hits
|
|
9977
|
+
// retract() → single canonical formatted reply, no flash.
|
|
9978
|
+
(ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED)
|
|
9839
9979
|
&& !turn.replyCalled
|
|
9840
9980
|
&& streamedMsgId != null
|
|
9841
9981
|
&& streamedFinalText.length > 0
|
|
@@ -20565,7 +20705,7 @@ void (async () => {
|
|
|
20565
20705
|
}
|
|
20566
20706
|
}
|
|
20567
20707
|
|
|
20568
|
-
process.stderr.write(`telegram gateway: answer-stream draft
|
|
20708
|
+
process.stderr.write(`telegram gateway: answer-stream lane=${DRAFT_ANSWER_LANE_RETIRED ? 'visible(draft-retired)' : (ANSWER_STREAM_VISIBLE_ENABLED ? 'visible' : 'draft')} draftFn=${sendMessageDraftFn != null ? 'available' : 'off'} grammy=${GRAMMY_VERSION}\n`)
|
|
20569
20709
|
process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? 'checklist'}\n`)
|
|
20570
20710
|
runnerHandle = run(bot, {
|
|
20571
20711
|
runner: {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect } from 'vitest'
|
|
9
|
-
import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
|
|
9
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
10
10
|
|
|
11
11
|
describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
12
12
|
it('defaults OFF when unset', () => {
|
|
@@ -25,3 +25,21 @@ describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
|
25
25
|
}
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-switch off', () => {
|
|
30
|
+
it('defaults to RETIRED (true) when unset — the draft lane is gone by default', () => {
|
|
31
|
+
expect(parseDraftLaneRetiredEnabled(undefined)).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('stays RETIRED for any non-disable value (including unrecognized)', () => {
|
|
35
|
+
for (const v of ['1', 'true', 'on', 'yes', '', ' ', 'whatever', 'retired']) {
|
|
36
|
+
expect(parseDraftLaneRetiredEnabled(v)).toBe(true)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('restores the legacy draft (false) ONLY on an explicit disable (case/space-insensitive)', () => {
|
|
41
|
+
for (const v of ['0', 'false', 'off', 'no', ' FALSE ', 'Off', 'NO']) {
|
|
42
|
+
expect(parseDraftLaneRetiredEnabled(v)).toBe(false)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft-answer-lane retirement — gateway wiring guards (2026-06-05).
|
|
3
|
+
*
|
|
4
|
+
* The retirement switches the live answer lane from the invisible compose-box
|
|
5
|
+
* draft to a real, mtcute-observable edit-in-place message, default-on. The
|
|
6
|
+
* design review flagged two ways this silently breaks (gateway IIFE can't be
|
|
7
|
+
* instantiated in-process, so these are source-level assertions, same pattern as
|
|
8
|
+
* silence-liveness-wiring.test):
|
|
9
|
+
*
|
|
10
|
+
* PRIMARY: drop sendMessageDraftFn but FORGET to flip minInitialChars to 1 →
|
|
11
|
+
* the lane becomes a total no-op (the MAX gate never opens it), losing ALL
|
|
12
|
+
* answer-lane status AND the #2169 onMetric silence-liveness reset.
|
|
13
|
+
* SECONDARY: flip the lane to visible but FORGET to broaden the
|
|
14
|
+
* materialize-as-answer guard → a text-only no-reply turn falls into retract()
|
|
15
|
+
* and deletes the user's only copy of the answer (a lost-answer bug).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
22
|
+
|
|
23
|
+
describe('draft-retirement wiring', () => {
|
|
24
|
+
it('sendMessageDraftFn is gated on the retirement (the single chokepoint)', () => {
|
|
25
|
+
expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('DRAFT_ANSWER_LANE_RETIRED is declared before its first use (no TDZ at boot)', () => {
|
|
29
|
+
const declIdx = gatewaySrc.indexOf('const DRAFT_ANSWER_LANE_RETIRED =')
|
|
30
|
+
const firstUseIdx = gatewaySrc.indexOf('!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft')
|
|
31
|
+
expect(declIdx).toBeGreaterThan(0)
|
|
32
|
+
expect(firstUseIdx).toBeGreaterThan(declIdx)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('PRIMARY GUARD: retired lane uses minInitialChars:1 (visible), never the MAX no-op gate', () => {
|
|
36
|
+
// The config must pick the {minInitialChars:1} branch when retired, so the
|
|
37
|
+
// lane actually opens a real message. The MAX branch is draft-only (legacy).
|
|
38
|
+
expect(gatewaySrc).toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\s*\n?\s*\?\s*\{ minInitialChars: 1 \}/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('SECONDARY GUARD: the materialize-as-answer guard is broadened in lockstep', () => {
|
|
42
|
+
// A text-only no-reply turn must materialize (ping + delete preview), not
|
|
43
|
+
// retract() the answer away.
|
|
44
|
+
expect(gatewaySrc).toMatch(/\(ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\)\s*\n?\s*&& !turn\.replyCalled/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('the #2169 onMetric silence-liveness reset is preserved (fires on visible sends now)', () => {
|
|
48
|
+
const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
|
|
49
|
+
expect(onMetric).toMatch(/silencePoke\.noteProduction/)
|
|
50
|
+
expect(onMetric).toMatch(/currentTurn === turn/)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -67,6 +67,51 @@ describe('component 3 — turn-origin reply routing', () => {
|
|
|
67
67
|
})
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
describe('framework-owned origin recovery (determinism residual, 2026-06-05)', () => {
|
|
71
|
+
it('a source-message reverse index is populated at enqueue and EVICTED in parity with recentTurnsById', () => {
|
|
72
|
+
expect(gatewaySrc).toMatch(/const recentTurnIdBySourceMessageId = new Map<number, string>\(\)/)
|
|
73
|
+
// Populated inside rememberRecentTurn from the turn's sourceMessageId.
|
|
74
|
+
const fn = gatewaySrc.split('function rememberRecentTurn')[1]?.split('\nfunction ')[0] ?? ''
|
|
75
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.set\(turn\.sourceMessageId, turn\.turnId\)/)
|
|
76
|
+
// Eviction parity: the reverse entry is dropped when its turn is evicted —
|
|
77
|
+
// so the index cannot outgrow the bounded RECENT_TURNS_MAX registry.
|
|
78
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.delete\(evicted\.sourceMessageId\)/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('both reply paths recover origin from the quoted message_id when the model omits the echo', () => {
|
|
82
|
+
for (const name of ['executeReply', 'executeStreamReply']) {
|
|
83
|
+
const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
|
|
84
|
+
// Echo first (authoritative), quoted message_id as the framework fallback.
|
|
85
|
+
expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
|
|
86
|
+
// Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
|
|
87
|
+
expect(fn).toMatch(/findTurnByQuotedMessageId\([^,]+, args\.reply_to\)/)
|
|
88
|
+
expect(fn).toMatch(/echoedTurn \?\? quotedTurn/)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('findTurnByQuotedMessageId is gated on the kill switch and resolves a real turn (never the live successor)', () => {
|
|
93
|
+
const fn = gatewaySrc.split('function findTurnByQuotedMessageId')[1]?.split('\nfunction ')[0] ?? ''
|
|
94
|
+
expect(fn).toMatch(/FRAMEWORK_ORIGIN_ROUTING_ENABLED/)
|
|
95
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.get\(mid\)/)
|
|
96
|
+
expect(fn).toMatch(/recentTurnsById\.get\(owner\)/)
|
|
97
|
+
// Cross-chat collision guard: the resolved turn must belong to this chat.
|
|
98
|
+
expect(fn).toMatch(/turn\.sessionChatId !== chatId/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('the irreducible no-echo residual is ALARMED (MISROUTE_RISK), never silently mis-routed', () => {
|
|
102
|
+
expect(gatewaySrc).toMatch(/MISROUTE_RISK\(no-echo→live-successor\)/)
|
|
103
|
+
expect(gatewaySrc).toMatch(/function hasDifferentThreadedRecentTurn/)
|
|
104
|
+
// The alarm is observability-only: it fires on via=live with a different
|
|
105
|
+
// recent topic, and does NOT change the resolved thread.
|
|
106
|
+
expect(gatewaySrc).toMatch(/const misrouteRisk =/)
|
|
107
|
+
expect(gatewaySrc).toMatch(/via === 'quoted' \? ' QUOTED\(framework-origin\)' : ''/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('the kill switch defaults ON and is independent of TURN_ORIGIN_ROUTING', () => {
|
|
111
|
+
expect(gatewaySrc).toMatch(/SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'/)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
70
115
|
describe('component 4 — per-turn topic framing', () => {
|
|
71
116
|
it('the gateway stamps a topic_scope directive for forum-topic inbounds (kill-switched)', () => {
|
|
72
117
|
expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-surface ordering stress — a DM question and a supergroup question
|
|
3
|
+
* back-to-back, assert each answer lands in ITS OWN surface (DM answer in the
|
|
4
|
+
* DM, channel answer in the channel) with no cross-contamination.
|
|
5
|
+
*
|
|
6
|
+
* One Claude CLI serves both the DM and the supergroup through a singleton
|
|
7
|
+
* currentTurn; a late or mis-attributed reply can leak across surfaces (a DM
|
|
8
|
+
* answer posted into the channel, or vice versa) — the cross-surface twin of
|
|
9
|
+
* the multi-topic bleed. This is the "handling messages in multiple channels"
|
|
10
|
+
* concern at the DM-vs-channel axis. Self-skips green without a resolvable
|
|
11
|
+
* SWITCHROOM_UAT_CHAT_ID supergroup (uat/** is non-gating).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
14
|
+
import { spinUp, type Scenario } from "../harness.js";
|
|
15
|
+
import { isWorkerFeedMessage, isActivityFeedMessage } from "../assertions.js";
|
|
16
|
+
import type { ObservedMessage } from "../driver.js";
|
|
17
|
+
|
|
18
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
19
|
+
|
|
20
|
+
interface Hit { chatId: number; text: string; messageId: number; }
|
|
21
|
+
|
|
22
|
+
function isAnswer(m: ObservedMessage, driverUserId: number): boolean {
|
|
23
|
+
return m.senderUserId !== driverUserId && !m.edited
|
|
24
|
+
&& !isWorkerFeedMessage(m) && !isActivityFeedMessage(m) && m.text.trim().length > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("uat: cross-surface ordering — DM Q + channel Q, no surface bleed", () => {
|
|
28
|
+
let sc: Scenario | null = null;
|
|
29
|
+
let postable = false;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
33
|
+
console.warn("[uat] SWITCHROOM_UAT_CHAT_ID unset — skipping cross-surface ordering");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
sc = await spinUp({ agent: "test-harness" });
|
|
37
|
+
await sc.driver.primeDialogs();
|
|
38
|
+
postable = await sc.driver.canResolve(SUPERGROUP_ID);
|
|
39
|
+
if (!postable) console.warn(`[uat] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("DM answer stays in the DM and channel answer stays in the channel (no leak)", async () => {
|
|
43
|
+
if (sc == null || !postable) return; // self-skip green
|
|
44
|
+
const { driver, driverUserId, botUserId } = sc;
|
|
45
|
+
await driver.primeDialogs();
|
|
46
|
+
|
|
47
|
+
const dmIter = driver.observeMessages(botUserId)[Symbol.asyncIterator]();
|
|
48
|
+
const sgIter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
|
|
49
|
+
|
|
50
|
+
// Distinct answers per surface: DM → 14, channel → 42.
|
|
51
|
+
await driver.sendText(botUserId, "Reply with only the number and nothing else: what is 7 + 7?");
|
|
52
|
+
await driver.sendText(SUPERGROUP_ID, "Reply with only the number and nothing else: what is 40 + 2?");
|
|
53
|
+
|
|
54
|
+
let dmHit: Hit | undefined;
|
|
55
|
+
let sgHit: Hit | undefined;
|
|
56
|
+
const strays: Hit[] = []; // an expected-answer token observed in the WRONG surface
|
|
57
|
+
|
|
58
|
+
const deadline = Date.now() + 120_000;
|
|
59
|
+
const pump = async (iter: AsyncIterator<ObservedMessage>, chatId: number) => {
|
|
60
|
+
while (Date.now() < deadline && !(dmHit && sgHit)) {
|
|
61
|
+
const next = await Promise.race([
|
|
62
|
+
iter.next(),
|
|
63
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
64
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
|
|
65
|
+
),
|
|
66
|
+
]);
|
|
67
|
+
if (next.done || next.value == null) break;
|
|
68
|
+
const m = next.value as ObservedMessage;
|
|
69
|
+
if (!isAnswer(m, driverUserId)) continue;
|
|
70
|
+
const hit: Hit = { chatId: m.chatId, text: m.text, messageId: m.messageId };
|
|
71
|
+
const has14 = /(^|\D)14(\D|$)/.test(m.text);
|
|
72
|
+
const has42 = /(^|\D)42(\D|$)/.test(m.text);
|
|
73
|
+
if (chatId === botUserId) {
|
|
74
|
+
if (has14 && dmHit == null) dmHit = hit;
|
|
75
|
+
if (has42) strays.push(hit); // channel answer leaked into the DM
|
|
76
|
+
} else {
|
|
77
|
+
if (has42 && sgHit == null) sgHit = hit;
|
|
78
|
+
if (has14) strays.push(hit); // DM answer leaked into the channel
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await Promise.all([pump(dmIter, botUserId), pump(sgIter, SUPERGROUP_ID)]);
|
|
83
|
+
void dmIter.return?.();
|
|
84
|
+
void sgIter.return?.();
|
|
85
|
+
|
|
86
|
+
console.log(
|
|
87
|
+
`[cross-surface] dm(7+7=14)=${dmHit ? `msg=${dmHit.messageId}` : "MISSING"} ` +
|
|
88
|
+
`channel(40+2=42)=${sgHit ? `msg=${sgHit.messageId}` : "MISSING"} strays=${strays.length}`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Invariant 1: both answered.
|
|
92
|
+
expect(dmHit, "DM question (7+7=14) was never answered in the DM").toBeDefined();
|
|
93
|
+
expect(sgHit, "channel question (40+2=42) was never answered in the channel").toBeDefined();
|
|
94
|
+
// Invariant 2: no answer leaked to the wrong surface.
|
|
95
|
+
expect(strays, `an answer leaked across surfaces: ${JSON.stringify(strays)}`).toHaveLength(0);
|
|
96
|
+
// Invariant 3 (belt+braces): the right answer is in the right chat.
|
|
97
|
+
expect(dmHit!.chatId).toBe(botUserId);
|
|
98
|
+
expect(sgHit!.chatId).toBe(SUPERGROUP_ID);
|
|
99
|
+
}, 150_000);
|
|
100
|
+
});
|