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.
- package/dist/cli/switchroom.js +143 -27
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +1152 -296
- package/telegram-plugin/format.ts +74 -1
- package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
- package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
- package/telegram-plugin/gateway/gateway.ts +61 -68
- package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
- package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
- package/telegram-plugin/gateway/reply-route-log.ts +118 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/gateway/stream-render.ts +1 -1
- package/telegram-plugin/history.ts +21 -0
- package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
- package/telegram-plugin/render/html-fold.ts +372 -0
- package/telegram-plugin/render/parse.ts +578 -29
- package/telegram-plugin/render/render.ts +14 -13
- package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
- package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
- package/telegram-plugin/tests/history.test.ts +78 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +9 -5
- package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/status-pin.test.ts +2 -2
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/telegram-format.test.ts +52 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- package/telegram-plugin/voice-normalize-text.ts +48 -9
- 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
|
|
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 {
|
|
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 (
|
|
114
|
-
if (
|
|
165
|
+
if (originResolved) return originThreadId
|
|
166
|
+
if (liveThreadId != null) return liveThreadId
|
|
115
167
|
if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
|
|
116
|
-
return
|
|
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 (
|
|
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 (
|
|
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
|
|
183
|
+
return liveThreadId
|
|
132
184
|
}
|