switchroom 0.21.9 → 0.21.11

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.
Files changed (46) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -2
  4. package/telegram-plugin/dist/gateway/gateway.js +1152 -296
  5. package/telegram-plugin/format.ts +74 -1
  6. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  7. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  8. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  9. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  10. package/telegram-plugin/gateway/gateway.ts +61 -68
  11. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  12. package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
  13. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  14. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  15. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  16. package/telegram-plugin/gateway/stream-render.ts +1 -1
  17. package/telegram-plugin/history.ts +21 -0
  18. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  19. package/telegram-plugin/render/html-fold.ts +372 -0
  20. package/telegram-plugin/render/parse.ts +578 -29
  21. package/telegram-plugin/render/render.ts +14 -13
  22. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  23. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  24. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  25. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  26. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  27. package/telegram-plugin/tests/history.test.ts +78 -0
  28. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  29. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  30. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
  31. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  32. package/telegram-plugin/tests/render/parse.test.ts +9 -5
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  36. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  37. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  38. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  39. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  40. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  41. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  42. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  43. package/telegram-plugin/tts-normalize.ts +47 -9
  44. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  45. package/telegram-plugin/voice-normalize-text.ts +48 -9
  46. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -85,6 +85,62 @@ export function codeSpanSafe(s: string): string {
85
85
  return s.replace(/`/g, '`​')
86
86
  }
87
87
 
88
+ /**
89
+ * Compute the delimiter for a fenced code block whose body is `text`.
90
+ *
91
+ * Fenced content is verbatim per the formatting guide; the only hazard is a
92
+ * backtick run inside the body CLOSING the fence early. A hardcoded ``` around
93
+ * a body that carries its own ``` ships three delimiters instead of two: the
94
+ * wire opens at the first, closes at the embedded one, reads the remainder as
95
+ * prose, and the trailing delimiter opens a fence that never terminates —
96
+ * `can't find end of Pre entity`, a 400, and a whole-message plain-text
97
+ * resend. Widening the delimiter to one backtick longer than the longest run
98
+ * already present makes the open/close pair the only runs of that width, so
99
+ * nothing in the body can match them.
100
+ *
101
+ * This is the canonical home for the rule (as `codeSpanSafe` is for the code
102
+ * SPAN hazard above): the markdown fence (`renderCodeBlock`), the degraded
103
+ * table fence (`degradeToCodeFence`) and the raw-HTML `<pre>` fold
104
+ * (`buildPreBlock`, render/parse.ts) all call it, so there is exactly ONE
105
+ * implementation of the width rule rather than a copy per call site.
106
+ *
107
+ * The scan is a LOOP, not `Math.max(0, ...runs.map(…))`. The spread form
108
+ * (which both former copies of this rule used) passes one argument per run,
109
+ * so a body with enough separate backtick runs blows the argument limit with
110
+ * `RangeError: Maximum call stack size exceeded`, throwing out of the render
111
+ * path entirely.
112
+ *
113
+ * The limit is ENGINE-DEPENDENT, so no single number describes it. The
114
+ * shipping runtime is Bun (`telegram-plugin/package.json` `start`,
115
+ * docker/Dockerfile.agent), where it measures at ~639k runs (~1.9 MB body);
116
+ * this file is also collected by the vitest/Node runner, where it is ~125k
117
+ * (~375 KB). Both measured on the pinned toolchain (Bun 1.3.13 — the CI
118
+ * default in .github/actions/setup-switchroom/action.yml — and Node 22), and
119
+ * both move with an engine upgrade.
120
+ *
121
+ * We deliberately do NOT rely on that threshold. Input here is unbounded:
122
+ * `renderOutboundChunks` renders the WHOLE raw body before any
123
+ * `splitMarkdownChunks` (render/rich-render.ts:146), so the fence scan is not
124
+ * bounded by the 32768-character rich cap. Being honest about the size: on
125
+ * Bun a ~1.9 MB single message is an unlikely body, so there this is a latent
126
+ * crash rather than a routine one. The loop is O(n) either way and cannot
127
+ * throw, so correctness costs nothing and the reachability argument does not
128
+ * have to be won.
129
+ */
130
+ export function codeFenceFor(text: string): string {
131
+ let longestRun = 0
132
+ let current = 0
133
+ for (let i = 0; i < text.length; i++) {
134
+ if (text[i] === '`') {
135
+ current++
136
+ if (current > longestRun) longestRun = current
137
+ } else {
138
+ current = 0
139
+ }
140
+ }
141
+ return '`'.repeat(Math.max(3, longestRun + 1))
142
+ }
143
+
88
144
  /**
89
145
  * Make a URL safe to interpolate as the destination of a `[label](href)`
90
146
  * inline link.
@@ -99,9 +155,26 @@ export function codeSpanSafe(s: string): string {
99
155
  * `\` first (so we never double-escape a following escape), then BOTH `(` and
100
156
  * `)` — the whole URL is preserved balanced and micromark decodes it back to
101
157
  * the original href on round-trip. Bot API 10.1 lists `(`/`)` as escapable.
158
+ *
159
+ * WHITESPACE and control characters are PERCENT-ENCODED rather than
160
+ * backslash-escaped. A bare link destination ends at the first ASCII
161
+ * whitespace character, so a space or a newline inside the href does not just
162
+ * truncate the URL — the remainder is re-read as a link title, or (for a
163
+ * newline) the inline link is terminated outright, leaving a structurally
164
+ * broken construct with URL fragments visible as prose. Backslash cannot
165
+ * rescue that: whitespace is not escapable in a bare destination. `%20` /
166
+ * `%0A` are the canonical URL encodings, so the href a client resolves is
167
+ * equivalent to the one the author wrote.
102
168
  */
103
169
  export function escapeLinkHref(href: string): string {
104
- return href.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
170
+ return href
171
+ .replace(/\\/g, '\\\\')
172
+ .replace(/\(/g, '\\(')
173
+ .replace(/\)/g, '\\)')
174
+ .replace(
175
+ /[\x00-\x20\x7f]/g,
176
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`,
177
+ )
105
178
  }
106
179
 
107
180
  /**
@@ -0,0 +1,163 @@
1
+ /**
2
+ * answer-route-overrides.ts — a bounded, in-memory record of the reply router's
3
+ * EXPLICIT-THREAD OVERRIDES, so a later consumer can ask "was an answer the model
4
+ * addressed to topic N actually delivered somewhere else?".
5
+ *
6
+ * WHY THIS EXISTS. `resolveAnswerThreadWithLog` already computes this fact and
7
+ * logs it (`EXPLICIT_OVERRIDDEN(model→4,routed→635)`): the model named a topic
8
+ * on its `reply`, and the framework's topic authority overrode that with the
9
+ * origin/live turn's topic. The answer therefore lands under a DIFFERENT
10
+ * `thread_id` than the one the model was answering — invisible to any
11
+ * thread-keyed history query for the topic the model meant.
12
+ *
13
+ * The obligation-escalation staleness check is exactly such a query, and the
14
+ * override is the ONLY per-turn evidence that ties an answer delivered in topic
15
+ * B to a question asked in topic A. Recording it turns "an answer landed
16
+ * somewhere in this chat" (an unfalsifiable chat-wide guess) into "an answer the
17
+ * model addressed to THIS topic landed in that topic" — a specific, checkable
18
+ * claim. See `escalation-staleness.ts` for the consumer and the field evidence.
19
+ *
20
+ * Bounded by construction: at most `maxKeys` (chat, intended-thread) keys and
21
+ * `maxPerKey` recent routings each, evicted oldest-first. Losing an entry can
22
+ * only ever cost one over-escalation (the safe direction — an extra advisory,
23
+ * never a swallowed message).
24
+ */
25
+
26
+ /** One observed override: the model meant `intendedThreadId`, it went here. */
27
+ export interface AnswerRouteOverride {
28
+ /** Where the answer actually went. `null` = chat root / no topic (history
29
+ * stores `thread_id IS NULL` for these — `recordOutbound` does `?? null`). */
30
+ routedThreadId: number | null
31
+ /** Wall-clock ms at which the routing decision was made. */
32
+ atMs: number
33
+ }
34
+
35
+ export interface NoteOverrideArgs {
36
+ chatId: string
37
+ /** `REPLY_TOPIC_AUTHORITY_ENABLED` — no authority, no override. */
38
+ enabled: boolean
39
+ /** The topic the model named on its reply, if any. */
40
+ explicitThreadId: number | undefined
41
+ /** Whether a framework anchor (origin or live turn) was present to override with. */
42
+ anchored: boolean
43
+ /** The thread the router actually resolved. `undefined` = chat root. */
44
+ routedThreadId: number | undefined
45
+ nowMs: number
46
+ }
47
+
48
+ export interface AnswerRouteOverrides {
49
+ /**
50
+ * Record the routing decision iff it WAS an explicit-thread override, and
51
+ * return whether it was — so the caller can use the same boolean for its
52
+ * telemetry instead of computing the predicate twice.
53
+ */
54
+ note(args: NoteOverrideArgs): boolean
55
+ /**
56
+ * Overrides recorded for `intendedThreadId` at or after `notBeforeMs`, one per
57
+ * distinct routed thread (the EARLIEST in-window record for that thread wins,
58
+ * so a consumer using `atMs` as a cutoff gets the widest correct one). Empty
59
+ * when no override was observed — which is the common case and means "no
60
+ * reason to look outside this topic".
61
+ *
62
+ * The caller MUST use each entry's `atMs` as the lower bound of whatever it
63
+ * looks up next, and MUST pass a `notBeforeMs` that bounds how old an override
64
+ * may be. An override only licenses a query for the answer THAT routing
65
+ * produced; it is not a standing licence to accept anything ever delivered in
66
+ * that thread. See `escalation-staleness.ts` for the incident that proves it.
67
+ */
68
+ routedOverridesSince(
69
+ chatId: string,
70
+ intendedThreadId: number | null | undefined,
71
+ notBeforeMs: number,
72
+ ): AnswerRouteOverride[]
73
+ /**
74
+ * The MOST RECENT override recorded for `intendedThreadId` at or after
75
+ * `notBeforeMs`, or `undefined` if there is none.
76
+ *
77
+ * Diagnostic-only, and deliberately separate from `routedOverridesSince`:
78
+ * that one returns the EARLIEST in-window record per routed thread because a
79
+ * consumer uses `atMs` as a history cutoff and wants the widest correct one.
80
+ * A consumer asking "did a record exist that my freshness bound rejected, and
81
+ * by how much?" wants the other end — the near-miss. Kept as its own method so
82
+ * answering that can never perturb the accept path's cutoff.
83
+ */
84
+ newestOverrideSince(
85
+ chatId: string,
86
+ intendedThreadId: number | null | undefined,
87
+ notBeforeMs: number,
88
+ ): AnswerRouteOverride | undefined
89
+ /** Live key count. Test/diagnostic surface. */
90
+ size(): number
91
+ /**
92
+ * Drop every recorded override. Test surface: the process-wide registry below
93
+ * is a module singleton, so without a reset one test's records leak into the
94
+ * next and a scenario silently depends on its neighbours' timestamps. Not
95
+ * called in production — the bounded eviction owns lifetime there.
96
+ */
97
+ clear(): void
98
+ }
99
+
100
+ function keyFor(chatId: string, threadId: number | null | undefined): string {
101
+ return `${chatId}:${threadId ?? '_'}`
102
+ }
103
+
104
+ export function createAnswerRouteOverrides(maxKeys = 64, maxPerKey = 8): AnswerRouteOverrides {
105
+ // Insertion-ordered Map; eviction is oldest-INSERTED-first (FIFO), not
106
+ // least-recently-used — entries are not re-inserted on read.
107
+ const byKey = new Map<string, AnswerRouteOverride[]>()
108
+ return {
109
+ note(args: NoteOverrideArgs): boolean {
110
+ const overridden =
111
+ args.enabled &&
112
+ args.explicitThreadId != null &&
113
+ args.anchored &&
114
+ args.routedThreadId !== args.explicitThreadId
115
+ if (!overridden) return false
116
+ const key = keyFor(args.chatId, args.explicitThreadId)
117
+ const list = byKey.get(key) ?? []
118
+ list.push({ routedThreadId: args.routedThreadId ?? null, atMs: args.nowMs })
119
+ while (list.length > maxPerKey) list.shift()
120
+ byKey.set(key, list)
121
+ while (byKey.size > maxKeys) {
122
+ const oldest = byKey.keys().next().value
123
+ if (oldest === undefined) break
124
+ byKey.delete(oldest)
125
+ }
126
+ return true
127
+ },
128
+ routedOverridesSince(chatId, intendedThreadId, notBeforeMs): AnswerRouteOverride[] {
129
+ const list = byKey.get(keyFor(chatId, intendedThreadId))
130
+ if (list == null) return []
131
+ const out: AnswerRouteOverride[] = []
132
+ for (const e of list) {
133
+ if (e.atMs < notBeforeMs) continue
134
+ if (out.some((seen) => seen.routedThreadId === e.routedThreadId)) continue
135
+ out.push({ ...e })
136
+ }
137
+ return out
138
+ },
139
+ newestOverrideSince(chatId, intendedThreadId, notBeforeMs): AnswerRouteOverride | undefined {
140
+ const list = byKey.get(keyFor(chatId, intendedThreadId))
141
+ if (list == null) return undefined
142
+ let newest: AnswerRouteOverride | undefined
143
+ for (const e of list) {
144
+ if (e.atMs < notBeforeMs) continue
145
+ if (newest == null || e.atMs > newest.atMs) newest = e
146
+ }
147
+ return newest == null ? undefined : { ...newest }
148
+ },
149
+ size(): number {
150
+ return byKey.size
151
+ },
152
+ clear(): void {
153
+ byKey.clear()
154
+ },
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Process-wide registry. The gateway is a single process and the router is the
160
+ * only writer; obligation-wiring is the only reader. Injected as a value into
161
+ * the pure decision functions so tests never touch this instance.
162
+ */
163
+ export const answerRouteOverrides = createAnswerRouteOverrides()
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { resolveAnswerThreadId, type AnswerThreadInput } from './answer-thread-resolve.js'
2
+ import {
3
+ resolveAnswerThreadId,
4
+ isCrossChatAnchor,
5
+ type AnswerThreadInput,
6
+ } from './answer-thread-resolve.js'
3
7
 
4
8
  // Distinct symbolic thread ids so an output's provenance is unambiguous (no two
5
9
  // tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
@@ -107,6 +111,143 @@ describe('resolveAnswerThreadId — legacy (frameworkTopicAuthority:false)', ()
107
111
  })
108
112
  })
109
113
 
114
+ // ── Cross-chat anchor guard (bug c, 2026-08-13) ─────────────────────────────
115
+ //
116
+ // The live failure: a reply into chat A resolved its thread from an anchor turn
117
+ // owned by forum supergroup B, so B's topic id rode along on the send into A and
118
+ // Telegram answered `400 Bad Request: message thread not found`. The retry
119
+ // fallback resent threadless and succeeded, so the symptom was a guaranteed-
120
+ // failed FIRST API call rather than a lost message. Each case below asserts the
121
+ // RESOLVED THREAD, not that a branch ran.
122
+ const CHAT_A = '12345678' // a DM — the reply target
123
+ const CHAT_B = '-1001234567890' // a forum supergroup — where the anchor lives
124
+
125
+ describe('resolveAnswerThreadId — cross-chat anchor guard', () => {
126
+ it("THE bug: a live turn in supergroup B must not lend its topic to a reply into chat A", () => {
127
+ expect(
128
+ resolveAnswerThreadId({
129
+ targetChatId: CHAT_A,
130
+ originResolved: false,
131
+ liveTurnPresent: true,
132
+ liveThreadId: 635, // B's topic — the id Telegram rejected
133
+ liveChatId: CHAT_B,
134
+ }),
135
+ ).toBeUndefined() // → no thread; the send into A is threadless and succeeds
136
+ })
137
+
138
+ it('a cross-chat ORIGIN anchor (origin_turn_id echoed from another chat) is ignored', () => {
139
+ expect(
140
+ resolveAnswerThreadId({
141
+ targetChatId: CHAT_A,
142
+ originResolved: true,
143
+ originThreadId: O,
144
+ originChatId: CHAT_B,
145
+ }),
146
+ ).toBeUndefined()
147
+ })
148
+
149
+ it('a dropped cross-chat anchor falls through to the model explicit (its only remaining signal)', () => {
150
+ expect(
151
+ resolveAnswerThreadId({
152
+ targetChatId: CHAT_A,
153
+ explicitThreadId: T,
154
+ originResolved: true,
155
+ originThreadId: O,
156
+ originChatId: CHAT_B,
157
+ liveTurnPresent: true,
158
+ liveThreadId: L,
159
+ liveChatId: CHAT_B,
160
+ }),
161
+ ).toBe(T)
162
+ })
163
+
164
+ it('a dropped cross-chat anchor falls through to the chat-scoped last-ended recovery when there is no explicit', () => {
165
+ expect(
166
+ resolveAnswerThreadId({
167
+ targetChatId: CHAT_A,
168
+ originResolved: false,
169
+ liveTurnPresent: true,
170
+ liveThreadId: L,
171
+ liveChatId: CHAT_B,
172
+ lastEndedResolvedForChat: true,
173
+ lastEndedThreadIdForChat: E,
174
+ }),
175
+ ).toBe(E)
176
+ })
177
+
178
+ it('a SAME-chat anchor is untouched — the guard only drops foreign chats', () => {
179
+ expect(
180
+ resolveAnswerThreadId({
181
+ targetChatId: CHAT_B,
182
+ explicitThreadId: T,
183
+ originResolved: true,
184
+ originThreadId: O,
185
+ originChatId: CHAT_B,
186
+ }),
187
+ ).toBe(O)
188
+ })
189
+
190
+ it('an origin in chat A survives while a live turn in chat B is dropped (independent guards)', () => {
191
+ expect(
192
+ resolveAnswerThreadId({
193
+ targetChatId: CHAT_A,
194
+ originResolved: true,
195
+ originThreadId: O,
196
+ originChatId: CHAT_A,
197
+ liveTurnPresent: true,
198
+ liveThreadId: L,
199
+ liveChatId: CHAT_B,
200
+ }),
201
+ ).toBe(O)
202
+ })
203
+
204
+ it('the guard also applies under the legacy kill switch — a wrong-chat topic is an API error, not a precedence policy', () => {
205
+ expect(
206
+ resolveAnswerThreadId({
207
+ frameworkTopicAuthority: false,
208
+ targetChatId: CHAT_A,
209
+ originResolved: true,
210
+ originThreadId: O,
211
+ originChatId: CHAT_B,
212
+ liveThreadId: L,
213
+ liveChatId: CHAT_B,
214
+ }),
215
+ ).toBeUndefined()
216
+ })
217
+
218
+ it('numeric-vs-string chat ids compare by value, so no anchor is dropped spuriously', () => {
219
+ expect(
220
+ resolveAnswerThreadId({
221
+ targetChatId: CHAT_B,
222
+ originResolved: true,
223
+ originThreadId: O,
224
+ originChatId: String(Number(CHAT_B)),
225
+ }),
226
+ ).toBe(O)
227
+ })
228
+
229
+ it('BACK-COMPAT: with no chat ids supplied the guard is inert (identical to pre-guard routing)', () => {
230
+ expect(
231
+ resolveAnswerThreadId({
232
+ originResolved: false,
233
+ liveTurnPresent: true,
234
+ liveThreadId: L,
235
+ }),
236
+ ).toBe(L)
237
+ })
238
+ })
239
+
240
+ describe('isCrossChatAnchor', () => {
241
+ it('true only when both ids are present and differ', () => {
242
+ expect(isCrossChatAnchor(CHAT_A, CHAT_B)).toBe(true)
243
+ expect(isCrossChatAnchor(CHAT_A, CHAT_A)).toBe(false)
244
+ expect(isCrossChatAnchor(undefined, CHAT_B)).toBe(false)
245
+ expect(isCrossChatAnchor(CHAT_A, undefined)).toBe(false)
246
+ expect(isCrossChatAnchor('', CHAT_B)).toBe(false)
247
+ expect(isCrossChatAnchor(CHAT_A, '')).toBe(false)
248
+ })
249
+ })
250
+
110
251
  // ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
111
252
  //
112
253
  // The operator standard (memory feedback_prove_finite_fsm_not_sample): a passing
@@ -238,4 +379,37 @@ describe('resolveAnswerThreadId — total-enumeration proof (54 reachable inputs
238
379
  it('INV-EXPLICIT-DOMINANCE (legacy): explicit set ⇒ output === explicit, independent of all other fields', () => {
239
380
  for (const i of LEGACY) if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
240
381
  })
382
+
383
+ // ── Cross-chat guard, proved over the same enumeration (both modes) ────────
384
+ it('INV-CROSS-CHAT: anchors in a FOREIGN chat route exactly as if they did not exist, on all 54 inputs × 2 modes', () => {
385
+ for (const i of [...FA, ...LEGACY]) {
386
+ const foreign = resolveAnswerThreadId({
387
+ ...i,
388
+ targetChatId: CHAT_A,
389
+ originChatId: CHAT_B,
390
+ liveChatId: CHAT_B,
391
+ })
392
+ const anchorless = resolveAnswerThreadId({
393
+ ...i,
394
+ originResolved: false,
395
+ originThreadId: undefined,
396
+ liveTurnPresent: false,
397
+ liveThreadId: undefined,
398
+ })
399
+ expect(foreign).toBe(anchorless)
400
+ }
401
+ })
402
+
403
+ it('INV-SAME-CHAT: annotating anchors with the TARGET chat changes nothing, on all 54 inputs × 2 modes', () => {
404
+ for (const i of [...FA, ...LEGACY]) {
405
+ expect(
406
+ resolveAnswerThreadId({
407
+ ...i,
408
+ targetChatId: CHAT_A,
409
+ originChatId: CHAT_A,
410
+ liveChatId: CHAT_A,
411
+ }),
412
+ ).toBe(resolveAnswerThreadId(i))
413
+ }
414
+ })
241
415
  })
@@ -44,6 +44,20 @@
44
44
  * orphaned-backstop reply lands in its topic instead of defaulting to the
45
45
  * main chat (General). Not the `chatThreadMap` last-seen heuristic.
46
46
  *
47
+ * **(c) cross-chat thread anchor (2026-08-13).** A reply targeting chat A can be
48
+ * resolved against a turn anchor that belongs to a DIFFERENT chat B — the
49
+ * `origin_turn_id` echo lookup is keyed by turn id alone (not chat-scoped), and
50
+ * the LIVE turn is whatever the session is currently running, which in a
51
+ * multi-chat agent is frequently another chat. When B is a forum supergroup, its
52
+ * topic id was attached to the send into A and Telegram rejected the call with
53
+ * `400 Bad Request: message thread not found`. A retry fallback resent with no
54
+ * thread and succeeded, so nothing was lost — but every occurrence was a
55
+ * guaranteed-failed first API call (observed 3× since 2026-08-08). Fixed by
56
+ * IGNORING any anchor whose chat id does not match the target chat: precedence
57
+ * falls through to the model's explicit thread / no thread, exactly as if the
58
+ * anchor did not exist. The chat ids are optional inputs, so a caller that
59
+ * supplies none keeps the pre-existing behaviour verbatim.
60
+ *
47
61
  * Setting `frameworkTopicAuthority: false` (kill switch
48
62
  * SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) restores the legacy explicit-first
49
63
  * precedence (the model's thread wins outright).
@@ -95,6 +109,33 @@ export interface AnswerThreadInput {
95
109
  * true. Kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0.
96
110
  */
97
111
  frameworkTopicAuthority?: boolean
112
+ /**
113
+ * Chat this reply is being SENT to (bug c). When supplied together with an
114
+ * anchor's chat id, an anchor from a DIFFERENT chat is ignored. Undefined
115
+ * disables the guard (behaviour identical to before the guard existed).
116
+ */
117
+ targetChatId?: string | undefined
118
+ /** Chat the ORIGIN anchor turn belongs to (`turn.sessionChatId`). */
119
+ originChatId?: string | undefined
120
+ /** Chat the LIVE in-flight turn belongs to (`turn.sessionChatId`). */
121
+ liveChatId?: string | undefined
122
+ }
123
+
124
+ /**
125
+ * True when `anchorChatId` names a DIFFERENT chat than the reply's target — i.e.
126
+ * the anchor's thread must NOT be attached to this send (bug c). Conservative:
127
+ * returns false when either id is absent, so an un-instrumented caller is
128
+ * unaffected. Exported so the gateway's telemetry (`via`,
129
+ * `CROSS_CHAT_ANCHOR_DROPPED`) derives from the SAME predicate that routes,
130
+ * rather than a second copy that can drift out of sync.
131
+ */
132
+ export function isCrossChatAnchor(
133
+ targetChatId: string | undefined,
134
+ anchorChatId: string | undefined,
135
+ ): boolean {
136
+ if (targetChatId == null || targetChatId === '') return false
137
+ if (anchorChatId == null || anchorChatId === '') return false
138
+ return String(anchorChatId) !== String(targetChatId)
98
139
  }
99
140
 
100
141
  /**
@@ -107,26 +148,37 @@ export interface AnswerThreadInput {
107
148
  * exists. The chat last-seen `chatThreadMap` heuristic is NOT in this chain.
108
149
  */
109
150
  export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefined {
151
+ // Bug (c): an anchor from another chat is not an anchor. Drop it BEFORE the
152
+ // precedence runs, in both modes — a wrong-chat topic id is an API error, not
153
+ // a routing policy, so the kill switch must not reinstate it. `lastEnded*` is
154
+ // already chat-scoped by its lookup, so it needs no guard here.
155
+ const originCrossChat = isCrossChatAnchor(input.targetChatId, input.originChatId)
156
+ const liveCrossChat = isCrossChatAnchor(input.targetChatId, input.liveChatId)
157
+ const originResolved = input.originResolved && !originCrossChat
158
+ const originThreadId = originCrossChat ? undefined : input.originThreadId
159
+ const liveTurnPresent = input.liveTurnPresent === true && !liveCrossChat
160
+ const liveThreadId = liveCrossChat ? undefined : input.liveThreadId
161
+
110
162
  if (input.frameworkTopicAuthority === false) {
111
163
  // ── Legacy precedence (kill switch): the model's explicit thread wins. ──
112
164
  if (input.explicitThreadId != null) return input.explicitThreadId
113
- if (input.originResolved) return input.originThreadId
114
- if (input.liveThreadId != null) return input.liveThreadId
165
+ if (originResolved) return originThreadId
166
+ if (liveThreadId != null) return liveThreadId
115
167
  if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
116
- return input.liveThreadId
168
+ return liveThreadId
117
169
  }
118
170
  // ── Framework-authority precedence (default) ───────────────────────────────
119
171
  // (1) origin turn → its thread (authoritative across a currentTurn flip; a
120
172
  // General/DM origin yields undefined → main chat / General).
121
- if (input.originResolved) return input.originThreadId
173
+ if (originResolved) return originThreadId
122
174
  // (2) a live in-flight turn → its thread. Key off PRESENCE, not the thread
123
175
  // value: a General live turn has an undefined thread but is still the
124
176
  // anchor, so the model's explicit can't pull the reply out of it.
125
- if (input.liveTurnPresent) return input.liveThreadId
177
+ if (liveTurnPresent) return liveThreadId
126
178
  // (3) no framework anchor (genuinely orphaned / proactive) → honour the
127
179
  // model's explicit thread, its only signal here.
128
180
  if (input.explicitThreadId != null) return input.explicitThreadId
129
181
  // (4) late reply, no anchor, no explicit → recover the chat's last-ended topic.
130
182
  if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
131
- return input.liveThreadId
183
+ return liveThreadId
132
184
  }