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.
Files changed (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -5,8 +5,8 @@
5
5
  * standalone foreman bot before its retirement.
6
6
  *
7
7
  * What lives here:
8
- * - `createRobustApiCall` thin re-export of createRetryApiCall pre-wired
9
- * with stderr logging (mirrors how gateway.ts constructs `robustApiCall`).
8
+ * - `installTgPostLogger` / `installRichMarkdownGuard` the grammy API
9
+ * transformers every outbound call transits.
10
10
  * - `makeSwitchroomExec` / `makeSwitchroomExecCombined` — factory fns for
11
11
  * the switchroom CLI exec helpers (callers pass their own CLI path / config
12
12
  * env so each process can be configured independently).
@@ -28,8 +28,7 @@ import { execFileSync, spawnSync } from 'child_process'
28
28
  import { createHash } from 'crypto'
29
29
  import { AsyncLocalStorage } from 'async_hooks'
30
30
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
31
- import { createRetryApiCall, classifyBenignTelegram400 } from '../retry-api-call.js'
32
- import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
31
+ import { classifyBenignTelegram400, willRetryTelegramFailure } from '../retry-api-call.js'
33
32
  import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
34
33
  import { shouldEmitTgPost, type TgPostStatus } from './gw-trace-gate.js'
35
34
  import { guardAccidentalFormatting } from '../rich-send.js'
@@ -102,7 +101,7 @@ function shortDesc(raw: string | undefined): string {
102
101
  *
103
102
  * Log shape (one line per POST, on both success and failure):
104
103
  *
105
- * tg-post method=<m> chat=<id> thread=<id|-> parse_mode=<HTML|MarkdownV2|none> bytes=<n> hash=<sha1-12> status=<ok|benign|err> err=<class-or--> code=<http-or--> desc=<short|-->
104
+ * tg-post method=<m> chat=<id> thread=<id|-> parse_mode=<HTML|MarkdownV2|none> bytes=<n> hash=<sha1-12> status=<ok|benign|retry|err> err=<class-or--> code=<http-or--> desc=<short|-->
106
105
  *
107
106
  * TRUTHFULNESS (#3927): grammY resolves the transformer chain with the raw
108
107
  * `ApiResponse`, and only converts `{ok:false}` into a thrown `GrammyError`
@@ -127,6 +126,17 @@ function shortDesc(raw: string | undefined): string {
127
126
  * instead, so promoting real rejections out of `status=ok` doesn't bury
128
127
  * `grep status=err` under high-volume card-repaint no-ops.
129
128
  *
129
+ * ATTEMPT vs OUTCOME (#3931): this transformer runs INSIDE `fn()`, i.e. below
130
+ * the retry policy, so it sees each attempt separately. A 429 that
131
+ * `createRetryApiCall` sleeps and then retries successfully is not a delivery
132
+ * failure — but it used to write `status=err`, and fleet-health's
133
+ * `reply-delivery-failure` detector (`src/fleet-health/detect.ts`) matches per
134
+ * LINE, so every retried-and-DELIVERED reply raised a severity-3 alert. The
135
+ * retry policy now publishes its attempt context (`_getTgAttemptContext`), and
136
+ * a failure the policy is about to retry is logged `status=retry` instead.
137
+ * `status=err` is therefore an OUTCOME: the logical send is over and nothing
138
+ * landed. Nothing is hidden — the retry lines are still emitted verbatim.
139
+ *
130
140
  * Body content is never logged — only its length and a 12-char sha1 prefix
131
141
  * so we can recognise repeated identical sends without leaking PII. The
132
142
  * `code` field carries the Telegram error_code (400/403/429/etc.) on
@@ -164,12 +174,27 @@ export function installTgPostLogger(bot: Bot): void {
164
174
  // converts `{ok:false}` to a thrown GrammyError only after this chain
165
175
  // returns (see the docblock). Same defensive shape as the edit fuse's
166
176
  // `runObserved` (edit-flood-fuse.ts).
167
- const r = res as unknown as { ok?: boolean; error_code?: number; description?: string }
177
+ const r = res as unknown as {
178
+ ok?: boolean
179
+ error_code?: number
180
+ description?: string
181
+ parameters?: { retry_after?: number }
182
+ }
168
183
  if (r != null && typeof r === 'object' && r.ok === false) {
169
184
  const code = r.error_code
170
185
  const benign = classifyBenignTelegram400(code, r.description)
186
+ // #3931 — an attempt the enclosing retry policy is about to repeat is
187
+ // not an outcome; label it `retry` so it is never read as a delivery
188
+ // failure. Terminal rejections keep `status=err`.
189
+ const retrying =
190
+ benign === null &&
191
+ willRetryTelegramFailure({
192
+ errorCode: code ?? null,
193
+ retryAfterSec: r.parameters?.retry_after ?? null,
194
+ message: r.description ?? null,
195
+ })
171
196
  emit(
172
- benign !== null ? 'benign' : 'err',
197
+ benign !== null ? 'benign' : retrying ? 'retry' : 'err',
173
198
  `telegram_${code ?? 'unknown'}`,
174
199
  code != null ? String(code) : '-',
175
200
  shortDesc(r.description),
@@ -186,7 +211,26 @@ export function installTgPostLogger(bot: Bot): void {
186
211
  const rawDesc = err instanceof GrammyError
187
212
  ? (err as GrammyError).description
188
213
  : (err instanceof Error ? err.message : '')
189
- emit('err', errClass, code, shortDesc(rawDesc))
214
+ // #3931 same attempt-vs-outcome rule as the resolved-rejection branch
215
+ // above. This branch carries the transport (`HttpError`) failures, whose
216
+ // transient members the retry policy backs off and repeats.
217
+ //
218
+ // #4123 — pass `cause`, not just `message`. grammy rewrites every
219
+ // transport failure's message to `Network request for '<method>' failed!`,
220
+ // so a message-only shape classified EVERY transport blip as terminal and
221
+ // wrote `status=err` — which fleet-health escalates as a
222
+ // `reply-delivery-failure` even when the next attempt delivers. The
223
+ // predicate needs the error itself to classify by type.
224
+ const retrying = willRetryTelegramFailure({
225
+ errorCode: err instanceof GrammyError ? (err as GrammyError).error_code : null,
226
+ retryAfterSec:
227
+ err instanceof GrammyError
228
+ ? ((err as GrammyError).parameters?.retry_after ?? null)
229
+ : null,
230
+ message: err instanceof Error ? err.message : String(err ?? ''),
231
+ cause: err,
232
+ })
233
+ emit(retrying ? 'retry' : 'err', errClass, code, shortDesc(rawDesc))
190
234
  throw err
191
235
  }
192
236
  })
@@ -248,32 +292,23 @@ export function installRichMarkdownGuard(bot: Bot): void {
248
292
  })
249
293
  }
250
294
 
251
- // ─── robustApiCall factory ────────────────────────────────────────────────
252
-
253
- /**
254
- * Creates a robust API call wrapper pre-wired with stderr logging.
255
- * This is exactly how gateway.ts constructs its `robustApiCall`.
256
- *
257
- * Usage:
258
- * const robustApiCall = createRobustApiCall()
259
- */
260
- export function createRobustApiCall(opts: { floodStatePath?: string } = {}) {
261
- return createRetryApiCall({
262
- log: (line) => process.stderr.write(line),
263
- // #2923: persist every observed 429 flood-wait window so a restart during
264
- // the ban can suppress non-essential sends (boot cards) instead of feeding
265
- // the per-bot flood counter and prolonging the ban.
266
- // #3084: and refuse to issue a call while that window is still open —
267
- // otherwise the card heartbeats re-drive a request into the ban every
268
- // few seconds once the policy stops sleeping long waits.
269
- ...(opts.floodStatePath
270
- ? {
271
- onFloodWait: makeFloodWaitRecorder(opts.floodStatePath),
272
- floodWaitRemainingMs: makeFloodWaitProbe(opts.floodStatePath),
273
- }
274
- : {}),
275
- })
276
- }
295
+ // ─── robustApiCall factory: REMOVED (#3863) ───────────────────────────────
296
+ //
297
+ // `createRobustApiCall` used to live here as a second `createRetryApiCall`
298
+ // wiring "pre-wired with stderr logging", kept for a standalone foreman bot
299
+ // that was retired. It had ZERO production callers, and its breaker hooks were
300
+ // OPTIONAL (`floodStatePath` defaulted to absent), so the one thing a second
301
+ // wiring must never be — a send path blind to the shared flood window — was
302
+ // its default. Docblocks in `flood-circuit-breaker.ts` and `flood-429-ledger.ts`
303
+ // cited it as a live wiring, which is how a dead function became load-bearing
304
+ // documentation for the breaker's completeness argument.
305
+ //
306
+ // There is one retry wiring per process, built at its own callsite with both
307
+ // breaker hooks (`gateway.ts`'s `robustApiCall`, `outbox-sweep.ts`'s sweep
308
+ // caller) and enforced by `scripts/check-retry-flood-hooks.mjs`. Do not
309
+ // reintroduce a convenience factory here: it re-creates the hook-optional
310
+ // default this deletion removed. `tests/no-robust-api-call-factory.test.ts`
311
+ // guards that.
277
312
 
278
313
  // ─── Markdown escape helpers (#2669) ──────────────────────────────────────
279
314
 
@@ -68,8 +68,18 @@ const ZERO_SIGNAL_POLL_METHODS = new Set(['getUpdates', 'getMe'])
68
68
  * `status=err` signal in high-volume card-repaint no-ops: `grep status=err`
69
69
  * stays a list of real failures, and the benign lines are still there,
70
70
  * honestly labelled, for anyone who wants them.
71
+ *
72
+ * `retry` (#3931) is a REJECTED call that the enclosing retry policy is about
73
+ * to attempt AGAIN — a 429 under the in-process sleep ceiling, or a transient
74
+ * transport error with attempts left. It is not an outcome: the logical send
75
+ * may still be delivered by the next attempt, and usually is. It exists
76
+ * because `status=err` is consumed as a delivery FAILURE by fleet-health's
77
+ * `reply-delivery-failure` detector (`src/fleet-health/detect.ts`), which
78
+ * matches per LINE — so labelling a survivable attempt `err` escalated
79
+ * successfully delivered replies. Post-#3931 a `status=err` line means the
80
+ * logical send is over and it did not land.
71
81
  */
72
- export type TgPostStatus = 'ok' | 'benign' | 'err'
82
+ export type TgPostStatus = 'ok' | 'benign' | 'err' | 'retry'
73
83
 
74
84
  /**
75
85
  * Decide whether a `tg-post` line should be written.
@@ -79,6 +89,8 @@ export type TgPostStatus = 'ok' | 'benign' | 'err'
79
89
  *
80
90
  * Always emitted:
81
91
  * - any `status=err` (real failures — the whole point of the log),
92
+ * - any `status=retry` (the survivable attempts behind a flood episode —
93
+ * suppressing them would hide the run-up to a ban, #3931),
82
94
  * - every other method's `ok` line (sendMessage/editMessageText/... are
83
95
  * genuine outbound observability, #656/#657),
84
96
  * - `status=benign` on any non-poll method — same volume as the
@@ -90,7 +102,7 @@ export function shouldEmitTgPost(
90
102
  verbose: boolean = gwTraceVerbose,
91
103
  ): boolean {
92
104
  if (verbose) return true
93
- if (status === 'err') return true
105
+ if (status === 'err' || status === 'retry') return true
94
106
  return !ZERO_SIGNAL_POLL_METHODS.has(method)
95
107
  }
96
108
 
@@ -66,6 +66,22 @@ export interface SilentEndState {
66
66
  * absent for the zero-prose (genuinely empty) silent-end.
67
67
  */
68
68
  pendingText?: string
69
+ /**
70
+ * W1-d follow-up (#4141) — one of THIS turn's reply-tool calls came back
71
+ * `is_error: true`, so `pendingText` is prose the model wrote *after* its
72
+ * delivery attempt failed, not text it chose to send as the answer.
73
+ *
74
+ * Stamped by the Stop hook's single-writer election
75
+ * (`silent-end-interrupt-stop.mjs` → `buildNextState`), the only component
76
+ * that can see the transcript; the gateway's own `writeSilentEndState` never
77
+ * sets it. Consumed by `deliverCapturedProse`, which prefixes a one-line
78
+ * provenance banner so the recipient is told the text was scraped off a
79
+ * failed turn rather than sent as an answer.
80
+ *
81
+ * Optional and only ever `true`: absent means "no positive evidence of a
82
+ * throw", which delivers exactly as before. It never suppresses.
83
+ */
84
+ replyToolThrewThisTurn?: boolean
69
85
  }
70
86
 
71
87
  export interface SilentEndDeps {
@@ -320,8 +336,16 @@ export const CAPTURED_PROSE_MIN_CHARS = 200
320
336
  export interface CapturedProseDecision {
321
337
  /** True → the gateway should deliver `text` directly on this silent-end. */
322
338
  deliver: boolean
323
- /** The prose to deliver; present iff `deliver === true`. */
339
+ /** The prose to deliver; present iff `deliver === true`. RAW — never framed
340
+ * here, so the caller's dedup and journal keys stay computed on the same
341
+ * bytes the sweep path uses (#4141). */
324
342
  text?: string
343
+ /**
344
+ * #4141 — the persisted record says this turn's reply tool threw. Surfaced
345
+ * (not acted on) so `deliverCapturedProse` can state the prose's provenance.
346
+ * `true` only on positive evidence; never affects `deliver`.
347
+ */
348
+ replyToolThrewThisTurn?: boolean
325
349
  /** Machine-readable reason (for logs / tests). */
326
350
  reason:
327
351
  | 'captured-prose'
@@ -394,7 +418,15 @@ export function decideCapturedProseDelivery(
394
418
  if (deps?.backstopDeliveredNonceHit?.(state.turnId) === true) {
395
419
  return { deliver: false, reason: 'already-delivered' }
396
420
  }
397
- return { deliver: true, text, reason: 'captured-prose' }
421
+ return {
422
+ deliver: true,
423
+ text,
424
+ reason: 'captured-prose',
425
+ // #4141: pass the hook's raw signal through untouched. Deliberately placed
426
+ // AFTER every `deliver:false` gate above — the banner is a labelling
427
+ // concern, and must never become an input to whether we deliver at all.
428
+ ...(state.replyToolThrewThisTurn === true ? { replyToolThrewThisTurn: true } : {}),
429
+ }
398
430
  }
399
431
 
400
432
  /**
@@ -60,23 +60,38 @@ export const STATUS_LINE_MAX = 200
60
60
  export const STATUS_CARD_CHAR_BUDGET = RICH_MESSAGE_MAX_CHARS
61
61
 
62
62
  /**
63
- * Indent marker for a nested (foreground sub-agent) step line.
64
- *
65
- * NOTE: the three leading spaces here are ASCII, which Telegram's server-side
66
- * markdown parser DROPS — the visible nesting cue on this surface is the `↳`
67
- * glyph, not the indent. That is a known latent wart, deliberately left alone
68
- * it needs its own live render check on the single-worker / agent card,
69
- * tracked in #3668. It is NOT broken by the #3662 mechanism (nothing here
70
- * depends on the ASCII run being visible), so it is not fixed here. If #3668
71
- * ever does make it a real indent, use U+2800 — NOT U+00A0, which was tried in
72
- * #3662 and renders flat. Do NOT copy this string as the idiom for a real
73
- * indent.
74
- *
75
- * @see WORKER_STEP_INDENTthe U+2800 indent used for actual left-nesting on
76
- * the combined (2+ worker) card, and the evidence for why ASCII (and U+00A0)
77
- * cannot work.
63
+ * Indent marker for a nested (foreground sub-agent) step line: three U+2800
64
+ * BRAILLE PATTERN BLANK, then the `↳` glyph.
65
+ *
66
+ * Until #3668 the indent was three ASCII spaces, which Telegram's server-side
67
+ * markdown parser LEFT-TRIMS off a content line so the child block rendered
68
+ * FLAT against its parent steps and the only surviving nesting cue was the `↳`
69
+ * glyph. This constant now uses the SAME idiom as {@link WORKER_STEP_INDENT}
70
+ * for exactly the same reason, and there is only one indent idiom on the card
71
+ * surfaces again.
72
+ *
73
+ * The evidence is not an inference about Telegram's parser: U+2800 is general
74
+ * category So (Symbol, other), not Zs, so no whitespace-trimming rule — ASCII
75
+ * `\s`, Unicode `White_Space`, or `Zs` can classify it as whitespace, yet it
76
+ * renders as blank width. It is ALSO live-verified: on 2026-07-26 four
77
+ * candidates were sent to a real phone in one message — three U+00A0 rendered
78
+ * FLAT (the #3662 failure), three U+2800 INDENTED CORRECTLY, a leading `↳ `
79
+ * rendered as visible ink, and a leading `· ` was promoted into a real list
80
+ * bullet. See {@link WORKER_STEP_INDENT}'s comment for the full record.
81
+ *
82
+ * The `↳ ` glyph is KEPT: it is what distinguishes a foreground sub-agent's
83
+ * step from the parent's own steps on a card where both appear, and the
84
+ * indent is what makes the block read as nested. The blanks lead so the
85
+ * glyph is not the thing being trimmed.
86
+ *
87
+ * Guard: `nested-prefix-indent.test.ts` asserts the PROPERTY (no character of
88
+ * the indent is whitespace under any of the three rules), not just the bytes —
89
+ * a byte-only assertion is what let #3662 ship green and inert.
90
+ *
91
+ * @see WORKER_STEP_INDENT — the same U+2800 idiom for the combined
92
+ * (2+ worker) card, and the full evidence for why ASCII and U+00A0 cannot work.
78
93
  */
79
- export const NESTED_PREFIX = ' ↳ '
94
+ export const NESTED_PREFIX = '\u2800\u2800\u2800↳ '
80
95
 
81
96
  /**
82
97
  * Left indent for a step line rendered UNDER a worker header on the combined
@@ -27,6 +27,12 @@ import {
27
27
  import { richMessage, isParseEntitiesError } from './rich-send.js'
28
28
  import { renderOutboundChunks } from './render/rich-render.js'
29
29
  import { isSendGateShed, type SendGateOpts } from './send-gate.js'
30
+ import {
31
+ buildReplyParameters,
32
+ dropQuoteFromSendOpts,
33
+ isQuoteRejectionError,
34
+ sendOptsHaveQuote,
35
+ } from './reply-quote.js'
30
36
 
31
37
  /**
32
38
  * Minimal bot.api surface the controller needs. Real callers pass grammy's
@@ -59,8 +65,14 @@ export interface StreamSendOpts {
59
65
  * message. Only meaningful on the initial `sendMessage` — `editMessageText`
60
66
  * cannot add a quote reference to an existing message, so the controller
61
67
  * strips this from edit opts internally.
68
+ *
69
+ * `quote` is a Bot API **String** — the quoted substring itself. The optional
70
+ * offset is a SIBLING `quote_position` Integer, which this plugin
71
+ * deliberately never emits. Typing it as an object is what produced
72
+ * `400 field "quote" must be of type String` on every quoted reply; see
73
+ * `reply-quote.ts`.
62
74
  */
63
- reply_parameters?: { message_id: number; quote?: { text: string; position: number } }
75
+ reply_parameters?: { message_id: number; quote?: string }
64
76
  /**
65
77
  * Inline keyboard markup. Included in both sendMessage and editMessageText
66
78
  * so that inline buttons persist through text edits. Without this,
@@ -121,7 +133,10 @@ export interface StreamControllerConfig {
121
133
  /**
122
134
  * Optional quote text for surgical quoting. When set along with
123
135
  * `replyToMessageId`, the initial send includes
124
- * `reply_parameters: { message_id, quote: { text, position: 0 } }`.
136
+ * `reply_parameters: { message_id, quote }` `quote` is a Bot API String
137
+ * (see `reply-quote.ts`). A quote Telegram cannot find in the replied-to
138
+ * message is dropped and the send retried unquoted, so the answer still
139
+ * lands.
125
140
  */
126
141
  quoteText?: string
127
142
  /**
@@ -222,14 +237,16 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
222
237
  ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
223
238
  ...(replyMarkup != null ? { reply_markup: replyMarkup } : {}),
224
239
  }
225
- const sendOpts: StreamSendOpts = {
240
+ // `let`, not `const`: a quote Telegram cannot find in the replied-to message
241
+ // is stripped ONCE for the whole stream (see sendPieceWithQuoteFallback) —
242
+ // re-attaching it to the next piece would just 400 again.
243
+ let sendOpts: StreamSendOpts = {
226
244
  ...baseOpts,
227
245
  ...(replyToMessageId != null
228
246
  ? {
229
- reply_parameters: {
230
- message_id: replyToMessageId,
231
- ...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
232
- },
247
+ // `quote` is a Bot API String; `quote_position` is a separate sibling
248
+ // Integer we deliberately don't emit. See reply-quote.ts.
249
+ reply_parameters: buildReplyParameters(replyToMessageId, quoteText),
233
250
  }
234
251
  : {}),
235
252
  ...(protectContent === true ? { protect_content: true } : {}),
@@ -263,6 +280,24 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
263
280
  delete richOpts.link_preview_options
264
281
  return bot.api.sendRichMessage(chatId, richMessage(piece.text), richOpts)
265
282
  }
283
+ // Send a piece, degrading gracefully when the SURGICAL QUOTE is the thing
284
+ // Telegram rejected. `reply_parameters.quote` must be an exact substring of
285
+ // the replied-to message; when it isn't, the 400 kills a perfectly good
286
+ // answer. Drop the highlight (for this send and every later one on this
287
+ // stream) and re-send — the reply still lands, still threaded under the same
288
+ // message.
289
+ const sendPieceWithQuoteFallback = async (piece: { text: string; rich: boolean }) => {
290
+ try {
291
+ return await sendPiece(piece, sendOpts)
292
+ } catch (err) {
293
+ if (!isQuoteRejectionError(err) || !sendOptsHaveQuote(sendOpts)) throw err
294
+ warn?.(
295
+ `stream-controller: quote not found in the replied-to message — re-sending without the quote (${err instanceof Error ? err.message : String(err)})`,
296
+ )
297
+ sendOpts = dropQuoteFromSendOpts(sendOpts)
298
+ return await sendPiece(piece, sendOpts)
299
+ }
300
+ }
266
301
  const editPiece = (id: number, piece: { text: string; rich: boolean }, opts: StreamSendOpts) => {
267
302
  if (!piece.rich) return bot.api.editMessageText(chatId, id, piece.text, opts)
268
303
  return bot.api.editMessageText(chatId, id, richMessage(piece.text), opts)
@@ -395,7 +430,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
395
430
  }
396
431
  // First emission of this tail piece → a fresh follow-up message.
397
432
  try {
398
- const sent = await retry(() => sendPiece(piece, sendOpts), { threadId, chat_id: chatId })
433
+ const sent = await retry(() => sendPieceWithQuoteFallback(piece), { threadId, chat_id: chatId })
399
434
  tailIds[ti] = sent.message_id
400
435
  tailLastText[ti] = piece.text
401
436
  onSend?.(sent.message_id, piece.text.length)
@@ -435,7 +470,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
435
470
  let anchorId: number | undefined
436
471
  try {
437
472
  const sent = await retry(
438
- () => sendPiece(head, sendOpts),
473
+ () => sendPieceWithQuoteFallback(head),
439
474
  { threadId, chat_id: chatId },
440
475
  )
441
476
  anchorId = sent.message_id
@@ -109,9 +109,11 @@ export interface StreamReplyArgs {
109
109
  disable_notification?: boolean
110
110
  /**
111
111
  * Optional surgical quote text. When set along with `reply_to`, the initial
112
- * send includes `reply_parameters: { message_id, quote: { text, position: 0 } }`
113
- * so Telegram highlights the specific quoted sentence rather than the whole
114
- * referenced message. Ignored when `reply_to` is absent.
112
+ * send includes `reply_parameters: { message_id, quote }` `quote` is a Bot
113
+ * API String (see `reply-quote.ts`) so Telegram highlights the specific
114
+ * quoted sentence rather than the whole referenced message. It must be an
115
+ * exact substring of the referenced message; one Telegram cannot find is
116
+ * dropped and the send retried unquoted. Ignored when `reply_to` is absent.
115
117
  */
116
118
  quote_text?: string
117
119
  /**
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { describe, it, expect } from 'vitest'
16
16
  import { renderBootCard, resolvePersonaName } from '../gateway/boot-card.js'
17
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
17
18
 
18
19
  describe('renderBootCard — quiet by default', () => {
19
20
  it('returns one-line ack with default ✅ when no probes and no restart reason', () => {
@@ -153,7 +154,7 @@ describe('renderBootCard — degraded conditions', () => {
153
154
  },
154
155
  })
155
156
  expect(out).toContain('🟡 **Skills** 10/10 dangling')
156
- expect(out).toContain(' ↳ Run `switchroom agent reconcile lawgpt` to rebuild symlinks')
157
+ expect(out).toContain(`${NESTED_PREFIX}Run \`switchroom agent reconcile lawgpt\` to rebuild symlinks`)
157
158
  })
158
159
 
159
160
  it('crash row carries a tail-logs next-step', () => {
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderStatusCard, renderCombinedWorkerFeed } from '../tool-activity-summary.js'
3
+ import { fitCardToBudget, hardTruncateCard, renderCardSpec } from '../card-layout.js'
4
+ import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
5
+
6
+ /**
7
+ * #3833 (and its duplicate #3682) — a rendered card must NEVER exceed
8
+ * `STATUS_CARD_CHAR_BUDGET`. An over-budget card is not a cosmetic problem: it
9
+ * is rejected by the Bot API, so the user's progress card simply does not
10
+ * appear (or stops updating) for the whole turn.
11
+ *
12
+ * Two defects produced over-budget cards, and both are asserted here:
13
+ *
14
+ * 1. UNDER-COUNTED FIXED COST. The deepest shrink level charged its
15
+ * header/footer as `[...chrome, ...footer].join('\n')`, but cards are
16
+ * rendered by `stackCard`, which emits a collapse separator plus a
17
+ * two-space GFM hard break at every line boundary — 4 chars per boundary,
18
+ * not 1 — and it also charges the collapsed parent-marker line. The budget
19
+ * the fitter handed the body was therefore too generous and the card came
20
+ * out a few chars over.
21
+ *
22
+ * 2. NO RE-CHECK ON THE LAST RESORT. `fitCardToBudget` returned the deepest
23
+ * render unmeasured. When the FIXED chrome alone is over budget (the #3682
24
+ * repro: a 40k-char worker description), no shrink level can help — every
25
+ * level keeps the chrome — so a 40k-char card went to the wire.
26
+ */
27
+
28
+ const HEADER = { emoji: '🛠', label: 'Worker', elapsedMs: 61_000, toolCount: 7, state: 'running' as const }
29
+
30
+ function card(opts: { desc?: string; steps?: string[]; children?: string[]; final?: boolean; result?: string }): string {
31
+ return renderStatusCard({
32
+ header: { ...HEADER, description: opts.desc ?? 'a task', state: opts.final === true ? 'done' : 'running' },
33
+ steps: opts.steps ?? ['step one'],
34
+ childSteps: opts.children,
35
+ final: opts.final ?? false,
36
+ stepCount: 3,
37
+ result: opts.result != null ? { emoji: '✅', text: opts.result } : undefined,
38
+ })!
39
+ }
40
+
41
+ describe('a rendered card never exceeds the char budget (#3833 / #3682)', () => {
42
+ it('the #3682 repro — a 40k-char description — comes back under budget', () => {
43
+ // Fail-before: 40069 chars against a 32768 budget on origin/main.
44
+ const out = card({ desc: 'd'.repeat(40_000), steps: ['x'.repeat(4_000)], children: ['y'.repeat(4_000)] })
45
+ expect(out.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
46
+ // And it is still a card, not an empty string.
47
+ expect(out.length).toBeGreaterThan(100)
48
+ expect(out.startsWith('🛠')).toBe(true)
49
+ })
50
+
51
+ it('the join-cost under-count boundary — every description length near the budget fits', () => {
52
+ // Fail-before: on origin/main the lengths 32693…32800 each rendered 1…98
53
+ // chars OVER budget. That band is exactly the fixed-cost under-count: the
54
+ // renderer's per-boundary separator + hard break, which the arithmetic
55
+ // estimate charged as one newline.
56
+ const over: Array<{ n: number; by: number }> = []
57
+ for (let n = 32_600; n <= 32_900; n++) {
58
+ const out = card({ desc: 'd'.repeat(n), steps: ['a', 'b', 'c'], result: 'done' })
59
+ if (out.length > STATUS_CARD_CHAR_BUDGET) over.push({ n, by: out.length - STATUS_CARD_CHAR_BUDGET })
60
+ }
61
+ expect(over).toEqual([])
62
+ })
63
+
64
+ it('PROPERTY: no combination of description / steps / children / result overflows', () => {
65
+ // A deterministic sweep over the shape space rather than a handful of
66
+ // hand-picked cases: the guarantee is universal, so the test is too.
67
+ //
68
+ // Fillers matter as much as sizes — escaping EXPANDS text (`&` → `&amp;`
69
+ // is 5×, markdown specials are 2×), which is what the fitter's clip loop
70
+ // has to converge against, and `👍` is astral so a naive slice could split
71
+ // a surrogate pair.
72
+ //
73
+ // Every filler is swept at the small/medium sizes; the huge sizes (where a
74
+ // single render costs seconds purely in `escapeMarkdown`, unchanged by this
75
+ // PR) are swept with the cheap-but-still-expanding fillers.
76
+ const cases: Array<{ size: number; fill: string }> = []
77
+ for (const size of [0, 1, 7, 200, 4_000, 16_000]) {
78
+ for (const fill of ['x', '&', '_*[`', '👍', 'é']) cases.push({ size, fill })
79
+ }
80
+ for (const size of [32_700, 33_000, 90_000]) {
81
+ for (const fill of ['x', '&', '👍']) cases.push({ size, fill })
82
+ }
83
+ for (const { size, fill } of cases) {
84
+ const blob = fill.repeat(Math.ceil(size / fill.length)).slice(0, size)
85
+ for (const final of [false, true]) {
86
+ for (const withChildren of [false, true]) {
87
+ const out = renderStatusCard({
88
+ header: { ...HEADER, description: blob, state: final ? 'done' : 'running' },
89
+ steps: [blob, blob, 'plain step'],
90
+ childSteps: withChildren ? [blob, blob] : undefined,
91
+ final,
92
+ liveSuffix: ' · 22s',
93
+ stepCount: 12,
94
+ result: { emoji: '✅', text: blob },
95
+ })
96
+ if (out == null) continue
97
+ expect(
98
+ out.length,
99
+ `over budget: size=${size} fill=${JSON.stringify(fill)} final=${final} children=${withChildren}`,
100
+ ).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
101
+ // Never blanked, and never sliced through a surrogate pair.
102
+ expect(out.length).toBeGreaterThan(0)
103
+ expect(/[\uD800-\uDBFF]$/.test(out)).toBe(false)
104
+ }
105
+ }
106
+ }
107
+ }, 60_000)
108
+
109
+ it('PROPERTY: the combined worker card also stays under budget for any fan-out', () => {
110
+ for (const rows of [1, 2, 6, 25]) {
111
+ for (const size of [50, 5_000, 40_000]) {
112
+ const out = renderCombinedWorkerFeed(
113
+ Array.from({ length: rows }, (_, i) => ({
114
+ ordinal: i + 1,
115
+ description: 'w'.repeat(size),
116
+ currentStep: 's'.repeat(size),
117
+ historyLines: ['h'.repeat(size), 'h2'],
118
+ elapsedMs: 60_000,
119
+ toolCount: 3,
120
+ })),
121
+ { maxRows: 8 },
122
+ )!
123
+ expect(out.length, `rows=${rows} size=${size}`).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
124
+ }
125
+ }
126
+ })
127
+
128
+ it('fitCardToBudget itself guarantees the budget even when every level overflows', () => {
129
+ // The mechanism, isolated from any particular card: a build function whose
130
+ // deepest level is still hopeless. Fail-before: this returned the raw
131
+ // 50k-char render.
132
+ const huge = 'q'.repeat(50_000)
133
+ const out = fitCardToBudget(() => ({ spec: { chrome: [huge], sections: [], footer: [] } }), 3)
134
+ expect(out.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
135
+ expect(out.length).toBeGreaterThan(0)
136
+ })
137
+
138
+ it('fitCardToBudget still returns the FIRST fitting level untouched', () => {
139
+ // The backstop must not change the normal path: a level that fits is
140
+ // returned byte-for-byte, and deeper levels are never built.
141
+ const built: number[] = []
142
+ const spec = { chrome: ['🛠 **Worker**'], sections: [], footer: ['_✓ 3 steps_'] }
143
+ const out = fitCardToBudget((level) => {
144
+ built.push(level)
145
+ return { spec }
146
+ }, 5)
147
+ expect(built).toEqual([0])
148
+ expect(out).toBe(renderCardSpec(spec))
149
+ })
150
+
151
+ it('hardTruncateCard cuts at a line boundary and never returns invalid UTF-16', () => {
152
+ const lines = ['aaaa', 'bbbb', 'cccc'].join(' \n')
153
+ // Budget lands inside the third line → cut back to the second boundary.
154
+ const cut = hardTruncateCard(lines, 12)
155
+ expect(cut.length).toBeLessThanOrEqual(12)
156
+ expect(cut).toBe('aaaa \nbbbb')
157
+ // Under budget → identity.
158
+ expect(hardTruncateCard(lines, 1000)).toBe(lines)
159
+ // A single over-long line with an astral char at the cut → no lone surrogate.
160
+ const astral = '👍'.repeat(50)
161
+ const sliced = hardTruncateCard(astral, 11)
162
+ expect(sliced.length).toBeLessThanOrEqual(11)
163
+ expect(/[\uD800-\uDBFF]$/.test(sliced)).toBe(false)
164
+ })
165
+ })