switchroom 0.14.89 → 0.14.90
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/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +26 -11
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +154 -132
- package/telegram-plugin/gateway/answer-thread-resolve.ts +79 -52
- package/telegram-plugin/gateway/gateway.ts +35 -6
- package/telegram-plugin/tests/answer-thread-resolve.test.ts +30 -5
package/dist/cli/switchroom.js
CHANGED
|
@@ -49815,8 +49815,8 @@ var {
|
|
|
49815
49815
|
} = import__.default;
|
|
49816
49816
|
|
|
49817
49817
|
// src/build-info.ts
|
|
49818
|
-
var VERSION = "0.14.
|
|
49819
|
-
var COMMIT_SHA = "
|
|
49818
|
+
var VERSION = "0.14.90";
|
|
49819
|
+
var COMMIT_SHA = "6386ff19";
|
|
49820
49820
|
|
|
49821
49821
|
// src/cli/agent.ts
|
|
49822
49822
|
init_source();
|
package/package.json
CHANGED
|
@@ -78,7 +78,7 @@ const mcp = new Server(
|
|
|
78
78
|
'',
|
|
79
79
|
'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
|
|
80
80
|
'',
|
|
81
|
-
'If a message includes message_thread_id, it came from a forum topic. The reply tool
|
|
81
|
+
'If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from — the framework owns the answer\'s topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
|
|
82
82
|
'',
|
|
83
83
|
'The default format is "html" — write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
84
84
|
'',
|
|
@@ -24571,7 +24571,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
|
|
|
24571
24571
|
"",
|
|
24572
24572
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24573
24573
|
"",
|
|
24574
|
-
"If a message includes message_thread_id, it came from a forum topic. The reply tool
|
|
24574
|
+
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
24575
24575
|
"",
|
|
24576
24576
|
'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
24577
24577
|
"",
|
|
@@ -48000,12 +48000,23 @@ function decideFeedReopen(input) {
|
|
|
48000
48000
|
|
|
48001
48001
|
// gateway/answer-thread-resolve.ts
|
|
48002
48002
|
function resolveAnswerThreadId(input) {
|
|
48003
|
-
if (input.
|
|
48004
|
-
|
|
48003
|
+
if (input.frameworkTopicAuthority === false) {
|
|
48004
|
+
if (input.explicitThreadId != null)
|
|
48005
|
+
return input.explicitThreadId;
|
|
48006
|
+
if (input.originResolved)
|
|
48007
|
+
return input.originThreadId;
|
|
48008
|
+
if (input.liveThreadId != null)
|
|
48009
|
+
return input.liveThreadId;
|
|
48010
|
+
if (input.lastEndedResolvedForChat)
|
|
48011
|
+
return input.lastEndedThreadIdForChat;
|
|
48012
|
+
return input.liveThreadId;
|
|
48013
|
+
}
|
|
48005
48014
|
if (input.originResolved)
|
|
48006
48015
|
return input.originThreadId;
|
|
48007
|
-
if (input.
|
|
48016
|
+
if (input.liveTurnPresent)
|
|
48008
48017
|
return input.liveThreadId;
|
|
48018
|
+
if (input.explicitThreadId != null)
|
|
48019
|
+
return input.explicitThreadId;
|
|
48009
48020
|
if (input.lastEndedResolvedForChat)
|
|
48010
48021
|
return input.lastEndedThreadIdForChat;
|
|
48011
48022
|
return input.liveThreadId;
|
|
@@ -52889,11 +52900,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52889
52900
|
}
|
|
52890
52901
|
|
|
52891
52902
|
// ../src/build-info.ts
|
|
52892
|
-
var VERSION = "0.14.
|
|
52893
|
-
var COMMIT_SHA = "
|
|
52894
|
-
var COMMIT_DATE = "2026-06-
|
|
52895
|
-
var LATEST_PR =
|
|
52896
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52903
|
+
var VERSION = "0.14.90";
|
|
52904
|
+
var COMMIT_SHA = "6386ff19";
|
|
52905
|
+
var COMMIT_DATE = "2026-06-08T00:05:33Z";
|
|
52906
|
+
var LATEST_PR = 2235;
|
|
52907
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52897
52908
|
|
|
52898
52909
|
// gateway/boot-version.ts
|
|
52899
52910
|
function formatRelativeAgo(iso) {
|
|
@@ -54140,6 +54151,7 @@ var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ?
|
|
|
54140
54151
|
var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
|
|
54141
54152
|
var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
|
|
54142
54153
|
var FRAMEWORK_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== "0";
|
|
54154
|
+
var REPLY_TOPIC_AUTHORITY_ENABLED = process.env.SWITCHROOM_REPLY_TOPIC_AUTHORITY !== "0";
|
|
54143
54155
|
var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
|
|
54144
54156
|
var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
|
|
54145
54157
|
var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
|
|
@@ -54233,15 +54245,18 @@ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, origin
|
|
|
54233
54245
|
originResolved: originTurn != null,
|
|
54234
54246
|
originThreadId: originTurn?.sessionThreadId,
|
|
54235
54247
|
liveThreadId: liveTurn?.sessionThreadId,
|
|
54248
|
+
liveTurnPresent: liveTurn != null,
|
|
54236
54249
|
lastEndedResolvedForChat: recovered != null,
|
|
54237
|
-
lastEndedThreadIdForChat: recovered?.sessionThreadId
|
|
54250
|
+
lastEndedThreadIdForChat: recovered?.sessionThreadId,
|
|
54251
|
+
frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED
|
|
54238
54252
|
});
|
|
54239
|
-
const via = explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
|
|
54253
|
+
const via = REPLY_TOPIC_AUTHORITY_ENABLED ? originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn != null ? "live" : explicitThreadId != null ? "explicit" : recovered != null ? "recovered" : "none" : explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
|
|
54254
|
+
const explicitOverridden = REPLY_TOPIC_AUTHORITY_ENABLED && explicitThreadId != null && (originTurn != null || liveTurn != null) && threadId !== explicitThreadId;
|
|
54240
54255
|
const ownerTurn = originTurn ?? recovered ?? liveTurn;
|
|
54241
54256
|
const isSupergroup = chatId.startsWith("-100");
|
|
54242
54257
|
const unrouted = isSupergroup && threadId == null && ownerTurn == null;
|
|
54243
54258
|
const misrouteRisk = isSupergroup && via === "live" && hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId);
|
|
54244
|
-
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)" : "") + `
|
|
54259
|
+
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)" : "") + (explicitOverridden ? ` EXPLICIT_OVERRIDDEN(model\u2192${explicitThreadId},routed\u2192${threadId ?? "-"})` : "") + `
|
|
54245
54260
|
`);
|
|
54246
54261
|
return threadId;
|
|
54247
54262
|
}
|
|
@@ -24268,7 +24268,7 @@ var init_bridge = __esm(async () => {
|
|
|
24268
24268
|
"",
|
|
24269
24269
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24270
24270
|
"",
|
|
24271
|
-
"If a message includes message_thread_id, it came from a forum topic. The reply tool
|
|
24271
|
+
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
24272
24272
|
"",
|
|
24273
24273
|
'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
24274
24274
|
"",
|
|
@@ -1,160 +1,190 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { resolveAnswerThreadId, type AnswerThreadInput } from './answer-thread-resolve.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Distinct symbolic thread ids so an output's provenance is unambiguous (no two
|
|
5
|
+
// tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
|
|
6
|
+
const T = 70 // explicit
|
|
7
|
+
const O = 50 // origin
|
|
8
|
+
const L = 30 // live
|
|
9
|
+
const E = 90 // lastEnded
|
|
10
|
+
|
|
11
|
+
// ── Framework-authority (DEFAULT) — human-readable map ──────────────────────
|
|
12
|
+
describe('resolveAnswerThreadId — framework authority (default)', () => {
|
|
13
|
+
it('origin turn wins over the model explicit (the General→CRM fix)', () => {
|
|
6
14
|
expect(
|
|
7
15
|
resolveAnswerThreadId({
|
|
8
|
-
explicitThreadId:
|
|
16
|
+
explicitThreadId: T,
|
|
9
17
|
originResolved: true,
|
|
10
|
-
originThreadId:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
lastEndedThreadIdForChat: 9,
|
|
18
|
+
originThreadId: O,
|
|
19
|
+
liveTurnPresent: true,
|
|
20
|
+
liveThreadId: L,
|
|
14
21
|
}),
|
|
15
|
-
).toBe(
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('(2) origin turn thread wins over the live turn (the Brevo→Meta fix)', () => {
|
|
19
|
-
expect(
|
|
20
|
-
resolveAnswerThreadId({ originResolved: true, originThreadId: 3, liveThreadId: 4 }),
|
|
21
|
-
).toBe(3)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('(2) a DM origin (resolved, thread undefined) pins to undefined, not the live thread', () => {
|
|
25
|
-
expect(
|
|
26
|
-
resolveAnswerThreadId({ originResolved: true, originThreadId: undefined, liveThreadId: 4 }),
|
|
27
|
-
).toBeUndefined()
|
|
22
|
+
).toBe(O)
|
|
28
23
|
})
|
|
29
24
|
|
|
30
|
-
it('
|
|
25
|
+
it('THE marko case: a General-origin question (origin resolved, thread undefined) + model explicit ⇒ General, NOT the model topic', () => {
|
|
31
26
|
expect(
|
|
32
|
-
resolveAnswerThreadId({
|
|
33
|
-
|
|
27
|
+
resolveAnswerThreadId({
|
|
28
|
+
explicitThreadId: 4, // model tried to send the answer to CRM (topic 4)
|
|
29
|
+
originResolved: true,
|
|
30
|
+
originThreadId: undefined, // General origin carries no thread
|
|
31
|
+
liveTurnPresent: true,
|
|
32
|
+
liveThreadId: undefined,
|
|
33
|
+
}),
|
|
34
|
+
).toBeUndefined() // → General, where it was asked
|
|
34
35
|
})
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
it('(4) no explicit, no origin, NO live turn → recovers the most-recent ended turn thread', () => {
|
|
38
|
-
// The marko bug: a reply that fired after the orphaned-reply backstop ended
|
|
39
|
-
// its turn. Pre-fix this returned undefined (General); now it recovers topic 3.
|
|
37
|
+
it('a live in-flight turn wins over the model explicit even with no origin echo', () => {
|
|
40
38
|
expect(
|
|
41
39
|
resolveAnswerThreadId({
|
|
40
|
+
explicitThreadId: T,
|
|
42
41
|
originResolved: false,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
lastEndedThreadIdForChat: 3,
|
|
42
|
+
liveTurnPresent: true,
|
|
43
|
+
liveThreadId: L,
|
|
46
44
|
}),
|
|
47
|
-
).toBe(
|
|
45
|
+
).toBe(L)
|
|
48
46
|
})
|
|
49
47
|
|
|
50
|
-
it('
|
|
48
|
+
it('a General live turn (present, thread undefined) still beats the model explicit', () => {
|
|
51
49
|
expect(
|
|
52
50
|
resolveAnswerThreadId({
|
|
51
|
+
explicitThreadId: T,
|
|
53
52
|
originResolved: false,
|
|
53
|
+
liveTurnPresent: true,
|
|
54
54
|
liveThreadId: undefined,
|
|
55
|
-
lastEndedResolvedForChat: true,
|
|
56
|
-
lastEndedThreadIdForChat: undefined,
|
|
57
55
|
}),
|
|
58
56
|
).toBeUndefined()
|
|
59
57
|
})
|
|
60
58
|
|
|
61
|
-
it('
|
|
59
|
+
it('model explicit is honoured only when there is NO origin and NO live turn (orphaned/proactive)', () => {
|
|
62
60
|
expect(
|
|
63
61
|
resolveAnswerThreadId({
|
|
62
|
+
explicitThreadId: T,
|
|
64
63
|
originResolved: false,
|
|
65
|
-
|
|
66
|
-
lastEndedResolvedForChat: true,
|
|
67
|
-
lastEndedThreadIdForChat: 3,
|
|
64
|
+
liveTurnPresent: false,
|
|
68
65
|
}),
|
|
69
|
-
).toBe(
|
|
66
|
+
).toBe(T)
|
|
70
67
|
})
|
|
71
68
|
|
|
72
|
-
it('
|
|
69
|
+
it('late reply, no anchor, no explicit → recovers the last-ended topic', () => {
|
|
73
70
|
expect(
|
|
74
71
|
resolveAnswerThreadId({
|
|
75
72
|
originResolved: false,
|
|
76
|
-
|
|
77
|
-
lastEndedResolvedForChat:
|
|
73
|
+
liveTurnPresent: false,
|
|
74
|
+
lastEndedResolvedForChat: true,
|
|
75
|
+
lastEndedThreadIdForChat: E,
|
|
78
76
|
}),
|
|
79
|
-
).
|
|
77
|
+
).toBe(E)
|
|
80
78
|
})
|
|
81
79
|
|
|
82
|
-
it('pure DM (
|
|
80
|
+
it('pure DM (no anchors) → undefined', () => {
|
|
83
81
|
expect(resolveAnswerThreadId({ originResolved: false })).toBeUndefined()
|
|
84
82
|
})
|
|
85
83
|
})
|
|
86
84
|
|
|
85
|
+
// ── Legacy precedence (kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) ───────
|
|
86
|
+
describe('resolveAnswerThreadId — legacy (frameworkTopicAuthority:false)', () => {
|
|
87
|
+
it('explicit wins outright (the old behaviour)', () => {
|
|
88
|
+
expect(
|
|
89
|
+
resolveAnswerThreadId({
|
|
90
|
+
frameworkTopicAuthority: false,
|
|
91
|
+
explicitThreadId: T,
|
|
92
|
+
originResolved: true,
|
|
93
|
+
originThreadId: O,
|
|
94
|
+
liveThreadId: L,
|
|
95
|
+
}),
|
|
96
|
+
).toBe(T)
|
|
97
|
+
})
|
|
98
|
+
it('origin beats live when no explicit (unchanged)', () => {
|
|
99
|
+
expect(
|
|
100
|
+
resolveAnswerThreadId({
|
|
101
|
+
frameworkTopicAuthority: false,
|
|
102
|
+
originResolved: true,
|
|
103
|
+
originThreadId: O,
|
|
104
|
+
liveThreadId: L,
|
|
105
|
+
}),
|
|
106
|
+
).toBe(O)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
87
110
|
// ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
|
|
88
111
|
//
|
|
89
|
-
// The operator standard (memory feedback_prove_finite_fsm_not_sample): a
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
// The operator standard (memory feedback_prove_finite_fsm_not_sample): a passing
|
|
113
|
+
// sample is not a proof. `resolveAnswerThreadId` is a PURE decision function over
|
|
114
|
+
// a FINITE input space — so we prove its behaviour by CONSTRUCTION: enumerate
|
|
115
|
+
// every REACHABLE input (origin/live/ended each have 3 reachable states ×
|
|
116
|
+
// explicit present/absent) for BOTH precedence modes, and assert totality,
|
|
117
|
+
// determinism, no-fabrication, the documented precedence, and the load-bearing
|
|
118
|
+
// guarantee: under framework authority the model's explicit thread CANNOT
|
|
119
|
+
// redirect a reply that has a framework anchor.
|
|
120
|
+
|
|
121
|
+
// Reachable sub-states (a thread value is only present when its 'resolved' flag is).
|
|
122
|
+
const ORIGIN_STATES: Array<Pick<AnswerThreadInput, 'originResolved' | 'originThreadId'>> = [
|
|
123
|
+
{ originResolved: false },
|
|
124
|
+
{ originResolved: true, originThreadId: undefined }, // DM/General origin
|
|
125
|
+
{ originResolved: true, originThreadId: O },
|
|
126
|
+
]
|
|
127
|
+
const LIVE_STATES: Array<Pick<AnswerThreadInput, 'liveTurnPresent' | 'liveThreadId'>> = [
|
|
128
|
+
{ liveTurnPresent: false },
|
|
129
|
+
{ liveTurnPresent: true, liveThreadId: undefined }, // General live turn
|
|
130
|
+
{ liveTurnPresent: true, liveThreadId: L },
|
|
131
|
+
]
|
|
132
|
+
const ENDED_STATES: Array<
|
|
133
|
+
Pick<AnswerThreadInput, 'lastEndedResolvedForChat' | 'lastEndedThreadIdForChat'>
|
|
134
|
+
> = [
|
|
135
|
+
{ lastEndedResolvedForChat: false },
|
|
136
|
+
{ lastEndedResolvedForChat: true, lastEndedThreadIdForChat: undefined },
|
|
137
|
+
{ lastEndedResolvedForChat: true, lastEndedThreadIdForChat: E },
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
function reachableInputs(frameworkTopicAuthority: boolean): AnswerThreadInput[] {
|
|
106
141
|
const rows: AnswerThreadInput[] = []
|
|
107
142
|
for (const explicitThreadId of [undefined, T])
|
|
108
|
-
for (const
|
|
109
|
-
for (const
|
|
110
|
-
for (const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
rows.push({
|
|
114
|
-
explicitThreadId,
|
|
115
|
-
originResolved,
|
|
116
|
-
originThreadId,
|
|
117
|
-
lastEndedResolvedForChat,
|
|
118
|
-
lastEndedThreadIdForChat,
|
|
119
|
-
liveThreadId,
|
|
120
|
-
})
|
|
121
|
-
return rows
|
|
143
|
+
for (const o of ORIGIN_STATES)
|
|
144
|
+
for (const lv of LIVE_STATES)
|
|
145
|
+
for (const en of ENDED_STATES)
|
|
146
|
+
rows.push({ explicitThreadId, ...o, ...lv, ...en, frameworkTopicAuthority })
|
|
147
|
+
return rows // 2 × 3 × 3 × 3 = 54
|
|
122
148
|
}
|
|
123
149
|
|
|
124
|
-
// Independent
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (i.
|
|
129
|
-
if (i.
|
|
130
|
-
if (i.
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
// Independent SPEC encodings, kept separate from the implementation so a
|
|
151
|
+
// regression in either surfaces as a divergence.
|
|
152
|
+
function specFramework(i: AnswerThreadInput): number | undefined {
|
|
153
|
+
if (i.originResolved) return i.originThreadId // (1)
|
|
154
|
+
if (i.liveTurnPresent) return i.liveThreadId // (2)
|
|
155
|
+
if (i.explicitThreadId != null) return i.explicitThreadId // (3)
|
|
156
|
+
if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat // (4)
|
|
157
|
+
return i.liveThreadId
|
|
158
|
+
}
|
|
159
|
+
function specLegacy(i: AnswerThreadInput): number | undefined {
|
|
160
|
+
if (i.explicitThreadId != null) return i.explicitThreadId
|
|
161
|
+
if (i.originResolved) return i.originThreadId
|
|
162
|
+
if (i.liveThreadId != null) return i.liveThreadId
|
|
163
|
+
if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat
|
|
164
|
+
return i.liveThreadId
|
|
133
165
|
}
|
|
134
166
|
|
|
135
|
-
describe('resolveAnswerThreadId — total-enumeration
|
|
136
|
-
const
|
|
167
|
+
describe('resolveAnswerThreadId — total-enumeration proof (54 reachable inputs × 2 modes)', () => {
|
|
168
|
+
const FA = reachableInputs(true)
|
|
169
|
+
const LEGACY = reachableInputs(false)
|
|
137
170
|
|
|
138
|
-
it('the input space is exactly
|
|
139
|
-
expect(
|
|
171
|
+
it('the reachable input space is exactly 54 rows per mode', () => {
|
|
172
|
+
expect(FA.length).toBe(54)
|
|
173
|
+
expect(LEGACY.length).toBe(54)
|
|
140
174
|
})
|
|
141
175
|
|
|
142
|
-
it('TOTAL: every input returns without throwing', () => {
|
|
143
|
-
for (const i of
|
|
144
|
-
expect(() => resolveAnswerThreadId(i)).not.toThrow()
|
|
145
|
-
}
|
|
176
|
+
it('TOTAL: every input returns without throwing (both modes)', () => {
|
|
177
|
+
for (const i of [...FA, ...LEGACY]) expect(() => resolveAnswerThreadId(i)).not.toThrow()
|
|
146
178
|
})
|
|
147
179
|
|
|
148
|
-
it('DETERMINISTIC: each input maps to exactly one output
|
|
149
|
-
for (const i of
|
|
150
|
-
|
|
151
|
-
const b = resolveAnswerThreadId({ ...i })
|
|
152
|
-
expect(b).toBe(a)
|
|
180
|
+
it('DETERMINISTIC: each input maps to exactly one output across repeated calls', () => {
|
|
181
|
+
for (const i of [...FA, ...LEGACY]) {
|
|
182
|
+
expect(resolveAnswerThreadId({ ...i })).toBe(resolveAnswerThreadId(i))
|
|
153
183
|
}
|
|
154
184
|
})
|
|
155
185
|
|
|
156
186
|
it('NO FABRICATION: every output is undefined or one of the four input thread fields', () => {
|
|
157
|
-
for (const i of
|
|
187
|
+
for (const i of [...FA, ...LEGACY]) {
|
|
158
188
|
const out = resolveAnswerThreadId(i)
|
|
159
189
|
const provenance = new Set([
|
|
160
190
|
undefined,
|
|
@@ -167,53 +197,45 @@ describe('resolveAnswerThreadId — total-enumeration determinism proof (all 64
|
|
|
167
197
|
}
|
|
168
198
|
})
|
|
169
199
|
|
|
170
|
-
it('PRECEDENCE: matches the documented spec on all
|
|
171
|
-
for (const i of
|
|
172
|
-
expect(resolveAnswerThreadId(i)).toBe(specExpected(i))
|
|
173
|
-
}
|
|
200
|
+
it('PRECEDENCE (framework): matches the documented spec on all 54 inputs', () => {
|
|
201
|
+
for (const i of FA) expect(resolveAnswerThreadId(i)).toBe(specFramework(i))
|
|
174
202
|
})
|
|
175
203
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
}
|
|
204
|
+
it('PRECEDENCE (legacy): matches the legacy spec on all 54 inputs', () => {
|
|
205
|
+
for (const i of LEGACY) expect(resolveAnswerThreadId(i)).toBe(specLegacy(i))
|
|
185
206
|
})
|
|
186
207
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
208
|
+
// ── The load-bearing guarantee ────────────────────────────────────────────
|
|
209
|
+
it('INV-ANCHOR (framework): the model explicit CANNOT redirect a reply that has a framework anchor — output is independent of explicitThreadId whenever originResolved OR liveTurnPresent', () => {
|
|
210
|
+
for (const i of FA) {
|
|
211
|
+
if (i.originResolved || i.liveTurnPresent) {
|
|
212
|
+
const withExplicit = resolveAnswerThreadId({ ...i, explicitThreadId: T })
|
|
213
|
+
const without = resolveAnswerThreadId({ ...i, explicitThreadId: undefined })
|
|
214
|
+
expect(withExplicit).toBe(without)
|
|
191
215
|
}
|
|
192
216
|
}
|
|
193
217
|
})
|
|
194
218
|
|
|
195
|
-
it('INV-
|
|
196
|
-
for (const i of
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
expect(i.explicitThreadId).toBeUndefined()
|
|
203
|
-
expect(i.originResolved).toBe(false)
|
|
204
|
-
expect(i.liveThreadId).toBeUndefined()
|
|
205
|
-
expect(i.lastEndedResolvedForChat).toBe(true)
|
|
206
|
-
}
|
|
219
|
+
it('INV-ORIGIN (framework): originResolved ⇒ output === originThreadId, for EVERY explicit/live/ended combo (flip-immunity + explicit-immunity)', () => {
|
|
220
|
+
for (const i of FA) if (i.originResolved) expect(resolveAnswerThreadId(i)).toBe(i.originThreadId)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('INV-LIVE (framework): ¬originResolved ∧ liveTurnPresent ⇒ output === liveThreadId, regardless of explicit', () => {
|
|
224
|
+
for (const i of FA) {
|
|
225
|
+
if (!i.originResolved && i.liveTurnPresent) expect(resolveAnswerThreadId(i)).toBe(i.liveThreadId)
|
|
207
226
|
}
|
|
208
227
|
})
|
|
209
228
|
|
|
210
|
-
it('INV-
|
|
211
|
-
for (const i of
|
|
212
|
-
|
|
213
|
-
if (out === L) {
|
|
214
|
-
expect(i.explicitThreadId).toBeUndefined()
|
|
229
|
+
it('INV-EXPLICIT-LAST (framework): an explicit-tier result occurs ONLY when no origin AND no live turn', () => {
|
|
230
|
+
for (const i of FA) {
|
|
231
|
+
if (resolveAnswerThreadId(i) === T) {
|
|
215
232
|
expect(i.originResolved).toBe(false)
|
|
233
|
+
expect(i.liveTurnPresent).toBe(false)
|
|
216
234
|
}
|
|
217
235
|
}
|
|
218
236
|
})
|
|
237
|
+
|
|
238
|
+
it('INV-EXPLICIT-DOMINANCE (legacy): explicit set ⇒ output === explicit, independent of all other fields', () => {
|
|
239
|
+
for (const i of LEGACY) if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
|
|
240
|
+
})
|
|
219
241
|
})
|
|
@@ -5,35 +5,48 @@
|
|
|
5
5
|
* Pure decision: which forum-topic thread should an ANSWER reply
|
|
6
6
|
* (`reply` / `stream_reply`) land in?
|
|
7
7
|
*
|
|
8
|
-
* ## The
|
|
8
|
+
* ## The bugs this closes
|
|
9
9
|
*
|
|
10
|
-
* In a forum supergroup one sequential
|
|
11
|
-
* a singleton `currentTurn`.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the model omitted `message_thread_id`, resolved the thread from
|
|
15
|
-
* `turn.sessionThreadId` (Meta's thread) — so Brevo's answer landed in
|
|
16
|
-
* Meta. A successor turn stole a predecessor's late reply.
|
|
10
|
+
* **(a) currentTurn flip (2026-06-05).** In a forum supergroup one sequential
|
|
11
|
+
* `claude` CLI owns every topic with a singleton `currentTurn`. A late reply
|
|
12
|
+
* landed after `currentTurn` had flipped to a successor topic, so the answer
|
|
13
|
+
* went to the wrong topic. Fixed by pinning to the ORIGIN turn's thread (tier 1).
|
|
17
14
|
*
|
|
18
|
-
*
|
|
15
|
+
* **(b) model-chosen topic override (2026-06-08).** A General-topic question's
|
|
16
|
+
* answer landed in the CRM topic because the model passed an explicit
|
|
17
|
+
* `message_thread_id` and the resolver let that win OUTRIGHT over the
|
|
18
|
+
* framework's record of where the question came from. General is the trap: its
|
|
19
|
+
* messages carry no thread id, so nothing forces the reply back — and the
|
|
20
|
+
* model's explicit "post to CRM" overrode the General origin. The user, reading
|
|
21
|
+
* General, saw silence. This is a model-dependency: a reply's topic must not be
|
|
22
|
+
* the model's free choice.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
24
|
+
* ## The precedence (answer paths) — FRAMEWORK AUTHORITY (default)
|
|
25
|
+
*
|
|
26
|
+
* The topic a reply lands in is owned by the TURN it answers, not the model's
|
|
27
|
+
* `message_thread_id`. The model's explicit thread is demoted to a last resort
|
|
28
|
+
* — used only when the framework has NO turn anchor at all (a genuinely
|
|
29
|
+
* orphaned / proactive send). Precedence:
|
|
30
|
+
*
|
|
31
|
+
* 1. ORIGIN turn's thread — the turn matched by the reply's `origin_turn_id`
|
|
32
|
+
* (echoed) or a quoted `reply_to` (framework reverse-index). Authoritative
|
|
33
|
+
* even when `currentTurn` flipped (closes bug a). A General/DM origin
|
|
34
|
+
* yields `undefined`, which correctly routes to the main chat / General.
|
|
35
|
+
* 2. LIVE in-flight turn's thread — keyed on the turn's PRESENCE, not its
|
|
36
|
+
* thread value, so a General live turn (thread `undefined`) still anchors
|
|
37
|
+
* the reply to the General conversation. The model's explicit cannot
|
|
38
|
+
* redirect a reply that belongs to an in-flight turn (closes bug b).
|
|
39
|
+
* 3. EXPLICIT model thread — only now, when there is neither an origin nor a
|
|
40
|
+
* live turn (a late / proactive send with no framework anchor). Here the
|
|
41
|
+
* model's `message_thread_id` is the only signal, so honour it.
|
|
42
|
+
* 4. LATE-reply recovery — no explicit, no origin, no live turn: recover the
|
|
43
|
+
* origin topic from the most-recently-ended turn for this chat, so an
|
|
44
|
+
* orphaned-backstop reply lands in its topic instead of defaulting to the
|
|
45
|
+
* main chat (General). Not the `chatThreadMap` last-seen heuristic.
|
|
46
|
+
*
|
|
47
|
+
* Setting `frameworkTopicAuthority: false` (kill switch
|
|
48
|
+
* SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) restores the legacy explicit-first
|
|
49
|
+
* precedence (the model's thread wins outright).
|
|
37
50
|
*
|
|
38
51
|
* The `chatThreadMap` last-seen fallback is preserved for NON-answer
|
|
39
52
|
* surfaces (`send_typing`, `forward_message`, `progress_update`) by NOT
|
|
@@ -50,13 +63,17 @@ export interface AnswerThreadInput {
|
|
|
50
63
|
* `originResolved` is true. */
|
|
51
64
|
originThreadId?: number | undefined
|
|
52
65
|
/** Whether an origin turn was resolved at all. Distinguishes
|
|
53
|
-
* "origin turn exists and its thread is undefined (a DM origin)" from
|
|
66
|
+
* "origin turn exists and its thread is undefined (a DM/General origin)" from
|
|
54
67
|
* "no origin turn" — both surface as `originThreadId === undefined`. */
|
|
55
68
|
originResolved: boolean
|
|
56
69
|
/** Thread of the LIVE `currentTurn` at execution time, or undefined
|
|
57
|
-
* (no live turn, or a DM live turn).
|
|
58
|
-
* no origin turn is resolvable. */
|
|
70
|
+
* (no live turn, or a DM/General live turn). */
|
|
59
71
|
liveThreadId?: number | undefined
|
|
72
|
+
/** Whether a LIVE in-flight turn exists at execution time. Distinguishes a
|
|
73
|
+
* General (thread-undefined) live turn — still a valid framework anchor —
|
|
74
|
+
* from no live turn at all. Mirrors `originResolved`. Absent in the legacy
|
|
75
|
+
* path (which keys off `liveThreadId != null`). */
|
|
76
|
+
liveTurnPresent?: boolean
|
|
60
77
|
/**
|
|
61
78
|
* Late-reply topic recovery (2026-06-05). Thread of the most-recently-ended
|
|
62
79
|
* turn for THIS chat (from `recentTurnsById`), used as a deterministic
|
|
@@ -64,42 +81,52 @@ export interface AnswerThreadInput {
|
|
|
64
81
|
* turn — the late-reply-after-turn-end case. Without it, a reply that fires
|
|
65
82
|
* after the orphaned-reply backstop closed its turn defaults to the main chat
|
|
66
83
|
* (General topic in a supergroup), so its answer vanishes from the topic the
|
|
67
|
-
* user is reading.
|
|
68
|
-
* which is correct.
|
|
84
|
+
* user is reading. A DM origin yields undefined, which is correct.
|
|
69
85
|
*/
|
|
70
86
|
lastEndedThreadIdForChat?: number | undefined
|
|
71
87
|
/** Whether a recently-ended turn exists for this chat — distinguishes
|
|
72
88
|
* "ended turn exists, DM (thread undefined)" from "no ended turn at all". */
|
|
73
89
|
lastEndedResolvedForChat?: boolean
|
|
90
|
+
/**
|
|
91
|
+
* When true (default), the framework's turn anchor (origin → live) owns the
|
|
92
|
+
* reply topic and the model's `explicitThreadId` is a last resort (consulted
|
|
93
|
+
* only when no anchor exists). When false, the legacy explicit-first
|
|
94
|
+
* precedence (the model's thread wins outright). Undefined is treated as
|
|
95
|
+
* true. Kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0.
|
|
96
|
+
*/
|
|
97
|
+
frameworkTopicAuthority?: boolean
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
/**
|
|
77
101
|
* Pure. Returns the thread id to send the answer to, or undefined for the
|
|
78
|
-
* main chat (DM / no thread).
|
|
102
|
+
* main chat (DM / General / no thread).
|
|
79
103
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* heuristic is what produced the Brevo→Meta wrong-topic bug, so answer
|
|
85
|
-
* paths never reach it.
|
|
104
|
+
* Default (framework authority): origin turn → live in-flight turn → explicit
|
|
105
|
+
* model thread → late-ended recovery. The model's `explicitThreadId` cannot
|
|
106
|
+
* override a resolved origin or a live turn — it is consulted only when neither
|
|
107
|
+
* exists. The chat last-seen `chatThreadMap` heuristic is NOT in this chain.
|
|
86
108
|
*/
|
|
87
109
|
export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefined {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
if (input.frameworkTopicAuthority === false) {
|
|
111
|
+
// ── Legacy precedence (kill switch): the model's explicit thread wins. ──
|
|
112
|
+
if (input.explicitThreadId != null) return input.explicitThreadId
|
|
113
|
+
if (input.originResolved) return input.originThreadId
|
|
114
|
+
if (input.liveThreadId != null) return input.liveThreadId
|
|
115
|
+
if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
|
|
116
|
+
return input.liveThreadId
|
|
117
|
+
}
|
|
118
|
+
// ── Framework-authority precedence (default) ───────────────────────────────
|
|
119
|
+
// (1) origin turn → its thread (authoritative across a currentTurn flip; a
|
|
120
|
+
// General/DM origin yields undefined → main chat / General).
|
|
93
121
|
if (input.originResolved) return input.originThreadId
|
|
94
|
-
// (
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
// fall through to liveThreadId (undefined) — the legacy result.
|
|
122
|
+
// (2) a live in-flight turn → its thread. Key off PRESENCE, not the thread
|
|
123
|
+
// value: a General live turn has an undefined thread but is still the
|
|
124
|
+
// anchor, so the model's explicit can't pull the reply out of it.
|
|
125
|
+
if (input.liveTurnPresent) return input.liveThreadId
|
|
126
|
+
// (3) no framework anchor (genuinely orphaned / proactive) → honour the
|
|
127
|
+
// model's explicit thread, its only signal here.
|
|
128
|
+
if (input.explicitThreadId != null) return input.explicitThreadId
|
|
129
|
+
// (4) late reply, no anchor, no explicit → recover the chat's last-ended topic.
|
|
103
130
|
if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
|
|
104
131
|
return input.liveThreadId
|
|
105
132
|
}
|
|
@@ -1566,6 +1566,15 @@ const TURN_ORIGIN_ROUTING_ENABLED =
|
|
|
1566
1566
|
// mask a misroute. Kill switch off (=0) → echo-only origin (today's behaviour).
|
|
1567
1567
|
const FRAMEWORK_ORIGIN_ROUTING_ENABLED =
|
|
1568
1568
|
process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'
|
|
1569
|
+
// Reply-topic framework authority (2026-06-08 determinism pass). The topic an
|
|
1570
|
+
// ANSWER lands in is owned by the TURN it answers (origin → live), NOT the
|
|
1571
|
+
// model's `message_thread_id`. The model's explicit thread is demoted to a
|
|
1572
|
+
// last resort (consulted only when there is no origin AND no live turn). Closes
|
|
1573
|
+
// the model-chosen-topic override that sent a General-topic question's answer
|
|
1574
|
+
// into the CRM topic (marko) — the model could redirect any reply. Kill switch
|
|
1575
|
+
// off (=0) → legacy explicit-first precedence (the model's thread wins).
|
|
1576
|
+
const REPLY_TOPIC_AUTHORITY_ENABLED =
|
|
1577
|
+
process.env.SWITCHROOM_REPLY_TOPIC_AUTHORITY !== '0'
|
|
1569
1578
|
// Component 4 (per-turn topic framing). Add a one-line directive to the
|
|
1570
1579
|
// channel meta + bridge instructions telling the model to answer ONLY the
|
|
1571
1580
|
// current message's topic. Kill switch off (=0) → no framing field.
|
|
@@ -2033,15 +2042,32 @@ function resolveAnswerThreadWithLog(
|
|
|
2033
2042
|
originResolved: originTurn != null,
|
|
2034
2043
|
originThreadId: originTurn?.sessionThreadId,
|
|
2035
2044
|
liveThreadId: liveTurn?.sessionThreadId,
|
|
2045
|
+
liveTurnPresent: liveTurn != null,
|
|
2036
2046
|
lastEndedResolvedForChat: recovered != null,
|
|
2037
2047
|
lastEndedThreadIdForChat: recovered?.sessionThreadId,
|
|
2048
|
+
frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED,
|
|
2038
2049
|
})
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2050
|
+
// `via` reflects the ACTIVE precedence so telemetry matches routing.
|
|
2051
|
+
const via = REPLY_TOPIC_AUTHORITY_ENABLED
|
|
2052
|
+
? (originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
2053
|
+
: liveTurn != null ? 'live'
|
|
2054
|
+
: explicitThreadId != null ? 'explicit'
|
|
2055
|
+
: recovered != null ? 'recovered'
|
|
2056
|
+
: 'none')
|
|
2057
|
+
: (explicitThreadId != null ? 'explicit'
|
|
2058
|
+
: originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
2059
|
+
: liveTurn?.sessionThreadId != null ? 'live'
|
|
2060
|
+
: recovered != null ? 'recovered'
|
|
2061
|
+
: 'none')
|
|
2062
|
+
// Observability: the model passed an explicit topic but a framework anchor
|
|
2063
|
+
// (origin/live) overrode it. This is the deterministic correction that fixes
|
|
2064
|
+
// the General→CRM misroute; surface it so the model's topic-grabbing is
|
|
2065
|
+
// visible rather than silent.
|
|
2066
|
+
const explicitOverridden =
|
|
2067
|
+
REPLY_TOPIC_AUTHORITY_ENABLED &&
|
|
2068
|
+
explicitThreadId != null &&
|
|
2069
|
+
(originTurn != null || liveTurn != null) &&
|
|
2070
|
+
threadId !== explicitThreadId
|
|
2045
2071
|
const ownerTurn = originTurn ?? recovered ?? liveTurn
|
|
2046
2072
|
const isSupergroup = chatId.startsWith('-100')
|
|
2047
2073
|
// UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
|
|
@@ -2068,6 +2094,9 @@ function resolveAnswerThreadWithLog(
|
|
|
2068
2094
|
(via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
|
|
2069
2095
|
(unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
|
|
2070
2096
|
(misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
|
|
2097
|
+
(explicitOverridden
|
|
2098
|
+
? ` EXPLICIT_OVERRIDDEN(model→${explicitThreadId},routed→${threadId ?? '-'})`
|
|
2099
|
+
: '') +
|
|
2071
2100
|
'\n',
|
|
2072
2101
|
)
|
|
2073
2102
|
return threadId
|
|
@@ -16,12 +16,25 @@ describe('resolveAnswerThreadId', () => {
|
|
|
16
16
|
const BREVO = 4
|
|
17
17
|
const META = 3
|
|
18
18
|
|
|
19
|
-
it('
|
|
19
|
+
it('the model explicit no longer wins over a framework anchor (origin wins); kill switch restores legacy', () => {
|
|
20
|
+
// Default (framework authority): the resolved origin owns the topic — the
|
|
21
|
+
// model passing a different explicit thread cannot redirect the reply.
|
|
20
22
|
expect(
|
|
21
23
|
resolveAnswerThreadId({
|
|
24
|
+
explicitThreadId: BREVO, // model tried to send it to Brevo
|
|
25
|
+
originResolved: true,
|
|
26
|
+
originThreadId: META, // but the question came from Meta
|
|
27
|
+
liveTurnPresent: true,
|
|
28
|
+
liveThreadId: META,
|
|
29
|
+
}),
|
|
30
|
+
).toBe(META)
|
|
31
|
+
// Kill switch (SWITCHROOM_REPLY_TOPIC_AUTHORITY=0): explicit wins outright.
|
|
32
|
+
expect(
|
|
33
|
+
resolveAnswerThreadId({
|
|
34
|
+
frameworkTopicAuthority: false,
|
|
22
35
|
explicitThreadId: BREVO,
|
|
23
36
|
originResolved: true,
|
|
24
|
-
originThreadId: META,
|
|
37
|
+
originThreadId: META,
|
|
25
38
|
liveThreadId: META,
|
|
26
39
|
}),
|
|
27
40
|
).toBe(BREVO)
|
|
@@ -86,18 +99,20 @@ describe('resolveAnswerThreadId', () => {
|
|
|
86
99
|
).toBeUndefined()
|
|
87
100
|
})
|
|
88
101
|
|
|
89
|
-
it('precedence
|
|
102
|
+
it('precedence (framework authority, default): origin > live > explicit (never chatThreadMap — not an input)', () => {
|
|
90
103
|
// The chat last-seen thread is deliberately NOT a parameter: answer
|
|
91
104
|
// paths can never reach it, which is what closes the wrong-topic bug.
|
|
92
|
-
// explicit beats
|
|
105
|
+
// The model's explicit no longer beats a framework anchor — origin wins
|
|
106
|
+
// even when the model asserted a different topic (the General→CRM fix):
|
|
93
107
|
expect(
|
|
94
108
|
resolveAnswerThreadId({
|
|
95
109
|
explicitThreadId: 9,
|
|
96
110
|
originResolved: true,
|
|
97
111
|
originThreadId: BREVO,
|
|
112
|
+
liveTurnPresent: true,
|
|
98
113
|
liveThreadId: META,
|
|
99
114
|
}),
|
|
100
|
-
).toBe(
|
|
115
|
+
).toBe(BREVO)
|
|
101
116
|
// no explicit, origin resolved → origin (not live):
|
|
102
117
|
expect(
|
|
103
118
|
resolveAnswerThreadId({
|
|
@@ -107,5 +122,15 @@ describe('resolveAnswerThreadId', () => {
|
|
|
107
122
|
liveThreadId: META,
|
|
108
123
|
}),
|
|
109
124
|
).toBe(BREVO)
|
|
125
|
+
// kill switch restores the legacy explicit-first precedence:
|
|
126
|
+
expect(
|
|
127
|
+
resolveAnswerThreadId({
|
|
128
|
+
frameworkTopicAuthority: false,
|
|
129
|
+
explicitThreadId: 9,
|
|
130
|
+
originResolved: true,
|
|
131
|
+
originThreadId: BREVO,
|
|
132
|
+
liveThreadId: META,
|
|
133
|
+
}),
|
|
134
|
+
).toBe(9)
|
|
110
135
|
})
|
|
111
136
|
})
|