switchroom 0.14.68 → 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.
@@ -49601,8 +49601,8 @@ var {
49601
49601
  } = import__.default;
49602
49602
 
49603
49603
  // src/build-info.ts
49604
- var VERSION = "0.14.68";
49605
- var COMMIT_SHA = "4f371b79";
49604
+ var VERSION = "0.14.69";
49605
+ var COMMIT_SHA = "a3def2a8";
49606
49606
 
49607
49607
  // src/cli/agent.ts
49608
49608
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.68",
3
+ "version": "0.14.69",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52776,10 +52776,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52776
52776
  }
52777
52777
 
52778
52778
  // ../src/build-info.ts
52779
- var VERSION = "0.14.68";
52780
- var COMMIT_SHA = "4f371b79";
52781
- var COMMIT_DATE = "2026-06-05T10:24:42Z";
52782
- var LATEST_PR = 2174;
52779
+ var VERSION = "0.14.69";
52780
+ var COMMIT_SHA = "a3def2a8";
52781
+ var COMMIT_DATE = "2026-06-05T22:00:53+10:00";
52782
+ var LATEST_PR = null;
52783
52783
  var COMMITS_AHEAD_OF_TAG = 3;
52784
52784
 
52785
52785
  // gateway/boot-version.ts
@@ -54026,6 +54026,7 @@ var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
54026
54026
  var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
54027
54027
  var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
54028
54028
  var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
54029
+ var FRAMEWORK_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== "0";
54029
54030
  var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
54030
54031
  var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
54031
54032
  var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
@@ -54058,15 +54059,39 @@ var progressUpdateTurnCount = new Map;
54058
54059
  var currentTurn = null;
54059
54060
  var RECENT_TURNS_MAX = 32;
54060
54061
  var recentTurnsById = new Map;
54062
+ var recentTurnIdBySourceMessageId = new Map;
54061
54063
  function rememberRecentTurn(turn) {
54062
54064
  recentTurnsById.set(turn.turnId, turn);
54065
+ if (turn.sourceMessageId != null) {
54066
+ recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId);
54067
+ }
54063
54068
  while (recentTurnsById.size > RECENT_TURNS_MAX) {
54064
54069
  const oldest = recentTurnsById.keys().next().value;
54065
54070
  if (oldest === undefined)
54066
54071
  break;
54072
+ const evicted = recentTurnsById.get(oldest);
54067
54073
  recentTurnsById.delete(oldest);
54074
+ if (evicted?.sourceMessageId != null && recentTurnIdBySourceMessageId.get(evicted.sourceMessageId) === oldest) {
54075
+ recentTurnIdBySourceMessageId.delete(evicted.sourceMessageId);
54076
+ }
54068
54077
  }
54069
54078
  }
54079
+ function findTurnByQuotedMessageId(chatId, replyTo) {
54080
+ if (!FRAMEWORK_ORIGIN_ROUTING_ENABLED)
54081
+ return null;
54082
+ if (replyTo == null)
54083
+ return null;
54084
+ const mid = Number(replyTo);
54085
+ if (!Number.isFinite(mid))
54086
+ return null;
54087
+ const owner = recentTurnIdBySourceMessageId.get(mid);
54088
+ if (owner == null)
54089
+ return null;
54090
+ const turn = recentTurnsById.get(owner) ?? null;
54091
+ if (turn == null || turn.sessionChatId !== chatId)
54092
+ return null;
54093
+ return turn;
54094
+ }
54070
54095
  function deriveTurnId(chatId, threadId, messageId) {
54071
54096
  if (messageId == null || messageId === "" || String(messageId) === "0")
54072
54097
  return null;
@@ -54088,7 +54113,7 @@ function findLatestEndedTurnForChat(chatId) {
54088
54113
  }
54089
54114
  return latest;
54090
54115
  }
54091
- function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTurn, surface) {
54116
+ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
54092
54117
  const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
54093
54118
  const threadId = resolveAnswerThreadId({
54094
54119
  explicitThreadId,
@@ -54098,14 +54123,23 @@ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTu
54098
54123
  lastEndedResolvedForChat: recovered != null,
54099
54124
  lastEndedThreadIdForChat: recovered?.sessionThreadId
54100
54125
  });
54101
- const via = explicitThreadId != null ? "explicit" : originTurn != null ? "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54126
+ const via = explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54102
54127
  const ownerTurn = originTurn ?? recovered ?? liveTurn;
54103
54128
  const isSupergroup = chatId.startsWith("-100");
54104
- const unrouted = isSupergroup && threadId == null;
54105
- process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + `
54129
+ const unrouted = isSupergroup && threadId == null && ownerTurn == null;
54130
+ const misrouteRisk = isSupergroup && via === "live" && hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId);
54131
+ process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (via === "quoted" ? " QUOTED(framework-origin)" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + (misrouteRisk ? " MISROUTE_RISK(no-echo\u2192live-successor)" : "") + `
54106
54132
  `);
54107
54133
  return threadId;
54108
54134
  }
54135
+ function hasDifferentThreadedRecentTurn(chatId, liveThreadId) {
54136
+ const live = liveThreadId ?? null;
54137
+ for (const t of recentTurnsById.values()) {
54138
+ if (t.sessionChatId === chatId && (t.sessionThreadId ?? null) !== live)
54139
+ return true;
54140
+ }
54141
+ return false;
54142
+ }
54109
54143
  function closeObligationOnSubstantiveReply(args, liveTurn) {
54110
54144
  if (!OBLIGATION_LEDGER_ENABLED)
54111
54145
  return;
@@ -56339,8 +56373,10 @@ ${url}`;
56339
56373
  let threadId;
56340
56374
  if (TURN_ORIGIN_ROUTING_ENABLED) {
56341
56375
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56342
- const originTurn = findTurnByOriginId(args.origin_turn_id);
56343
- threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, turn, "reply");
56376
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id);
56377
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null;
56378
+ const originTurn = echoedTurn ?? quotedTurn;
56379
+ threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "reply");
56344
56380
  } else {
56345
56381
  threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
56346
56382
  }
@@ -56699,8 +56735,10 @@ async function executeStreamReply(args) {
56699
56735
  if (args.message_thread_id == null) {
56700
56736
  let injected;
56701
56737
  if (TURN_ORIGIN_ROUTING_ENABLED) {
56702
- const originTurn = findTurnByOriginId(args.origin_turn_id);
56703
- injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, turn, "stream_reply");
56738
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id);
56739
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
56740
+ const originTurn = echoedTurn ?? quotedTurn;
56741
+ injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "stream_reply");
56704
56742
  } else {
56705
56743
  injected = turn?.sessionThreadId;
56706
56744
  }
@@ -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
+ })
@@ -1549,6 +1549,18 @@ const SERIALIZE_NOREPLY_DRAIN_MS =
1549
1549
  // behaviour (#1664: thread from the live currentTurn capture).
1550
1550
  const TURN_ORIGIN_ROUTING_ENABLED =
1551
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'
1552
1564
  // Component 4 (per-turn topic framing). Add a one-line directive to the
1553
1565
  // channel meta + bridge instructions telling the model to answer ONLY the
1554
1566
  // current message's topic. Kill switch off (=0) → no framing field.
@@ -1858,14 +1870,57 @@ let currentTurn: CurrentTurn | null = null
1858
1870
  // a long-lived supergroup session.
1859
1871
  const RECENT_TURNS_MAX = 32
1860
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>()
1861
1879
  function rememberRecentTurn(turn: CurrentTurn): void {
1862
1880
  recentTurnsById.set(turn.turnId, turn)
1881
+ if (turn.sourceMessageId != null) {
1882
+ recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId)
1883
+ }
1863
1884
  while (recentTurnsById.size > RECENT_TURNS_MAX) {
1864
1885
  const oldest = recentTurnsById.keys().next().value
1865
1886
  if (oldest === undefined) break
1887
+ const evicted = recentTurnsById.get(oldest)
1866
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
+ }
1867
1895
  }
1868
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
+ }
1869
1924
  /**
1870
1925
  * Component 3 — derive the stable per-turn identity from the chat, thread,
1871
1926
  * and originating message id. Stamped into the inbound meta at build time
@@ -1932,13 +1987,26 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
1932
1987
  * timestamps. This wrapper logs, per reply: which precedence tier won (`via`),
1933
1988
  * the resolved thread, the origin turn + its thread, and whether the reply was
1934
1989
  * late (turn already ended). `via=recovered` marks a late reply this fix saved
1935
- * from General; `UNROUTED` flags a supergroup reply that still resolved to no
1936
- * topic (the residual gap to watch).
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.
1937
2004
  */
1938
2005
  function resolveAnswerThreadWithLog(
1939
2006
  chatId: string,
1940
2007
  explicitThreadId: number | undefined,
1941
2008
  originTurn: CurrentTurn | null,
2009
+ originVia: 'echo' | 'quoted' | null,
1942
2010
  liveTurn: CurrentTurn | null,
1943
2011
  surface: 'reply' | 'stream_reply',
1944
2012
  ): number | undefined {
@@ -1965,24 +2033,60 @@ function resolveAnswerThreadWithLog(
1965
2033
  })
1966
2034
  const via =
1967
2035
  explicitThreadId != null ? 'explicit'
1968
- : originTurn != null ? 'origin'
2036
+ : originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
1969
2037
  : liveTurn?.sessionThreadId != null ? 'live'
1970
2038
  : recovered != null ? 'recovered'
1971
2039
  : 'none'
1972
2040
  const ownerTurn = originTurn ?? recovered ?? liveTurn
1973
2041
  const isSupergroup = chatId.startsWith('-100')
1974
- const unrouted = isSupergroup && threadId == null
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)
1975
2058
  process.stderr.write(
1976
2059
  `telegram gateway: reply-route surface=${surface} chat=${chatId} ` +
1977
2060
  `resolved_thread=${threadId ?? '-'} via=${via} late=${liveTurn == null} ` +
1978
2061
  `originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
1979
2062
  (via === 'recovered' ? ' RECOVERED' : '') +
2063
+ (via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
1980
2064
  (unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
2065
+ (misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
1981
2066
  '\n',
1982
2067
  )
1983
2068
  return threadId
1984
2069
  }
1985
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
+
1986
2090
  /**
1987
2091
  * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
1988
2092
  * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
@@ -6631,11 +6735,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6631
6735
  let threadId: number | undefined
6632
6736
  if (TURN_ORIGIN_ROUTING_ENABLED) {
6633
6737
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
6634
- const originTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
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
6635
6744
  threadId = resolveAnswerThreadWithLog(
6636
6745
  chat_id,
6637
6746
  Number.isFinite(explicit as number) ? (explicit as number) : undefined,
6638
6747
  originTurn,
6748
+ originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
6639
6749
  turn,
6640
6750
  'reply',
6641
6751
  )
@@ -7288,11 +7398,17 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
7288
7398
  if (args.message_thread_id == null) {
7289
7399
  let injected: number | undefined
7290
7400
  if (TURN_ORIGIN_ROUTING_ENABLED) {
7291
- const originTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
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
7292
7407
  injected = resolveAnswerThreadWithLog(
7293
7408
  String(args.chat_id),
7294
7409
  undefined,
7295
7410
  originTurn,
7411
+ originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
7296
7412
  turn,
7297
7413
  'stream_reply',
7298
7414
  )
@@ -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
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Multi-topic routing stress (channel) — two questions in TWO forum topics
3
+ * back-to-back, assert each answer lands in ITS OWN topic with no cross-bleed.
4
+ *
5
+ * This exercises live the failure class behind #2137 ("two questions in two
6
+ * forum topics → both answers in one topic, the other unanswered") and the
7
+ * #2166 late-reply topic recovery — the exact "handling messages in multiple
8
+ * channels" concern. One Claude CLI owns every topic via a singleton
9
+ * currentTurn, so a late/misrouted reply can land in the wrong topic.
10
+ *
11
+ * The test supergroup has General (message_thread_id omitted → observed
12
+ * threadId undefined) plus a real topic (thread=5). mtcute sends into a topic
13
+ * via messageThreadId→replyTo and reads each observed message's threadId, so we
14
+ * can prove WHICH topic each answer landed in. Self-skips green without
15
+ * SWITCHROOM_UAT_CHAT_ID or an unresolvable supergroup (uat/** is non-gating).
16
+ *
17
+ * mtcute caveat: it can't enumerate topics, so the topic id is pinned (5, the
18
+ * active non-General topic in the test supergroup). If that topic is ever
19
+ * deleted the send 400s and the test skips with a clear message.
20
+ */
21
+ import { describe, it, expect, beforeAll } from "vitest";
22
+ import { spinUp, type Scenario } from "../harness.js";
23
+ import { isWorkerFeedMessage, isActivityFeedMessage } from "../assertions.js";
24
+ import type { ObservedMessage } from "../driver.js";
25
+
26
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
27
+ const TOPIC_THREAD = 5; // a real non-General topic in the test supergroup
28
+
29
+ interface Hit {
30
+ text: string;
31
+ threadId: number | undefined;
32
+ messageId: number;
33
+ at: number;
34
+ }
35
+
36
+ describe("uat: multi-topic routing — two topics back-to-back, no answer bleed", () => {
37
+ let sc: Scenario | null = null;
38
+ let postable = false;
39
+
40
+ beforeAll(async () => {
41
+ if (!Number.isFinite(SUPERGROUP_ID)) {
42
+ console.warn("[uat] SWITCHROOM_UAT_CHAT_ID unset — skipping multi-topic routing");
43
+ return;
44
+ }
45
+ sc = await spinUp({ agent: "test-harness" });
46
+ await sc.driver.primeDialogs();
47
+ postable = await sc.driver.canResolve(SUPERGROUP_ID);
48
+ if (!postable) console.warn(`[uat] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
49
+ });
50
+
51
+ it("topic-5 Q and General Q are each answered IN THEIR OWN topic (no swap, no drop)", async () => {
52
+ if (sc == null || !postable) return; // self-skip green
53
+ const driver = sc.driver;
54
+ const driverUserId = sc.driverUserId;
55
+ await driver.primeDialogs();
56
+
57
+ // Distinct, unambiguous answers so we can tell the two replies apart by
58
+ // content and check the topic each landed in. 7+7=14 (topic 5), 40+2=42
59
+ // (General) — no digit overlap, no accidental substring match.
60
+ const iter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
61
+ let topic5: Hit | undefined;
62
+ let general: Hit | undefined;
63
+
64
+ try {
65
+ await driver.sendText(
66
+ SUPERGROUP_ID,
67
+ "Reply with only the number and nothing else: what is 7 + 7?",
68
+ { messageThreadId: TOPIC_THREAD },
69
+ );
70
+ } catch (err) {
71
+ console.warn(`[uat] could not post to topic ${TOPIC_THREAD} (${(err as Error).message}) — skipping`);
72
+ return;
73
+ }
74
+ // Back-to-back (the #2137 bleed trigger): General question immediately after.
75
+ await driver.sendText(
76
+ SUPERGROUP_ID,
77
+ "Reply with only the number and nothing else: what is 40 + 2?",
78
+ );
79
+
80
+ const deadline = Date.now() + 120_000;
81
+ while (Date.now() < deadline && !(topic5 && general)) {
82
+ const next = await Promise.race([
83
+ iter.next(),
84
+ new Promise<{ done: true; value: undefined }>((r) =>
85
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
86
+ ),
87
+ ]);
88
+ if (next.done || next.value == null) break;
89
+ const m = next.value as ObservedMessage;
90
+ if (m.senderUserId === driverUserId) continue; // our own sends
91
+ if (isWorkerFeedMessage(m) || isActivityFeedMessage(m)) continue; // status surfaces, not answers
92
+ const hit: Hit = { text: m.text, threadId: m.threadId, messageId: m.messageId, at: Date.now() };
93
+ if (topic5 == null && /(^|\D)14(\D|$)/.test(m.text)) topic5 = hit;
94
+ if (general == null && /(^|\D)42(\D|$)/.test(m.text)) general = hit;
95
+ }
96
+ void iter.return?.();
97
+
98
+ console.log(
99
+ `[multitopic] topic5(7+7=14)=${topic5 ? `thread=${topic5.threadId ?? "-"} msg=${topic5.messageId}` : "MISSING"} ` +
100
+ `general(40+2=42)=${general ? `thread=${general.threadId ?? "-"} msg=${general.messageId}` : "MISSING"}`,
101
+ );
102
+
103
+ // Invariant 1: BOTH questions were answered (no drop — the #2137 "other
104
+ // topic unanswered" half).
105
+ expect(topic5, "topic-5 question (7+7=14) was never answered").toBeDefined();
106
+ expect(general, "General question (40+2=42) was never answered").toBeDefined();
107
+
108
+ // Invariant 2: each answer landed in ITS OWN topic (no bleed/swap). The
109
+ // topic-5 answer carries threadId=5; the General answer omits the thread.
110
+ expect(topic5!.threadId).toBe(TOPIC_THREAD);
111
+ expect(general!.threadId).toBeUndefined();
112
+ }, 150_000);
113
+
114
+ it("a SLOW topic-5 turn whose answer lands LATE (after a fast General turn) still routes to topic-5", async () => {
115
+ if (sc == null || !postable) return; // self-skip green
116
+ const { driver, driverUserId } = sc;
117
+ await driver.primeDialogs();
118
+
119
+ // The #2137 trigger: topic-5 turn is still working when General's turn
120
+ // arrives, so the singleton currentTurn flips to General. The slow topic-5
121
+ // answer then lands AFTER General's — it must STILL route to topic-5 (via
122
+ // origin_turn_id / the #2166 late-reply recovery), not bleed into General.
123
+ const iter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
124
+ let slowHit: Hit | undefined; // topic-5, distinctive token
125
+ let fastHit: Hit | undefined; // General, the number 42
126
+
127
+ try {
128
+ await driver.sendText(
129
+ SUPERGROUP_ID,
130
+ "Do this slowly, ONE step at a time with a brief note on each (I want to see you work): run uname -a, then nproc, " +
131
+ "then lscpu, then cat /proc/cpuinfo | grep -c processor, then free -h, then df -h. After all six, tell me how many " +
132
+ "CPU cores this machine has and end your reply with the exact token CORESDONE on its own line.",
133
+ { messageThreadId: TOPIC_THREAD },
134
+ );
135
+ } catch (err) {
136
+ console.warn(`[uat] could not post to topic ${TOPIC_THREAD} (${(err as Error).message}) — skipping`);
137
+ return;
138
+ }
139
+ // Let the slow turn get underway, then fire the fast General question while
140
+ // it's still working. Use an unusual WORD token (not a number) so the slow
141
+ // turn's tool output (which prints numbers like "42Gi") can't false-match.
142
+ await new Promise((r) => setTimeout(r, 3000));
143
+ await driver.sendText(SUPERGROUP_ID, "Reply with only this exact word and nothing else: ZUCCHINI.");
144
+
145
+ const deadline = Date.now() + 130_000;
146
+ while (Date.now() < deadline && !(slowHit && fastHit)) {
147
+ const next = await Promise.race([
148
+ iter.next(),
149
+ new Promise<{ done: true; value: undefined }>((r) =>
150
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
151
+ ),
152
+ ]);
153
+ if (next.done || next.value == null) break;
154
+ const m = next.value as ObservedMessage;
155
+ if (m.senderUserId === driverUserId) continue;
156
+ if (isWorkerFeedMessage(m) || isActivityFeedMessage(m)) continue;
157
+ const hit: Hit = { text: m.text, threadId: m.threadId, messageId: m.messageId, at: Date.now() };
158
+ if (slowHit == null && /CORESDONE/i.test(m.text)) slowHit = hit;
159
+ if (fastHit == null && /ZUCCHINI/i.test(m.text)) fastHit = hit;
160
+ }
161
+ void iter.return?.();
162
+
163
+ console.log(
164
+ `[multitopic-late] slow(CORESDONE)=${slowHit ? `thread=${slowHit.threadId ?? "-"} msg=${slowHit.messageId}` : "MISSING"} ` +
165
+ `fast(ZUCCHINI)=${fastHit ? `thread=${fastHit.threadId ?? "-"} msg=${fastHit.messageId}` : "MISSING"} ` +
166
+ `slowArrivedAfterFast=${slowHit && fastHit ? slowHit.at > fastHit.at : "n/a"}`,
167
+ );
168
+
169
+ expect(slowHit, "slow topic-5 answer (CORESDONE) never arrived").toBeDefined();
170
+ expect(fastHit, "fast General answer (42) never arrived").toBeDefined();
171
+ // Honest about whether the LATE-after-flip stress was actually exercised:
172
+ // it only is if the slow topic-5 answer landed AFTER the fast General one.
173
+ if (slowHit!.at <= fastHit!.at) {
174
+ console.warn(
175
+ "[multitopic-late] topic-5 answered BEFORE General — the late-after-flip case " +
176
+ "was not exercised this run (routing still asserted below).",
177
+ );
178
+ }
179
+ // The crux: the slow answer routed to topic-5, NOT bled into General — and
180
+ // this holds whether or not it arrived late (the stronger claim is when late).
181
+ expect(slowHit!.threadId).toBe(TOPIC_THREAD);
182
+ expect(fastHit!.threadId).toBeUndefined();
183
+ }, 160_000);
184
+ });