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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* standalone foreman bot before its retirement.
|
|
6
6
|
*
|
|
7
7
|
* What lives here:
|
|
8
|
-
* - `
|
|
9
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 {
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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?:
|
|
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
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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(() =>
|
|
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
|
-
() =>
|
|
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
|
|
113
|
-
*
|
|
114
|
-
* referenced message.
|
|
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(
|
|
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 (`&` → `&`
|
|
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
|
+
})
|