switchroom 0.21.10 → 0.21.12
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 +5 -2
- package/skills/dev-protocol/SKILL.md +14 -10
- package/skills/switchroom-release/SKILL.md +11 -5
- package/telegram-plugin/dist/gateway/gateway.js +385 -35
- package/telegram-plugin/format.ts +56 -0
- 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 +55 -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/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 +22 -4
- package/telegram-plugin/render/parse.ts +9 -1
- 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 +76 -3
- package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
- 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/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +2 -2
|
@@ -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
|
}
|