switchroom 0.14.68 → 0.14.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +50 -12
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +135 -1
- package/telegram-plugin/gateway/gateway.ts +122 -6
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +45 -0
- package/telegram-plugin/uat/scenarios/fuzz-cross-surface-ordering-channel.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/fuzz-multitopic-routing-channel.test.ts +184 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49601,8 +49601,8 @@ var {
|
|
|
49601
49601
|
} = import__.default;
|
|
49602
49602
|
|
|
49603
49603
|
// src/build-info.ts
|
|
49604
|
-
var VERSION = "0.14.
|
|
49605
|
-
var COMMIT_SHA = "
|
|
49604
|
+
var VERSION = "0.14.69";
|
|
49605
|
+
var COMMIT_SHA = "a3def2a8";
|
|
49606
49606
|
|
|
49607
49607
|
// src/cli/agent.ts
|
|
49608
49608
|
init_source();
|
package/package.json
CHANGED
|
@@ -52776,10 +52776,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52776
52776
|
}
|
|
52777
52777
|
|
|
52778
52778
|
// ../src/build-info.ts
|
|
52779
|
-
var VERSION = "0.14.
|
|
52780
|
-
var COMMIT_SHA = "
|
|
52781
|
-
var COMMIT_DATE = "2026-06-
|
|
52782
|
-
var LATEST_PR =
|
|
52779
|
+
var VERSION = "0.14.69";
|
|
52780
|
+
var COMMIT_SHA = "a3def2a8";
|
|
52781
|
+
var COMMIT_DATE = "2026-06-05T22:00:53+10:00";
|
|
52782
|
+
var LATEST_PR = null;
|
|
52783
52783
|
var COMMITS_AHEAD_OF_TAG = 3;
|
|
52784
52784
|
|
|
52785
52785
|
// gateway/boot-version.ts
|
|
@@ -54026,6 +54026,7 @@ var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
|
|
|
54026
54026
|
var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
|
|
54027
54027
|
var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
|
|
54028
54028
|
var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
|
|
54029
|
+
var FRAMEWORK_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== "0";
|
|
54029
54030
|
var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
|
|
54030
54031
|
var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
|
|
54031
54032
|
var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
|
|
@@ -54058,15 +54059,39 @@ var progressUpdateTurnCount = new Map;
|
|
|
54058
54059
|
var currentTurn = null;
|
|
54059
54060
|
var RECENT_TURNS_MAX = 32;
|
|
54060
54061
|
var recentTurnsById = new Map;
|
|
54062
|
+
var recentTurnIdBySourceMessageId = new Map;
|
|
54061
54063
|
function rememberRecentTurn(turn) {
|
|
54062
54064
|
recentTurnsById.set(turn.turnId, turn);
|
|
54065
|
+
if (turn.sourceMessageId != null) {
|
|
54066
|
+
recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId);
|
|
54067
|
+
}
|
|
54063
54068
|
while (recentTurnsById.size > RECENT_TURNS_MAX) {
|
|
54064
54069
|
const oldest = recentTurnsById.keys().next().value;
|
|
54065
54070
|
if (oldest === undefined)
|
|
54066
54071
|
break;
|
|
54072
|
+
const evicted = recentTurnsById.get(oldest);
|
|
54067
54073
|
recentTurnsById.delete(oldest);
|
|
54074
|
+
if (evicted?.sourceMessageId != null && recentTurnIdBySourceMessageId.get(evicted.sourceMessageId) === oldest) {
|
|
54075
|
+
recentTurnIdBySourceMessageId.delete(evicted.sourceMessageId);
|
|
54076
|
+
}
|
|
54068
54077
|
}
|
|
54069
54078
|
}
|
|
54079
|
+
function findTurnByQuotedMessageId(chatId, replyTo) {
|
|
54080
|
+
if (!FRAMEWORK_ORIGIN_ROUTING_ENABLED)
|
|
54081
|
+
return null;
|
|
54082
|
+
if (replyTo == null)
|
|
54083
|
+
return null;
|
|
54084
|
+
const mid = Number(replyTo);
|
|
54085
|
+
if (!Number.isFinite(mid))
|
|
54086
|
+
return null;
|
|
54087
|
+
const owner = recentTurnIdBySourceMessageId.get(mid);
|
|
54088
|
+
if (owner == null)
|
|
54089
|
+
return null;
|
|
54090
|
+
const turn = recentTurnsById.get(owner) ?? null;
|
|
54091
|
+
if (turn == null || turn.sessionChatId !== chatId)
|
|
54092
|
+
return null;
|
|
54093
|
+
return turn;
|
|
54094
|
+
}
|
|
54070
54095
|
function deriveTurnId(chatId, threadId, messageId) {
|
|
54071
54096
|
if (messageId == null || messageId === "" || String(messageId) === "0")
|
|
54072
54097
|
return null;
|
|
@@ -54088,7 +54113,7 @@ function findLatestEndedTurnForChat(chatId) {
|
|
|
54088
54113
|
}
|
|
54089
54114
|
return latest;
|
|
54090
54115
|
}
|
|
54091
|
-
function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTurn, surface) {
|
|
54116
|
+
function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
|
|
54092
54117
|
const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
|
|
54093
54118
|
const threadId = resolveAnswerThreadId({
|
|
54094
54119
|
explicitThreadId,
|
|
@@ -54098,14 +54123,23 @@ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTu
|
|
|
54098
54123
|
lastEndedResolvedForChat: recovered != null,
|
|
54099
54124
|
lastEndedThreadIdForChat: recovered?.sessionThreadId
|
|
54100
54125
|
});
|
|
54101
|
-
const via = explicitThreadId != null ? "explicit" : originTurn != null ? "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
|
|
54126
|
+
const via = explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
|
|
54102
54127
|
const ownerTurn = originTurn ?? recovered ?? liveTurn;
|
|
54103
54128
|
const isSupergroup = chatId.startsWith("-100");
|
|
54104
|
-
const unrouted = isSupergroup && threadId == null;
|
|
54105
|
-
|
|
54129
|
+
const unrouted = isSupergroup && threadId == null && ownerTurn == null;
|
|
54130
|
+
const misrouteRisk = isSupergroup && via === "live" && hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId);
|
|
54131
|
+
process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (via === "quoted" ? " QUOTED(framework-origin)" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + (misrouteRisk ? " MISROUTE_RISK(no-echo\u2192live-successor)" : "") + `
|
|
54106
54132
|
`);
|
|
54107
54133
|
return threadId;
|
|
54108
54134
|
}
|
|
54135
|
+
function hasDifferentThreadedRecentTurn(chatId, liveThreadId) {
|
|
54136
|
+
const live = liveThreadId ?? null;
|
|
54137
|
+
for (const t of recentTurnsById.values()) {
|
|
54138
|
+
if (t.sessionChatId === chatId && (t.sessionThreadId ?? null) !== live)
|
|
54139
|
+
return true;
|
|
54140
|
+
}
|
|
54141
|
+
return false;
|
|
54142
|
+
}
|
|
54109
54143
|
function closeObligationOnSubstantiveReply(args, liveTurn) {
|
|
54110
54144
|
if (!OBLIGATION_LEDGER_ENABLED)
|
|
54111
54145
|
return;
|
|
@@ -56339,8 +56373,10 @@ ${url}`;
|
|
|
56339
56373
|
let threadId;
|
|
56340
56374
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
56341
56375
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
56342
|
-
const
|
|
56343
|
-
|
|
56376
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id);
|
|
56377
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null;
|
|
56378
|
+
const originTurn = echoedTurn ?? quotedTurn;
|
|
56379
|
+
threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "reply");
|
|
56344
56380
|
} else {
|
|
56345
56381
|
threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
|
|
56346
56382
|
}
|
|
@@ -56699,8 +56735,10 @@ async function executeStreamReply(args) {
|
|
|
56699
56735
|
if (args.message_thread_id == null) {
|
|
56700
56736
|
let injected;
|
|
56701
56737
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
56702
|
-
const
|
|
56703
|
-
|
|
56738
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id);
|
|
56739
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
|
|
56740
|
+
const originTurn = echoedTurn ?? quotedTurn;
|
|
56741
|
+
injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "stream_reply");
|
|
56704
56742
|
} else {
|
|
56705
56743
|
injected = turn?.sessionThreadId;
|
|
56706
56744
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
2
|
+
import { resolveAnswerThreadId, type AnswerThreadInput } from './answer-thread-resolve.js'
|
|
3
3
|
|
|
4
4
|
describe('resolveAnswerThreadId — precedence', () => {
|
|
5
5
|
it('(1) explicit model thread wins over everything', () => {
|
|
@@ -83,3 +83,137 @@ describe('resolveAnswerThreadId — precedence', () => {
|
|
|
83
83
|
expect(resolveAnswerThreadId({ originResolved: false })).toBeUndefined()
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
// ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// The operator standard (memory feedback_prove_finite_fsm_not_sample): a
|
|
90
|
+
// passing sample is not a proof. `resolveAnswerThreadId` is a PURE decision
|
|
91
|
+
// function over a FINITE input space — so we can prove its determinism by
|
|
92
|
+
// CONSTRUCTION: enumerate every reachable input and assert totality,
|
|
93
|
+
// determinism, no-fabrication, and the precedence the doc-comment promises.
|
|
94
|
+
// Any future edit that perturbs the decision table on ANY of the 64 inputs
|
|
95
|
+
// fails here — this block is the regression guard, the 9 examples above are
|
|
96
|
+
// the human-readable map.
|
|
97
|
+
//
|
|
98
|
+
// Distinct symbolic thread ids so an output's provenance is unambiguous (no
|
|
99
|
+
// two tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
|
|
100
|
+
const T = 70 // explicit (tier 1)
|
|
101
|
+
const O = 50 // origin (tier 2)
|
|
102
|
+
const L = 30 // live (tier 3)
|
|
103
|
+
const E = 90 // lastEnded(tier 4)
|
|
104
|
+
|
|
105
|
+
function allInputs(): AnswerThreadInput[] {
|
|
106
|
+
const rows: AnswerThreadInput[] = []
|
|
107
|
+
for (const explicitThreadId of [undefined, T])
|
|
108
|
+
for (const originResolved of [false, true])
|
|
109
|
+
for (const originThreadId of [undefined, O])
|
|
110
|
+
for (const liveThreadId of [undefined, L])
|
|
111
|
+
for (const lastEndedResolvedForChat of [false, true])
|
|
112
|
+
for (const lastEndedThreadIdForChat of [undefined, E])
|
|
113
|
+
rows.push({
|
|
114
|
+
explicitThreadId,
|
|
115
|
+
originResolved,
|
|
116
|
+
originThreadId,
|
|
117
|
+
lastEndedResolvedForChat,
|
|
118
|
+
lastEndedThreadIdForChat,
|
|
119
|
+
liveThreadId,
|
|
120
|
+
})
|
|
121
|
+
return rows
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Independent reference encoding the documented precedence (the SPEC), kept
|
|
125
|
+
// deliberately separate from the implementation so a regression in either
|
|
126
|
+
// surfaces as a divergence rather than a silently-shared bug.
|
|
127
|
+
function specExpected(i: AnswerThreadInput): number | undefined {
|
|
128
|
+
if (i.explicitThreadId != null) return i.explicitThreadId // tier 1
|
|
129
|
+
if (i.originResolved) return i.originThreadId // tier 2 (may be undefined: DM origin)
|
|
130
|
+
if (i.liveThreadId != null) return i.liveThreadId // tier 3
|
|
131
|
+
if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat // tier 4
|
|
132
|
+
return i.liveThreadId // catch-all (undefined here)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('resolveAnswerThreadId — total-enumeration determinism proof (all 64 inputs)', () => {
|
|
136
|
+
const ROWS = allInputs()
|
|
137
|
+
|
|
138
|
+
it('the input space is exactly 64 rows (2^6)', () => {
|
|
139
|
+
expect(ROWS.length).toBe(64)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('TOTAL: every input returns without throwing', () => {
|
|
143
|
+
for (const i of ROWS) {
|
|
144
|
+
expect(() => resolveAnswerThreadId(i)).not.toThrow()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('DETERMINISTIC: each input maps to exactly one output (idempotent across repeated calls)', () => {
|
|
149
|
+
for (const i of ROWS) {
|
|
150
|
+
const a = resolveAnswerThreadId(i)
|
|
151
|
+
const b = resolveAnswerThreadId({ ...i })
|
|
152
|
+
expect(b).toBe(a)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('NO FABRICATION: every output is undefined or one of the four input thread fields', () => {
|
|
157
|
+
for (const i of ROWS) {
|
|
158
|
+
const out = resolveAnswerThreadId(i)
|
|
159
|
+
const provenance = new Set([
|
|
160
|
+
undefined,
|
|
161
|
+
i.explicitThreadId,
|
|
162
|
+
i.originThreadId,
|
|
163
|
+
i.liveThreadId,
|
|
164
|
+
i.lastEndedThreadIdForChat,
|
|
165
|
+
])
|
|
166
|
+
expect(provenance.has(out)).toBe(true)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('PRECEDENCE: matches the documented spec on all 64 inputs', () => {
|
|
171
|
+
for (const i of ROWS) {
|
|
172
|
+
expect(resolveAnswerThreadId(i)).toBe(specExpected(i))
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ── By-construction invariants: the output depends ONLY on the highest
|
|
177
|
+
// RESOLVED tier, so no lower-tier input (notably a flipped live turn)
|
|
178
|
+
// can perturb a higher tier's decision. These are the routing guarantees
|
|
179
|
+
// the resolver exists to provide. ─────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
it('INV-1 explicit DOMINANCE: explicit set ⇒ output === explicit, independent of all other fields', () => {
|
|
182
|
+
for (const i of ROWS) {
|
|
183
|
+
if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('INV-2 origin FLIP-IMMUNITY: no explicit + originResolved ⇒ output === originThreadId, for EVERY liveThreadId/lastEnded combo (the Brevo→Meta fix: a currentTurn flip cannot steal a resolved origin)', () => {
|
|
188
|
+
for (const i of ROWS) {
|
|
189
|
+
if (i.explicitThreadId == null && i.originResolved) {
|
|
190
|
+
expect(resolveAnswerThreadId(i)).toBe(i.originThreadId)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('INV-3 recovery REACHABILITY: tier-4 (lastEnded) result occurs ONLY when no explicit, no origin, no live turn', () => {
|
|
196
|
+
for (const i of ROWS) {
|
|
197
|
+
const out = resolveAnswerThreadId(i)
|
|
198
|
+
// If the result came from the lastEnded field (and that field is the
|
|
199
|
+
// only one carrying that distinct value E), the three higher tiers must
|
|
200
|
+
// all be absent.
|
|
201
|
+
if (out === E) {
|
|
202
|
+
expect(i.explicitThreadId).toBeUndefined()
|
|
203
|
+
expect(i.originResolved).toBe(false)
|
|
204
|
+
expect(i.liveThreadId).toBeUndefined()
|
|
205
|
+
expect(i.lastEndedResolvedForChat).toBe(true)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('INV-4 live-tier REACHABILITY: tier-3 (live) result occurs ONLY when no explicit and no resolved origin', () => {
|
|
211
|
+
for (const i of ROWS) {
|
|
212
|
+
const out = resolveAnswerThreadId(i)
|
|
213
|
+
if (out === L) {
|
|
214
|
+
expect(i.explicitThreadId).toBeUndefined()
|
|
215
|
+
expect(i.originResolved).toBe(false)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -1549,6 +1549,18 @@ const SERIALIZE_NOREPLY_DRAIN_MS =
|
|
|
1549
1549
|
// behaviour (#1664: thread from the live currentTurn capture).
|
|
1550
1550
|
const TURN_ORIGIN_ROUTING_ENABLED =
|
|
1551
1551
|
process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== '0'
|
|
1552
|
+
// Framework-owned origin recovery (2026-06-05 determinism pass). The origin
|
|
1553
|
+
// signal that survives a currentTurn flip (tier 2) travels through the MODEL
|
|
1554
|
+
// (it echoes `origin_turn_id`). When the model OMITS it, routing falls to the
|
|
1555
|
+
// live turn — the wrong topic if currentTurn flipped (HOLE a). The model rarely
|
|
1556
|
+
// passes `reply_to` explicitly, but WHEN it quotes a specific earlier message
|
|
1557
|
+
// that message_id is a FRAMEWORK-owned anchor: reverse-index it to the turn
|
|
1558
|
+
// that owns it and recover the origin without the model echo. Strictly
|
|
1559
|
+
// additive — it only fires when the echo is absent AND a quote is present, and
|
|
1560
|
+
// resolves to the ACTUAL origin turn (never the live successor), so it cannot
|
|
1561
|
+
// mask a misroute. Kill switch off (=0) → echo-only origin (today's behaviour).
|
|
1562
|
+
const FRAMEWORK_ORIGIN_ROUTING_ENABLED =
|
|
1563
|
+
process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'
|
|
1552
1564
|
// Component 4 (per-turn topic framing). Add a one-line directive to the
|
|
1553
1565
|
// channel meta + bridge instructions telling the model to answer ONLY the
|
|
1554
1566
|
// current message's topic. Kill switch off (=0) → no framing field.
|
|
@@ -1858,14 +1870,57 @@ let currentTurn: CurrentTurn | null = null
|
|
|
1858
1870
|
// a long-lived supergroup session.
|
|
1859
1871
|
const RECENT_TURNS_MAX = 32
|
|
1860
1872
|
const recentTurnsById = new Map<string, CurrentTurn>()
|
|
1873
|
+
// Framework-owned origin recovery: reverse-index from an inbound's source
|
|
1874
|
+
// message_id to the turnId that owns it, so a reply that QUOTES a specific
|
|
1875
|
+
// message (args.reply_to) resolves its origin turn deterministically — a
|
|
1876
|
+
// real message_id the framework stamped, never a model-asserted thread.
|
|
1877
|
+
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
1878
|
+
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
1861
1879
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
1862
1880
|
recentTurnsById.set(turn.turnId, turn)
|
|
1881
|
+
if (turn.sourceMessageId != null) {
|
|
1882
|
+
recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId)
|
|
1883
|
+
}
|
|
1863
1884
|
while (recentTurnsById.size > RECENT_TURNS_MAX) {
|
|
1864
1885
|
const oldest = recentTurnsById.keys().next().value
|
|
1865
1886
|
if (oldest === undefined) break
|
|
1887
|
+
const evicted = recentTurnsById.get(oldest)
|
|
1866
1888
|
recentTurnsById.delete(oldest)
|
|
1889
|
+
// Drop the reverse-index entry for the evicted turn (only when it still
|
|
1890
|
+
// points at THIS turn — a newer turn may have reused the same message id).
|
|
1891
|
+
if (evicted?.sourceMessageId != null &&
|
|
1892
|
+
recentTurnIdBySourceMessageId.get(evicted.sourceMessageId) === oldest) {
|
|
1893
|
+
recentTurnIdBySourceMessageId.delete(evicted.sourceMessageId)
|
|
1894
|
+
}
|
|
1867
1895
|
}
|
|
1868
1896
|
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Framework-owned origin recovery (kill switch SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING).
|
|
1900
|
+
* Resolve the turn that owns a reply from the message_id it QUOTES (args.reply_to),
|
|
1901
|
+
* via the source-message reverse index. Returns null when disabled, when reply_to
|
|
1902
|
+
* is absent/non-numeric, or when no turn owns that message id (evicted / unknown).
|
|
1903
|
+
* Deterministic: a real framework-stamped message_id → its turn, with no model
|
|
1904
|
+
* thread assertion and no dependence on the live currentTurn.
|
|
1905
|
+
*
|
|
1906
|
+
* SCOPED to `chatId`: Telegram numbers message ids PER CHAT, so the same numeric
|
|
1907
|
+
* id exists in a DM and a supergroup. The reverse index is keyed by id alone, so
|
|
1908
|
+
* the resolved turn MUST be confirmed to belong to this reply's chat — otherwise
|
|
1909
|
+
* a reply quoting its own id could inherit another chat's thread (a cross-chat
|
|
1910
|
+
* leak). On a chat mismatch we return null (decline to recover → fall through to
|
|
1911
|
+
* the live-turn behaviour), never a wrong-chat thread.
|
|
1912
|
+
*/
|
|
1913
|
+
function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null {
|
|
1914
|
+
if (!FRAMEWORK_ORIGIN_ROUTING_ENABLED) return null
|
|
1915
|
+
if (replyTo == null) return null
|
|
1916
|
+
const mid = Number(replyTo)
|
|
1917
|
+
if (!Number.isFinite(mid)) return null
|
|
1918
|
+
const owner = recentTurnIdBySourceMessageId.get(mid)
|
|
1919
|
+
if (owner == null) return null
|
|
1920
|
+
const turn = recentTurnsById.get(owner) ?? null
|
|
1921
|
+
if (turn == null || turn.sessionChatId !== chatId) return null
|
|
1922
|
+
return turn
|
|
1923
|
+
}
|
|
1869
1924
|
/**
|
|
1870
1925
|
* Component 3 — derive the stable per-turn identity from the chat, thread,
|
|
1871
1926
|
* and originating message id. Stamped into the inbound meta at build time
|
|
@@ -1932,13 +1987,26 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
1932
1987
|
* timestamps. This wrapper logs, per reply: which precedence tier won (`via`),
|
|
1933
1988
|
* the resolved thread, the origin turn + its thread, and whether the reply was
|
|
1934
1989
|
* late (turn already ended). `via=recovered` marks a late reply this fix saved
|
|
1935
|
-
* from General; `
|
|
1936
|
-
*
|
|
1990
|
+
* from General; `via=quoted` marks an origin recovered from the framework-owned
|
|
1991
|
+
* quoted message_id (no model echo); `UNROUTED` flags a supergroup reply that
|
|
1992
|
+
* resolved to no topic with NO owner turn to attribute it to (genuinely lost).
|
|
1993
|
+
* `MISROUTE_RISK` flags the irreducible determinism residual: a no-echo,
|
|
1994
|
+
* no-quote reply that fell to the LIVE turn while a DIFFERENT topic recently had
|
|
1995
|
+
* a turn — the framework cannot tell which topic the bare reply answers, so the
|
|
1996
|
+
* routing MIGHT be wrong (HOLE a). It is observability only (the reply still
|
|
1997
|
+
* routes to the live turn) — it makes the one case that is genuinely
|
|
1998
|
+
* model-dependent visible instead of silently mis-routed. A General-topic turn
|
|
1999
|
+
* legitimately has no thread, so its replies are NOT UNROUTED-flagged.
|
|
2000
|
+
*
|
|
2001
|
+
* `originVia` distinguishes how the origin turn was resolved: 'echo' (model
|
|
2002
|
+
* echoed origin_turn_id), 'quoted' (framework recovered it from args.reply_to),
|
|
2003
|
+
* or null (no origin turn). It only affects the `via` label, never the routing.
|
|
1937
2004
|
*/
|
|
1938
2005
|
function resolveAnswerThreadWithLog(
|
|
1939
2006
|
chatId: string,
|
|
1940
2007
|
explicitThreadId: number | undefined,
|
|
1941
2008
|
originTurn: CurrentTurn | null,
|
|
2009
|
+
originVia: 'echo' | 'quoted' | null,
|
|
1942
2010
|
liveTurn: CurrentTurn | null,
|
|
1943
2011
|
surface: 'reply' | 'stream_reply',
|
|
1944
2012
|
): number | undefined {
|
|
@@ -1965,24 +2033,60 @@ function resolveAnswerThreadWithLog(
|
|
|
1965
2033
|
})
|
|
1966
2034
|
const via =
|
|
1967
2035
|
explicitThreadId != null ? 'explicit'
|
|
1968
|
-
: originTurn != null ? 'origin'
|
|
2036
|
+
: originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
1969
2037
|
: liveTurn?.sessionThreadId != null ? 'live'
|
|
1970
2038
|
: recovered != null ? 'recovered'
|
|
1971
2039
|
: 'none'
|
|
1972
2040
|
const ownerTurn = originTurn ?? recovered ?? liveTurn
|
|
1973
2041
|
const isSupergroup = chatId.startsWith('-100')
|
|
1974
|
-
|
|
2042
|
+
// UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
|
|
2043
|
+
// to attribute it to (genuinely lost). A General-topic turn legitimately has
|
|
2044
|
+
// no thread, so a reply owned by it resolving to `-` is CORRECT, not lost —
|
|
2045
|
+
// gate on `ownerTurn == null` so General replies don't false-alarm (found by
|
|
2046
|
+
// the multi-topic UAT stress, 2026-06-05).
|
|
2047
|
+
const unrouted = isSupergroup && threadId == null && ownerTurn == null
|
|
2048
|
+
// MISROUTE_RISK = the irreducible determinism residual (HOLE a). A no-echo,
|
|
2049
|
+
// no-quote reply fell to the LIVE turn (via=live), but a DIFFERENT topic
|
|
2050
|
+
// recently had a turn for this chat — so this bare reply MIGHT belong to that
|
|
2051
|
+
// other topic and we cannot tell without the model's echo. Observability only;
|
|
2052
|
+
// routing is unchanged. This is the one case framework state cannot
|
|
2053
|
+
// disambiguate, surfaced instead of silently mis-routed.
|
|
2054
|
+
const misrouteRisk =
|
|
2055
|
+
isSupergroup &&
|
|
2056
|
+
via === 'live' &&
|
|
2057
|
+
hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId)
|
|
1975
2058
|
process.stderr.write(
|
|
1976
2059
|
`telegram gateway: reply-route surface=${surface} chat=${chatId} ` +
|
|
1977
2060
|
`resolved_thread=${threadId ?? '-'} via=${via} late=${liveTurn == null} ` +
|
|
1978
2061
|
`originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
|
|
1979
2062
|
(via === 'recovered' ? ' RECOVERED' : '') +
|
|
2063
|
+
(via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
|
|
1980
2064
|
(unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
|
|
2065
|
+
(misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
|
|
1981
2066
|
'\n',
|
|
1982
2067
|
)
|
|
1983
2068
|
return threadId
|
|
1984
2069
|
}
|
|
1985
2070
|
|
|
2071
|
+
/**
|
|
2072
|
+
* Determinism-residual detector (HOLE a observability). True when a DIFFERENT
|
|
2073
|
+
* forum topic recently had a turn for this chat than the live turn's thread —
|
|
2074
|
+
* i.e. a currentTurn flip plausibly happened, so a no-echo / no-quote reply
|
|
2075
|
+
* routed to the live turn MIGHT belong to the other topic. Scans the bounded
|
|
2076
|
+
* recently-ended registry; cheap (≤32 entries). Used only to ALARM, never to
|
|
2077
|
+
* route.
|
|
2078
|
+
*/
|
|
2079
|
+
function hasDifferentThreadedRecentTurn(
|
|
2080
|
+
chatId: string,
|
|
2081
|
+
liveThreadId: number | undefined,
|
|
2082
|
+
): boolean {
|
|
2083
|
+
const live = liveThreadId ?? null
|
|
2084
|
+
for (const t of recentTurnsById.values()) {
|
|
2085
|
+
if (t.sessionChatId === chatId && (t.sessionThreadId ?? null) !== live) return true
|
|
2086
|
+
}
|
|
2087
|
+
return false
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1986
2090
|
/**
|
|
1987
2091
|
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
1988
2092
|
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
@@ -6631,11 +6735,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6631
6735
|
let threadId: number | undefined
|
|
6632
6736
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
6633
6737
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
6634
|
-
|
|
6738
|
+
// Origin precedence: model echo first (authoritative), then the
|
|
6739
|
+
// framework-owned quoted message_id (deterministic, no model thread
|
|
6740
|
+
// assertion) as a fallback when the model omitted the echo.
|
|
6741
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
6742
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
|
|
6743
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
6635
6744
|
threadId = resolveAnswerThreadWithLog(
|
|
6636
6745
|
chat_id,
|
|
6637
6746
|
Number.isFinite(explicit as number) ? (explicit as number) : undefined,
|
|
6638
6747
|
originTurn,
|
|
6748
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
6639
6749
|
turn,
|
|
6640
6750
|
'reply',
|
|
6641
6751
|
)
|
|
@@ -7288,11 +7398,17 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7288
7398
|
if (args.message_thread_id == null) {
|
|
7289
7399
|
let injected: number | undefined
|
|
7290
7400
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7291
|
-
|
|
7401
|
+
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7402
|
+
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7403
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7404
|
+
const quotedTurn =
|
|
7405
|
+
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7406
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
7292
7407
|
injected = resolveAnswerThreadWithLog(
|
|
7293
7408
|
String(args.chat_id),
|
|
7294
7409
|
undefined,
|
|
7295
7410
|
originTurn,
|
|
7411
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
7296
7412
|
turn,
|
|
7297
7413
|
'stream_reply',
|
|
7298
7414
|
)
|
|
@@ -67,6 +67,51 @@ describe('component 3 — turn-origin reply routing', () => {
|
|
|
67
67
|
})
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
describe('framework-owned origin recovery (determinism residual, 2026-06-05)', () => {
|
|
71
|
+
it('a source-message reverse index is populated at enqueue and EVICTED in parity with recentTurnsById', () => {
|
|
72
|
+
expect(gatewaySrc).toMatch(/const recentTurnIdBySourceMessageId = new Map<number, string>\(\)/)
|
|
73
|
+
// Populated inside rememberRecentTurn from the turn's sourceMessageId.
|
|
74
|
+
const fn = gatewaySrc.split('function rememberRecentTurn')[1]?.split('\nfunction ')[0] ?? ''
|
|
75
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.set\(turn\.sourceMessageId, turn\.turnId\)/)
|
|
76
|
+
// Eviction parity: the reverse entry is dropped when its turn is evicted —
|
|
77
|
+
// so the index cannot outgrow the bounded RECENT_TURNS_MAX registry.
|
|
78
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.delete\(evicted\.sourceMessageId\)/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('both reply paths recover origin from the quoted message_id when the model omits the echo', () => {
|
|
82
|
+
for (const name of ['executeReply', 'executeStreamReply']) {
|
|
83
|
+
const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
|
|
84
|
+
// Echo first (authoritative), quoted message_id as the framework fallback.
|
|
85
|
+
expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
|
|
86
|
+
// Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
|
|
87
|
+
expect(fn).toMatch(/findTurnByQuotedMessageId\([^,]+, args\.reply_to\)/)
|
|
88
|
+
expect(fn).toMatch(/echoedTurn \?\? quotedTurn/)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('findTurnByQuotedMessageId is gated on the kill switch and resolves a real turn (never the live successor)', () => {
|
|
93
|
+
const fn = gatewaySrc.split('function findTurnByQuotedMessageId')[1]?.split('\nfunction ')[0] ?? ''
|
|
94
|
+
expect(fn).toMatch(/FRAMEWORK_ORIGIN_ROUTING_ENABLED/)
|
|
95
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.get\(mid\)/)
|
|
96
|
+
expect(fn).toMatch(/recentTurnsById\.get\(owner\)/)
|
|
97
|
+
// Cross-chat collision guard: the resolved turn must belong to this chat.
|
|
98
|
+
expect(fn).toMatch(/turn\.sessionChatId !== chatId/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('the irreducible no-echo residual is ALARMED (MISROUTE_RISK), never silently mis-routed', () => {
|
|
102
|
+
expect(gatewaySrc).toMatch(/MISROUTE_RISK\(no-echo→live-successor\)/)
|
|
103
|
+
expect(gatewaySrc).toMatch(/function hasDifferentThreadedRecentTurn/)
|
|
104
|
+
// The alarm is observability-only: it fires on via=live with a different
|
|
105
|
+
// recent topic, and does NOT change the resolved thread.
|
|
106
|
+
expect(gatewaySrc).toMatch(/const misrouteRisk =/)
|
|
107
|
+
expect(gatewaySrc).toMatch(/via === 'quoted' \? ' QUOTED\(framework-origin\)' : ''/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('the kill switch defaults ON and is independent of TURN_ORIGIN_ROUTING', () => {
|
|
111
|
+
expect(gatewaySrc).toMatch(/SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'/)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
70
115
|
describe('component 4 — per-turn topic framing', () => {
|
|
71
116
|
it('the gateway stamps a topic_scope directive for forum-topic inbounds (kill-switched)', () => {
|
|
72
117
|
expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-surface ordering stress — a DM question and a supergroup question
|
|
3
|
+
* back-to-back, assert each answer lands in ITS OWN surface (DM answer in the
|
|
4
|
+
* DM, channel answer in the channel) with no cross-contamination.
|
|
5
|
+
*
|
|
6
|
+
* One Claude CLI serves both the DM and the supergroup through a singleton
|
|
7
|
+
* currentTurn; a late or mis-attributed reply can leak across surfaces (a DM
|
|
8
|
+
* answer posted into the channel, or vice versa) — the cross-surface twin of
|
|
9
|
+
* the multi-topic bleed. This is the "handling messages in multiple channels"
|
|
10
|
+
* concern at the DM-vs-channel axis. Self-skips green without a resolvable
|
|
11
|
+
* SWITCHROOM_UAT_CHAT_ID supergroup (uat/** is non-gating).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
14
|
+
import { spinUp, type Scenario } from "../harness.js";
|
|
15
|
+
import { isWorkerFeedMessage, isActivityFeedMessage } from "../assertions.js";
|
|
16
|
+
import type { ObservedMessage } from "../driver.js";
|
|
17
|
+
|
|
18
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
19
|
+
|
|
20
|
+
interface Hit { chatId: number; text: string; messageId: number; }
|
|
21
|
+
|
|
22
|
+
function isAnswer(m: ObservedMessage, driverUserId: number): boolean {
|
|
23
|
+
return m.senderUserId !== driverUserId && !m.edited
|
|
24
|
+
&& !isWorkerFeedMessage(m) && !isActivityFeedMessage(m) && m.text.trim().length > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("uat: cross-surface ordering — DM Q + channel Q, no surface bleed", () => {
|
|
28
|
+
let sc: Scenario | null = null;
|
|
29
|
+
let postable = false;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
33
|
+
console.warn("[uat] SWITCHROOM_UAT_CHAT_ID unset — skipping cross-surface ordering");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
sc = await spinUp({ agent: "test-harness" });
|
|
37
|
+
await sc.driver.primeDialogs();
|
|
38
|
+
postable = await sc.driver.canResolve(SUPERGROUP_ID);
|
|
39
|
+
if (!postable) console.warn(`[uat] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("DM answer stays in the DM and channel answer stays in the channel (no leak)", async () => {
|
|
43
|
+
if (sc == null || !postable) return; // self-skip green
|
|
44
|
+
const { driver, driverUserId, botUserId } = sc;
|
|
45
|
+
await driver.primeDialogs();
|
|
46
|
+
|
|
47
|
+
const dmIter = driver.observeMessages(botUserId)[Symbol.asyncIterator]();
|
|
48
|
+
const sgIter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
|
|
49
|
+
|
|
50
|
+
// Distinct answers per surface: DM → 14, channel → 42.
|
|
51
|
+
await driver.sendText(botUserId, "Reply with only the number and nothing else: what is 7 + 7?");
|
|
52
|
+
await driver.sendText(SUPERGROUP_ID, "Reply with only the number and nothing else: what is 40 + 2?");
|
|
53
|
+
|
|
54
|
+
let dmHit: Hit | undefined;
|
|
55
|
+
let sgHit: Hit | undefined;
|
|
56
|
+
const strays: Hit[] = []; // an expected-answer token observed in the WRONG surface
|
|
57
|
+
|
|
58
|
+
const deadline = Date.now() + 120_000;
|
|
59
|
+
const pump = async (iter: AsyncIterator<ObservedMessage>, chatId: number) => {
|
|
60
|
+
while (Date.now() < deadline && !(dmHit && sgHit)) {
|
|
61
|
+
const next = await Promise.race([
|
|
62
|
+
iter.next(),
|
|
63
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
64
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
|
|
65
|
+
),
|
|
66
|
+
]);
|
|
67
|
+
if (next.done || next.value == null) break;
|
|
68
|
+
const m = next.value as ObservedMessage;
|
|
69
|
+
if (!isAnswer(m, driverUserId)) continue;
|
|
70
|
+
const hit: Hit = { chatId: m.chatId, text: m.text, messageId: m.messageId };
|
|
71
|
+
const has14 = /(^|\D)14(\D|$)/.test(m.text);
|
|
72
|
+
const has42 = /(^|\D)42(\D|$)/.test(m.text);
|
|
73
|
+
if (chatId === botUserId) {
|
|
74
|
+
if (has14 && dmHit == null) dmHit = hit;
|
|
75
|
+
if (has42) strays.push(hit); // channel answer leaked into the DM
|
|
76
|
+
} else {
|
|
77
|
+
if (has42 && sgHit == null) sgHit = hit;
|
|
78
|
+
if (has14) strays.push(hit); // DM answer leaked into the channel
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await Promise.all([pump(dmIter, botUserId), pump(sgIter, SUPERGROUP_ID)]);
|
|
83
|
+
void dmIter.return?.();
|
|
84
|
+
void sgIter.return?.();
|
|
85
|
+
|
|
86
|
+
console.log(
|
|
87
|
+
`[cross-surface] dm(7+7=14)=${dmHit ? `msg=${dmHit.messageId}` : "MISSING"} ` +
|
|
88
|
+
`channel(40+2=42)=${sgHit ? `msg=${sgHit.messageId}` : "MISSING"} strays=${strays.length}`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Invariant 1: both answered.
|
|
92
|
+
expect(dmHit, "DM question (7+7=14) was never answered in the DM").toBeDefined();
|
|
93
|
+
expect(sgHit, "channel question (40+2=42) was never answered in the channel").toBeDefined();
|
|
94
|
+
// Invariant 2: no answer leaked to the wrong surface.
|
|
95
|
+
expect(strays, `an answer leaked across surfaces: ${JSON.stringify(strays)}`).toHaveLength(0);
|
|
96
|
+
// Invariant 3 (belt+braces): the right answer is in the right chat.
|
|
97
|
+
expect(dmHit!.chatId).toBe(botUserId);
|
|
98
|
+
expect(sgHit!.chatId).toBe(SUPERGROUP_ID);
|
|
99
|
+
}, 150_000);
|
|
100
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-topic routing stress (channel) — two questions in TWO forum topics
|
|
3
|
+
* back-to-back, assert each answer lands in ITS OWN topic with no cross-bleed.
|
|
4
|
+
*
|
|
5
|
+
* This exercises live the failure class behind #2137 ("two questions in two
|
|
6
|
+
* forum topics → both answers in one topic, the other unanswered") and the
|
|
7
|
+
* #2166 late-reply topic recovery — the exact "handling messages in multiple
|
|
8
|
+
* channels" concern. One Claude CLI owns every topic via a singleton
|
|
9
|
+
* currentTurn, so a late/misrouted reply can land in the wrong topic.
|
|
10
|
+
*
|
|
11
|
+
* The test supergroup has General (message_thread_id omitted → observed
|
|
12
|
+
* threadId undefined) plus a real topic (thread=5). mtcute sends into a topic
|
|
13
|
+
* via messageThreadId→replyTo and reads each observed message's threadId, so we
|
|
14
|
+
* can prove WHICH topic each answer landed in. Self-skips green without
|
|
15
|
+
* SWITCHROOM_UAT_CHAT_ID or an unresolvable supergroup (uat/** is non-gating).
|
|
16
|
+
*
|
|
17
|
+
* mtcute caveat: it can't enumerate topics, so the topic id is pinned (5, the
|
|
18
|
+
* active non-General topic in the test supergroup). If that topic is ever
|
|
19
|
+
* deleted the send 400s and the test skips with a clear message.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
22
|
+
import { spinUp, type Scenario } from "../harness.js";
|
|
23
|
+
import { isWorkerFeedMessage, isActivityFeedMessage } from "../assertions.js";
|
|
24
|
+
import type { ObservedMessage } from "../driver.js";
|
|
25
|
+
|
|
26
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
27
|
+
const TOPIC_THREAD = 5; // a real non-General topic in the test supergroup
|
|
28
|
+
|
|
29
|
+
interface Hit {
|
|
30
|
+
text: string;
|
|
31
|
+
threadId: number | undefined;
|
|
32
|
+
messageId: number;
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("uat: multi-topic routing — two topics back-to-back, no answer bleed", () => {
|
|
37
|
+
let sc: Scenario | null = null;
|
|
38
|
+
let postable = false;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
42
|
+
console.warn("[uat] SWITCHROOM_UAT_CHAT_ID unset — skipping multi-topic routing");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
sc = await spinUp({ agent: "test-harness" });
|
|
46
|
+
await sc.driver.primeDialogs();
|
|
47
|
+
postable = await sc.driver.canResolve(SUPERGROUP_ID);
|
|
48
|
+
if (!postable) console.warn(`[uat] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("topic-5 Q and General Q are each answered IN THEIR OWN topic (no swap, no drop)", async () => {
|
|
52
|
+
if (sc == null || !postable) return; // self-skip green
|
|
53
|
+
const driver = sc.driver;
|
|
54
|
+
const driverUserId = sc.driverUserId;
|
|
55
|
+
await driver.primeDialogs();
|
|
56
|
+
|
|
57
|
+
// Distinct, unambiguous answers so we can tell the two replies apart by
|
|
58
|
+
// content and check the topic each landed in. 7+7=14 (topic 5), 40+2=42
|
|
59
|
+
// (General) — no digit overlap, no accidental substring match.
|
|
60
|
+
const iter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
|
|
61
|
+
let topic5: Hit | undefined;
|
|
62
|
+
let general: Hit | undefined;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await driver.sendText(
|
|
66
|
+
SUPERGROUP_ID,
|
|
67
|
+
"Reply with only the number and nothing else: what is 7 + 7?",
|
|
68
|
+
{ messageThreadId: TOPIC_THREAD },
|
|
69
|
+
);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn(`[uat] could not post to topic ${TOPIC_THREAD} (${(err as Error).message}) — skipping`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Back-to-back (the #2137 bleed trigger): General question immediately after.
|
|
75
|
+
await driver.sendText(
|
|
76
|
+
SUPERGROUP_ID,
|
|
77
|
+
"Reply with only the number and nothing else: what is 40 + 2?",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const deadline = Date.now() + 120_000;
|
|
81
|
+
while (Date.now() < deadline && !(topic5 && general)) {
|
|
82
|
+
const next = await Promise.race([
|
|
83
|
+
iter.next(),
|
|
84
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
85
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
|
|
86
|
+
),
|
|
87
|
+
]);
|
|
88
|
+
if (next.done || next.value == null) break;
|
|
89
|
+
const m = next.value as ObservedMessage;
|
|
90
|
+
if (m.senderUserId === driverUserId) continue; // our own sends
|
|
91
|
+
if (isWorkerFeedMessage(m) || isActivityFeedMessage(m)) continue; // status surfaces, not answers
|
|
92
|
+
const hit: Hit = { text: m.text, threadId: m.threadId, messageId: m.messageId, at: Date.now() };
|
|
93
|
+
if (topic5 == null && /(^|\D)14(\D|$)/.test(m.text)) topic5 = hit;
|
|
94
|
+
if (general == null && /(^|\D)42(\D|$)/.test(m.text)) general = hit;
|
|
95
|
+
}
|
|
96
|
+
void iter.return?.();
|
|
97
|
+
|
|
98
|
+
console.log(
|
|
99
|
+
`[multitopic] topic5(7+7=14)=${topic5 ? `thread=${topic5.threadId ?? "-"} msg=${topic5.messageId}` : "MISSING"} ` +
|
|
100
|
+
`general(40+2=42)=${general ? `thread=${general.threadId ?? "-"} msg=${general.messageId}` : "MISSING"}`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Invariant 1: BOTH questions were answered (no drop — the #2137 "other
|
|
104
|
+
// topic unanswered" half).
|
|
105
|
+
expect(topic5, "topic-5 question (7+7=14) was never answered").toBeDefined();
|
|
106
|
+
expect(general, "General question (40+2=42) was never answered").toBeDefined();
|
|
107
|
+
|
|
108
|
+
// Invariant 2: each answer landed in ITS OWN topic (no bleed/swap). The
|
|
109
|
+
// topic-5 answer carries threadId=5; the General answer omits the thread.
|
|
110
|
+
expect(topic5!.threadId).toBe(TOPIC_THREAD);
|
|
111
|
+
expect(general!.threadId).toBeUndefined();
|
|
112
|
+
}, 150_000);
|
|
113
|
+
|
|
114
|
+
it("a SLOW topic-5 turn whose answer lands LATE (after a fast General turn) still routes to topic-5", async () => {
|
|
115
|
+
if (sc == null || !postable) return; // self-skip green
|
|
116
|
+
const { driver, driverUserId } = sc;
|
|
117
|
+
await driver.primeDialogs();
|
|
118
|
+
|
|
119
|
+
// The #2137 trigger: topic-5 turn is still working when General's turn
|
|
120
|
+
// arrives, so the singleton currentTurn flips to General. The slow topic-5
|
|
121
|
+
// answer then lands AFTER General's — it must STILL route to topic-5 (via
|
|
122
|
+
// origin_turn_id / the #2166 late-reply recovery), not bleed into General.
|
|
123
|
+
const iter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
|
|
124
|
+
let slowHit: Hit | undefined; // topic-5, distinctive token
|
|
125
|
+
let fastHit: Hit | undefined; // General, the number 42
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await driver.sendText(
|
|
129
|
+
SUPERGROUP_ID,
|
|
130
|
+
"Do this slowly, ONE step at a time with a brief note on each (I want to see you work): run uname -a, then nproc, " +
|
|
131
|
+
"then lscpu, then cat /proc/cpuinfo | grep -c processor, then free -h, then df -h. After all six, tell me how many " +
|
|
132
|
+
"CPU cores this machine has and end your reply with the exact token CORESDONE on its own line.",
|
|
133
|
+
{ messageThreadId: TOPIC_THREAD },
|
|
134
|
+
);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.warn(`[uat] could not post to topic ${TOPIC_THREAD} (${(err as Error).message}) — skipping`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Let the slow turn get underway, then fire the fast General question while
|
|
140
|
+
// it's still working. Use an unusual WORD token (not a number) so the slow
|
|
141
|
+
// turn's tool output (which prints numbers like "42Gi") can't false-match.
|
|
142
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
143
|
+
await driver.sendText(SUPERGROUP_ID, "Reply with only this exact word and nothing else: ZUCCHINI.");
|
|
144
|
+
|
|
145
|
+
const deadline = Date.now() + 130_000;
|
|
146
|
+
while (Date.now() < deadline && !(slowHit && fastHit)) {
|
|
147
|
+
const next = await Promise.race([
|
|
148
|
+
iter.next(),
|
|
149
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
150
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
|
|
151
|
+
),
|
|
152
|
+
]);
|
|
153
|
+
if (next.done || next.value == null) break;
|
|
154
|
+
const m = next.value as ObservedMessage;
|
|
155
|
+
if (m.senderUserId === driverUserId) continue;
|
|
156
|
+
if (isWorkerFeedMessage(m) || isActivityFeedMessage(m)) continue;
|
|
157
|
+
const hit: Hit = { text: m.text, threadId: m.threadId, messageId: m.messageId, at: Date.now() };
|
|
158
|
+
if (slowHit == null && /CORESDONE/i.test(m.text)) slowHit = hit;
|
|
159
|
+
if (fastHit == null && /ZUCCHINI/i.test(m.text)) fastHit = hit;
|
|
160
|
+
}
|
|
161
|
+
void iter.return?.();
|
|
162
|
+
|
|
163
|
+
console.log(
|
|
164
|
+
`[multitopic-late] slow(CORESDONE)=${slowHit ? `thread=${slowHit.threadId ?? "-"} msg=${slowHit.messageId}` : "MISSING"} ` +
|
|
165
|
+
`fast(ZUCCHINI)=${fastHit ? `thread=${fastHit.threadId ?? "-"} msg=${fastHit.messageId}` : "MISSING"} ` +
|
|
166
|
+
`slowArrivedAfterFast=${slowHit && fastHit ? slowHit.at > fastHit.at : "n/a"}`,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(slowHit, "slow topic-5 answer (CORESDONE) never arrived").toBeDefined();
|
|
170
|
+
expect(fastHit, "fast General answer (42) never arrived").toBeDefined();
|
|
171
|
+
// Honest about whether the LATE-after-flip stress was actually exercised:
|
|
172
|
+
// it only is if the slow topic-5 answer landed AFTER the fast General one.
|
|
173
|
+
if (slowHit!.at <= fastHit!.at) {
|
|
174
|
+
console.warn(
|
|
175
|
+
"[multitopic-late] topic-5 answered BEFORE General — the late-after-flip case " +
|
|
176
|
+
"was not exercised this run (routing still asserted below).",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
// The crux: the slow answer routed to topic-5, NOT bled into General — and
|
|
180
|
+
// this holds whether or not it arrived late (the stronger claim is when late).
|
|
181
|
+
expect(slowHit!.threadId).toBe(TOPIC_THREAD);
|
|
182
|
+
expect(fastHit!.threadId).toBeUndefined();
|
|
183
|
+
}, 160_000);
|
|
184
|
+
});
|