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.
@@ -49815,8 +49815,8 @@ var {
49815
49815
  } = import__.default;
49816
49816
 
49817
49817
  // src/build-info.ts
49818
- var VERSION = "0.14.89";
49819
- var COMMIT_SHA = "125cbcc5";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.89",
3
+ "version": "0.14.90",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 will automatically route replies back to the same topic — no need to pass message_thread_id manually unless you want to override. 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.',
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 will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. 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.",
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.explicitThreadId != null)
48004
- return input.explicitThreadId;
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.liveThreadId != null)
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.89";
52893
- var COMMIT_SHA = "125cbcc5";
52894
- var COMMIT_DATE = "2026-06-08T07:42:50+10:00";
52895
- var LATEST_PR = null;
52896
- var COMMITS_AHEAD_OF_TAG = 2;
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 will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. 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.",
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
- describe('resolveAnswerThreadId precedence', () => {
5
- it('(1) explicit model thread wins over everything', () => {
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: 7,
16
+ explicitThreadId: T,
9
17
  originResolved: true,
10
- originThreadId: 3,
11
- liveThreadId: 4,
12
- lastEndedResolvedForChat: true,
13
- lastEndedThreadIdForChat: 9,
18
+ originThreadId: O,
19
+ liveTurnPresent: true,
20
+ liveThreadId: L,
14
21
  }),
15
- ).toBe(7)
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('(3) no origin falls back to the live turn thread (legacy #1664)', () => {
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({ originResolved: false, liveThreadId: 4 }),
33
- ).toBe(4)
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
- // ── tier (4): late-reply topic recovery (2026-06-05) ──────────────────────
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
- liveThreadId: undefined,
44
- lastEndedResolvedForChat: true,
45
- lastEndedThreadIdForChat: 3,
42
+ liveTurnPresent: true,
43
+ liveThreadId: L,
46
44
  }),
47
- ).toBe(3)
45
+ ).toBe(L)
48
46
  })
49
47
 
50
- it('(4) a recovered DM turn (ended, thread undefined) stays threadless', () => {
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('(4) recovery does NOT override a live turn live thread still wins at tier 3', () => {
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
- liveThreadId: 4,
66
- lastEndedResolvedForChat: true,
67
- lastEndedThreadIdForChat: 3,
64
+ liveTurnPresent: false,
68
65
  }),
69
- ).toBe(4)
66
+ ).toBe(T)
70
67
  })
71
68
 
72
- it('(4) no recovery candidatelegacy result (undefined), unchanged', () => {
69
+ it('late reply, no anchor, no explicit recovers the last-ended topic', () => {
73
70
  expect(
74
71
  resolveAnswerThreadId({
75
72
  originResolved: false,
76
- liveThreadId: undefined,
77
- lastEndedResolvedForChat: false,
73
+ liveTurnPresent: false,
74
+ lastEndedResolvedForChat: true,
75
+ lastEndedThreadIdForChat: E,
78
76
  }),
79
- ).toBeUndefined()
77
+ ).toBe(E)
80
78
  })
81
79
 
82
- it('pure DM (every tier undefined) → undefined', () => {
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
- // 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[] {
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 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
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 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)
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 determinism proof (all 64 inputs)', () => {
136
- const ROWS = allInputs()
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 64 rows (2^6)', () => {
139
- expect(ROWS.length).toBe(64)
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 ROWS) {
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 (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)
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 ROWS) {
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 64 inputs', () => {
171
- for (const i of ROWS) {
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
- // ── 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
- }
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
- 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)
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-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
- }
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-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()
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 bug this closes
8
+ * ## The bugs this closes
9
9
  *
10
- * In a forum supergroup one sequential `claude` CLI owns every topic with
11
- * a singleton `currentTurn`. The Brevo turn's reply landed ~42s after its
12
- * turn-end event; by then `currentTurn` had flipped to the Meta turn.
13
- * `executeReply` captured `const turn = currentTurn` at execution and, when
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
- * ## The precedence (answer paths)
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
- * 1. An explicit `message_thread_id` the MODEL passed wins outright
21
- * the model is asserting the destination topic.
22
- * 2. Else the ORIGIN turn's thread: the turn matched by the reply's
23
- * `origin_turn_id` (the meta field the model echoes back). This is
24
- * authoritative even when `currentTurn` has flipped, because the
25
- * origin turn is looked up in a recently-ended registry.
26
- * 3. Else the LIVE turn's thread — but ONLY when the live turn IS the
27
- * origin turn (no flip happened) OR no origin turn could be resolved
28
- * at all (origin id absent/unknown; legacy / pre-stamp path).
29
- * 4. Else (no explicit, no origin echoed, no live turn) a LATE reply that
30
- * fired after its turn already ended (the orphaned-reply backstop case)
31
- * recover the origin topic from the most-recently-ended turn for this
32
- * chat. Without this, such a reply defaults to the main chat (General in a
33
- * supergroup) and its answer vanishes from the topic the user is reading
34
- * (the 2026-06-05 marko triage). Still NOT the `chatThreadMap` last-seen
35
- * heuristicthe recovered turn is the chat's own most-recent turn, not
36
- * whichever topic last received any message.
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). The legacy (#1664) fallback when
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. Only consulted at tier (4); a DM origin yields undefined,
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
- * Precedence: explicit model thread origin turn's thread (authoritative
81
- * across a currentTurn flip; this is the wrong-topic fix) live turn's
82
- * thread (legacy #1664 fallback when origin can't be resolved). Crucially
83
- * the chat last-seen `chatThreadMap` heuristic is NOT in this chain — that
84
- * heuristic is what produced the Brevo→Meta wrong-topic bug, so answer
85
- * paths never reach it.
104
+ * Default (framework authority): origin turnlive 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
- // (1) explicit model thread wins.
89
- if (input.explicitThreadId != null) return input.explicitThreadId
90
- // (2) origin turn resolved → pin to its thread (authoritative even when
91
- // currentTurn has flipped to a successor). A DM origin yields
92
- // undefined, which is correct.
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
- // (3) no origin resolved (legacy / pre-stamp / evicted) fall back to
95
- // the live turn's thread, the existing turn-pinned behaviour (#1664).
96
- if (input.liveThreadId != null) return input.liveThreadId
97
- // (4) no explicit, no origin echoed, no live turn — a LATE reply that fired
98
- // after its turn already ended (the orphaned-reply backstop case).
99
- // Recover the origin topic from the most-recently-ended turn for this
100
- // chat so the answer lands in the topic it belongs to instead of
101
- // defaulting to the main chat (General). When no ended turn is known,
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
- const via =
2040
- explicitThreadId != null ? 'explicit'
2041
- : originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
2042
- : liveTurn?.sessionThreadId != null ? 'live'
2043
- : recovered != null ? 'recovered'
2044
- : 'none'
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('explicit model thread wins outright', () => {
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, // even if origin says otherwise
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: explicit > origin > live (never chatThreadMap — not an input)', () => {
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 both:
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(9)
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
  })