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
|
@@ -98,7 +98,7 @@ import * as pendingProgress from '../pending-work-progress.js'
|
|
|
98
98
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
99
99
|
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
100
100
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
101
|
-
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
101
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
|
|
102
102
|
import { type SessionEvent } from '../session-tail.js'
|
|
103
103
|
import {
|
|
104
104
|
shouldSuppressToolActivity,
|
|
@@ -1549,6 +1549,18 @@ const SERIALIZE_NOREPLY_DRAIN_MS =
|
|
|
1549
1549
|
// behaviour (#1664: thread from the live currentTurn capture).
|
|
1550
1550
|
const TURN_ORIGIN_ROUTING_ENABLED =
|
|
1551
1551
|
process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== '0'
|
|
1552
|
+
// Framework-owned origin recovery (2026-06-05 determinism pass). The origin
|
|
1553
|
+
// signal that survives a currentTurn flip (tier 2) travels through the MODEL
|
|
1554
|
+
// (it echoes `origin_turn_id`). When the model OMITS it, routing falls to the
|
|
1555
|
+
// live turn — the wrong topic if currentTurn flipped (HOLE a). The model rarely
|
|
1556
|
+
// passes `reply_to` explicitly, but WHEN it quotes a specific earlier message
|
|
1557
|
+
// that message_id is a FRAMEWORK-owned anchor: reverse-index it to the turn
|
|
1558
|
+
// that owns it and recover the origin without the model echo. Strictly
|
|
1559
|
+
// additive — it only fires when the echo is absent AND a quote is present, and
|
|
1560
|
+
// resolves to the ACTUAL origin turn (never the live successor), so it cannot
|
|
1561
|
+
// mask a misroute. Kill switch off (=0) → echo-only origin (today's behaviour).
|
|
1562
|
+
const FRAMEWORK_ORIGIN_ROUTING_ENABLED =
|
|
1563
|
+
process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'
|
|
1552
1564
|
// Component 4 (per-turn topic framing). Add a one-line directive to the
|
|
1553
1565
|
// channel meta + bridge instructions telling the model to answer ONLY the
|
|
1554
1566
|
// current message's topic. Kill switch off (=0) → no framing field.
|
|
@@ -1858,14 +1870,57 @@ let currentTurn: CurrentTurn | null = null
|
|
|
1858
1870
|
// a long-lived supergroup session.
|
|
1859
1871
|
const RECENT_TURNS_MAX = 32
|
|
1860
1872
|
const recentTurnsById = new Map<string, CurrentTurn>()
|
|
1873
|
+
// Framework-owned origin recovery: reverse-index from an inbound's source
|
|
1874
|
+
// message_id to the turnId that owns it, so a reply that QUOTES a specific
|
|
1875
|
+
// message (args.reply_to) resolves its origin turn deterministically — a
|
|
1876
|
+
// real message_id the framework stamped, never a model-asserted thread.
|
|
1877
|
+
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
1878
|
+
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
1861
1879
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
1862
1880
|
recentTurnsById.set(turn.turnId, turn)
|
|
1881
|
+
if (turn.sourceMessageId != null) {
|
|
1882
|
+
recentTurnIdBySourceMessageId.set(turn.sourceMessageId, turn.turnId)
|
|
1883
|
+
}
|
|
1863
1884
|
while (recentTurnsById.size > RECENT_TURNS_MAX) {
|
|
1864
1885
|
const oldest = recentTurnsById.keys().next().value
|
|
1865
1886
|
if (oldest === undefined) break
|
|
1887
|
+
const evicted = recentTurnsById.get(oldest)
|
|
1866
1888
|
recentTurnsById.delete(oldest)
|
|
1889
|
+
// Drop the reverse-index entry for the evicted turn (only when it still
|
|
1890
|
+
// points at THIS turn — a newer turn may have reused the same message id).
|
|
1891
|
+
if (evicted?.sourceMessageId != null &&
|
|
1892
|
+
recentTurnIdBySourceMessageId.get(evicted.sourceMessageId) === oldest) {
|
|
1893
|
+
recentTurnIdBySourceMessageId.delete(evicted.sourceMessageId)
|
|
1894
|
+
}
|
|
1867
1895
|
}
|
|
1868
1896
|
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Framework-owned origin recovery (kill switch SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING).
|
|
1900
|
+
* Resolve the turn that owns a reply from the message_id it QUOTES (args.reply_to),
|
|
1901
|
+
* via the source-message reverse index. Returns null when disabled, when reply_to
|
|
1902
|
+
* is absent/non-numeric, or when no turn owns that message id (evicted / unknown).
|
|
1903
|
+
* Deterministic: a real framework-stamped message_id → its turn, with no model
|
|
1904
|
+
* thread assertion and no dependence on the live currentTurn.
|
|
1905
|
+
*
|
|
1906
|
+
* SCOPED to `chatId`: Telegram numbers message ids PER CHAT, so the same numeric
|
|
1907
|
+
* id exists in a DM and a supergroup. The reverse index is keyed by id alone, so
|
|
1908
|
+
* the resolved turn MUST be confirmed to belong to this reply's chat — otherwise
|
|
1909
|
+
* a reply quoting its own id could inherit another chat's thread (a cross-chat
|
|
1910
|
+
* leak). On a chat mismatch we return null (decline to recover → fall through to
|
|
1911
|
+
* the live-turn behaviour), never a wrong-chat thread.
|
|
1912
|
+
*/
|
|
1913
|
+
function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null {
|
|
1914
|
+
if (!FRAMEWORK_ORIGIN_ROUTING_ENABLED) return null
|
|
1915
|
+
if (replyTo == null) return null
|
|
1916
|
+
const mid = Number(replyTo)
|
|
1917
|
+
if (!Number.isFinite(mid)) return null
|
|
1918
|
+
const owner = recentTurnIdBySourceMessageId.get(mid)
|
|
1919
|
+
if (owner == null) return null
|
|
1920
|
+
const turn = recentTurnsById.get(owner) ?? null
|
|
1921
|
+
if (turn == null || turn.sessionChatId !== chatId) return null
|
|
1922
|
+
return turn
|
|
1923
|
+
}
|
|
1869
1924
|
/**
|
|
1870
1925
|
* Component 3 — derive the stable per-turn identity from the chat, thread,
|
|
1871
1926
|
* and originating message id. Stamped into the inbound meta at build time
|
|
@@ -1932,13 +1987,26 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
1932
1987
|
* timestamps. This wrapper logs, per reply: which precedence tier won (`via`),
|
|
1933
1988
|
* the resolved thread, the origin turn + its thread, and whether the reply was
|
|
1934
1989
|
* late (turn already ended). `via=recovered` marks a late reply this fix saved
|
|
1935
|
-
* from General; `
|
|
1936
|
-
*
|
|
1990
|
+
* from General; `via=quoted` marks an origin recovered from the framework-owned
|
|
1991
|
+
* quoted message_id (no model echo); `UNROUTED` flags a supergroup reply that
|
|
1992
|
+
* resolved to no topic with NO owner turn to attribute it to (genuinely lost).
|
|
1993
|
+
* `MISROUTE_RISK` flags the irreducible determinism residual: a no-echo,
|
|
1994
|
+
* no-quote reply that fell to the LIVE turn while a DIFFERENT topic recently had
|
|
1995
|
+
* a turn — the framework cannot tell which topic the bare reply answers, so the
|
|
1996
|
+
* routing MIGHT be wrong (HOLE a). It is observability only (the reply still
|
|
1997
|
+
* routes to the live turn) — it makes the one case that is genuinely
|
|
1998
|
+
* model-dependent visible instead of silently mis-routed. A General-topic turn
|
|
1999
|
+
* legitimately has no thread, so its replies are NOT UNROUTED-flagged.
|
|
2000
|
+
*
|
|
2001
|
+
* `originVia` distinguishes how the origin turn was resolved: 'echo' (model
|
|
2002
|
+
* echoed origin_turn_id), 'quoted' (framework recovered it from args.reply_to),
|
|
2003
|
+
* or null (no origin turn). It only affects the `via` label, never the routing.
|
|
1937
2004
|
*/
|
|
1938
2005
|
function resolveAnswerThreadWithLog(
|
|
1939
2006
|
chatId: string,
|
|
1940
2007
|
explicitThreadId: number | undefined,
|
|
1941
2008
|
originTurn: CurrentTurn | null,
|
|
2009
|
+
originVia: 'echo' | 'quoted' | null,
|
|
1942
2010
|
liveTurn: CurrentTurn | null,
|
|
1943
2011
|
surface: 'reply' | 'stream_reply',
|
|
1944
2012
|
): number | undefined {
|
|
@@ -1965,24 +2033,60 @@ function resolveAnswerThreadWithLog(
|
|
|
1965
2033
|
})
|
|
1966
2034
|
const via =
|
|
1967
2035
|
explicitThreadId != null ? 'explicit'
|
|
1968
|
-
: originTurn != null ? 'origin'
|
|
2036
|
+
: originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
1969
2037
|
: liveTurn?.sessionThreadId != null ? 'live'
|
|
1970
2038
|
: recovered != null ? 'recovered'
|
|
1971
2039
|
: 'none'
|
|
1972
2040
|
const ownerTurn = originTurn ?? recovered ?? liveTurn
|
|
1973
2041
|
const isSupergroup = chatId.startsWith('-100')
|
|
1974
|
-
|
|
2042
|
+
// UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
|
|
2043
|
+
// to attribute it to (genuinely lost). A General-topic turn legitimately has
|
|
2044
|
+
// no thread, so a reply owned by it resolving to `-` is CORRECT, not lost —
|
|
2045
|
+
// gate on `ownerTurn == null` so General replies don't false-alarm (found by
|
|
2046
|
+
// the multi-topic UAT stress, 2026-06-05).
|
|
2047
|
+
const unrouted = isSupergroup && threadId == null && ownerTurn == null
|
|
2048
|
+
// MISROUTE_RISK = the irreducible determinism residual (HOLE a). A no-echo,
|
|
2049
|
+
// no-quote reply fell to the LIVE turn (via=live), but a DIFFERENT topic
|
|
2050
|
+
// recently had a turn for this chat — so this bare reply MIGHT belong to that
|
|
2051
|
+
// other topic and we cannot tell without the model's echo. Observability only;
|
|
2052
|
+
// routing is unchanged. This is the one case framework state cannot
|
|
2053
|
+
// disambiguate, surfaced instead of silently mis-routed.
|
|
2054
|
+
const misrouteRisk =
|
|
2055
|
+
isSupergroup &&
|
|
2056
|
+
via === 'live' &&
|
|
2057
|
+
hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId)
|
|
1975
2058
|
process.stderr.write(
|
|
1976
2059
|
`telegram gateway: reply-route surface=${surface} chat=${chatId} ` +
|
|
1977
2060
|
`resolved_thread=${threadId ?? '-'} via=${via} late=${liveTurn == null} ` +
|
|
1978
2061
|
`originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
|
|
1979
2062
|
(via === 'recovered' ? ' RECOVERED' : '') +
|
|
2063
|
+
(via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
|
|
1980
2064
|
(unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
|
|
2065
|
+
(misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
|
|
1981
2066
|
'\n',
|
|
1982
2067
|
)
|
|
1983
2068
|
return threadId
|
|
1984
2069
|
}
|
|
1985
2070
|
|
|
2071
|
+
/**
|
|
2072
|
+
* Determinism-residual detector (HOLE a observability). True when a DIFFERENT
|
|
2073
|
+
* forum topic recently had a turn for this chat than the live turn's thread —
|
|
2074
|
+
* i.e. a currentTurn flip plausibly happened, so a no-echo / no-quote reply
|
|
2075
|
+
* routed to the live turn MIGHT belong to the other topic. Scans the bounded
|
|
2076
|
+
* recently-ended registry; cheap (≤32 entries). Used only to ALARM, never to
|
|
2077
|
+
* route.
|
|
2078
|
+
*/
|
|
2079
|
+
function hasDifferentThreadedRecentTurn(
|
|
2080
|
+
chatId: string,
|
|
2081
|
+
liveThreadId: number | undefined,
|
|
2082
|
+
): boolean {
|
|
2083
|
+
const live = liveThreadId ?? null
|
|
2084
|
+
for (const t of recentTurnsById.values()) {
|
|
2085
|
+
if (t.sessionChatId === chatId && (t.sessionThreadId ?? null) !== live) return true
|
|
2086
|
+
}
|
|
2087
|
+
return false
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1986
2090
|
/**
|
|
1987
2091
|
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
1988
2092
|
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
@@ -4485,6 +4589,16 @@ const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
|
|
|
4485
4589
|
const ANSWER_STREAM_VISIBLE_ENABLED = parseVisibleAnswerStreamEnabled(
|
|
4486
4590
|
process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM,
|
|
4487
4591
|
)
|
|
4592
|
+
// Single source of truth for the answer-lane behaviour (flash-decouple,
|
|
4593
|
+
// 2026-06-05). The visible preview gates on the visible flag ALONE; the draft
|
|
4594
|
+
// flag controls only the transport. Resolved here once and consulted at the
|
|
4595
|
+
// createAnswerStream config, the materialize-as-answer guard, and the boot log,
|
|
4596
|
+
// so all three can never drift back into the `visible || retired` conflation
|
|
4597
|
+
// that re-opened the flash. Total-enumerated in answer-stream-flag.test.ts.
|
|
4598
|
+
const ANSWER_LANE = resolveAnswerLaneConfig({
|
|
4599
|
+
visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED,
|
|
4600
|
+
draftFnAvailable: sendMessageDraftFn != null,
|
|
4601
|
+
})
|
|
4488
4602
|
|
|
4489
4603
|
// Whether to DELETE the activity/status feed when the final answer lands.
|
|
4490
4604
|
// Default OFF (2026-06-04, operator request): the status message stays in the
|
|
@@ -6631,11 +6745,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6631
6745
|
let threadId: number | undefined
|
|
6632
6746
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
6633
6747
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
6634
|
-
|
|
6748
|
+
// Origin precedence: model echo first (authoritative), then the
|
|
6749
|
+
// framework-owned quoted message_id (deterministic, no model thread
|
|
6750
|
+
// assertion) as a fallback when the model omitted the echo.
|
|
6751
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
6752
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
|
|
6753
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
6635
6754
|
threadId = resolveAnswerThreadWithLog(
|
|
6636
6755
|
chat_id,
|
|
6637
6756
|
Number.isFinite(explicit as number) ? (explicit as number) : undefined,
|
|
6638
6757
|
originTurn,
|
|
6758
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
6639
6759
|
turn,
|
|
6640
6760
|
'reply',
|
|
6641
6761
|
)
|
|
@@ -7288,11 +7408,17 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7288
7408
|
if (args.message_thread_id == null) {
|
|
7289
7409
|
let injected: number | undefined
|
|
7290
7410
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7291
|
-
|
|
7411
|
+
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7412
|
+
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7413
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7414
|
+
const quotedTurn =
|
|
7415
|
+
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7416
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
7292
7417
|
injected = resolveAnswerThreadWithLog(
|
|
7293
7418
|
String(args.chat_id),
|
|
7294
7419
|
undefined,
|
|
7295
7420
|
originTurn,
|
|
7421
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
7296
7422
|
turn,
|
|
7297
7423
|
'stream_reply',
|
|
7298
7424
|
)
|
|
@@ -9557,15 +9683,29 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9557
9683
|
// General). With the gate unreachable the only posted message is
|
|
9558
9684
|
// the canonical reply. (The gate is bypassed for DM draft
|
|
9559
9685
|
// transport, so DM draft streaming is unaffected.)
|
|
9560
|
-
//
|
|
9561
|
-
//
|
|
9562
|
-
//
|
|
9563
|
-
//
|
|
9564
|
-
//
|
|
9565
|
-
//
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9686
|
+
// VISIBLE preview gating decoupled from the draft-transport flag
|
|
9687
|
+
// (2026-06-05 flash regression fix). The visible flag ALONE decides
|
|
9688
|
+
// whether a user-visible preview opens; DRAFT_ANSWER_LANE_RETIRED
|
|
9689
|
+
// controls only the TRANSPORT (whether sendMessageDraftFn exists).
|
|
9690
|
+
// The earlier `|| DRAFT_ANSWER_LANE_RETIRED` here meant retiring the
|
|
9691
|
+
// draft (the default since v0.14.68) silently forced minInitialChars:1
|
|
9692
|
+
// → a visible preliminary opened on every streaming turn and was then
|
|
9693
|
+
// retracted (deleted) when the reply tool fired — the exact "raw bubble
|
|
9694
|
+
// appears, formatted reply lands, raw bubble vanishes" flash that
|
|
9695
|
+
// turning the visible stream OFF (v0.14.52) was meant to remove. So
|
|
9696
|
+
// v0.14.68 silently undid v0.14.52 fleet-wide. Now:
|
|
9697
|
+
// - VISIBLE on (opt-in) → minInitialChars:1, a real edit-in-place
|
|
9698
|
+
// preview (observable by UAT, silence-liveness reset on its sends).
|
|
9699
|
+
// - VISIBLE off (default) → minInitialChars:MAX so NO visible preview
|
|
9700
|
+
// ever opens; the reply tool is the single canonical formatted
|
|
9701
|
+
// message (no flash). With the draft retired (default) there is no
|
|
9702
|
+
// transport either, so the lane stays dormant; with the kill switch
|
|
9703
|
+
// DRAFT_ANSWER_LANE=0 the legacy compose-box draft transport is
|
|
9704
|
+
// restored (sendMessageDraftFn defined above, gate bypassed for DM
|
|
9705
|
+
// draft so #1664 DM draft streaming is unaffected).
|
|
9706
|
+
...(ANSWER_LANE.usesDraftTransport
|
|
9707
|
+
? { sendMessageDraft: sendMessageDraftFn, minInitialChars: ANSWER_LANE.minInitialChars }
|
|
9708
|
+
: { minInitialChars: ANSWER_LANE.minInitialChars }),
|
|
9569
9709
|
// #1075: route through robustApiCall so flood-wait,
|
|
9570
9710
|
// benign-400, and THREAD_NOT_FOUND are handled uniformly
|
|
9571
9711
|
// instead of crashing the answer-stream loop on a deleted
|
|
@@ -9853,13 +9993,18 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9853
9993
|
const streamedMsgId = stream.messageId()
|
|
9854
9994
|
const streamedFinalText = turn.capturedText.join('').trim()
|
|
9855
9995
|
if (
|
|
9856
|
-
//
|
|
9857
|
-
//
|
|
9858
|
-
// delete the preview
|
|
9859
|
-
//
|
|
9860
|
-
//
|
|
9861
|
-
//
|
|
9862
|
-
(
|
|
9996
|
+
// Only when a VISIBLE preview actually opened (visible flag on): a
|
|
9997
|
+
// text-only no-reply turn that streamed a visible preview must
|
|
9998
|
+
// materialize a pinged final answer + delete the preview, NOT fall into
|
|
9999
|
+
// the else-branch retract() which would delete the user's only copy of
|
|
10000
|
+
// the answer (a lost-answer bug). Gated on the visible flag alone (the
|
|
10001
|
+
// flash-regression decoupling): with the visible stream OFF (default)
|
|
10002
|
+
// no preview opens (minInitialChars:MAX), so streamedMsgId is null and
|
|
10003
|
+
// this branch is unreachable — the no-reply answer is delivered by the
|
|
10004
|
+
// turn-flush backstop below instead, the pre-v0.14.68 path. The
|
|
10005
|
+
// reply-tool branch hits retract() on a non-opened lane (a no-op), so
|
|
10006
|
+
// there is no preliminary to flash.
|
|
10007
|
+
ANSWER_LANE.opensVisiblePreview
|
|
9863
10008
|
&& !turn.replyCalled
|
|
9864
10009
|
&& streamedMsgId != null
|
|
9865
10010
|
&& streamedFinalText.length > 0
|
|
@@ -20589,7 +20734,13 @@ void (async () => {
|
|
|
20589
20734
|
}
|
|
20590
20735
|
}
|
|
20591
20736
|
|
|
20592
|
-
|
|
20737
|
+
// Lane state (post flash-decouple): VISIBLE only when the visible flag is
|
|
20738
|
+
// Lane state from the single-source-of-truth resolver: 'visible' (preview
|
|
20739
|
+
// on), 'draft' (compose-box transport), or 'dormant' (the default: no
|
|
20740
|
+
// preview, no draft — reply tool is the only message). The old label
|
|
20741
|
+
// wrongly reported 'visible(draft-retired)' for the dormant default, which
|
|
20742
|
+
// masked the flash regression.
|
|
20743
|
+
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}\n`)
|
|
20593
20744
|
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'}\n`)
|
|
20594
20745
|
runnerHandle = run(bot, {
|
|
20595
20746
|
runner: {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect } from 'vitest'
|
|
9
|
-
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
9
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
|
|
10
10
|
|
|
11
11
|
describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
12
12
|
it('defaults OFF when unset', () => {
|
|
@@ -43,3 +43,75 @@ describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-sw
|
|
|
43
43
|
}
|
|
44
44
|
})
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
// ── resolveAnswerLaneConfig — TOTAL-ENUMERATION REGRESSION PROOF ─────────────
|
|
48
|
+
//
|
|
49
|
+
// This is the behavioural guard for the flash regression (the gateway IIFE is
|
|
50
|
+
// not importable, so the decision lives in this pure function and the gateway
|
|
51
|
+
// delegates to it). The input space is finite — visibleEnabled × draftFnAvailable
|
|
52
|
+
// = 4 — so we enumerate ALL of it and assert the full decision table plus the
|
|
53
|
+
// load-bearing INVARIANT: opensVisiblePreview === visibleEnabled, ALWAYS. That
|
|
54
|
+
// invariant is exactly what v0.14.68 broke (it made the preview depend on the
|
|
55
|
+
// draft flag), so a future change that re-conflates them fails here, not in prod.
|
|
56
|
+
describe('resolveAnswerLaneConfig — total enumeration (flash-regression proof)', () => {
|
|
57
|
+
const MAX = Number.MAX_SAFE_INTEGER
|
|
58
|
+
const ALL = [
|
|
59
|
+
{ visibleEnabled: false, draftFnAvailable: false }, // the DEFAULT (visible off, draft retired)
|
|
60
|
+
{ visibleEnabled: false, draftFnAvailable: true }, // draft kill switch on
|
|
61
|
+
{ visibleEnabled: true, draftFnAvailable: false }, // opt-in visible
|
|
62
|
+
{ visibleEnabled: true, draftFnAvailable: true }, // visible wins over draft
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
it('the input space is exactly 4 rows (2×2)', () => {
|
|
66
|
+
expect(ALL.length).toBe(4)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('INVARIANT (the regression guard): opensVisiblePreview === visibleEnabled for EVERY draftFnAvailable', () => {
|
|
70
|
+
for (const input of ALL) {
|
|
71
|
+
expect(resolveAnswerLaneConfig(input).opensVisiblePreview).toBe(input.visibleEnabled)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('TOTAL: every input returns a defined config and never throws', () => {
|
|
76
|
+
for (const input of ALL) {
|
|
77
|
+
expect(() => resolveAnswerLaneConfig(input)).not.toThrow()
|
|
78
|
+
expect(resolveAnswerLaneConfig(input).state).toBeDefined()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('DEFAULT (visible off, draft retired) → DORMANT: no preview, no draft, MAX gate (no flash)', () => {
|
|
83
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false })).toEqual({
|
|
84
|
+
minInitialChars: MAX,
|
|
85
|
+
usesDraftTransport: false,
|
|
86
|
+
opensVisiblePreview: false,
|
|
87
|
+
state: 'dormant',
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('visible off + draft transport available → DRAFT: no visible preview, draft renders', () => {
|
|
92
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: true })).toEqual({
|
|
93
|
+
minInitialChars: MAX,
|
|
94
|
+
usesDraftTransport: true,
|
|
95
|
+
opensVisiblePreview: false,
|
|
96
|
+
state: 'draft',
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('visible on → VISIBLE: preview opens on the first chunk (minChars 1), no draft', () => {
|
|
101
|
+
for (const draftFnAvailable of [false, true]) {
|
|
102
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: true, draftFnAvailable })).toEqual({
|
|
103
|
+
minInitialChars: 1,
|
|
104
|
+
usesDraftTransport: false,
|
|
105
|
+
opensVisiblePreview: true,
|
|
106
|
+
state: 'visible',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('a visible preview NEVER opens unless explicitly enabled (no draftFnAvailable forces it on)', () => {
|
|
112
|
+
// The exact v0.14.68 failure shape: retiring the draft (draftFnAvailable=false)
|
|
113
|
+
// must NOT open a visible preview.
|
|
114
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).opensVisiblePreview).toBe(false)
|
|
115
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).minInitialChars).toBe(MAX)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DRAFT_METHOD_UNAVAILABLE_RE,
|
|
7
7
|
DRAFT_CHAT_UNSUPPORTED_RE,
|
|
8
8
|
} from '../answer-stream.js'
|
|
9
|
+
import { resolveAnswerLaneConfig, ANSWER_LANE_NEVER_OPENS } from '../answer-stream-flag.js'
|
|
9
10
|
|
|
10
11
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -96,6 +97,35 @@ describe('answer-stream — minInitialChars threshold', () => {
|
|
|
96
97
|
expect(editMessageText).not.toHaveBeenCalled()
|
|
97
98
|
})
|
|
98
99
|
|
|
100
|
+
it('FLASH REGRESSION: the DORMANT config (visible off, draft retired) sends NOTHING, even for a full answer', async () => {
|
|
101
|
+
// End-to-end proof that the resolved default config produces no visible
|
|
102
|
+
// preview → nothing to retract → no flash. Wires the ACTUAL resolver output
|
|
103
|
+
// (not a hand-picked threshold) into the stream.
|
|
104
|
+
const lane = resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false })
|
|
105
|
+
expect(lane.state).toBe('dormant')
|
|
106
|
+
expect(lane.minInitialChars).toBe(ANSWER_LANE_NEVER_OPENS)
|
|
107
|
+
|
|
108
|
+
const sendMessage = makeSendMessage()
|
|
109
|
+
const editMessageText = makeEditMessageText()
|
|
110
|
+
const stream = createAnswerStream({
|
|
111
|
+
chatId: 'chat1',
|
|
112
|
+
isPrivateChat: false,
|
|
113
|
+
minInitialChars: lane.minInitialChars,
|
|
114
|
+
throttleMs: 250,
|
|
115
|
+
sendMessage,
|
|
116
|
+
editMessageText,
|
|
117
|
+
// no sendMessageDraft — dormant lane has no transport
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// A realistic full answer — 2000 chars, far above any normal threshold.
|
|
121
|
+
stream.update('The answer is '.repeat(150))
|
|
122
|
+
vi.advanceTimersByTime(5000)
|
|
123
|
+
await flushMicrotasks()
|
|
124
|
+
|
|
125
|
+
expect(sendMessage).not.toHaveBeenCalled()
|
|
126
|
+
expect(editMessageText).not.toHaveBeenCalled()
|
|
127
|
+
})
|
|
128
|
+
|
|
99
129
|
it('calls transport exactly once when text meets minInitialChars', async () => {
|
|
100
130
|
const sendMessage = makeSendMessage()
|
|
101
131
|
const editMessageText = makeEditMessageText()
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Answer-lane wiring guards — draft retirement + flash decoupling.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* History:
|
|
5
|
+
* - v0.14.52 turned the VISIBLE answer-stream OFF by default to remove the
|
|
6
|
+
* "raw preview appears, formatted reply lands, raw preview deleted" flash.
|
|
7
|
+
* - v0.14.68 retired the invisible compose-box DRAFT transport. The original
|
|
8
|
+
* wiring (and the first version of this test) conflated the two flags —
|
|
9
|
+
* `ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED` — so retiring
|
|
10
|
+
* the draft (default) silently forced a VISIBLE preview (minInitialChars:1)
|
|
11
|
+
* even with the visible flag off, re-introducing the flash FLEET-WIDE
|
|
12
|
+
* (v0.14.68 undid v0.14.52). The first revision of this file pinned that
|
|
13
|
+
* conflation as a "guard" — i.e. it asserted the bug.
|
|
14
|
+
* - 2026-06-05 flash-decouple: the VISIBLE preview now gates on the visible
|
|
15
|
+
* flag ALONE; DRAFT_ANSWER_LANE_RETIRED controls only the TRANSPORT. With
|
|
16
|
+
* defaults (visible off, draft retired) the lane is DORMANT — no preview, the
|
|
17
|
+
* reply tool is the single formatted message, no flash. The no-reply text-only
|
|
18
|
+
* answer is delivered by the turn-flush backstop (the pre-v0.14.68 path).
|
|
9
19
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* answer-lane status AND the #2169 onMetric silence-liveness reset.
|
|
13
|
-
* SECONDARY: flip the lane to visible but FORGET to broaden the
|
|
14
|
-
* materialize-as-answer guard → a text-only no-reply turn falls into retract()
|
|
15
|
-
* and deletes the user's only copy of the answer (a lost-answer bug).
|
|
20
|
+
* gateway IIFE can't be instantiated in-process, so these are source-level
|
|
21
|
+
* assertions (same pattern as silence-liveness-wiring.test).
|
|
16
22
|
*/
|
|
17
23
|
import { describe, it, expect } from 'vitest'
|
|
18
24
|
import { readFileSync } from 'node:fs'
|
|
@@ -20,8 +26,8 @@ import { resolve } from 'node:path'
|
|
|
20
26
|
|
|
21
27
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
22
28
|
|
|
23
|
-
describe('draft
|
|
24
|
-
it('sendMessageDraftFn is gated on the retirement (the single chokepoint)', () => {
|
|
29
|
+
describe('answer-lane wiring (draft retirement + flash decoupling)', () => {
|
|
30
|
+
it('sendMessageDraftFn is gated on the retirement (the single transport chokepoint)', () => {
|
|
25
31
|
expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
|
|
26
32
|
})
|
|
27
33
|
|
|
@@ -32,19 +38,43 @@ describe('draft-retirement wiring', () => {
|
|
|
32
38
|
expect(firstUseIdx).toBeGreaterThan(declIdx)
|
|
33
39
|
})
|
|
34
40
|
|
|
35
|
-
it('
|
|
36
|
-
// The config
|
|
37
|
-
//
|
|
38
|
-
|
|
41
|
+
it('the lane behaviour is resolved by the single-source-of-truth pure function', () => {
|
|
42
|
+
// The flag→config decision lives in resolveAnswerLaneConfig (total-enumerated
|
|
43
|
+
// in answer-stream-flag.test.ts); the gateway delegates so the three use-sites
|
|
44
|
+
// can never drift apart.
|
|
45
|
+
expect(gatewaySrc).toMatch(/const ANSWER_LANE = resolveAnswerLaneConfig\(\{/)
|
|
46
|
+
expect(gatewaySrc).toMatch(/visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED/)
|
|
47
|
+
expect(gatewaySrc).toMatch(/draftFnAvailable: sendMessageDraftFn != null/)
|
|
39
48
|
})
|
|
40
49
|
|
|
41
|
-
it('
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
expect(gatewaySrc).toMatch(
|
|
50
|
+
it('FLASH GUARD: the createAnswerStream config is driven by ANSWER_LANE (preview gated on the visible flag alone)', () => {
|
|
51
|
+
// The minInitialChars / draft-transport choice comes from the resolved lane,
|
|
52
|
+
// never from a `visible || retired` inline conflation.
|
|
53
|
+
expect(gatewaySrc).toMatch(/\.\.\.\(ANSWER_LANE\.usesDraftTransport/)
|
|
54
|
+
expect(gatewaySrc).toMatch(/minInitialChars: ANSWER_LANE\.minInitialChars/)
|
|
45
55
|
})
|
|
46
56
|
|
|
47
|
-
it('
|
|
57
|
+
it('FLASH GUARD: the visible-OR-retired conflation is GONE everywhere (it pinned the flash)', () => {
|
|
58
|
+
// No answer-lane path may re-introduce `VISIBLE || RETIRED` — that is the
|
|
59
|
+
// exact regression this fix removes.
|
|
60
|
+
expect(gatewaySrc).not.toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('LOST-ANSWER GUARD: materialize-as-answer gates on ANSWER_LANE.opensVisiblePreview (only fires when a preview actually opened)', () => {
|
|
64
|
+
// A text-only no-reply turn materializes (ping + keep) ONLY when a visible
|
|
65
|
+
// preview opened. With visible off the lane is dormant (minInitialChars:MAX),
|
|
66
|
+
// so this branch is unreachable and the answer is delivered by turn-flush
|
|
67
|
+
// instead — never retracted away.
|
|
68
|
+
expect(gatewaySrc).toMatch(/\n\s*ANSWER_LANE\.opensVisiblePreview\s*\n\s*&& !turn\.replyCalled/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('LOST-ANSWER GUARD: turn-flush is skipped ONLY when the stream finalized as the answer (so dormant-lane no-reply turns still flush)', () => {
|
|
72
|
+
// flushDecision skips only on streamFinalizedAsAnswer; with the lane dormant
|
|
73
|
+
// that stays false, so decideTurnFlush runs and delivers the no-reply answer.
|
|
74
|
+
expect(gatewaySrc).toMatch(/const flushDecision = streamFinalizedAsAnswer\s*\n?\s*\?/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('the #2169 onMetric silence-liveness reset is still wired (fires on visible sends when opted in)', () => {
|
|
48
78
|
const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
|
|
49
79
|
expect(onMetric).toMatch(/silencePoke\.noteProduction/)
|
|
50
80
|
expect(onMetric).toMatch(/currentTurn === turn/)
|
|
@@ -67,6 +67,51 @@ describe('component 3 — turn-origin reply routing', () => {
|
|
|
67
67
|
})
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
describe('framework-owned origin recovery (determinism residual, 2026-06-05)', () => {
|
|
71
|
+
it('a source-message reverse index is populated at enqueue and EVICTED in parity with recentTurnsById', () => {
|
|
72
|
+
expect(gatewaySrc).toMatch(/const recentTurnIdBySourceMessageId = new Map<number, string>\(\)/)
|
|
73
|
+
// Populated inside rememberRecentTurn from the turn's sourceMessageId.
|
|
74
|
+
const fn = gatewaySrc.split('function rememberRecentTurn')[1]?.split('\nfunction ')[0] ?? ''
|
|
75
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.set\(turn\.sourceMessageId, turn\.turnId\)/)
|
|
76
|
+
// Eviction parity: the reverse entry is dropped when its turn is evicted —
|
|
77
|
+
// so the index cannot outgrow the bounded RECENT_TURNS_MAX registry.
|
|
78
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.delete\(evicted\.sourceMessageId\)/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('both reply paths recover origin from the quoted message_id when the model omits the echo', () => {
|
|
82
|
+
for (const name of ['executeReply', 'executeStreamReply']) {
|
|
83
|
+
const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
|
|
84
|
+
// Echo first (authoritative), quoted message_id as the framework fallback.
|
|
85
|
+
expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
|
|
86
|
+
// Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
|
|
87
|
+
expect(fn).toMatch(/findTurnByQuotedMessageId\([^,]+, args\.reply_to\)/)
|
|
88
|
+
expect(fn).toMatch(/echoedTurn \?\? quotedTurn/)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('findTurnByQuotedMessageId is gated on the kill switch and resolves a real turn (never the live successor)', () => {
|
|
93
|
+
const fn = gatewaySrc.split('function findTurnByQuotedMessageId')[1]?.split('\nfunction ')[0] ?? ''
|
|
94
|
+
expect(fn).toMatch(/FRAMEWORK_ORIGIN_ROUTING_ENABLED/)
|
|
95
|
+
expect(fn).toMatch(/recentTurnIdBySourceMessageId\.get\(mid\)/)
|
|
96
|
+
expect(fn).toMatch(/recentTurnsById\.get\(owner\)/)
|
|
97
|
+
// Cross-chat collision guard: the resolved turn must belong to this chat.
|
|
98
|
+
expect(fn).toMatch(/turn\.sessionChatId !== chatId/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('the irreducible no-echo residual is ALARMED (MISROUTE_RISK), never silently mis-routed', () => {
|
|
102
|
+
expect(gatewaySrc).toMatch(/MISROUTE_RISK\(no-echo→live-successor\)/)
|
|
103
|
+
expect(gatewaySrc).toMatch(/function hasDifferentThreadedRecentTurn/)
|
|
104
|
+
// The alarm is observability-only: it fires on via=live with a different
|
|
105
|
+
// recent topic, and does NOT change the resolved thread.
|
|
106
|
+
expect(gatewaySrc).toMatch(/const misrouteRisk =/)
|
|
107
|
+
expect(gatewaySrc).toMatch(/via === 'quoted' \? ' QUOTED\(framework-origin\)' : ''/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('the kill switch defaults ON and is independent of TURN_ORIGIN_ROUTING', () => {
|
|
111
|
+
expect(gatewaySrc).toMatch(/SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'/)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
70
115
|
describe('component 4 — per-turn topic framing', () => {
|
|
71
116
|
it('the gateway stamps a topic_scope directive for forum-topic inbounds (kill-switched)', () => {
|
|
72
117
|
expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
|