switchroom 0.18.13 → 0.18.15
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 +49 -9
- package/dist/auth-broker/index.js +152 -46
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +1185 -1072
- package/dist/host-control/main.js +53 -52
- package/dist/vault/approvals/kernel-server.js +16 -13
- package/dist/vault/broker/server.js +672 -669
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +765 -67
- package/telegram-plugin/dist/server.js +24 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +270 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +234 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/runtime-metrics.ts +31 -0
- package/telegram-plugin/session-tail.ts +14 -2
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
- package/telegram-plugin/throttle-tier.ts +323 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* throttle-tier.ts — the 429 throttle tier (pure decision + notice rendering).
|
|
3
|
+
*
|
|
4
|
+
* Operator-approved behavior spec: "retry in place under 5 min, else mark +
|
|
5
|
+
* failover, honest reset messaging."
|
|
6
|
+
*
|
|
7
|
+
* A terminal TRANSIENT 429 (a `rate_limit_error` whose wording explicitly
|
|
8
|
+
* negates the account-quota reading — see `transientUpstreamSignals` in
|
|
9
|
+
* model-unavailable.ts) used to take the fully calm path: no broker state, no
|
|
10
|
+
* account attribution, a generic "🚦 Rate limited" card. That is right for a
|
|
11
|
+
* few-second burst but wrong for the minutes-long per-account throttles
|
|
12
|
+
* Anthropic emits with a "resets 8:50am (TZ)" hint: the fleet has no memory
|
|
13
|
+
* that the account is throttled (cron fires walk straight into the same 429)
|
|
14
|
+
* and the operator can't tell a throttle from a wall.
|
|
15
|
+
*
|
|
16
|
+
* This module owns the DECISION and the NOTICE TEXT; the gateway wires the
|
|
17
|
+
* side effects (broker `mark-throttled`, the Telegram send, the delayed
|
|
18
|
+
* retry nudge):
|
|
19
|
+
*
|
|
20
|
+
* - reset parseable and ≤ threshold (default 5 min) → `throttle`: stay on
|
|
21
|
+
* the account, record `throttled_until` broker-side, notify once.
|
|
22
|
+
* - reset parseable and > threshold → `failover`: escalate to the existing
|
|
23
|
+
* mark-exhausted + fleet-failover machinery with the parsed reset as the
|
|
24
|
+
* mark expiry.
|
|
25
|
+
* - reset unparseable → `throttle` with a conservative now+60s wait.
|
|
26
|
+
*
|
|
27
|
+
* Wall wording ("You've hit your limit", out_of_credits) never reaches this
|
|
28
|
+
* module — session-tail classifies those `quota-exhausted` and the gateway's
|
|
29
|
+
* existing failover path handles them unchanged.
|
|
30
|
+
*
|
|
31
|
+
* PURE — no IPC, no bot, no clock except the injected `now`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { escapeMarkdown } from './card-format.js'
|
|
35
|
+
import { formatResetRelative } from './quota-check.js'
|
|
36
|
+
import {
|
|
37
|
+
isLitellmProxyLocal429,
|
|
38
|
+
parseLitellmLimitDetail,
|
|
39
|
+
parseResetTime,
|
|
40
|
+
} from './model-unavailable.js'
|
|
41
|
+
import type { RuntimeMetricEvent } from './runtime-metrics.js'
|
|
42
|
+
|
|
43
|
+
// ─── Account-scoped throttle wording ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ACCOUNT-AFFIRMING subset of `transientUpstreamSignals` (model-unavailable.ts).
|
|
47
|
+
* The throttle tier takes ACCOUNT-level actions — broker `mark-throttled`,
|
|
48
|
+
* per-account notice, retry nudge — so it must key on wording that affirms the
|
|
49
|
+
* account's OWN rate limit ("would exceed your account's rate limit",
|
|
50
|
+
* "not your account…"), NOT the server-side phrasings ("Server is temporarily
|
|
51
|
+
* limiting requests (not your usage limit)", 529 overload wording). A
|
|
52
|
+
* server-wide condition recorded as an account throttle would bench the wrong
|
|
53
|
+
* thing and restart-nudge an agent straight back into the same server-side
|
|
54
|
+
* wall. Server-side transients keep the existing calm rate-limited path
|
|
55
|
+
* (Claude Code's internal retry) untouched.
|
|
56
|
+
*/
|
|
57
|
+
export const accountScopedThrottleSignals = [
|
|
58
|
+
"would exceed your account's rate limit",
|
|
59
|
+
'would exceed your account’s rate limit',
|
|
60
|
+
// Covers both "not your account" and "not your account's" (substring).
|
|
61
|
+
'not your account',
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
/** True when `text` carries an EXPLICIT account-affirming throttle marker.
|
|
65
|
+
* Never throws on weird input. */
|
|
66
|
+
export function isAccountScopedThrottle(text: string): boolean {
|
|
67
|
+
if (typeof text !== 'string' || text.length === 0) return false
|
|
68
|
+
const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
|
|
69
|
+
const lower = sample.toLowerCase()
|
|
70
|
+
return accountScopedThrottleSignals.some((s) => lower.includes(s))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Three-way 429 classification ────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a terminal 429-family failure originated:
|
|
77
|
+
* - `account-scoped` — Anthropic throttled THIS account ("would exceed
|
|
78
|
+
* your account's rate limit"). Eligible for the throttle tier below
|
|
79
|
+
* (broker mark-throttled / failover).
|
|
80
|
+
* - `litellm-local` — the LiteLLM proxy's OWN limiter tripped
|
|
81
|
+
* (`tpm_limit`/`rpm_limit` cap, router cooldown — see
|
|
82
|
+
* `litellmProxyLocal429Signals` in model-unavailable.ts). The request
|
|
83
|
+
* never reached Anthropic; account state must not be touched.
|
|
84
|
+
* - `generic-transient` — everything else in the rate-limit family
|
|
85
|
+
* (server-side 429/529 wording, bare `rate_limit_error`). Calm path.
|
|
86
|
+
*/
|
|
87
|
+
export type RateLimit429Classification =
|
|
88
|
+
| 'account-scoped'
|
|
89
|
+
| 'litellm-local'
|
|
90
|
+
| 'generic-transient'
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Classify a terminal `rate-limited` operator event's detail text.
|
|
94
|
+
*
|
|
95
|
+
* TIE-BREAK (both wordings present): account-scoped wins, and only on its
|
|
96
|
+
* EXPLICIT wording. Rationale: LiteLLM never emits the account-affirming
|
|
97
|
+
* strings itself, so when they co-occur with LiteLLM wording the detail is a
|
|
98
|
+
* genuine upstream Anthropic account 429 that traversed (and was wrapped by)
|
|
99
|
+
* the proxy — e.g. the pass-through's "litellm.RateLimitError: …would exceed
|
|
100
|
+
* your account's rate limit…" exception mapping. Classifying that as
|
|
101
|
+
* proxy-local would drop the broker throttle mark and walk every retry
|
|
102
|
+
* straight back into the same account throttle. The reverse risk is nil: a
|
|
103
|
+
* purely proxy-local 429 (limiter fired BEFORE any upstream call) cannot
|
|
104
|
+
* contain Anthropic's account wording.
|
|
105
|
+
*
|
|
106
|
+
* BOUND: operator events forwarded over IPC carry `detail` truncated to
|
|
107
|
+
* 1000 chars (bridge.ts `sendOperatorEvent`'s `.slice(0, 1000)`;
|
|
108
|
+
* `OPERATOR_EVENT_DETAIL_MAX` in gateway/ipc-server.ts), so on that path the
|
|
109
|
+
* tie-break sees only the first 1000 chars of the body. In practice both
|
|
110
|
+
* wordings sit well inside that window (real Anthropic and LiteLLM bodies
|
|
111
|
+
* are <400 chars), and a truncated-away account marker would merely
|
|
112
|
+
* downgrade account-scoped → litellm-local/generic-transient — the calm
|
|
113
|
+
* path, never a wrong account mark.
|
|
114
|
+
*
|
|
115
|
+
* Never throws on weird input (both matchers are total).
|
|
116
|
+
*/
|
|
117
|
+
export function classify429Detail(text: string): RateLimit429Classification {
|
|
118
|
+
if (isAccountScopedThrottle(text)) return 'account-scoped'
|
|
119
|
+
if (isLitellmProxyLocal429(text)) return 'litellm-local'
|
|
120
|
+
return 'generic-transient'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build the `rate_limit_429_classified` runtime metric for one terminal
|
|
125
|
+
* rate-limited operator event — the instrumentation that lets an operator
|
|
126
|
+
* correlate Anthropic ACCOUNT 429s with fleet TPM (the prerequisite for
|
|
127
|
+
* enabling LiteLLM `tpm_limit` caps; see docs/auth.md § LiteLLM-proxy-local
|
|
128
|
+
* 429s). Pure builder so the payload shape is unit-testable; the gateway
|
|
129
|
+
* emits the result via emitRuntimeMetric (PostHog + JSONL dual sink).
|
|
130
|
+
*
|
|
131
|
+
* `action` is what the gateway decided for this event:
|
|
132
|
+
* - `throttle` / `failover` — the account-scoped throttle tier's decision
|
|
133
|
+
* - `calm` — the existing calm rate-limited path (litellm-local and
|
|
134
|
+
* generic-transient always land here; no broker mark, no failover)
|
|
135
|
+
*
|
|
136
|
+
* Reset detail is best-effort: the Anthropic-shaped `parseResetTime` first,
|
|
137
|
+
* then LiteLLM's own shapes ("Limit resets at: … UTC" / "Try again in Ns")
|
|
138
|
+
* via `parseLitellmLimitDetail`. Limit fields parse only from LiteLLM
|
|
139
|
+
* wording (Anthropic bodies carry no numeric limit).
|
|
140
|
+
*/
|
|
141
|
+
export function build429ClassifiedMetric(opts: {
|
|
142
|
+
agent: string
|
|
143
|
+
detail: string
|
|
144
|
+
classification: RateLimit429Classification
|
|
145
|
+
action: 'throttle' | 'failover' | 'calm'
|
|
146
|
+
now: number
|
|
147
|
+
}): Extract<RuntimeMetricEvent, { kind: 'rate_limit_429_classified' }> {
|
|
148
|
+
const detail = typeof opts.detail === 'string' ? opts.detail : ''
|
|
149
|
+
const litellm = parseLitellmLimitDetail(detail, new Date(opts.now))
|
|
150
|
+
const anthropicResetMs = parseResetTime(detail, new Date(opts.now))?.getTime() ?? null
|
|
151
|
+
const resetAtMs = anthropicResetMs ?? litellm.resetAtMs
|
|
152
|
+
return {
|
|
153
|
+
kind: 'rate_limit_429_classified',
|
|
154
|
+
agent: opts.agent,
|
|
155
|
+
classification: opts.classification,
|
|
156
|
+
action: opts.action,
|
|
157
|
+
reset_at_ms: resetAtMs,
|
|
158
|
+
reset_in_ms: resetAtMs != null ? Math.max(0, resetAtMs - opts.now) : null,
|
|
159
|
+
limit_type: litellm.limitType,
|
|
160
|
+
limit: litellm.limit,
|
|
161
|
+
current_usage: litellm.currentUsage,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Default retry-in-place ceiling: a transient 429 whose reset is within this
|
|
167
|
+
* window waits on the account instead of failing the fleet over. Override
|
|
168
|
+
* with SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS.
|
|
169
|
+
*/
|
|
170
|
+
export const THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT = 5 * 60_000
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Wait applied when a transient 429 carries NO parseable reset. Anthropic's
|
|
174
|
+
* burst throttles clear in seconds; 60s is comfortably past the common case
|
|
175
|
+
* without benching the account for long on a misread.
|
|
176
|
+
*/
|
|
177
|
+
export const THROTTLE_DEFAULT_WAIT_MS = 60_000
|
|
178
|
+
|
|
179
|
+
/** Resolve the retry-in-place threshold from env (ms), else the default. */
|
|
180
|
+
export function throttleRetryInPlaceMaxMs(
|
|
181
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
182
|
+
): number {
|
|
183
|
+
const raw = env.SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS
|
|
184
|
+
if (raw == null || raw === '') return THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
|
|
185
|
+
const n = Number(raw)
|
|
186
|
+
return Number.isFinite(n) && n > 0 ? n : THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type ThrottleTierDecision =
|
|
190
|
+
/** Not a transient per-account 429 — caller falls through to its
|
|
191
|
+
* existing handling (calm card / quota path). */
|
|
192
|
+
| { action: 'none' }
|
|
193
|
+
/** Stay on the account; record throttled_until broker-side; notify once. */
|
|
194
|
+
| { action: 'throttle'; throttledUntilMs: number; resetParsed: boolean }
|
|
195
|
+
/** Reset is beyond the retry-in-place threshold — escalate to the
|
|
196
|
+
* existing mark-exhausted + fleet-failover machinery. */
|
|
197
|
+
| { action: 'failover'; resetAtMs: number }
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* THE throttle-tier decision for a terminal `rate-limited` operator event.
|
|
201
|
+
* `detail` is the user-facing error text from the transcript (the source of
|
|
202
|
+
* both the transient-negation wording and the "resets …" prose).
|
|
203
|
+
*/
|
|
204
|
+
export function decideThrottleTier(opts: {
|
|
205
|
+
detail: string
|
|
206
|
+
now: number
|
|
207
|
+
/** Retry-in-place ceiling (ms). Callers pass throttleRetryInPlaceMaxMs(). */
|
|
208
|
+
thresholdMs: number
|
|
209
|
+
}): ThrottleTierDecision {
|
|
210
|
+
const { detail, now, thresholdMs } = opts
|
|
211
|
+
// Account-affirming wording ONLY — the wider transient list includes
|
|
212
|
+
// server-side phrasings (529 / "server is temporarily limiting requests")
|
|
213
|
+
// for which an account-scoped throttle would be the wrong action.
|
|
214
|
+
if (!isAccountScopedThrottle(detail)) return { action: 'none' }
|
|
215
|
+
const resetAt = parseResetTime(detail, new Date(now))
|
|
216
|
+
const resetAtMs = resetAt?.getTime()
|
|
217
|
+
if (resetAtMs == null || !Number.isFinite(resetAtMs) || resetAtMs <= now) {
|
|
218
|
+
// No usable reset — wait the conservative default in place.
|
|
219
|
+
return {
|
|
220
|
+
action: 'throttle',
|
|
221
|
+
throttledUntilMs: now + THROTTLE_DEFAULT_WAIT_MS,
|
|
222
|
+
resetParsed: false,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (resetAtMs - now <= thresholdMs) {
|
|
226
|
+
return { action: 'throttle', throttledUntilMs: resetAtMs, resetParsed: true }
|
|
227
|
+
}
|
|
228
|
+
return { action: 'failover', resetAtMs }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Per-account notice cooldown ─────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Cooldown for the lightweight throttle notice, PER ACCOUNT. A burst of
|
|
235
|
+
* transient 429s (session-tail forwards every terminal error line) must
|
|
236
|
+
* produce ONE notice, not a stream — same shape and rationale as the
|
|
237
|
+
* failure-notice / all-blocked cooldowns in auto-fallback-fleet.ts. Keyed by
|
|
238
|
+
* account (not agent): the throttle is account-scoped, so every agent riding
|
|
239
|
+
* the same throttled account shares one window.
|
|
240
|
+
*/
|
|
241
|
+
export const THROTTLE_NOTICE_COOLDOWN_MS = 10 * 60_000
|
|
242
|
+
|
|
243
|
+
export interface ThrottleNoticeState {
|
|
244
|
+
/** account label → unix ms of the last notice sent for it. */
|
|
245
|
+
lastSentAtMsByAccount: Record<string, number>
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function evaluateThrottleNotice(
|
|
249
|
+
prev: ThrottleNoticeState,
|
|
250
|
+
account: string,
|
|
251
|
+
now: number,
|
|
252
|
+
cooldownMs: number = THROTTLE_NOTICE_COOLDOWN_MS,
|
|
253
|
+
): { send: boolean; next: ThrottleNoticeState } {
|
|
254
|
+
const last = prev.lastSentAtMsByAccount[account] ?? 0
|
|
255
|
+
if (now - last >= cooldownMs) {
|
|
256
|
+
return {
|
|
257
|
+
send: true,
|
|
258
|
+
next: {
|
|
259
|
+
lastSentAtMsByAccount: {
|
|
260
|
+
...prev.lastSentAtMsByAccount,
|
|
261
|
+
[account]: now,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { send: false, next: prev }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Notice rendering ────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The ONE lightweight operator notice for the throttle path. Deliberately not
|
|
273
|
+
* the ⚠️ model-unavailable card: nothing is exhausted and nothing failed
|
|
274
|
+
* over — the point is to say so, name the account, and name the reset.
|
|
275
|
+
*/
|
|
276
|
+
export function renderThrottleNotice(opts: {
|
|
277
|
+
/** Account label from the broker's mark-throttled response; null when the
|
|
278
|
+
* broker was unreachable and the account could not be attributed. */
|
|
279
|
+
account: string | null
|
|
280
|
+
agent: string
|
|
281
|
+
throttledUntilMs: number
|
|
282
|
+
/** True when the reset came from parsed "resets …" prose (vs the 60s default). */
|
|
283
|
+
resetParsed: boolean
|
|
284
|
+
now?: Date
|
|
285
|
+
}): string {
|
|
286
|
+
const now = opts.now ?? new Date()
|
|
287
|
+
// "resets in 3m" — same countdown dialect /usage speaks.
|
|
288
|
+
const resetStr = formatResetRelative(new Date(opts.throttledUntilMs), now)
|
|
289
|
+
const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
|
|
290
|
+
const lines = [
|
|
291
|
+
`🚦 **Rate-limited, staying put** — ${acct} hit a transient rate limit on **${escapeMarkdown(opts.agent)}**.`,
|
|
292
|
+
`This is a short throttle, not a quota wall — ${
|
|
293
|
+
opts.resetParsed ? resetStr : `no reset given, retrying in ~60s`
|
|
294
|
+
}.`,
|
|
295
|
+
`_Staying on ${acct}; no failover needed. The turn retries automatically after the reset._`,
|
|
296
|
+
]
|
|
297
|
+
return lines.join('\n')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Notice for the broker's ESCALATION outcome: repeated transient 429s on one
|
|
302
|
+
* account were corroborated by a live probe as a genuine wall, so the broker
|
|
303
|
+
* ran the standard mark-exhausted + roll. The gateway that raised the
|
|
304
|
+
* mark-throttled announces it (the reactive-path doctrine — same reason the
|
|
305
|
+
* plain mark-exhausted path announces gateway-side rather than via
|
|
306
|
+
* `last_fleet_roll`), which also covers PINNED (non-fleet-active) accounts.
|
|
307
|
+
*/
|
|
308
|
+
export function renderThrottleEscalationNotice(opts: {
|
|
309
|
+
account: string | null
|
|
310
|
+
agent: string
|
|
311
|
+
/** The account the fleet/agents rolled to; null = every fallback blocked. */
|
|
312
|
+
rolledTo: string | null
|
|
313
|
+
}): string {
|
|
314
|
+
const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
|
|
315
|
+
const head =
|
|
316
|
+
`⛔️ **Rate limit was actually a wall** — repeated 429s on ${acct} ` +
|
|
317
|
+
`(trigger: **${escapeMarkdown(opts.agent)}**) were corroborated by a live quota probe.`
|
|
318
|
+
const tail = opts.rolledTo
|
|
319
|
+
? `Marked exhausted and rolled to \`${escapeMarkdown(opts.rolledTo)}\`.`
|
|
320
|
+
: `Marked exhausted — no fallback account had quota (all blocked). ` +
|
|
321
|
+
`Use \`/auth add <label>\` to attach another subscription.`
|
|
322
|
+
return `${head}\n${tail}`
|
|
323
|
+
}
|
|
@@ -61,13 +61,14 @@ import { richRenderEnabled } from "../../render/rich-render.js";
|
|
|
61
61
|
const AGENT = "test-harness";
|
|
62
62
|
|
|
63
63
|
// The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
|
|
64
|
-
//
|
|
65
|
-
// collapsible round-trip proof below only runs when
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
64
|
+
// ON BY DEFAULT, with `SWITCHROOM_RICH_RENDER=0` as the escape hatch. The
|
|
65
|
+
// expandable / collapsible round-trip proof below only runs when the driver
|
|
66
|
+
// creds are present AND the renderer hasn't been killed off — the same
|
|
67
|
+
// setting must hold in the gateway process under test for the renderer to
|
|
68
|
+
// actually shape the outbound message, so gating the scenario on the same
|
|
69
|
+
// check keeps it honest (no false green when the wiring isn't live). When
|
|
70
|
+
// the kill-switch is set, this scenario self-skips green exactly like the
|
|
71
|
+
// credential-less case.
|
|
71
72
|
const RICH_RENDER_ON = richRenderEnabled();
|
|
72
73
|
|
|
73
74
|
// The driver session is the load-bearing credential. Absent it, spinUp()
|