switchroom 0.14.69 → 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.69";
49605
- var COMMIT_SHA = "a3def2a8";
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.69",
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.69";
52780
- var COMMIT_SHA = "a3def2a8";
52781
- var COMMIT_DATE = "2026-06-05T22:00:53+10:00";
52804
+ var VERSION = "0.14.70";
52805
+ var COMMIT_SHA = "fdaeb2c4";
52806
+ var COMMIT_DATE = "2026-06-05T23:46:18+10:00";
52782
52807
  var LATEST_PR = null;
52783
- var COMMITS_AHEAD_OF_TAG = 3;
52808
+ var COMMITS_AHEAD_OF_TAG = 2;
52784
52809
 
52785
52810
  // gateway/boot-version.ts
52786
52811
  function formatRelativeAgo(iso) {
@@ -55213,6 +55238,10 @@ var STREAM_THROTTLE_MS_OVERRIDE = (() => {
55213
55238
  })();
55214
55239
  var TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled();
55215
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
+ });
55216
55245
  var CLEAR_STATUS_ON_COMPLETION = (() => {
55217
55246
  const raw = process.env.SWITCHROOM_TG_CLEAR_STATUS_ON_COMPLETION;
55218
55247
  if (raw == null)
@@ -58151,7 +58180,7 @@ function handleSessionEvent(ev) {
58151
58180
  chatId: turn.sessionChatId,
58152
58181
  isPrivateChat: turn.isDm,
58153
58182
  threadId: turn.sessionThreadId,
58154
- ...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 },
58155
58184
  sendMessage: async (chatId, text, params) => {
58156
58185
  const tid = params?.message_thread_id;
58157
58186
  const silent = params?.purpose !== "materialize";
@@ -58283,7 +58312,7 @@ function handleSessionEvent(ev) {
58283
58312
  const stream = turn.answerStream;
58284
58313
  const streamedMsgId = stream.messageId();
58285
58314
  const streamedFinalText = turn.capturedText.join("").trim();
58286
- 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) {
58287
58316
  turn.answerStream = null;
58288
58317
  streamFinalizedAsAnswer = true;
58289
58318
  turn.finalAnswerDelivered = true;
@@ -64619,7 +64648,7 @@ var didOneTimeSetup = false;
64619
64648
  }
64620
64649
  }
64621
64650
  }
64622
- 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}
64623
64652
  `);
64624
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"}
64625
64654
  `);
@@ -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,
@@ -4589,6 +4589,16 @@ const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
4589
4589
  const ANSWER_STREAM_VISIBLE_ENABLED = parseVisibleAnswerStreamEnabled(
4590
4590
  process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM,
4591
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
+ })
4592
4602
 
4593
4603
  // Whether to DELETE the activity/status feed when the final answer lands.
4594
4604
  // Default OFF (2026-06-04, operator request): the status message stays in the
@@ -9673,15 +9683,29 @@ function handleSessionEvent(ev: SessionEvent): void {
9673
9683
  // General). With the gate unreachable the only posted message is
9674
9684
  // the canonical reply. (The gate is bypassed for DM draft
9675
9685
  // transport, so DM draft streaming is unaffected.)
9676
- // Draft retired (default) OR visible explicitly on → a real
9677
- // edit-in-place message (minInitialChars:1, no draft): observable by
9678
- // the UAT and the onMetric silence-liveness reset fires on visible
9679
- // sends in DMs AND supergroups. Legacy draft only when the kill
9680
- // switch re-enables it (DRAFT_ANSWER_LANE_RETIRED=false), which also
9681
- // restores sendMessageDraftFn above.
9682
- ...(ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED
9683
- ? { minInitialChars: 1 }
9684
- : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER }),
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 }),
9685
9709
  // #1075: route through robustApiCall so flood-wait,
9686
9710
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
9687
9711
  // instead of crashing the answer-stream loop on a deleted
@@ -9969,13 +9993,18 @@ function handleSessionEvent(ev: SessionEvent): void {
9969
9993
  const streamedMsgId = stream.messageId()
9970
9994
  const streamedFinalText = turn.capturedText.join('').trim()
9971
9995
  if (
9972
- // Broadened for draft retirement: a text-only no-reply turn that
9973
- // streamed a VISIBLE preview must materialize a pinged final answer +
9974
- // delete the preview. Without this, the retired-default path would
9975
- // fall into the else-branch retract() and delete the user's only copy
9976
- // of the answer (a lost-answer bug). The reply-tool branch still hits
9977
- // retract() single canonical formatted reply, no flash.
9978
- (ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED)
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
9979
10008
  && !turn.replyCalled
9980
10009
  && streamedMsgId != null
9981
10010
  && streamedFinalText.length > 0
@@ -20705,7 +20734,13 @@ void (async () => {
20705
20734
  }
20706
20735
  }
20707
20736
 
20708
- 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}\n`)
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`)
20709
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`)
20710
20745
  runnerHandle = run(bot, {
20711
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
- * Draft-answer-lane retirementgateway wiring guards (2026-06-05).
2
+ * Answer-lane wiring guards draft retirement + flash decoupling.
3
3
  *
4
- * The retirement switches the live answer lane from the invisible compose-box
5
- * draft to a real, mtcute-observable edit-in-place message, default-on. The
6
- * design review flagged two ways this silently breaks (gateway IIFE can't be
7
- * instantiated in-process, so these are source-level assertions, same pattern as
8
- * silence-liveness-wiring.test):
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
- * PRIMARY: drop sendMessageDraftFn but FORGET to flip minInitialChars to 1
11
- * the lane becomes a total no-op (the MAX gate never opens it), losing ALL
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-retirement wiring', () => {
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('PRIMARY GUARD: retired lane uses minInitialChars:1 (visible), never the MAX no-op gate', () => {
36
- // The config must pick the {minInitialChars:1} branch when retired, so the
37
- // lane actually opens a real message. The MAX branch is draft-only (legacy).
38
- expect(gatewaySrc).toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\s*\n?\s*\?\s*\{ minInitialChars: 1 \}/)
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('SECONDARY GUARD: the materialize-as-answer guard is broadened in lockstep', () => {
42
- // A text-only no-reply turn must materialize (ping + delete preview), not
43
- // retract() the answer away.
44
- expect(gatewaySrc).toMatch(/\(ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\)\s*\n?\s*&& !turn\.replyCalled/)
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('the #2169 onMetric silence-liveness reset is preserved (fires on visible sends now)', () => {
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/)