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.
- package/dist/auth-broker/index.js +104 -11
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- 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',
|
|
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
|
+
})
|