switchroom 0.19.3 → 0.19.5

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.
Files changed (32) hide show
  1. package/dist/auth-broker/index.js +104 -11
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +524 -293
  8. package/telegram-plugin/gateway/command-format.ts +253 -0
  9. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  10. package/telegram-plugin/gateway/gateway.ts +97 -255
  11. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  12. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  13. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  14. package/telegram-plugin/gateway/stream-render.ts +18 -1
  15. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  16. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  17. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  18. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  19. package/telegram-plugin/render/line-start-guard.ts +76 -4
  20. package/telegram-plugin/rich-send.ts +8 -1
  21. package/telegram-plugin/tests/command-format.test.ts +212 -0
  22. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  23. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  24. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  25. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  26. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  27. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  29. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  30. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  31. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  32. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -55,6 +55,9 @@
55
55
  // (`done:true`) call as the qualifying one regardless of how many
56
56
  // intermediate non-final `stream_reply` calls preceded it. No gap found;
57
57
  // re-verify only if a new outbound-delivery tool is added to bridge.ts.
58
+ import { statSync } from 'node:fs'
59
+ import { join } from 'node:path'
60
+
58
61
  const REPLY_TOOLS = new Set([
59
62
  'mcp__switchroom-telegram__reply',
60
63
  'mcp__switchroom-telegram__stream_reply',
@@ -97,6 +100,68 @@ export function endsWithSilentMarker(text) {
97
100
  return SILENT_MARKER_RE.test(lines[lines.length - 1])
98
101
  }
99
102
 
103
+ // ── Narration heuristics — ported from `turn-flush-safety.ts:198-274` ──
104
+ //
105
+ // Kept byte-parallel with `selectFlushDeliveryText` / `isNarrationBlock` so
106
+ // the JOINED multi-block prose the scan persists as `pendingText` (for the
107
+ // capture-divergence corner) matches what the gateway flush would itself have
108
+ // delivered. The scan has no structural `followedByToolUse` provenance, so it
109
+ // uses the opener/trailer heuristic fallback (the same branch the TS side
110
+ // takes when the flag is absent). MUST stay in sync with the TS source; a
111
+ // drift only affects the rare capture-empty corner, never the primary flush.
112
+ const NARRATION_OPENER =
113
+ /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
114
+ const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
115
+
116
+ function isTrailingNarrationLine(block) {
117
+ const t = block.trim()
118
+ if (t.length === 0 || t.length >= FINAL_ANSWER_MIN_CHARS) return false
119
+ if (t.includes('\n')) return false
120
+ return NARRATION_TRAILER.test(t)
121
+ }
122
+
123
+ function isNarrationBlock(block) {
124
+ return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
125
+ }
126
+
127
+ /**
128
+ * Choose the prose the captured-prose bridge should deliver from the trailing
129
+ * text blocks in the ZERO-reply case, mirroring `selectFlushDeliveryText`
130
+ * (`turn-flush-safety.ts:198-232`). `texts` are already trimmed non-empty.
131
+ *
132
+ * - 0 blocks → undefined (nothing to deliver).
133
+ * - 1 block → that block (the real single-block short-answer shape;
134
+ * persisted even below the substance floor so the lowered-
135
+ * floor capture-divergence corner can deliver it).
136
+ * - ≥2 blocks → strip leading narration exactly as the flush does: if EVERY
137
+ * preceding block is narration, deliver only the terminal
138
+ * block; otherwise JOIN all blocks with a paragraph break (a
139
+ * genuine multi-paragraph answer split across blocks). When
140
+ * the result is narration-only (terminal block is itself
141
+ * narration AND all preceding are narration) → undefined, so a
142
+ * pure narration run never masquerades as an answer (#3228
143
+ * Finding 2 parity).
144
+ *
145
+ * @param {string[]} texts
146
+ * @returns {string | undefined}
147
+ */
148
+ export function selectBridgePendingText(texts) {
149
+ const candidates = texts.map((t) => t.trim()).filter((t) => t.length > 0)
150
+ if (candidates.length === 0) return undefined
151
+ if (candidates.length === 1) return candidates[0]
152
+ const answer = candidates[candidates.length - 1]
153
+ const preceding = candidates.slice(0, -1)
154
+ const allPrecedingNarration = preceding.every((b) => isNarrationBlock(b))
155
+ if (allPrecedingNarration) {
156
+ // Deliver only the terminal block — unless it too is pure narration, in
157
+ // which case the whole run is narration and there is no answer to bridge.
158
+ return isNarrationBlock(answer) ? undefined : answer
159
+ }
160
+ // A preceding block carries real content → keep the whole answer joined so a
161
+ // multi-block answer is never truncated to its last paragraph.
162
+ return candidates.join('\n\n')
163
+ }
164
+
100
165
  /**
101
166
  * Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
102
167
  * Kept in this .mjs so the hook is fully self-contained (no TS import).
@@ -193,8 +258,16 @@ function buildTurnId(chatId, threadId, messageId) {
193
258
  * clears the substance floor, so the gateway never re-delivers a short
194
259
  * trailing pleasantry. Omitted entirely otherwise.
195
260
  */
196
- function buildBlockResult(envelope, reason, pendingText) {
261
+ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse) {
197
262
  const block = { decided: 'block', reason }
263
+ // Single-writer election input (#duplicate-message fix): does ANY
264
+ // non-empty, non-silent trailing text block exist after the last
265
+ // delivery event? The zero-reply election allows only when this is
266
+ // true — `decideTurnFlush` has no length floor, so any non-empty
267
+ // non-silent captured text WILL flush; but a turn with NO trailing
268
+ // prose at all (tool calls only) has nothing for any delivery
269
+ // machine to send and must keep blocking.
270
+ if (hasTrailingProse === true) block.hasTrailingProse = true
198
271
  if (envelope.chatId) {
199
272
  block.chatId = envelope.chatId
200
273
  block.threadId = envelope.threadId
@@ -432,6 +505,32 @@ export function scanTurnForFinalReply(jsonl) {
432
505
  substantiveBlocks.length > 0
433
506
  ? substantiveBlocks[substantiveBlocks.length - 1].text
434
507
  : undefined
508
+ const trailingTextBlocks = undeliveredSlice.filter(
509
+ (b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0,
510
+ )
511
+ const hasTrailingProse = trailingTextBlocks.length > 0
512
+ // Capture-divergence bridge (#duplicate-message fix): in the ZERO-reply
513
+ // case, persist the last trailing block even when no single block clears
514
+ // the 200-char substance floor. The gateway's flush normally delivers any
515
+ // non-empty captured text, but when the gateway's own capture diverged
516
+ // (captured empty) the captured-prose bridge is the only delivery machine
517
+ // left, and it reads `pendingText` — the gateway lowers `minChars` for
518
+ // exactly this corner (`capturedProseMinCharsFor`, silent-end.ts). The
519
+ // interim-ack case (`trailing-text-after-reply`) keeps the substantive
520
+ // floor unchanged — a short closer after a real reply is not a dropped
521
+ // answer.
522
+ //
523
+ // Multi-block corner (review item 3): a real answer split across ≥2
524
+ // individually-sub-200 blocks (e.g. two ~150-char paragraphs) would
525
+ // otherwise yield NO pendingText — and in the capture-divergence-empty
526
+ // corner (gateway `capturedText` empty → flush skips 'empty-text') the
527
+ // bridge would then have nothing to deliver and the hook already allowed the
528
+ // stop: a DROPPED ANSWER. `selectBridgePendingText` mirrors the flush's own
529
+ // `selectFlushDeliveryText` narration-strip/join, so the bridge delivers the
530
+ // joined prose (with the lowered `minChars`) instead of dropping. The #3228
531
+ // Finding 2 guard is preserved: a pure narration run still yields undefined.
532
+ const zeroReplyPendingText =
533
+ pendingText ?? selectBridgePendingText(trailingTextBlocks.map((b) => b.text))
435
534
 
436
535
  if (lastAllowBlockIdx === -1) {
437
536
  // No qualifying delivery/silence event anywhere in the turn.
@@ -444,7 +543,7 @@ export function scanTurnForFinalReply(jsonl) {
444
543
  if (envelope.source === 'cron') {
445
544
  return { decided: 'allow', reason: 'cron-source' }
446
545
  }
447
- return buildBlockResult(envelope, 'no-final-reply', pendingText)
546
+ return buildBlockResult(envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse)
448
547
  }
449
548
 
450
549
  if (sawUndeliveredTextAfterAllow) {
@@ -453,8 +552,169 @@ export function scanTurnForFinalReply(jsonl) {
453
552
  // sent through a delivery tool. This is the "at least once" bug:
454
553
  // an early ack (or any qualifying reply) must not amnesty
455
554
  // everything written afterward.
456
- return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText)
555
+ return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse)
457
556
  }
458
557
 
459
558
  return { decided: 'allow', reason: lastAllowReason }
460
559
  }
560
+
561
+ // ── Single-writer election (duplicate-message fix) ───────────────────
562
+ //
563
+ // RCA: when a turn ends with its final answer as plain transcript text,
564
+ // TWO uncoordinated recovery paths both fire — (A) the gateway's
565
+ // deterministic turn-end flush (`decideTurnFlush` /
566
+ // `answer-ready-flush.ts`) delivers the captured transcript prose, AND
567
+ // (B) this Stop hook blocks and re-prompts the model, which regenerates
568
+ // a REWORDED reply that defeats the exact-match dedup
569
+ // (`flushed-turn-supersede.ts` `flushedAnswerMatchesReply`). The user
570
+ // gets two near-identical messages.
571
+ //
572
+ // Fix: the Stop hook is the single elector. On a would-BLOCK scan it
573
+ // ALLOWS the stop (still persisting the state file so the gateway's
574
+ // delivery machines have their input) IFF it can PROVE a gateway
575
+ // delivery machine will handle the trailing prose. Every gate below
576
+ // exists to prevent a DROP (worse than a duplicate):
577
+ //
578
+ // 1. deliverable trailing prose exists AND the right machine covers it
579
+ // - zero-reply (`no-final-reply`): ANY non-empty non-silent
580
+ // trailing prose — `decideTurnFlush` has no length floor and
581
+ // flushes any non-empty non-silent captured text. (No 200-char
582
+ // floor here.)
583
+ // - interim-ack (`trailing-text-after-reply`, replyCalled=true):
584
+ // ONLY the captured-prose bridge delivers there (the flush skips
585
+ // on reply-called), and its floor is 200 — so short trailing
586
+ // text after a real reply keeps BLOCKING (no duplicate exists in
587
+ // that case today).
588
+ // 2. the governing delivery flag is enabled (read by the hook from
589
+ // its own env): zero-reply needs the turn-flush-safety flag AND
590
+ // the captured-prose flag (the bridge is the capture-divergence
591
+ // backstop when the gateway captured empty); interim-ack needs the
592
+ // captured-prose flag. Flag off → the machine won't fire → BLOCK.
593
+ // 3. retryCount === 0 — a prior failed delivery must fall back to
594
+ // today's BLOCK, preserving the #3228 send-failure recovery net.
595
+ // 4. gateway liveness — the gateway heartbeat file must be FRESH.
596
+ // Allowing into a dead gateway is the one drop worse than a
597
+ // duplicate; when liveness can't be established, BLOCK.
598
+ //
599
+ // Pure and deterministic: the hook wrapper does the IO (env read, stat,
600
+ // state-file write) and maps this verdict to exit-0-allow vs
601
+ // decision:block.
602
+
603
+ /**
604
+ * @param {{
605
+ * scan: ReturnType<typeof scanTurnForFinalReply>,
606
+ * retryCount: number,
607
+ * turnFlushSafetyEnabled: boolean,
608
+ * capturedProseDeliveryEnabled: boolean,
609
+ * gatewayLive: boolean,
610
+ * }} input
611
+ * @returns {{ action: 'allow-scan' | 'allow-elected' | 'block', reason: string }}
612
+ */
613
+ export function decideStopHookDisposition(input) {
614
+ const {
615
+ scan,
616
+ retryCount,
617
+ turnFlushSafetyEnabled,
618
+ capturedProseDeliveryEnabled,
619
+ gatewayLive,
620
+ } = input
621
+ if (scan == null || scan.decided !== 'block') {
622
+ return { action: 'allow-scan', reason: `scan-${scan?.decided ?? 'unknown'}` }
623
+ }
624
+ // Gate 4 — never allow into a possibly-dead gateway.
625
+ if (gatewayLive !== true) {
626
+ return { action: 'block', reason: 'gateway-liveness-not-fresh' }
627
+ }
628
+ // Gate 3 — a retry ladder already in flight means a prior delivery
629
+ // attempt failed; today's BLOCK is the recovery net (#3228).
630
+ if (retryCount !== 0) {
631
+ return { action: 'block', reason: 'retry-ladder-in-flight' }
632
+ }
633
+ if (scan.reason === 'no-final-reply') {
634
+ // Gate 2 (zero-reply): the turn-end flush is the delivery machine;
635
+ // the captured-prose bridge is the capture-divergence backstop.
636
+ if (!turnFlushSafetyEnabled) {
637
+ return { action: 'block', reason: 'turn-flush-flag-disabled' }
638
+ }
639
+ if (!capturedProseDeliveryEnabled) {
640
+ return { action: 'block', reason: 'captured-prose-flag-disabled' }
641
+ }
642
+ // Gate 1 (zero-reply): any non-empty non-silent trailing prose.
643
+ if (scan.hasTrailingProse !== true) {
644
+ return { action: 'block', reason: 'no-trailing-prose' }
645
+ }
646
+ return { action: 'allow-elected', reason: 'flush-will-deliver' }
647
+ }
648
+ if (scan.reason === 'trailing-text-after-reply') {
649
+ // Gate 2 (interim-ack): only the captured-prose bridge delivers here.
650
+ if (!capturedProseDeliveryEnabled) {
651
+ return { action: 'block', reason: 'captured-prose-flag-disabled' }
652
+ }
653
+ // Gate 1 (interim-ack): the bridge's substance floor is 200 chars
654
+ // (CAPTURED_PROSE_MIN_CHARS, silent-end.ts). Below it the bridge
655
+ // will NOT deliver — keep blocking (matches today: no duplicate
656
+ // exists for short trailing text after a real reply).
657
+ if (
658
+ typeof scan.pendingText !== 'string' ||
659
+ scan.pendingText.trim().length < FINAL_ANSWER_MIN_CHARS
660
+ ) {
661
+ return { action: 'block', reason: 'short-trailing-after-reply' }
662
+ }
663
+ return { action: 'allow-elected', reason: 'bridge-will-deliver' }
664
+ }
665
+ // Unknown block reason — conservatively keep today's behaviour.
666
+ return { action: 'block', reason: `unrecognized-scan-reason-${scan.reason}` }
667
+ }
668
+
669
+ /**
670
+ * Mirror of `isTurnFlushSafetyEnabled` (`turn-flush-safety.ts:392-400`).
671
+ * Kept in this .mjs so the hook is self-contained (no TS import); MUST
672
+ * stay in sync — default ON, disabled by `0` / `false` / `off` / `no`.
673
+ *
674
+ * @param {Record<string, string | undefined>} env
675
+ * @returns {boolean}
676
+ */
677
+ export function isTurnFlushSafetyEnabledEnv(env) {
678
+ const raw = env.SWITCHROOM_TG_TURN_FLUSH_SAFETY
679
+ if (raw == null) return true
680
+ const v = raw.trim().toLowerCase()
681
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
682
+ }
683
+
684
+ /**
685
+ * Mirror of `CAPTURED_PROSE_DELIVERY_ENABLED` (`gateway/gateway.ts` —
686
+ * `SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'`). MUST stay in sync.
687
+ *
688
+ * @param {Record<string, string | undefined>} env
689
+ * @returns {boolean}
690
+ */
691
+ export function isCapturedProseDeliveryEnabledEnv(env) {
692
+ return env.SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'
693
+ }
694
+
695
+ // MUST stay in sync with gateway/gateway-heartbeat.ts (the hook is a
696
+ // standalone .mjs and can't import the TS module). The gateway touches
697
+ // the file every GATEWAY_HEARTBEAT_INTERVAL_MS (15s); freshness bound is
698
+ // 4 intervals — conservative against scheduler jitter, small enough that
699
+ // a dead gateway is detected before its stale heartbeat can swallow more
700
+ // than one turn's election.
701
+ export const GATEWAY_HEARTBEAT_FILE = 'gateway-heartbeat'
702
+ export const GATEWAY_HEARTBEAT_FRESH_MS = 60_000
703
+
704
+ /**
705
+ * Gate 4 IO helper: is the gateway heartbeat file fresh? Missing,
706
+ * unstattable, or stale ⇒ false (BLOCK — never allow into a possibly-
707
+ * dead gateway).
708
+ *
709
+ * @param {string} stateDir
710
+ * @param {number} [now]
711
+ * @returns {boolean}
712
+ */
713
+ export function isGatewayHeartbeatFresh(stateDir, now = Date.now()) {
714
+ try {
715
+ const st = statSync(join(stateDir, GATEWAY_HEARTBEAT_FILE))
716
+ return now - st.mtimeMs <= GATEWAY_HEARTBEAT_FRESH_MS
717
+ } catch {
718
+ return false
719
+ }
720
+ }
@@ -43,11 +43,20 @@
43
43
  // punctuation may be backslash-escaped" rule; Telegram's rich parser strips
44
44
  // the backslash the same way `escapeMarkdown` relies on for `~ = | * _`.
45
45
  //
46
+ // ── Accidental heading (`#3460`, `#foo`) — guarded by guardAccidentalHeading ─
47
+ // Telegram's Bot API rich-markdown parser is NON-spec: it promotes ANY
48
+ // line-leading `#{1,6}` run to a heading even WITHOUT the CommonMark-required
49
+ // trailing space (`#3460 done` renders as a huge heading). This is
50
+ // deterministically disambiguable: a `#{1,6}` run followed by a space,
51
+ // another `#`, or end-of-line is an INTENDED heading (or ambiguous) and is
52
+ // LEFT ALONE; a run glued to a non-space, non-`#` char (`#3460`, `#foo`,
53
+ // `###x`) is the accidental Telegram-only heading and IS escaped. See
54
+ // `guardAccidentalHeading` below — it mirrors the render.ts:escapeLineLeadingHash
55
+ // fix (#3306) but runs at the universal wire seam, independent of the
56
+ // SWITCHROOM_RICH_RENDER kill-switch, so cards/banners/status/approval sends
57
+ // (which bypass the rich renderer) are also covered.
58
+ //
46
59
  // ── What is DEFERRED (left to the rich-formatting workstream) ────────────
47
- // • Heading `# ` (with the required space): indistinguishable from an
48
- // INTENDED heading. `#1` / `#foo` (NO space) is not a GFM heading at all
49
- // (ATX headings require `#`+space) → Telegram renders it literally → no
50
- // guard needed. So there is no safely-guardable heading sub-case.
51
60
  // • Bullet lists `-`/`+`/`*` + space (`- 5 degrees`): genuinely ambiguous
52
61
  // with the heavily-used bullet construct; the glued form `-5` (no space)
53
62
  // is not a list item → already literal → no guard needed. Escaping the
@@ -165,3 +174,66 @@ export function guardAccidentalBlockConstructs(text: string): string {
165
174
 
166
175
  return out;
167
176
  }
177
+
178
+ /** Line-start `#{1,6}` run glued DIRECTLY to a non-space, non-`#` char — the
179
+ * Telegram-non-spec accidental heading (`#3460`, `#foo`, `###x`). The negative
180
+ * lookahead `(?=[^\s#])` requires a following char that is neither whitespace,
181
+ * a `#`, nor end-of-line, so an intended `# Title` / `## Sub` (space-delimited)
182
+ * and a bare `#` / `##` (end-of-line) are NEVER matched. Up to 3 leading spaces
183
+ * are permitted (4+ is indented code, and the `{0,3}` bound then leaves a space
184
+ * before the `#`, so it correctly does not match). Only the first `#` of the run
185
+ * is backslash-escaped, which is sufficient to stop Telegram promoting the line
186
+ * to a heading. Idempotent: an already-escaped `\#3460` starts with `\`, so the
187
+ * `#{1,6}` no longer sits at the (post-indent) line start. */
188
+ const ACCIDENTAL_HEADING = /^([ \t]{0,3})(#{1,6})(?=[^\s#])/;
189
+
190
+ /**
191
+ * Escape the accidental heading trigger at the start of ONE line (the string
192
+ * must NOT contain a newline; the caller guarantees a true line start). A no-op
193
+ * unless the line begins with a `#{1,6}` run glued to a non-space, non-`#` char.
194
+ */
195
+ function escapeAccidentalHeadingLine(line: string): string {
196
+ return line.replace(ACCIDENTAL_HEADING, "$1\\$2");
197
+ }
198
+
199
+ /**
200
+ * Neutralise accidental line-start HEADING promotion (`#3460 done` → giant
201
+ * heading) on the FINAL rendered rich-markdown string. Telegram's Bot API
202
+ * parser promotes a line-leading `#{1,6}` run to a heading even without the
203
+ * CommonMark-required trailing space; this escapes ONLY the space-less,
204
+ * non-`#`-adjacent form and leaves genuine `# Title` / `## Sub` headings
205
+ * untouched. Code spans / fenced blocks / link destinations / table rows are
206
+ * never touched (shared `splitProtectedSegments`). Idempotent and a strict
207
+ * no-op absent a real signal. Mirrors render.ts:escapeLineLeadingHash (#3306)
208
+ * but runs at the universal wire seam, so it also covers the card / banner /
209
+ * status / approval sends that bypass the rich renderer.
210
+ */
211
+ export function guardAccidentalHeading(text: string): string {
212
+ // Cheap short-circuit: no `#` at all => no-op.
213
+ if (!text.includes("#")) return text;
214
+
215
+ const segments = splitProtectedSegments(text);
216
+ let out = "";
217
+ // True at text start and immediately after any emitted `\n`.
218
+ let atLineStart = true;
219
+
220
+ for (const seg of segments) {
221
+ if (seg.code) {
222
+ out += seg.text;
223
+ atLineStart = seg.text.endsWith("\n");
224
+ continue;
225
+ }
226
+ const lines = seg.text.split("\n");
227
+ for (let k = 0; k < lines.length; k++) {
228
+ // A prose segment can begin MID-LINE (right after an inline code span),
229
+ // so its first line is a real line start only if the running flag says so.
230
+ const lineIsAtStart = k === 0 ? atLineStart : true;
231
+ const processed = lineIsAtStart ? escapeAccidentalHeadingLine(lines[k]) : lines[k];
232
+ out += processed;
233
+ if (k < lines.length - 1) out += "\n";
234
+ }
235
+ atLineStart = seg.text.endsWith("\n");
236
+ }
237
+
238
+ return out;
239
+ }
@@ -20,7 +20,7 @@
20
20
  import { GrammyError } from 'grammy'
21
21
  import { guardDollarMath } from './render/dollar-math-guard.js'
22
22
  import { guardAccidentalEmphasis } from './render/emphasis-guard.js'
23
- import { guardAccidentalBlockConstructs } from './render/line-start-guard.js'
23
+ import { guardAccidentalBlockConstructs, guardAccidentalHeading } from './render/line-start-guard.js'
24
24
  import { guardAccidentalInlinePairs } from './render/inline-pairs-guard.js'
25
25
 
26
26
  /** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
@@ -36,6 +36,12 @@ export interface InputRichMessageMarkdown {
36
36
  * idempotent and safe to apply once per send.
37
37
  *
38
38
  * ── Ordering (deliberate, not arbitrary) ──────────────────────────────────
39
+ * `guardAccidentalHeading` runs right after emphasis and before block-constructs.
40
+ * It only ever inserts `\` before a line-leading `#{1,6}` run; `#` is inspected
41
+ * and inserted by no other guard (disjoint char set), so its position among the
42
+ * siblings is order-independent — it neither creates nor destroys a signal for
43
+ * any of them.
44
+ *
39
45
  * `guardDollarMath` runs LAST. It backslash-escapes `$` → `\$`, and the
40
46
  * inline-pairs guard's approximation-tilde signal is `~(?=\$?\.?\d)` — a `~`
41
47
  * glued to a `$digit`. If the dollar guard ran first, a body like
@@ -51,6 +57,7 @@ export interface InputRichMessageMarkdown {
51
57
  export function guardAccidentalFormatting(markdown: string): string {
52
58
  let out = markdown
53
59
  out = guardAccidentalEmphasis(out)
60
+ out = guardAccidentalHeading(out)
54
61
  out = guardAccidentalBlockConstructs(out)
55
62
  out = guardAccidentalInlinePairs(out)
56
63
  out = guardDollarMath(out)
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Direct unit tests for `gateway/command-format.ts` — the pure
3
+ * slash-command formatting/keyboard helpers extracted from gateway.ts
4
+ * in switchroom#3461 (testability win tracked by #3460).
5
+ *
6
+ * These assert real rendered outcomes (exact strings, keyboard shapes,
7
+ * thrown errors), not just that the code runs.
8
+ */
9
+ import { describe, it, expect } from 'vitest'
10
+ import {
11
+ formatSwitchroomOutput,
12
+ stripAnsi,
13
+ escapeHtmlForTg,
14
+ preBlock,
15
+ getCommandArgs,
16
+ hasDemoFlag,
17
+ assertSafeAgentName,
18
+ formatAuthOutputForTelegram,
19
+ buildAuthUrlKeyboard,
20
+ buildDeferredSecretKeyboard,
21
+ renderVaultOpFailure,
22
+ statusIcon,
23
+ renderAuthCodeOutcome,
24
+ buildDoctorScopeKeyboard,
25
+ formatDoctorReport,
26
+ } from '../gateway/command-format.js'
27
+ import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
28
+ import type { Context } from 'grammy'
29
+
30
+ describe('formatSwitchroomOutput', () => {
31
+ it('trims and passes short output through unchanged', () => {
32
+ expect(formatSwitchroomOutput(' hello\nworld ')).toBe('hello\nworld')
33
+ })
34
+ it('truncates past the rich-message cap with a marker', () => {
35
+ const big = 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 100)
36
+ const out = formatSwitchroomOutput(big)
37
+ expect(out.endsWith('\n... (truncated)')).toBe(true)
38
+ expect(out.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
39
+ })
40
+ it('honours an explicit maxLen', () => {
41
+ expect(formatSwitchroomOutput('abcdefghij'.repeat(10), 50)).toBe(
42
+ 'abcdefghij'.repeat(10).slice(0, 30) + '\n... (truncated)',
43
+ )
44
+ })
45
+ })
46
+
47
+ describe('stripAnsi', () => {
48
+ it('removes CSI escape sequences and keeps text', () => {
49
+ expect(stripAnsi('\x1b[31mred\x1b[0m plain')).toBe('red plain')
50
+ })
51
+ })
52
+
53
+ describe('escapeHtmlForTg', () => {
54
+ it('backslash-escapes GFM specials', () => {
55
+ expect(escapeHtmlForTg('a*b_c`d[e]f|g~h=i\\j')).toBe(
56
+ 'a\\*b\\_c\\`d\\[e\\]f\\|g\\~h\\=i\\\\j',
57
+ )
58
+ })
59
+ it('leaves ordinary text alone', () => {
60
+ expect(escapeHtmlForTg('hello world 123')).toBe('hello world 123')
61
+ })
62
+ })
63
+
64
+ describe('preBlock', () => {
65
+ it('wraps content in a fenced code block', () => {
66
+ expect(preBlock('ls -la')).toBe('```\nls -la\n```')
67
+ })
68
+ it('defuses embedded triple-backticks so the fence cannot break', () => {
69
+ const out = preBlock('a```b')
70
+ expect(out.startsWith('```\n')).toBe(true)
71
+ expect(out.endsWith('\n```')).toBe(true)
72
+ // interior fence broken up (zero-width space injected)
73
+ expect(out.slice(4, -4)).not.toContain('```')
74
+ })
75
+ })
76
+
77
+ describe('getCommandArgs', () => {
78
+ it('prefers ctx.match when present', () => {
79
+ const ctx = { match: ' foo bar ', msg: { text: '/cmd other' } } as unknown as Context
80
+ expect(getCommandArgs(ctx)).toBe('foo bar')
81
+ })
82
+ it('falls back to parsing the message text', () => {
83
+ const ctx = { match: undefined, msg: { text: '/restart alpha beta' } } as unknown as Context
84
+ expect(getCommandArgs(ctx)).toBe('alpha beta')
85
+ })
86
+ it('returns empty string for a bare command', () => {
87
+ const ctx = { match: '', msg: { text: '/status' } } as unknown as Context
88
+ expect(getCommandArgs(ctx)).toBe('')
89
+ })
90
+ })
91
+
92
+ describe('hasDemoFlag', () => {
93
+ it('matches trailing demo token case-insensitively', () => {
94
+ expect(hasDemoFlag('demo')).toBe(true)
95
+ expect(hasDemoFlag('show DEMO')).toBe(true)
96
+ })
97
+ it('does not match demo as a prefix of another token', () => {
98
+ expect(hasDemoFlag('demo-foo')).toBe(false)
99
+ expect(hasDemoFlag('demo mid')).toBe(false)
100
+ })
101
+ })
102
+
103
+ describe('assertSafeAgentName', () => {
104
+ it('accepts alphanumeric/hyphen/underscore names and the all keyword', () => {
105
+ expect(() => assertSafeAgentName('my-agent_01')).not.toThrow()
106
+ expect(() => assertSafeAgentName('all')).not.toThrow()
107
+ })
108
+ it('rejects shell metacharacters and over-long names', () => {
109
+ expect(() => assertSafeAgentName('a;rm -rf')).toThrow(/invalid agent name/)
110
+ expect(() => assertSafeAgentName('x'.repeat(65))).toThrow(/invalid agent name/)
111
+ })
112
+ })
113
+
114
+ describe('formatAuthOutputForTelegram', () => {
115
+ it('returns a plain pre-block when no URL is present', () => {
116
+ const { text, url } = formatAuthOutputForTelegram('\x1b[32mno link here\x1b[0m')
117
+ expect(url).toBeNull()
118
+ expect(text).toBe('```\nno link here\n```')
119
+ })
120
+ it('extracts the URL, drops CLI hint lines, and appends mobile guidance', () => {
121
+ const raw = [
122
+ 'Started Claude auth for agent overlord',
123
+ 'Open this URL to authorize:',
124
+ 'https://claude.ai/oauth/authorize?code=abc123',
125
+ 'switchroom auth code overlord <code>',
126
+ 'Cancel with: switchroom auth cancel overlord',
127
+ ].join('\n')
128
+ const { text, url } = formatAuthOutputForTelegram(raw)
129
+ expect(url).toBe('https://claude.ai/oauth/authorize?code=abc123')
130
+ expect(text).not.toContain('switchroom auth code')
131
+ expect(text).not.toContain('Cancel with:')
132
+ expect(text).toContain('**Started Claude auth for agent overlord**')
133
+ expect(text).toContain('_Open this URL to authorize:_')
134
+ expect(text.trimEnd().endsWith(url as string)).toBe(true)
135
+ })
136
+ })
137
+
138
+ describe('keyboards', () => {
139
+ it('buildAuthUrlKeyboard produces a single url button', () => {
140
+ const kb = buildAuthUrlKeyboard('https://example.com/x')
141
+ const rows = kb.inline_keyboard
142
+ expect(rows).toHaveLength(1)
143
+ expect(rows[0]).toHaveLength(1)
144
+ expect(rows[0][0]).toMatchObject({ text: '🔐 Open Claude auth', url: 'https://example.com/x' })
145
+ })
146
+ it('buildDeferredSecretKeyboard encodes unlock/cancel callback_data', () => {
147
+ const kb = buildDeferredSecretKeyboard('123:456')
148
+ const row = kb.inline_keyboard[0]
149
+ expect(row.map(b => (b as { callback_data?: string }).callback_data)).toEqual([
150
+ 'vd:unlock:123:456',
151
+ 'vd:cancel:123:456',
152
+ ])
153
+ })
154
+ it('buildDeferredSecretKeyboard throws on a 64-byte callback_data overflow', () => {
155
+ expect(() => buildDeferredSecretKeyboard('9'.repeat(80))).toThrow(/callback_data overflow/)
156
+ })
157
+ it('buildDoctorScopeKeyboard offers fleet and self scopes', () => {
158
+ const row = buildDoctorScopeKeyboard().inline_keyboard[0]
159
+ expect(row.map(b => (b as { callback_data?: string }).callback_data)).toEqual([
160
+ 'dr:fleet',
161
+ 'dr:self',
162
+ ])
163
+ })
164
+ })
165
+
166
+ describe('renderVaultOpFailure', () => {
167
+ it('falls back to a raw pre-block for unrecognised CLI output', () => {
168
+ const out = renderVaultOpFailure('get', 'some random failure', 'mykey')
169
+ expect(out).toBe('**vault get failed:**\n```\nsome random failure\n```')
170
+ })
171
+ it('routes recognised broker-denied markers through the structured renderer', () => {
172
+ const out = renderVaultOpFailure('get', 'VAULT-BROKER-DENIED: no grant for key', 'mykey')
173
+ expect(out).not.toContain('**vault get failed:**')
174
+ expect(out.length).toBeGreaterThan(0)
175
+ })
176
+ })
177
+
178
+ describe('statusIcon', () => {
179
+ it('maps statuses to glyphs', () => {
180
+ expect(statusIcon('running')).toBe('🟢')
181
+ expect(statusIcon('active')).toBe('🟢')
182
+ expect(statusIcon('stopped')).toBe('🔴')
183
+ expect(statusIcon('failed')).toBe('⚠️')
184
+ expect(statusIcon('whatever')).toBe('⚪')
185
+ })
186
+ })
187
+
188
+ describe('renderAuthCodeOutcome', () => {
189
+ it('returns null for success or missing outcome', () => {
190
+ expect(renderAuthCodeOutcome(null)).toBeNull()
191
+ expect(renderAuthCodeOutcome({ kind: 'success' } as never)).toBeNull()
192
+ })
193
+ it('renders invalid-code with escaped pane tail', () => {
194
+ const out = renderAuthCodeOutcome({ kind: 'invalid-code', paneTailText: 'bad*code' } as never)
195
+ expect(out).toContain('Code rejected by Claude')
196
+ expect(out).toContain('bad\\*code')
197
+ })
198
+ it('renders pane-not-ready and timeout variants', () => {
199
+ expect(renderAuthCodeOutcome({ kind: 'pane-not-ready' } as never)).toContain('Auth pane not ready')
200
+ expect(renderAuthCodeOutcome({ kind: 'timeout' } as never)).toContain('Still waiting after 2 min')
201
+ })
202
+ })
203
+
204
+ describe('formatDoctorReport', () => {
205
+ it('swaps check glyphs for traffic lights inside a pre block', () => {
206
+ const raw = '\x1b[32m✓ ok check\x1b[0m\n✗ bad check\n! warn check'
207
+ expect(formatDoctorReport(raw)).toBe('```\n🟢 ok check\n🔴 bad check\n🟡 warn check\n```')
208
+ })
209
+ it('handles empty output', () => {
210
+ expect(formatDoctorReport(' \x1b[0m ')).toBe('doctor: no output')
211
+ })
212
+ })