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.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/answer-stream-flag.ts +69 -0
- package/telegram-plugin/dist/gateway/gateway.js +83 -16
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +135 -1
- package/telegram-plugin/gateway/gateway.ts +175 -24
- package/telegram-plugin/tests/answer-stream-flag.test.ts +73 -1
- package/telegram-plugin/tests/answer-stream.test.ts +30 -0
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +53 -23
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +45 -0
- package/telegram-plugin/uat/scenarios/fuzz-cross-surface-ordering-channel.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/fuzz-multitopic-routing-channel.test.ts +184 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49601,8 +49601,8 @@ var {
|
|
|
49601
49601
|
} = import__.default;
|
|
49602
49602
|
|
|
49603
49603
|
// src/build-info.ts
|
|
49604
|
-
var VERSION = "0.14.
|
|
49605
|
-
var COMMIT_SHA = "
|
|
49604
|
+
var VERSION = "0.14.70";
|
|
49605
|
+
var COMMIT_SHA = "fdaeb2c4";
|
|
49606
49606
|
|
|
49607
49607
|
// src/cli/agent.ts
|
|
49608
49608
|
init_source();
|
package/package.json
CHANGED
|
@@ -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.
|
|
52780
|
-
var COMMIT_SHA = "
|
|
52781
|
-
var COMMIT_DATE = "2026-06-
|
|
52782
|
-
var LATEST_PR =
|
|
52783
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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
|
-
|
|
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
|
|
56343
|
-
|
|
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
|
|
56703
|
-
|
|
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
|
-
...
|
|
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 (
|
|
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=${
|
|
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
|
+
})
|