switchroom 0.14.68 → 0.14.70

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.
@@ -49601,8 +49601,8 @@ var {
49601
49601
  } = import__.default;
49602
49602
 
49603
49603
  // src/build-info.ts
49604
- var VERSION = "0.14.68";
49605
- var COMMIT_SHA = "4f371b79";
49604
+ var VERSION = "0.14.70";
49605
+ var COMMIT_SHA = "fdaeb2c4";
49606
49606
 
49607
49607
  // src/cli/agent.ts
49608
49608
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.68",
3
+ "version": "0.14.70",
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": {
@@ -35,3 +35,72 @@ export function parseDraftLaneRetiredEnabled(raw: string | undefined): boolean {
35
35
  const v = raw.trim().toLowerCase()
36
36
  return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
37
37
  }
38
+
39
+ /**
40
+ * `minInitialChars` sentinel meaning "never open a visible chat-timeline
41
+ * preview" — mirrors the `Number.MAX_SAFE_INTEGER` gate the createAnswerStream
42
+ * call site uses so the lane stays silent.
43
+ */
44
+ export const ANSWER_LANE_NEVER_OPENS = Number.MAX_SAFE_INTEGER
45
+
46
+ export type AnswerLaneState = 'visible' | 'draft' | 'dormant'
47
+
48
+ export interface AnswerLaneConfig {
49
+ /** `minInitialChars` for createAnswerStream: `1` opens a visible preview on
50
+ * the first text chunk; `ANSWER_LANE_NEVER_OPENS` suppresses it. */
51
+ minInitialChars: number
52
+ /** Whether the lane streams to the invisible compose-box draft transport. */
53
+ usesDraftTransport: boolean
54
+ /** Whether a USER-VISIBLE chat-timeline preview opens — i.e. the surface that
55
+ * flashed (raw preview → formatted reply → preview deleted). This is THE
56
+ * regression invariant: it must equal `visibleEnabled`, never depend on the
57
+ * draft flag. */
58
+ opensVisiblePreview: boolean
59
+ /** Label for the boot log. */
60
+ state: AnswerLaneState
61
+ }
62
+
63
+ /**
64
+ * Resolve the answer-lane config from the two INDEPENDENT inputs.
65
+ *
66
+ * The visible PREVIEW (the flash surface) is gated on `visibleEnabled` ALONE;
67
+ * draft retirement controls only the TRANSPORT (whether `sendMessageDraft` is
68
+ * available). Conflating them was the v0.14.68 regression: retiring the draft
69
+ * (the default) forced a visible preview that flashed on every streaming turn,
70
+ * re-opening the flash v0.14.52 had removed. The load-bearing invariant —
71
+ * `opensVisiblePreview === visibleEnabled` for EVERY `draftFnAvailable` — is
72
+ * what this function exists to make total-enumerable (the gateway IIFE is not).
73
+ *
74
+ * visibleEnabled → 'visible' (preview opens, minChars 1)
75
+ * !visible, draft transport available → 'draft' (no preview; draft renders)
76
+ * !visible, no draft transport → 'dormant' (no preview, no draft:
77
+ * the reply tool is the
78
+ * only message — the default)
79
+ */
80
+ export function resolveAnswerLaneConfig(input: {
81
+ visibleEnabled: boolean
82
+ draftFnAvailable: boolean
83
+ }): AnswerLaneConfig {
84
+ if (input.visibleEnabled) {
85
+ return {
86
+ minInitialChars: 1,
87
+ usesDraftTransport: false,
88
+ opensVisiblePreview: true,
89
+ state: 'visible',
90
+ }
91
+ }
92
+ if (input.draftFnAvailable) {
93
+ return {
94
+ minInitialChars: ANSWER_LANE_NEVER_OPENS,
95
+ usesDraftTransport: true,
96
+ opensVisiblePreview: false,
97
+ state: 'draft',
98
+ }
99
+ }
100
+ return {
101
+ minInitialChars: ANSWER_LANE_NEVER_OPENS,
102
+ usesDraftTransport: false,
103
+ opensVisiblePreview: false,
104
+ state: 'dormant',
105
+ }
106
+ }
@@ -39767,6 +39767,31 @@ function parseDraftLaneRetiredEnabled(raw) {
39767
39767
  const v = raw.trim().toLowerCase();
39768
39768
  return !(v === "0" || v === "false" || v === "off" || v === "no");
39769
39769
  }
39770
+ var ANSWER_LANE_NEVER_OPENS = Number.MAX_SAFE_INTEGER;
39771
+ function resolveAnswerLaneConfig(input) {
39772
+ if (input.visibleEnabled) {
39773
+ return {
39774
+ minInitialChars: 1,
39775
+ usesDraftTransport: false,
39776
+ opensVisiblePreview: true,
39777
+ state: "visible"
39778
+ };
39779
+ }
39780
+ if (input.draftFnAvailable) {
39781
+ return {
39782
+ minInitialChars: ANSWER_LANE_NEVER_OPENS,
39783
+ usesDraftTransport: true,
39784
+ opensVisiblePreview: false,
39785
+ state: "draft"
39786
+ };
39787
+ }
39788
+ return {
39789
+ minInitialChars: ANSWER_LANE_NEVER_OPENS,
39790
+ usesDraftTransport: false,
39791
+ opensVisiblePreview: false,
39792
+ state: "dormant"
39793
+ };
39794
+ }
39770
39795
 
39771
39796
  // pty-tail.ts
39772
39797
  var import_headless = __toESM(require_xterm_headless(), 1);
@@ -52776,11 +52801,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52776
52801
  }
52777
52802
 
52778
52803
  // ../src/build-info.ts
52779
- var VERSION = "0.14.68";
52780
- var COMMIT_SHA = "4f371b79";
52781
- var COMMIT_DATE = "2026-06-05T10:24:42Z";
52782
- var LATEST_PR = 2174;
52783
- var COMMITS_AHEAD_OF_TAG = 3;
52804
+ var VERSION = "0.14.70";
52805
+ var COMMIT_SHA = "fdaeb2c4";
52806
+ var COMMIT_DATE = "2026-06-05T23:46:18+10:00";
52807
+ var LATEST_PR = null;
52808
+ var COMMITS_AHEAD_OF_TAG = 2;
52784
52809
 
52785
52810
  // gateway/boot-version.ts
52786
52811
  function formatRelativeAgo(iso) {
@@ -54026,6 +54051,7 @@ var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
54026
54051
  var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
54027
54052
  var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
54028
54053
  var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
54054
+ var FRAMEWORK_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== "0";
54029
54055
  var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
54030
54056
  var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
54031
54057
  var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
@@ -54058,15 +54084,39 @@ var progressUpdateTurnCount = new Map;
54058
54084
  var currentTurn = null;
54059
54085
  var RECENT_TURNS_MAX = 32;
54060
54086
  var recentTurnsById = new Map;
54087
+ var recentTurnIdBySourceMessageId = new Map;
54061
54088
  function rememberRecentTurn(turn) {
54062
54089
  recentTurnsById.set(turn.turnId, turn);
54090
+ if (turn.sourceMessageId != null) {
54091
+ recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId);
54092
+ }
54063
54093
  while (recentTurnsById.size > RECENT_TURNS_MAX) {
54064
54094
  const oldest = recentTurnsById.keys().next().value;
54065
54095
  if (oldest === undefined)
54066
54096
  break;
54097
+ const evicted = recentTurnsById.get(oldest);
54067
54098
  recentTurnsById.delete(oldest);
54099
+ if (evicted?.sourceMessageId != null && recentTurnIdBySourceMessageId.get(evicted.sourceMessageId) === oldest) {
54100
+ recentTurnIdBySourceMessageId.delete(evicted.sourceMessageId);
54101
+ }
54068
54102
  }
54069
54103
  }
54104
+ function findTurnByQuotedMessageId(chatId, replyTo) {
54105
+ if (!FRAMEWORK_ORIGIN_ROUTING_ENABLED)
54106
+ return null;
54107
+ if (replyTo == null)
54108
+ return null;
54109
+ const mid = Number(replyTo);
54110
+ if (!Number.isFinite(mid))
54111
+ return null;
54112
+ const owner = recentTurnIdBySourceMessageId.get(mid);
54113
+ if (owner == null)
54114
+ return null;
54115
+ const turn = recentTurnsById.get(owner) ?? null;
54116
+ if (turn == null || turn.sessionChatId !== chatId)
54117
+ return null;
54118
+ return turn;
54119
+ }
54070
54120
  function deriveTurnId(chatId, threadId, messageId) {
54071
54121
  if (messageId == null || messageId === "" || String(messageId) === "0")
54072
54122
  return null;
@@ -54088,7 +54138,7 @@ function findLatestEndedTurnForChat(chatId) {
54088
54138
  }
54089
54139
  return latest;
54090
54140
  }
54091
- function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTurn, surface) {
54141
+ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
54092
54142
  const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
54093
54143
  const threadId = resolveAnswerThreadId({
54094
54144
  explicitThreadId,
@@ -54098,14 +54148,23 @@ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTu
54098
54148
  lastEndedResolvedForChat: recovered != null,
54099
54149
  lastEndedThreadIdForChat: recovered?.sessionThreadId
54100
54150
  });
54101
- const via = explicitThreadId != null ? "explicit" : originTurn != null ? "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54151
+ const via = explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54102
54152
  const ownerTurn = originTurn ?? recovered ?? liveTurn;
54103
54153
  const isSupergroup = chatId.startsWith("-100");
54104
- const unrouted = isSupergroup && threadId == null;
54105
- 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" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + `
54154
+ const unrouted = isSupergroup && threadId == null && ownerTurn == null;
54155
+ const misrouteRisk = isSupergroup && via === "live" && hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId);
54156
+ 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
54157
  `);
54107
54158
  return threadId;
54108
54159
  }
54160
+ function hasDifferentThreadedRecentTurn(chatId, liveThreadId) {
54161
+ const live = liveThreadId ?? null;
54162
+ for (const t of recentTurnsById.values()) {
54163
+ if (t.sessionChatId === chatId && (t.sessionThreadId ?? null) !== live)
54164
+ return true;
54165
+ }
54166
+ return false;
54167
+ }
54109
54168
  function closeObligationOnSubstantiveReply(args, liveTurn) {
54110
54169
  if (!OBLIGATION_LEDGER_ENABLED)
54111
54170
  return;
@@ -55179,6 +55238,10 @@ var STREAM_THROTTLE_MS_OVERRIDE = (() => {
55179
55238
  })();
55180
55239
  var TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled();
55181
55240
  var ANSWER_STREAM_VISIBLE_ENABLED = parseVisibleAnswerStreamEnabled(process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM);
55241
+ var ANSWER_LANE = resolveAnswerLaneConfig({
55242
+ visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED,
55243
+ draftFnAvailable: sendMessageDraftFn != null
55244
+ });
55182
55245
  var CLEAR_STATUS_ON_COMPLETION = (() => {
55183
55246
  const raw = process.env.SWITCHROOM_TG_CLEAR_STATUS_ON_COMPLETION;
55184
55247
  if (raw == null)
@@ -56339,8 +56402,10 @@ ${url}`;
56339
56402
  let threadId;
56340
56403
  if (TURN_ORIGIN_ROUTING_ENABLED) {
56341
56404
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56342
- const originTurn = findTurnByOriginId(args.origin_turn_id);
56343
- threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, turn, "reply");
56405
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id);
56406
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null;
56407
+ const originTurn = echoedTurn ?? quotedTurn;
56408
+ threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "reply");
56344
56409
  } else {
56345
56410
  threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
56346
56411
  }
@@ -56699,8 +56764,10 @@ async function executeStreamReply(args) {
56699
56764
  if (args.message_thread_id == null) {
56700
56765
  let injected;
56701
56766
  if (TURN_ORIGIN_ROUTING_ENABLED) {
56702
- const originTurn = findTurnByOriginId(args.origin_turn_id);
56703
- injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, turn, "stream_reply");
56767
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id);
56768
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
56769
+ const originTurn = echoedTurn ?? quotedTurn;
56770
+ injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "stream_reply");
56704
56771
  } else {
56705
56772
  injected = turn?.sessionThreadId;
56706
56773
  }
@@ -58113,7 +58180,7 @@ function handleSessionEvent(ev) {
58113
58180
  chatId: turn.sessionChatId,
58114
58181
  isPrivateChat: turn.isDm,
58115
58182
  threadId: turn.sessionThreadId,
58116
- ...ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER },
58183
+ ...ANSWER_LANE.usesDraftTransport ? { sendMessageDraft: sendMessageDraftFn, minInitialChars: ANSWER_LANE.minInitialChars } : { minInitialChars: ANSWER_LANE.minInitialChars },
58117
58184
  sendMessage: async (chatId, text, params) => {
58118
58185
  const tid = params?.message_thread_id;
58119
58186
  const silent = params?.purpose !== "materialize";
@@ -58245,7 +58312,7 @@ function handleSessionEvent(ev) {
58245
58312
  const stream = turn.answerStream;
58246
58313
  const streamedMsgId = stream.messageId();
58247
58314
  const streamedFinalText = turn.capturedText.join("").trim();
58248
- if ((ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED) && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
58315
+ if (ANSWER_LANE.opensVisiblePreview && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
58249
58316
  turn.answerStream = null;
58250
58317
  streamFinalizedAsAnswer = true;
58251
58318
  turn.finalAnswerDelivered = true;
@@ -64581,7 +64648,7 @@ var didOneTimeSetup = false;
64581
64648
  }
64582
64649
  }
64583
64650
  }
64584
- process.stderr.write(`telegram gateway: answer-stream lane=${DRAFT_ANSWER_LANE_RETIRED ? "visible(draft-retired)" : ANSWER_STREAM_VISIBLE_ENABLED ? "visible" : "draft"} draftFn=${sendMessageDraftFn != null ? "available" : "off"} grammy=${GRAMMY_VERSION}
64651
+ process.stderr.write(`telegram gateway: answer-stream lane=${ANSWER_LANE.state} draftFn=${sendMessageDraftFn != null ? "available" : "off"} visible=${ANSWER_STREAM_VISIBLE_ENABLED} draftRetired=${DRAFT_ANSWER_LANE_RETIRED} grammy=${GRAMMY_VERSION}
64585
64652
  `);
64586
64653
  process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? "-"} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? "checklist"}
64587
64654
  `);
@@ -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
+ })