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.
@@ -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
- typeof _rawSendMessageDraft === 'function'
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; `UNROUTED` flags a supergroup reply that still resolved to no
1924
- * 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.
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
- 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)
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
- 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
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
- 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
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
- ...(ANSWER_STREAM_VISIBLE_ENABLED
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
- ANSWER_STREAM_VISIBLE_ENABLED
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 transport=${sendMessageDraftFn != null ? 'available' : 'unavailable'} grammy=${GRAMMY_VERSION}\n`)
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
+ });