switchroom 0.19.41 → 0.19.43
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/agent-scheduler/index.js +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -16996,7 +16996,10 @@ var init_plugin_logger = __esm(() => {
|
|
|
16996
16996
|
});
|
|
16997
16997
|
|
|
16998
16998
|
// format.ts
|
|
16999
|
-
var
|
|
16999
|
+
var DELIMITER_RUN_CHARS;
|
|
17000
|
+
var init_format = __esm(() => {
|
|
17001
|
+
DELIMITER_RUN_CHARS = new Set(["*", "_", "`", "~", "|"]);
|
|
17002
|
+
});
|
|
17000
17003
|
|
|
17001
17004
|
// raw-error-scrub.ts
|
|
17002
17005
|
function extractRequestId(raw) {
|
|
@@ -24849,7 +24852,7 @@ var init_bridge = __esm(async () => {
|
|
|
24849
24852
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." },
|
|
24850
24853
|
disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
|
|
24851
24854
|
protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
|
|
24852
|
-
quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
|
|
24855
|
+
quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Must be an EXACT substring of that message (copy it verbatim, do not paraphrase; max 1024 chars) \u2014 a quote Telegram cannot find is dropped and the reply lands unquoted. Requires reply_to." },
|
|
24853
24856
|
disable_notification: { type: "boolean", description: 'When true, Telegram delivers the message silently \u2014 no device ping for this user. Default false (pings). Use true for mid-turn updates ("still working through X") so only the final answer pings. Always omit (or pass false) on the final answer of a turn.' },
|
|
24854
24857
|
inline_keyboard: {
|
|
24855
24858
|
type: "array",
|
|
@@ -362,9 +362,11 @@ export function writeFlood429Ledger(
|
|
|
362
362
|
* call at the ~5s cadence a long ban produces, because the fold collapses
|
|
363
363
|
* those into a single episode instead of growing the file.
|
|
364
364
|
*
|
|
365
|
-
* Read-fold-write is not atomic across processes
|
|
366
|
-
*
|
|
367
|
-
*
|
|
365
|
+
* Read-fold-write is not atomic across processes. Today every writer lives in
|
|
366
|
+
* the gateway process (its two `createRetryApiCall` wirings plus the outbox
|
|
367
|
+
* sweep's, all on one event loop), but the ledger is keyed by state DIR, so a
|
|
368
|
+
* second process wiring the breaker could drop one episode on a simultaneous
|
|
369
|
+
* 429. That is deliberate — the alternative is a lock
|
|
368
370
|
* on the send path's error handler, and the cost of losing an episode is
|
|
369
371
|
* nil: a penalty stays on the ledger for seven days and is re-recorded by the
|
|
370
372
|
* next 429, so no verdict tier turns on a single write landing.
|
|
@@ -235,9 +235,17 @@ export function writeFloodState(
|
|
|
235
235
|
* classifies it (`src/cli/doctor-flood-pressure.ts`).
|
|
236
236
|
*
|
|
237
237
|
* Recording here rather than at the gateway callsites is deliberate: this is
|
|
238
|
-
* the one function EVERY `onFloodWait` wiring goes through
|
|
239
|
-
*
|
|
240
|
-
*
|
|
238
|
+
* the one function EVERY `onFloodWait` wiring goes through, so no future
|
|
239
|
+
* callsite can record a window without also recording its history. The live
|
|
240
|
+
* wirings are `gateway/gateway.ts:5715` (`robustApiCall`) and `:5849`
|
|
241
|
+
* (`nonEssentialApiCall`), plus `gateway/outbox-sweep.ts:582` — and
|
|
242
|
+
* `scripts/check-retry-flood-hooks.mjs` fails the build if a new
|
|
243
|
+
* `createRetryApiCall` site omits either breaker hook.
|
|
244
|
+
*
|
|
245
|
+
* (Until #3863 this docblock also cited `shared/bot-runtime.ts`'s
|
|
246
|
+
* `createRobustApiCall` as a live wiring. It had zero production callers and
|
|
247
|
+
* its breaker hooks were OPTIONAL, so it was deleted rather than counted.
|
|
248
|
+
* Cite only wirings the lint gate can see.)
|
|
241
249
|
*/
|
|
242
250
|
export function makeFloodWaitRecorder(
|
|
243
251
|
path: string,
|
|
@@ -30,6 +30,20 @@
|
|
|
30
30
|
*/
|
|
31
31
|
export const RICH_MESSAGE_MAX_CHARS = 32768
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The legacy plain-text `sendMessage` / `editMessageText` wire cap (4096
|
|
35
|
+
* UTF-16 units). It does NOT apply to the rich path (`sendRichMessage`, up to
|
|
36
|
+
* {@link RICH_MESSAGE_MAX_CHARS}), but it DOES apply the moment a send path
|
|
37
|
+
* DEGRADES to the plain endpoint — the parse-reject fallbacks that resend a
|
|
38
|
+
* rich chunk as plain text, and `renderSafe`'s `mode: "plain"` results.
|
|
39
|
+
*
|
|
40
|
+
* Lives here (next to the rich cap and the splitters) rather than in
|
|
41
|
+
* `render/rich-render.ts` so every plain-degradation site can re-cap without
|
|
42
|
+
* importing the renderer; `render/rich-render.ts` re-exports it for its
|
|
43
|
+
* existing importers.
|
|
44
|
+
*/
|
|
45
|
+
export const PLAIN_TEXT_MAX_CHARS = 4096
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* Escape the GFM-markdown special characters so a dynamic value
|
|
35
49
|
* (a filename, an id, arbitrary user text) renders LITERALLY inside a
|
|
@@ -782,8 +796,16 @@ function isFullyBolded(fragment: string): boolean {
|
|
|
782
796
|
* treated as a legitimate pseudo-heading (`**Section**`) rather than an
|
|
783
797
|
* over-bolded paragraph. Single source of truth for BOTH the per-block rule
|
|
784
798
|
* and the global-ratio heading exemption.
|
|
799
|
+
*
|
|
800
|
+
* 64, not 48: at 48 a legitimate long section label
|
|
801
|
+
* (`**Deployment status across all three regions:**` — 50 chars with markers)
|
|
802
|
+
* was read as an over-bolded paragraph and flattened. 64 still bounds the
|
|
803
|
+
* exemption to something that reads as a label on a phone; the alternative of
|
|
804
|
+
* exempting any fully-bold line ending in `:` regardless of length was
|
|
805
|
+
* rejected because an arbitrarily long bolded paragraph that happens to end
|
|
806
|
+
* in a colon would then escape the tripwire entirely.
|
|
785
807
|
*/
|
|
786
|
-
const PSEUDO_HEADING_MAX_CHARS =
|
|
808
|
+
const PSEUDO_HEADING_MAX_CHARS = 64
|
|
787
809
|
|
|
788
810
|
/**
|
|
789
811
|
* True when a blank-line-delimited block is a single-line pseudo-heading: one
|
|
@@ -828,7 +850,12 @@ function unbold(fragment: string): string {
|
|
|
828
850
|
* encourages) and is NOT stripped — neither by the per-block rule NOR by
|
|
829
851
|
* the global-ratio rule (it still counts toward the global ratio, so a
|
|
830
852
|
* genuinely over-bolded message still trips, but its section headings
|
|
831
|
-
* survive instead of the whole reply going plain).
|
|
853
|
+
* survive instead of the whole reply going plain). The one exception:
|
|
854
|
+
* when EVERY block of the message is such a heading there is no body to
|
|
855
|
+
* preserve contrast against, so the global rule strips them all rather
|
|
856
|
+
* than leaving a 100%-bold message untouched. A block that is nothing but
|
|
857
|
+
* masked code (a bare fence, a lone inline span) does NOT count as body
|
|
858
|
+
* for that test — see the note at the call site.
|
|
832
859
|
* - A list with any non-fully-bolded item is left alone.
|
|
833
860
|
*
|
|
834
861
|
* Code spans/fences are masked (maskCodeRegions) and never counted or
|
|
@@ -862,8 +889,33 @@ export function stripExcessBold(
|
|
|
862
889
|
// Clearly over-bolded — strip every bold span, keeping the text, EXCEPT
|
|
863
890
|
// standalone short pseudo-heading blocks (`**Section**`), which stay bold
|
|
864
891
|
// so a bold-dense digest keeps its section headings.
|
|
892
|
+
//
|
|
893
|
+
// The exemption only makes sense when there is body text for the headings
|
|
894
|
+
// to stand out FROM. A message whose every block is a pseudo-heading has
|
|
895
|
+
// no body: exempting all of them leaves a 100%-bold message untouched and
|
|
896
|
+
// unlogged. In that case the exemption is dropped and the whole message is
|
|
897
|
+
// stripped, which is what the ratio rule says.
|
|
898
|
+
//
|
|
899
|
+
// "Body" here means VISIBLE (non-code) text, so a block that is nothing but
|
|
900
|
+
// masked code is skipped rather than counted as body (#4114). Masked code
|
|
901
|
+
// is invisible to every other measurement in this function — it is stripped
|
|
902
|
+
// out of `visible` before the ratio is taken — so counting a bare fence as
|
|
903
|
+
// body would be the one place code influences the bold decision, and it
|
|
904
|
+
// would do so by RESTORING the exact #4017 symptom: a headings-plus-one-
|
|
905
|
+
// snippet digest (a very ordinary agent status report) stayed 100% bold
|
|
906
|
+
// with no onStrip line, because the code block broke `every(...)`. The
|
|
907
|
+
// contrast a code block provides is real but partial, and it does not make
|
|
908
|
+
// every remaining visible character being bold the right render.
|
|
909
|
+
//
|
|
910
|
+
// Filtering with `stripPlaceholders` (the masker's own "remove EVERY mask"
|
|
911
|
+
// primitive) rather than a fence-specific test means all mask kinds — the
|
|
912
|
+
// FENCE and INLINE prefixes today, anything maskCodeRegions adds later —
|
|
913
|
+
// are handled by construction instead of by enumeration.
|
|
914
|
+
const contentBlocks = blocks.filter((b) => stripPlaceholders(b).trim() !== '')
|
|
915
|
+
const allHeadings =
|
|
916
|
+
contentBlocks.length > 0 && contentBlocks.every(isPseudoHeadingBlock)
|
|
865
917
|
const rebuilt = blocks.map((block) =>
|
|
866
|
-
isPseudoHeadingBlock(block) ? block : unbold(block),
|
|
918
|
+
!allHeadings && isPseudoHeadingBlock(block) ? block : unbold(block),
|
|
867
919
|
)
|
|
868
920
|
const out = rejoinBlocks(masked, rebuilt)
|
|
869
921
|
if (out !== masked) onStrip?.({ rule: 'global', ratio })
|
|
@@ -1160,6 +1212,41 @@ export function hardSliceToCap(text: string, cap = RICH_MESSAGE_MAX_CHARS): stri
|
|
|
1160
1212
|
return out
|
|
1161
1213
|
}
|
|
1162
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* Re-cap a body that is about to be sent through the PLAIN `sendMessage` /
|
|
1217
|
+
* `editMessageText` endpoint (switchroom #4043).
|
|
1218
|
+
*
|
|
1219
|
+
* Every plain-text FALLBACK in the send path inherits a chunk that was split
|
|
1220
|
+
* for the RICH cap ({@link RICH_MESSAGE_MAX_CHARS} = 32768). Handing such a
|
|
1221
|
+
* chunk to the plain endpoint (which caps at {@link PLAIN_TEXT_MAX_CHARS} =
|
|
1222
|
+
* 4096) makes Telegram reject it with `message is too long` — so the fallback
|
|
1223
|
+
* that exists precisely to rescue a failed send fails too, and the user never
|
|
1224
|
+
* receives the message (in the outbox sweep it retries forever).
|
|
1225
|
+
*
|
|
1226
|
+
* Cuts at `splitMarkdownChunks`' safe boundaries first (never bisecting a
|
|
1227
|
+
* fenced block or a table row) and hard-slices any residual indivisible region,
|
|
1228
|
+
* so EVERY returned piece is guaranteed `<= cap`. Returns the input as a
|
|
1229
|
+
* single-element array when it already fits (the overwhelmingly common case,
|
|
1230
|
+
* byte-identical to the pre-fix behaviour).
|
|
1231
|
+
*/
|
|
1232
|
+
export function splitPlainTextToCap(text: string, cap = PLAIN_TEXT_MAX_CHARS): string[] {
|
|
1233
|
+
if (cap <= 0) return [text]
|
|
1234
|
+
if (text.length <= cap) return [text]
|
|
1235
|
+
const out: string[] = []
|
|
1236
|
+
for (const piece of splitMarkdownChunks(text, cap)) {
|
|
1237
|
+
if (piece.length <= cap) {
|
|
1238
|
+
if (piece.length > 0) out.push(piece)
|
|
1239
|
+
continue
|
|
1240
|
+
}
|
|
1241
|
+
// `splitMarkdownChunks` emits an unsplittable region whole; hard-cut it so
|
|
1242
|
+
// the plain endpoint can actually accept it.
|
|
1243
|
+
for (const slice of hardSliceToCap(piece, cap)) {
|
|
1244
|
+
if (slice.length > 0) out.push(slice)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return out.length > 0 ? out : [text]
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1163
1250
|
// ---------------------------------------------------------------------------
|
|
1164
1251
|
// Markdown-aware chunking — never bisects a code fence or a table row
|
|
1165
1252
|
// ---------------------------------------------------------------------------
|
|
@@ -1208,16 +1295,10 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
1208
1295
|
cut = spaceIdx
|
|
1209
1296
|
}
|
|
1210
1297
|
|
|
1211
|
-
// Back off
|
|
1212
|
-
|
|
1213
|
-
//
|
|
1214
|
-
cut =
|
|
1215
|
-
// Back off so the cut doesn't bisect an inline entity (`**bold**`,
|
|
1216
|
-
// `` `code` ``, `_italic_`, `[label](href)`), which would leave an unclosed
|
|
1217
|
-
// delimiter in the emitted chunk (Telegram then parse-rejects it to
|
|
1218
|
-
// plaintext, or mis-renders the continuation). Runs LAST so it also cleans
|
|
1219
|
-
// up a boundary the fence/table back-offs landed on.
|
|
1220
|
-
cut = backOffOpenInline(rest, cut)
|
|
1298
|
+
// Back the cut off any markdown entity it would bisect — fenced block,
|
|
1299
|
+
// table row, inline span. One shared mechanism (`safeMarkdownCut`), also
|
|
1300
|
+
// used by the card fitter's last-resort clip (#4116).
|
|
1301
|
+
cut = safeMarkdownCut(rest, cut)
|
|
1221
1302
|
|
|
1222
1303
|
if (cut <= 0) {
|
|
1223
1304
|
// Could not find a safe boundary below maxLen — the region is one
|
|
@@ -1334,8 +1415,137 @@ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
|
|
|
1334
1415
|
/__[^_\n]+__/g, // underline
|
|
1335
1416
|
/(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
|
|
1336
1417
|
/\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
|
|
1418
|
+
/~~[^~\n]+~~/g, // strikethrough
|
|
1419
|
+
/\|\|[^|\n]+\|\|/g, // spoiler
|
|
1337
1420
|
]
|
|
1338
1421
|
|
|
1422
|
+
/** Characters that form multi-character markdown delimiter runs (`**`, `~~`, `||`, ` ``` `). */
|
|
1423
|
+
const DELIMITER_RUN_CHARS = new Set(['*', '_', '`', '~', '|'])
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The opening delimiter of every entity kind {@link safeMarkdownCut} can
|
|
1427
|
+
* retreat past, anchored at the head of a string. Used ONLY by
|
|
1428
|
+
* {@link truncateMarkdownSafe}'s repair step — see the doc there for why
|
|
1429
|
+
* dropping an opener is the right repair. Kept adjacent to
|
|
1430
|
+
* `INLINE_SPAN_PATTERNS` so the two marker vocabularies stay in step.
|
|
1431
|
+
*/
|
|
1432
|
+
const FENCE_OPENER_AT_HEAD = /^(\n?)(?:`{3,}|~{3,})[^\n]*/
|
|
1433
|
+
const INLINE_OPENER_AT_HEAD = /^(?:\*{1,3}|_{1,3}|`+|~~|\|\||\[)/
|
|
1434
|
+
const TABLE_ROW_OPENER_AT_HEAD = /^(\n?[ \t]*)\|/
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Bound on {@link truncateMarkdownSafe}'s opener-drop repair loop. Each pass
|
|
1438
|
+
* removes at least one character, so this only caps a pathologically nested
|
|
1439
|
+
* head (`***` + `` ` `` + `[` …); eight is far past anything a card produces.
|
|
1440
|
+
*/
|
|
1441
|
+
const MAX_OPENER_REPAIRS = 8
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* The largest index `<= cut` at which `text` can be split without bisecting a
|
|
1445
|
+
* markdown entity — the ONE place "where is it safe to cut markdown?" is
|
|
1446
|
+
* answered.
|
|
1447
|
+
*
|
|
1448
|
+
* Composes the three back-offs in the order the chunker has always applied
|
|
1449
|
+
* them (fenced block → table row → inline span; the inline pass runs last so
|
|
1450
|
+
* it also cleans a boundary the first two landed on), then refuses to split a
|
|
1451
|
+
* surrogate pair so the result is always valid UTF-16 as well as valid
|
|
1452
|
+
* markdown.
|
|
1453
|
+
*
|
|
1454
|
+
* Returns `0` when NO safe boundary exists at or below `cut` — i.e. an entity
|
|
1455
|
+
* that starts at index 0 swallows the whole window. Callers must handle that
|
|
1456
|
+
* case; see {@link truncateMarkdownSafe}.
|
|
1457
|
+
*/
|
|
1458
|
+
export function safeMarkdownCut(text: string, cut: number): number {
|
|
1459
|
+
if (cut <= 0) return 0
|
|
1460
|
+
if (cut >= text.length) return text.length
|
|
1461
|
+
let safe = backOffOpenFence(text, cut)
|
|
1462
|
+
safe = backOffTableRow(text, safe)
|
|
1463
|
+
safe = backOffOpenInline(text, safe)
|
|
1464
|
+
// Never cut INSIDE a delimiter run: `**bold**` cut between the two closing
|
|
1465
|
+
// stars emits one lone `*` (odd marker count → parse-reject), and the span
|
|
1466
|
+
// back-off above cannot see it because the run's first half is a complete
|
|
1467
|
+
// span's tail, not a straddle. Retreat to the run's start.
|
|
1468
|
+
while (safe > 0 && DELIMITER_RUN_CHARS.has(text[safe]) && text[safe - 1] === text[safe]) safe--
|
|
1469
|
+
if (safe <= 0) return 0
|
|
1470
|
+
// Never leave a lone high surrogate by cutting through an astral character.
|
|
1471
|
+
const prev = text.charCodeAt(safe - 1)
|
|
1472
|
+
const here = text.charCodeAt(safe)
|
|
1473
|
+
if (prev >= 0xd800 && prev <= 0xdbff && here >= 0xdc00 && here <= 0xdfff) safe -= 1
|
|
1474
|
+
return safe
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Truncate `text` to at most `budget` characters such that the RESULT IS
|
|
1479
|
+
* ALWAYS PARSEABLE MARKDOWN — no half-open `**`/`_`/`` ` ``/`~~`/`||` run, no
|
|
1480
|
+
* unclosed fence, no bisected link or table row, no split surrogate pair.
|
|
1481
|
+
*
|
|
1482
|
+
* Why this exists (#4116): the card fitter's last-resort clip used to slice
|
|
1483
|
+
* the raw markdown by character count. A cut landing between a `**` pair
|
|
1484
|
+
* yields a body Telegram parse-REJECTS — so the code path that exists
|
|
1485
|
+
* specifically to guarantee delivery within the char budget could itself make
|
|
1486
|
+
* the send fail. A clipped card beats no card, but only if it can be sent.
|
|
1487
|
+
*
|
|
1488
|
+
* Two steps, in order:
|
|
1489
|
+
*
|
|
1490
|
+
* 1. **Back off** to the nearest safe boundary ({@link safeMarkdownCut}).
|
|
1491
|
+
* This is the normal case and it preserves formatting exactly: an entity
|
|
1492
|
+
* that straddles the limit simply doesn't make the cut.
|
|
1493
|
+
*
|
|
1494
|
+
* 2. **Drop the opener** when backing off would keep less than HALF the
|
|
1495
|
+
* window. That happens when a single entity is longer than the window —
|
|
1496
|
+
* `**<40k-char description>**`, the #3682 repro — and a back-off would
|
|
1497
|
+
* return a near-empty card, which is no better than the dropped card the
|
|
1498
|
+
* clip exists to prevent. The entity cannot render anyway (its closing
|
|
1499
|
+
* delimiter is past the window), so we drop its OPENING delimiter and ask
|
|
1500
|
+
* again: the text survives as literal prose, the markdown stays balanced,
|
|
1501
|
+
* and the card is delivered.
|
|
1502
|
+
*
|
|
1503
|
+
* The window END IS HELD FIXED IN SOURCE TERMS across a repair (`end` shrinks
|
|
1504
|
+
* by exactly the characters removed). Letting the window slide forward by the
|
|
1505
|
+
* dropped opener's width would pull the entity's CLOSING delimiter into the
|
|
1506
|
+
* output — an orphan closer, unbalanced again. That is a real defect this
|
|
1507
|
+
* function had before its property test swept every offset.
|
|
1508
|
+
*
|
|
1509
|
+
* The repair loop is bounded (each pass removes at least one character; at
|
|
1510
|
+
* most `MAX_OPENER_REPAIRS` passes) so a pathological body cannot spin.
|
|
1511
|
+
*/
|
|
1512
|
+
export function truncateMarkdownSafe(text: string, budget: number): string {
|
|
1513
|
+
if (budget <= 0) return ''
|
|
1514
|
+
if (text.length <= budget) return text
|
|
1515
|
+
let t = text
|
|
1516
|
+
let end = budget
|
|
1517
|
+
for (let pass = 0; pass <= MAX_OPENER_REPAIRS; pass++) {
|
|
1518
|
+
const safe = safeMarkdownCut(t, end)
|
|
1519
|
+
// A back-off that keeps at least half the window is the good outcome: the
|
|
1520
|
+
// straddling entity is dropped whole and everything before it renders as
|
|
1521
|
+
// authored.
|
|
1522
|
+
if (safe * 2 >= end) return t.slice(0, safe)
|
|
1523
|
+
const repaired = dropLeadingOpener(t.slice(safe))
|
|
1524
|
+
// No opener to drop (unreachable in principle — every back-off above
|
|
1525
|
+
// retreats TO one). Prefer the short-but-valid back-off over a slice that
|
|
1526
|
+
// cannot be parsed.
|
|
1527
|
+
if (repaired == null) return t.slice(0, safe)
|
|
1528
|
+
end -= t.length - safe - repaired.length
|
|
1529
|
+
t = t.slice(0, safe) + repaired
|
|
1530
|
+
}
|
|
1531
|
+
return t.slice(0, Math.max(0, safeMarkdownCut(t, end)))
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Remove the opening delimiter of the entity that begins at the head of
|
|
1536
|
+
* `rest`, preserving any leading newline so line structure survives. Returns
|
|
1537
|
+
* `null` when the head is not an entity opener.
|
|
1538
|
+
*/
|
|
1539
|
+
function dropLeadingOpener(rest: string): string | null {
|
|
1540
|
+
const fence = FENCE_OPENER_AT_HEAD.exec(rest)
|
|
1541
|
+
if (fence != null) return fence[1] + rest.slice(fence[0].length)
|
|
1542
|
+
const inline = INLINE_OPENER_AT_HEAD.exec(rest)
|
|
1543
|
+
if (inline != null) return rest.slice(inline[0].length)
|
|
1544
|
+
const row = TABLE_ROW_OPENER_AT_HEAD.exec(rest)
|
|
1545
|
+
if (row != null) return row[1] + rest.slice(row[0].length)
|
|
1546
|
+
return null
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1339
1549
|
function backOffOpenInline(text: string, cut: number): number {
|
|
1340
1550
|
if (cut <= 0 || cut >= text.length) return cut
|
|
1341
1551
|
let earliest = cut
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for #4069 — the "kernel record failed" false toast.
|
|
3
|
+
*
|
|
4
|
+
* The kernel's approval_consume_record reply carries `decision_id`, but
|
|
5
|
+
* the client decodes every reply through ResponseSchema (a plain
|
|
6
|
+
* z.union, first-match wins, unknown keys stripped). With
|
|
7
|
+
* OkApprovalConsumeResponseSchema ordered before
|
|
8
|
+
* OkApprovalConsumeRecordResponseSchema, the consume_record reply
|
|
9
|
+
* matched the prefix schema and `decision_id` was silently stripped —
|
|
10
|
+
* so handleApprovalCallback hit its defensive `!result.decision_id`
|
|
11
|
+
* branch, toasted "kernel record failed", and returned before editing
|
|
12
|
+
* the card to its granted state. Deterministic on EVERY `apv:` approval
|
|
13
|
+
* tap. The audit row was never lost — the kernel committed it — this
|
|
14
|
+
* was purely the client's mangled view of the reply.
|
|
15
|
+
*
|
|
16
|
+
* This test drives handleApprovalCallback end-to-end against a fake
|
|
17
|
+
* kernel serving a REAL wire frame through the REAL
|
|
18
|
+
* rpcRaw/decodeResponse path (the kernel suites parse the wire with raw
|
|
19
|
+
* JSON.parse and bypass decodeResponse — which is exactly why they
|
|
20
|
+
* missed this). It asserts the tap lands: no "kernel record failed"
|
|
21
|
+
* toast, and the card advances to its granted body carrying the
|
|
22
|
+
* decision_id revoke hint.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
26
|
+
import * as net from "node:net";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import type { Context } from "grammy";
|
|
31
|
+
import { handleApprovalCallback } from "./approval-callback.js";
|
|
32
|
+
|
|
33
|
+
const REQUEST_ID = "0123456789abcdef0123456789abcdef";
|
|
34
|
+
const DECISION_ID = "dec-4069";
|
|
35
|
+
|
|
36
|
+
/** One-shot fake kernel: replies to the first request line with `reply`. */
|
|
37
|
+
function startFakeKernel(
|
|
38
|
+
socketPath: string,
|
|
39
|
+
reply: Record<string, unknown>,
|
|
40
|
+
seen: string[],
|
|
41
|
+
): Promise<net.Server> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const server = net.createServer((conn) => {
|
|
44
|
+
let buf = "";
|
|
45
|
+
conn.on("data", (chunk) => {
|
|
46
|
+
buf += chunk.toString("utf8");
|
|
47
|
+
const idx = buf.indexOf("\n");
|
|
48
|
+
if (idx === -1) return;
|
|
49
|
+
seen.push(buf.slice(0, idx));
|
|
50
|
+
conn.write(JSON.stringify(reply) + "\n");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
server.on("error", reject);
|
|
54
|
+
server.listen(socketPath, () => resolve(server));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeFakeCtx(): {
|
|
59
|
+
ctx: Context;
|
|
60
|
+
toasts: string[];
|
|
61
|
+
edits: Array<{ markdown: string }>;
|
|
62
|
+
} {
|
|
63
|
+
const toasts: string[] = [];
|
|
64
|
+
const edits: Array<{ markdown: string }> = [];
|
|
65
|
+
const ctx = {
|
|
66
|
+
from: { id: 42 },
|
|
67
|
+
answerCallbackQuery: async (args?: { text?: string }) => {
|
|
68
|
+
toasts.push(args?.text ?? "");
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
editMessageText: async (body: { markdown: string }) => {
|
|
72
|
+
edits.push(body);
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
} as unknown as Context;
|
|
76
|
+
return { ctx, toasts, edits };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("handleApprovalCallback × real decodeResponse (#4069)", () => {
|
|
80
|
+
let server: net.Server | undefined;
|
|
81
|
+
let tmpDir: string | undefined;
|
|
82
|
+
const savedEnv = process.env.SWITCHROOM_KERNEL_SOCKET;
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
if (server) await new Promise((r) => server!.close(r));
|
|
86
|
+
server = undefined;
|
|
87
|
+
if (savedEnv === undefined) delete process.env.SWITCHROOM_KERNEL_SOCKET;
|
|
88
|
+
else process.env.SWITCHROOM_KERNEL_SOCKET = savedEnv;
|
|
89
|
+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
90
|
+
tmpDir = undefined;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("approve tap records + advances the card — no false 'kernel record failed'", async () => {
|
|
94
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sr4069-"));
|
|
95
|
+
const socketPath = path.join(tmpDir, "kernel.sock");
|
|
96
|
+
const seen: string[] = [];
|
|
97
|
+
server = await startFakeKernel(
|
|
98
|
+
socketPath,
|
|
99
|
+
{
|
|
100
|
+
ok: true,
|
|
101
|
+
consumed: true,
|
|
102
|
+
decision_id: DECISION_ID,
|
|
103
|
+
agent_unit: "clerk",
|
|
104
|
+
scope: "doc:gdrive:D1",
|
|
105
|
+
action: "write",
|
|
106
|
+
why: null,
|
|
107
|
+
},
|
|
108
|
+
seen,
|
|
109
|
+
);
|
|
110
|
+
process.env.SWITCHROOM_KERNEL_SOCKET = socketPath;
|
|
111
|
+
|
|
112
|
+
const { ctx, toasts, edits } = makeFakeCtx();
|
|
113
|
+
await handleApprovalCallback(ctx, `apv:${REQUEST_ID}:once`);
|
|
114
|
+
|
|
115
|
+
// The fake kernel really received the atomic consume+record op.
|
|
116
|
+
expect(seen.length).toBe(1);
|
|
117
|
+
expect(JSON.parse(seen[0]!)).toMatchObject({
|
|
118
|
+
op: "approval_consume_record",
|
|
119
|
+
request_id: REQUEST_ID,
|
|
120
|
+
decision: "allow_once",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Outcome: the tap must land as Approved, never the false-failure
|
|
124
|
+
// toast, and the card must advance to its granted body carrying the
|
|
125
|
+
// decision_id revoke hint.
|
|
126
|
+
expect(toasts).not.toContain("kernel record failed");
|
|
127
|
+
expect(toasts).toContain("Approved");
|
|
128
|
+
expect(edits.length).toBe(1);
|
|
129
|
+
expect(edits[0]!.markdown).toContain(DECISION_ID);
|
|
130
|
+
expect(edits[0]!.markdown).toContain("granted once");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
AGENT_LIVE_POLL_INTERVAL_MS,
|
|
54
54
|
} from './boot-probes.js'
|
|
55
55
|
import { escapeMarkdown, stackCardLines } from '../card-format.js'
|
|
56
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
56
57
|
import {
|
|
57
58
|
loadCache as loadBootIssueCache,
|
|
58
59
|
diffProbes as diffBootProbes,
|
|
@@ -540,7 +541,11 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
540
541
|
const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
541
542
|
? `docker logs --tail 100 switchroom-${agentSlug}`
|
|
542
543
|
: `journalctl --user -u switchroom-${agentSlug} -n 100`
|
|
543
|
-
|
|
544
|
+
// One indent idiom, repo-wide (#4115): NESTED_PREFIX, not ASCII spaces.
|
|
545
|
+
// Telegram left-trims a leading ASCII-whitespace run off a content line,
|
|
546
|
+
// so the ASCII-space indent this row used to carry rendered FLAT on a
|
|
547
|
+
// phone — the #3668 failure, live-verified 2026-07-26.
|
|
548
|
+
degradedRows.push(`${NESTED_PREFIX}Tail logs: \`${tailCmd}\``)
|
|
544
549
|
}
|
|
545
550
|
|
|
546
551
|
// Probe rows — only those that surfaced as degraded/fail. Healthy
|
|
@@ -569,7 +574,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
569
574
|
// user does or doesn't see across consecutive boots.
|
|
570
575
|
degradedRows.push(`${dot} **${PROBE_LABELS[key]}** ${escapeMarkdown(r.detail)}`)
|
|
571
576
|
if (r.nextStep) {
|
|
572
|
-
degradedRows.push(
|
|
577
|
+
degradedRows.push(`${NESTED_PREFIX}${renderNextStep(r.nextStep)}`)
|
|
573
578
|
}
|
|
574
579
|
}
|
|
575
580
|
}
|
|
@@ -145,6 +145,11 @@ export function queueFloodBlockedReply(input: FloodQueueInput): FloodQueueResult
|
|
|
145
145
|
textSha256: sha256Hex(text),
|
|
146
146
|
createdAt,
|
|
147
147
|
source: FLOOD_QUEUED_SOURCE,
|
|
148
|
+
// W1-d (#3865): a flood-deferred record is a reply the agent ALREADY chose
|
|
149
|
+
// to send to this chat — its audience is never in question. Stamped
|
|
150
|
+
// explicitly rather than relying on the legacy default, so this producer
|
|
151
|
+
// stays correct if that default is ever revisited.
|
|
152
|
+
audience: 'user',
|
|
148
153
|
...(input.originChatId != null ? { originChatId: input.originChatId } : {}),
|
|
149
154
|
...(input.originThreadId != null ? { originThreadId: input.originThreadId } : {}),
|
|
150
155
|
}
|