switchroom 0.19.26 → 0.19.27
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/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider-credit.ts — the ONE registry of "this paid third-party provider is
|
|
3
|
+
* out of credit / its billing is blocked" signals, plus the pure classifier
|
|
4
|
+
* both surfaces consult.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS (the leak it closes)
|
|
7
|
+
* ------------------------------------
|
|
8
|
+
* Every quota/credit wording switchroom recognised was an ANTHROPIC wording
|
|
9
|
+
* (`model-unavailable.ts`'s `quotaSignals`, `operator-events.ts`'s
|
|
10
|
+
* `credit_balance_too_low` branch). OpenRouter — which serves the whole
|
|
11
|
+
* `gpt-oss-20b` group and every `openrouter/*` model in
|
|
12
|
+
* `docker/litellm-proxy/litellm-config.yaml` — answers an exhausted balance
|
|
13
|
+
* with HTTP 402 `payment_required` / "insufficient credits", which matched
|
|
14
|
+
* NONE of them. It fell through `classifyClaudeError` to `unknown-4xx`, and
|
|
15
|
+
* `unknown-4xx` is NOT in `OPERATOR_ACTIONABLE_KINDS`, so the card (with the
|
|
16
|
+
* raw provider error in a code span, and a "🔐 Reauth" button nobody can act
|
|
17
|
+
* on) was BROADCAST to every allowlist chat — an end user included.
|
|
18
|
+
*
|
|
19
|
+
* Ken's standing product rule: a raw API/auth error that is OPERATOR-actionable
|
|
20
|
+
* rather than USER-actionable must never reach an end user. So a provider
|
|
21
|
+
* credit wall must classify to an operator-only kind.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS MODULE IS AND IS NOT
|
|
24
|
+
* ------------------------------
|
|
25
|
+
* It is DATA (`PROVIDER_CREDIT_REGISTRY` + `CREDIT_EXHAUSTION_SIGNALS`) plus
|
|
26
|
+
* two pure predicates over that data. It is deliberately NOT wired to the
|
|
27
|
+
* Anthropic quota path: `detectModelUnavailable`'s `quota_exhausted` kind is
|
|
28
|
+
* what fires `fireFleetAutoFallback` (swap the Anthropic ACCOUNT SLOT). An
|
|
29
|
+
* OpenRouter balance of $0 says nothing about any Anthropic account, and
|
|
30
|
+
* rotating Anthropic slots would not buy a single OpenRouter token — so
|
|
31
|
+
* teaching the Anthropic detector these wordings would fire a bogus fleet
|
|
32
|
+
* failover on every OpenRouter 402. See the explicit early-return for
|
|
33
|
+
* `provider-credit-exhausted` in `resolveModelUnavailableFromOperatorEvent`.
|
|
34
|
+
*
|
|
35
|
+
* PROVENANCE of each wording (verified against live vendor docs 2026-07-28):
|
|
36
|
+
* - OpenRouter — https://openrouter.ai/docs/api-reference/errors:
|
|
37
|
+
* "402: Your account or API key has insufficient credits. Add more credits
|
|
38
|
+
* and retry the request." Typed error code `payment_required`. The limits
|
|
39
|
+
* page (https://openrouter.ai/docs/api-reference/limits) documents the same
|
|
40
|
+
* 402 for both an exhausted ACCOUNT balance and an exhausted PER-KEY
|
|
41
|
+
* `limit_remaining`.
|
|
42
|
+
* - OpenAI — https://platform.openai.com/docs/guides/error-codes: 429 with
|
|
43
|
+
* `code: "insufficient_quota"`, message "You exceeded your current quota,
|
|
44
|
+
* please check your plan and billing details"; hard billing stop surfaces as
|
|
45
|
+
* `billing_hard_limit_reached`. NOTE the status is 429, NOT 402 — which is
|
|
46
|
+
* exactly why status alone is not a sufficient signal and the wording list
|
|
47
|
+
* carries its own weight.
|
|
48
|
+
* - Perplexity — https://docs.perplexity.ai/guides/usage-tiers: an exhausted
|
|
49
|
+
* balance answers 401/403 with "insufficient credits"/"credit balance"; the
|
|
50
|
+
* account is otherwise valid. Perplexity reaches switchroom through the MCP
|
|
51
|
+
* tool surface, not through LiteLLM, so this registry is consumed there too
|
|
52
|
+
* (`mcp-credential-failure.ts`) — one registry, both paths.
|
|
53
|
+
*
|
|
54
|
+
* Pure module: no IPC, no bot, no FS, no network. Trivially unit-testable.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export interface ProviderCreditEntry {
|
|
60
|
+
/** Stable id — also the `mcp_servers.<name>` key where one exists. */
|
|
61
|
+
id: string
|
|
62
|
+
/** Human display name for the operator card. */
|
|
63
|
+
label: string
|
|
64
|
+
/**
|
|
65
|
+
* The VAULT KEY NAME the operator must top up / re-issue. A NAME only —
|
|
66
|
+
* this module never sees, reads, or renders a secret VALUE.
|
|
67
|
+
*/
|
|
68
|
+
vaultKey: string
|
|
69
|
+
/** Where the operator actually fixes it. */
|
|
70
|
+
consoleUrl: string
|
|
71
|
+
/** The one-line remedy sentence rendered on the operator card. */
|
|
72
|
+
action: string
|
|
73
|
+
/**
|
|
74
|
+
* Lowercase substrings that ATTRIBUTE an error to this provider. Matched
|
|
75
|
+
* against the raw error text. Deliberately specific (hostnames, route
|
|
76
|
+
* prefixes, SDK exception names) so a passing mention can't misattribute.
|
|
77
|
+
*/
|
|
78
|
+
markers: string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The providers whose credit exhaustion switchroom can recognise. Adding a
|
|
83
|
+
* provider is a DATA edit here — never a new conditional at a call site.
|
|
84
|
+
*/
|
|
85
|
+
export const PROVIDER_CREDIT_REGISTRY: readonly ProviderCreditEntry[] = [
|
|
86
|
+
{
|
|
87
|
+
id: 'openrouter',
|
|
88
|
+
label: 'OpenRouter',
|
|
89
|
+
vaultKey: 'openrouter/api-key',
|
|
90
|
+
consoleUrl: 'https://openrouter.ai/credits',
|
|
91
|
+
action: 'Top up the OpenRouter balance (or raise the key’s credit limit) in the OpenRouter console.',
|
|
92
|
+
markers: ['openrouter', 'openrouter.ai'],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'openai',
|
|
96
|
+
label: 'OpenAI',
|
|
97
|
+
vaultKey: 'openai/api-key',
|
|
98
|
+
consoleUrl: 'https://platform.openai.com/settings/organization/billing',
|
|
99
|
+
action: 'Add credit / raise the billing limit in the OpenAI console.',
|
|
100
|
+
markers: ['api.openai.com', 'openai.', 'openaiexception', 'openai_api'],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'perplexity',
|
|
104
|
+
label: 'Perplexity',
|
|
105
|
+
vaultKey: 'perplexity/api-key',
|
|
106
|
+
consoleUrl: 'https://www.perplexity.ai/settings/api',
|
|
107
|
+
action: 'Top up the Perplexity API balance (or re-issue the key) in the Perplexity console.',
|
|
108
|
+
markers: ['perplexity', 'api.perplexity.ai', 'pplx'],
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
// ─── Credit-exhaustion wording ───────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Provider-agnostic markers of a CREDIT/BILLING wall — "there is no money
|
|
116
|
+
* left", as distinct from "you are going too fast" (rate limit) or "your key
|
|
117
|
+
* is wrong" (auth).
|
|
118
|
+
*
|
|
119
|
+
* Every entry is a multi-word phrase or an explicit vendor error CODE. A bare
|
|
120
|
+
* `'402'` is deliberately ABSENT: a three-digit substring appears in model ids,
|
|
121
|
+
* token counts, request ids and timestamps, and matching it would misclassify
|
|
122
|
+
* unrelated errors as a billing wall. HTTP 402 is honoured only as a STRUCTURED
|
|
123
|
+
* status field — see {@link isProviderCreditStatus}.
|
|
124
|
+
*/
|
|
125
|
+
export const CREDIT_EXHAUSTION_SIGNALS: readonly string[] = [
|
|
126
|
+
// OpenRouter (402)
|
|
127
|
+
'payment_required',
|
|
128
|
+
'payment required',
|
|
129
|
+
'insufficient credits',
|
|
130
|
+
'insufficient_credits',
|
|
131
|
+
'more credits are required',
|
|
132
|
+
'requires more credits',
|
|
133
|
+
'add more credits',
|
|
134
|
+
'out of credits',
|
|
135
|
+
// OpenAI (429 insufficient_quota / hard billing stop)
|
|
136
|
+
'insufficient_quota',
|
|
137
|
+
'exceeded your current quota',
|
|
138
|
+
'billing_hard_limit_reached',
|
|
139
|
+
'billing hard limit',
|
|
140
|
+
// Cross-vendor balance wordings (Perplexity, Brevo, and the generic shape)
|
|
141
|
+
'insufficient balance',
|
|
142
|
+
'insufficient_balance',
|
|
143
|
+
'credit balance is too low',
|
|
144
|
+
'credit balance too low',
|
|
145
|
+
'credit_balance_too_low',
|
|
146
|
+
'no credits remaining',
|
|
147
|
+
'quota exceeded for credits',
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
/** HTTP statuses that, on their own, mean "billing". Only 402 qualifies. */
|
|
151
|
+
export const CREDIT_EXHAUSTION_STATUSES: readonly number[] = [402]
|
|
152
|
+
|
|
153
|
+
const MAX_SCAN_CHARS = 16_384
|
|
154
|
+
|
|
155
|
+
function sample(text: unknown): string {
|
|
156
|
+
if (typeof text !== 'string' || text.length === 0) return ''
|
|
157
|
+
return (text.length > MAX_SCAN_CHARS ? text.slice(0, MAX_SCAN_CHARS) : text).toLowerCase()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True when `status` is a structured HTTP status that means "out of credit".
|
|
162
|
+
* Structured only — never inferred from a substring (see the note on
|
|
163
|
+
* {@link CREDIT_EXHAUSTION_SIGNALS}).
|
|
164
|
+
*/
|
|
165
|
+
export function isProviderCreditStatus(status: unknown): boolean {
|
|
166
|
+
return typeof status === 'number' && CREDIT_EXHAUSTION_STATUSES.includes(status)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** True when `text` carries an explicit credit/billing-exhaustion wording. */
|
|
170
|
+
export function hasCreditExhaustionWording(text: unknown): boolean {
|
|
171
|
+
const lower = sample(text)
|
|
172
|
+
if (lower.length === 0) return false
|
|
173
|
+
return CREDIT_EXHAUSTION_SIGNALS.some(s => lower.includes(s))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Attribute an error string to a registered provider, or `null` when nothing
|
|
178
|
+
* in the text names one. `null` is a legitimate, honest outcome: the card then
|
|
179
|
+
* says "an upstream model provider" rather than guessing a vendor (and a wrong
|
|
180
|
+
* vendor name would send the operator to the wrong console).
|
|
181
|
+
*/
|
|
182
|
+
export function attributeProvider(text: unknown): ProviderCreditEntry | null {
|
|
183
|
+
const lower = sample(text)
|
|
184
|
+
if (lower.length === 0) return null
|
|
185
|
+
for (const entry of PROVIDER_CREDIT_REGISTRY) {
|
|
186
|
+
if (entry.markers.some(m => lower.includes(m))) return entry
|
|
187
|
+
}
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ProviderCreditDetection {
|
|
192
|
+
/** The registry entry, when the text named a known provider. */
|
|
193
|
+
provider: ProviderCreditEntry | null
|
|
194
|
+
/** Which evidence fired — useful in logs and in the test assertions. */
|
|
195
|
+
via: 'status' | 'wording'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The single decision: is this error a THIRD-PARTY PROVIDER credit/billing
|
|
200
|
+
* wall? Returns `null` when it is not — callers then fall through to their
|
|
201
|
+
* existing classification unchanged.
|
|
202
|
+
*
|
|
203
|
+
* `status` is the structured HTTP status when the caller has one (an SDK throw,
|
|
204
|
+
* a LiteLLM error body with `"status": 402`). Omit it and the decision rests on
|
|
205
|
+
* wording alone.
|
|
206
|
+
*
|
|
207
|
+
* ANTHROPIC IS DELIBERATELY EXCLUDED. `credit_balance_too_low` is in the
|
|
208
|
+
* wording list because it is a genuine cross-vendor balance phrase, but an
|
|
209
|
+
* Anthropic-attributed credit wall already has its own `credit-exhausted` kind
|
|
210
|
+
* with slot-switch advice (`/auth use`). Callers keep that branch FIRST; this
|
|
211
|
+
* one only catches what that branch does not. See `classifyClaudeError`.
|
|
212
|
+
*/
|
|
213
|
+
export function detectProviderCreditExhaustion(
|
|
214
|
+
text: unknown,
|
|
215
|
+
status?: unknown,
|
|
216
|
+
): ProviderCreditDetection | null {
|
|
217
|
+
if (isProviderCreditStatus(status)) {
|
|
218
|
+
return { provider: attributeProvider(text), via: 'status' }
|
|
219
|
+
}
|
|
220
|
+
if (hasCreditExhaustionWording(text)) {
|
|
221
|
+
return { provider: attributeProvider(text), via: 'wording' }
|
|
222
|
+
}
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The operator-facing remedy line for a detection. Names the provider, the
|
|
228
|
+
* VAULT KEY NAME (never a value) and the console to act in. Used by both the
|
|
229
|
+
* operator-event card and the MCP credential alert so the two speak with one
|
|
230
|
+
* voice.
|
|
231
|
+
*/
|
|
232
|
+
export function describeProviderCreditRemedy(provider: ProviderCreditEntry | null): string {
|
|
233
|
+
if (provider == null) {
|
|
234
|
+
return 'An upstream model provider reports no credit remaining. Check the provider console for the key the LiteLLM proxy uses.'
|
|
235
|
+
}
|
|
236
|
+
return `${provider.action} Vault key: \`${provider.vaultKey}\` → ${provider.consoleUrl}`
|
|
237
|
+
}
|
|
@@ -40,18 +40,48 @@ ATTEMPT_TIMEOUT="${BUN_TEST_ATTEMPT_TIMEOUT:-180}"
|
|
|
40
40
|
KILL_AFTER="${BUN_TEST_KILL_AFTER:-30}"
|
|
41
41
|
MAX_ATTEMPTS="${BUN_TEST_MAX_ATTEMPTS:-3}"
|
|
42
42
|
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# the
|
|
47
|
-
#
|
|
43
|
+
# THE single source of truth for what the bun CI job runs — `bun test` with
|
|
44
|
+
# no args recurses into telegram-plugin/uat/scenarios/, which needs live
|
|
45
|
+
# Telegram creds. Both ci-tests-plugin.yml and ci-full.yml invoke this script
|
|
46
|
+
# rather than re-typing the list (they used to keep hand-copied duplicates,
|
|
47
|
+
# which is how a test file ends up in no runner at all — see
|
|
48
|
+
# scripts/check-test-runner-coverage.mjs). BUN_TEST_TARGETS (space-separated)
|
|
49
|
+
# overrides the list — used only to exercise the watchdog against a subset.
|
|
48
50
|
if [ -n "${BUN_TEST_TARGETS:-}" ]; then
|
|
49
51
|
# shellcheck disable=SC2206
|
|
50
52
|
BUN_TEST_ARGS=(${BUN_TEST_TARGETS})
|
|
51
53
|
else
|
|
52
54
|
BUN_TEST_ARGS=(
|
|
53
|
-
|
|
55
|
+
# TRAILING SLASHES ARE LOAD-BEARING. A `bun test` positional is a plain
|
|
56
|
+
# SUBSTRING match on the file path, not a directory selector: the bare
|
|
57
|
+
# `gateway` this list used to carry also matched
|
|
58
|
+
# `uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts`, quietly
|
|
59
|
+
# pulling a live-Telegram scenario into the ordinary bun-test job (it only
|
|
60
|
+
# stayed harmless because that scenario self-skips without creds). `gateway/`
|
|
61
|
+
# matches the directory and nothing else.
|
|
62
|
+
admin-commands/ gateway/ registry/ secret-detect/ tests/
|
|
54
63
|
channel-envelope-safety.test.ts
|
|
64
|
+
# Hosted UAT unit tests: no creds, no live Telegram, no driver session.
|
|
65
|
+
# Named as specific files / the runners dir — never a bare `uat` — so the
|
|
66
|
+
# filter can NEVER widen into uat/scenarios/, which does hit real Telegram
|
|
67
|
+
# and must stay on the gated uat-host runner (ci-uat.yml) only.
|
|
68
|
+
#
|
|
69
|
+
# These ran in NEITHER CI runner before this change: vitest.config.ts
|
|
70
|
+
# excludes `**/telegram-plugin/uat/**` wholesale (right for scenarios/,
|
|
71
|
+
# collateral damage for these) and this list never named them. So
|
|
72
|
+
# feed-matcher.test.ts — whose own docblock calls it "the CI-verifiable
|
|
73
|
+
# floor" for the worker-feed matcher, and which #3821 extended with
|
|
74
|
+
# `stripCardNesting` assertions — was executed by nothing.
|
|
75
|
+
#
|
|
76
|
+
# They go on the BUN side rather than being un-excluded from vitest because
|
|
77
|
+
# three of them import `bun:test`, and uat/runners/skill-coverage.test.ts
|
|
78
|
+
# transitively imports uat/driver.ts → `@mtcute/node`, which vite's resolver
|
|
79
|
+
# cannot load out of the bun workspace layout (bun's resolver has no
|
|
80
|
+
# trouble). Fenced by scripts/check-test-runner-coverage.mjs.
|
|
81
|
+
uat/feed-matcher.test.ts
|
|
82
|
+
uat/load-env.test.ts
|
|
83
|
+
uat/uat-driver.test.ts
|
|
84
|
+
uat/runners/
|
|
55
85
|
)
|
|
56
86
|
fi
|
|
57
87
|
|
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
makeFloodWaitActiveError,
|
|
74
74
|
isFloodWaitActiveError,
|
|
75
75
|
} from './retry-api-call.js'
|
|
76
|
+
import { withOutboundClass, type OutboundClass } from './outbound-class.js'
|
|
76
77
|
|
|
77
78
|
/** Injectable time source. Default binds to real wall clock + setTimeout. */
|
|
78
79
|
export interface Clock {
|
|
@@ -112,6 +113,20 @@ export type ChatType = 'private' | 'group' | 'supergroup' | 'channel'
|
|
|
112
113
|
*/
|
|
113
114
|
export type PriorityClass = 'critical' | 'useful' | 'cosmetic'
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Compile-time lock: `PriorityClass` and `outbound-class.ts`'s `OutboundClass`
|
|
118
|
+
* must stay identical, because the gate publishes the former into the latter's
|
|
119
|
+
* AsyncLocalStorage so the edit-flood fuse (a grammY transformer, which sees
|
|
120
|
+
* only `(method, payload)`) can tell a repaint from a reply. Adding a class to
|
|
121
|
+
* one and not the other fails `tsc`, not review.
|
|
122
|
+
*/
|
|
123
|
+
type _ClassesMatch =
|
|
124
|
+
OutboundClass extends PriorityClass
|
|
125
|
+
? PriorityClass extends OutboundClass ? true : never
|
|
126
|
+
: never
|
|
127
|
+
const _classesMatch: _ClassesMatch = true
|
|
128
|
+
void _classesMatch
|
|
129
|
+
|
|
115
130
|
/**
|
|
116
131
|
* Priority class an UNTAGGED non-edit send is admitted as. `critical` =
|
|
117
132
|
* non-droppable (L2). See the `PriorityClass` doc above.
|
|
@@ -1130,7 +1145,13 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1130
1145
|
// the fact that the only production caller is `robustApiCall`, whose
|
|
1131
1146
|
// own request/retry timeouts bound every send — so `p.fn()` is
|
|
1132
1147
|
// guaranteed to settle. No separate watchdog is needed here.
|
|
1133
|
-
|
|
1148
|
+
// Publish the class for the edit-flood fuse (a grammY transformer
|
|
1149
|
+
// downstream of here, which otherwise cannot tell a cosmetic repaint
|
|
1150
|
+
// from an approval-card edit). `p.priorityClass` is the COALESCED
|
|
1151
|
+
// class — a cosmetic edit that a `useful` one superseded is sent as
|
|
1152
|
+
// `useful`, which is exactly what should happen: the frame going out
|
|
1153
|
+
// carries the higher-priority payload.
|
|
1154
|
+
const res = await withOutboundClass(p.priorityClass, () => p.fn())
|
|
1134
1155
|
// M1: only record the payload as on-screen AFTER a successful send,
|
|
1135
1156
|
// so a FAILED edit can be retried with the same payload (not dropped
|
|
1136
1157
|
// as a phantom no-op).
|
|
@@ -1363,7 +1384,8 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1363
1384
|
|
|
1364
1385
|
try {
|
|
1365
1386
|
// N4: count `sent` only AFTER a successful send, mirroring the edit path.
|
|
1366
|
-
|
|
1387
|
+
// Class published for the fuse — see the edit path's call above.
|
|
1388
|
+
const res = await withOutboundClass(priority, () => fn())
|
|
1367
1389
|
counters.sent++
|
|
1368
1390
|
return res
|
|
1369
1391
|
} catch (err) {
|
|
@@ -83,6 +83,16 @@ export const NESTED_PREFIX = ' ↳ '
|
|
|
83
83
|
* (2+ worker) card — three U+2800 BRAILLE PATTERN BLANK, written as escapes so
|
|
84
84
|
* the bytes are visible in source.
|
|
85
85
|
*
|
|
86
|
+
* This is now the ONLY indent any card carries (#3842). #3820/#3821 also
|
|
87
|
+
* indented the whole worker card by one level and prefixed line 1 with `└─ `,
|
|
88
|
+
* which cost a level of horizontal space on a phone and asserted a
|
|
89
|
+
* parent/child relationship that is not always true (the worker card does not
|
|
90
|
+
* always sit below the agent card). That whole-card subordination is gone:
|
|
91
|
+
* every card is flush at the left margin, and this indent survives for the one
|
|
92
|
+
* job that genuinely needs it — telling one worker's step lines apart from the
|
|
93
|
+
* next worker's on the combined card, where the numbered row headers stay
|
|
94
|
+
* flush and their steps sit one level in.
|
|
95
|
+
*
|
|
86
96
|
* ── Why not ASCII, and why not U+00A0 ────────────────────────────────
|
|
87
97
|
* Card bodies reach Telegram as raw GFM markdown (`richMessage` →
|
|
88
98
|
* `sendRichMessage` / `editMessageText({ markdown })`, #2669) and are parsed
|
|
@@ -125,51 +135,3 @@ export const NESTED_PREFIX = ' ↳ '
|
|
|
125
135
|
*/
|
|
126
136
|
export const WORKER_STEP_INDENT = '\u2800\u2800\u2800'
|
|
127
137
|
|
|
128
|
-
/**
|
|
129
|
-
* Line-1 prefix that marks a card as structurally SUBORDINATE to the \ud83e\udd16 agent
|
|
130
|
-
* card (#3820). Worker cards carry it; the agent card never does.
|
|
131
|
-
*
|
|
132
|
-
* `\u2514\u2500` (U+2514 BOX DRAWINGS LIGHT UP AND RIGHT + U+2500 BOX DRAWINGS LIGHT
|
|
133
|
-
* HORIZONTAL) is ordinary ink to Telegram's server-side GFM parser: it is not
|
|
134
|
-
* a line-start block trigger (`#`, `>`, `-`/`+`/`*`, `N.` \u2014 see
|
|
135
|
-
* render/line-start-guard.ts for the full trigger set), so it can neither be
|
|
136
|
-
* promoted to a list/quote/heading nor left-trimmed the way a leading
|
|
137
|
-
* whitespace run is (the #3662 failure documented on WORKER_STEP_INDENT).
|
|
138
|
-
*
|
|
139
|
-
* LENGTH INVARIANT: exactly the same length as `SUBORDINATE_LINE_INDENT`, so
|
|
140
|
-
* the char-budget arithmetic in `fitCardToBudget` charges one flat per-line
|
|
141
|
-
* cost instead of special-casing line 1. `status-accent.test.ts` asserts it.
|
|
142
|
-
*/
|
|
143
|
-
export const SUBORDINATE_HEADER_PREFIX = '\u2514\u2500 '
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Left indent applied to EVERY line of a subordinate (worker) card after
|
|
147
|
-
* line 1 \u2014 the whole card block sits one level in from the agent card's left
|
|
148
|
-
* margin, so "parent vs child" is readable from the block's SHAPE at a glance
|
|
149
|
-
* on a phone, not from reading its label (#3820).
|
|
150
|
-
*
|
|
151
|
-
* Same U+2800 run as `WORKER_STEP_INDENT` and for the same live-verified
|
|
152
|
-
* reason (category So, not Zs \u2192 survives Telegram's inline left-trim; ASCII
|
|
153
|
-
* spaces and U+00A0 both render flat). Aliased rather than re-declared so the
|
|
154
|
-
* two indents can never drift to different glyphs.
|
|
155
|
-
*
|
|
156
|
-
* On the combined (2+ worker) card this composes with `WORKER_STEP_INDENT`:
|
|
157
|
-
* chrome / row headers land at one level, their steps at two.
|
|
158
|
-
*/
|
|
159
|
-
export const SUBORDINATE_LINE_INDENT = WORKER_STEP_INDENT
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Apply subordinate-card nesting to a card's pre-rendered lines: line 1 gets
|
|
163
|
-
* `SUBORDINATE_HEADER_PREFIX`, every later line gets `SUBORDINATE_LINE_INDENT`.
|
|
164
|
-
*
|
|
165
|
-
* Prefixes go OUTSIDE the markdown spans (lines arrive already wrapped in
|
|
166
|
-
* `**` / `_` / `~~`), exactly like `renderStepFeed`'s `indent` parameter, so a
|
|
167
|
-
* prefix can never land inside an emphasis run and break it.
|
|
168
|
-
*
|
|
169
|
-
* Pure; returns a new array. Empty in \u2192 empty out.
|
|
170
|
-
*/
|
|
171
|
-
export function nestSubordinateCardLines(lines: string[]): string[] {
|
|
172
|
-
return lines.map((line, i) =>
|
|
173
|
-
i === 0 ? `${SUBORDINATE_HEADER_PREFIX}${line}` : `${SUBORDINATE_LINE_INDENT}${line}`,
|
|
174
|
-
)
|
|
175
|
-
}
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Status-pin driver — executes
|
|
2
|
+
* Status-pin driver — executes ONE already-decided `PinLegAction` against a
|
|
3
|
+
* Telegram Bot API.
|
|
3
4
|
*
|
|
4
|
-
* The pure decision lives in `status-pin.ts` (decidePinAction)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the next
|
|
8
|
-
* a `Map<pinKey,
|
|
5
|
+
* The pure decision lives in `status-pin.ts` (`decidePinAction`) and is taken
|
|
6
|
+
* exactly ONCE per leg, by the orchestrator (`gateway/status-pin-retarget.ts`).
|
|
7
|
+
* This module is the side-effecting half only: it is HANDED a leg action,
|
|
8
|
+
* executes it, and returns the next claim. The state itself stays in the caller
|
|
9
|
+
* (the gateway holds a `Map<pinKey, StatusPinClaim>` and re-passes the entry on
|
|
10
|
+
* every call).
|
|
11
|
+
*
|
|
12
|
+
* #3831 — why this takes an ACTION and not a `desired` state. It used to take
|
|
13
|
+
* `(prevState, desired)` and re-run `decidePinAction` itself, which meant one
|
|
14
|
+
* reconcile decided the same transition up to THREE times (orchestrator leg
|
|
15
|
+
* split → persist-op mapping → driver) and, worse, gave the driver a `repin`
|
|
16
|
+
* branch to implement. That branch expanded the retarget a SECOND time, with no
|
|
17
|
+
* per-leg persistence, and was unreachable from the gateway — a dead fork that
|
|
18
|
+
* a future caller wiring `reconcilePin` directly would have silently inherited.
|
|
19
|
+
* The parameter type is now `PinLegAction`, which cannot express `repin`, so
|
|
20
|
+
* the expansion exists in one place and cannot re-fork.
|
|
9
21
|
*
|
|
10
22
|
* Copied from `slot-banner-driver.ts`. The load-bearing contract (see
|
|
11
23
|
* slot-banner-driver.ts:100-110):
|
|
@@ -36,8 +48,8 @@
|
|
|
36
48
|
* job spec.
|
|
37
49
|
*/
|
|
38
50
|
|
|
39
|
-
import type { PinState,
|
|
40
|
-
import {
|
|
51
|
+
import type { PinState, PinLegAction, PinRightsCache } from './status-pin.js'
|
|
52
|
+
import { isPinRightsError, isUnpinTerminalError } from './status-pin.js'
|
|
41
53
|
|
|
42
54
|
/** Minimal subset of grammy's `bot.api` the pin driver depends on.
|
|
43
55
|
* Lets tests swap in a fake without dragging in the full Bot type. */
|
|
@@ -53,13 +65,13 @@ export interface PinBotApi {
|
|
|
53
65
|
): Promise<unknown>
|
|
54
66
|
}
|
|
55
67
|
|
|
56
|
-
export interface
|
|
68
|
+
export interface ExecutePinLegArgs {
|
|
57
69
|
api: PinBotApi
|
|
58
70
|
chatId: string
|
|
59
71
|
/** State the caller is holding from the last reconcile. `null` on first. */
|
|
60
72
|
prevState: PinState | null
|
|
61
|
-
/**
|
|
62
|
-
|
|
73
|
+
/** The single transition to execute, decided by the orchestrator. */
|
|
74
|
+
action: PinLegAction
|
|
63
75
|
/** Optional API-failure observer. Default: silent. */
|
|
64
76
|
onError?: (phase: 'pin' | 'unpin', err: unknown) => void
|
|
65
77
|
/** Optional per-process rights-aware negative cache (issue #3024). When a
|
|
@@ -75,9 +87,9 @@ export interface ReconcilePinArgs {
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
78
|
-
* Execute
|
|
79
|
-
*
|
|
80
|
-
*
|
|
90
|
+
* Execute ONE decided pin transition for a key. Returns the new `PinState`
|
|
91
|
+
* (or `null` when unpinned / nothing pinned). Always resolves; never throws —
|
|
92
|
+
* API errors route through `onError`.
|
|
81
93
|
*
|
|
82
94
|
* - `pin` : pins the existing message SILENTLY; on failure the claim is
|
|
83
95
|
* NOT taken (returns prevState) so the next reconcile retries
|
|
@@ -86,43 +98,19 @@ export interface ReconcilePinArgs {
|
|
|
86
98
|
* success and on a TERMINAL failure (never leave state stuck
|
|
87
99
|
* pinned). A never-confirmed failure returns prevState so the
|
|
88
100
|
* still-pinned message keeps a record to retry from (#3664).
|
|
89
|
-
* - `repin`: the wanted message CHANGED — unpins the stale claim then pins
|
|
90
|
-
* the new message, both in this one call, returning the new
|
|
91
|
-
* claim. A never-confirmed unpin aborts the pin leg and retains
|
|
92
|
-
* the old claim (the old message is still up and must keep a
|
|
93
|
-
* record). Single-shot callers depend on this: they never
|
|
94
|
-
* reconcile the key a second time.
|
|
95
101
|
* - `noop` : returns prevState unchanged.
|
|
102
|
+
*
|
|
103
|
+
* A RETARGET is NOT executable here by construction — `PinLegAction` has no
|
|
104
|
+
* `repin` member. `runStatusPinReconcile` splits it into an unpin leg and a pin
|
|
105
|
+
* leg, each persisted, and owns the abort rule (#3831).
|
|
96
106
|
*/
|
|
97
|
-
export async function
|
|
98
|
-
args:
|
|
107
|
+
export async function executePinLeg(
|
|
108
|
+
args: ExecutePinLegArgs,
|
|
99
109
|
): Promise<PinState | null> {
|
|
100
|
-
const action =
|
|
110
|
+
const { action } = args
|
|
101
111
|
|
|
102
112
|
if (action.kind === 'noop') return args.prevState
|
|
103
113
|
|
|
104
|
-
if (action.kind === 'repin') {
|
|
105
|
-
// RETARGET: unpin the stale claim, then pin the new message — BOTH legs in
|
|
106
|
-
// this one call. Callers that reconcile a key exactly once (the foreground
|
|
107
|
-
// activity card) have no "next reconcile" to finish the job, so splitting
|
|
108
|
-
// it left nothing pinned. See the `repin` docblock in status-pin.ts.
|
|
109
|
-
const afterUnpin = await reconcilePin({
|
|
110
|
-
...args,
|
|
111
|
-
desired: { pinned: false },
|
|
112
|
-
})
|
|
113
|
-
// A non-null result here means the unpin was NEVER CONFIRMED (#3664
|
|
114
|
-
// Defect B): the old message is provably still pinned and the claim was
|
|
115
|
-
// deliberately retained. Pinning the new one now would leave two pins with
|
|
116
|
-
// a record of only one. Keep the retained claim and let the next reconcile
|
|
117
|
-
// / the mid-session reaper / the boot sweep retry.
|
|
118
|
-
if (afterUnpin != null) return afterUnpin
|
|
119
|
-
return reconcilePin({
|
|
120
|
-
...args,
|
|
121
|
-
prevState: null,
|
|
122
|
-
desired: { pinned: true, messageId: action.pinMessageId },
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
114
|
if (action.kind === 'unpin') {
|
|
127
115
|
// Skip the unpin API call in a chat the bot can't manage pins in — the
|
|
128
116
|
// call would fail with the same rights 400 and spam the log. The claim is
|
|
@@ -38,7 +38,21 @@ export type DesiredPin =
|
|
|
38
38
|
/** Work is done (or never opened a message); nothing should be pinned. */
|
|
39
39
|
| { pinned: false }
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A SINGLE, directly-executable pin transition — the only shape the driver
|
|
43
|
+
* (`status-pin-driver.ts`) can execute.
|
|
44
|
+
*
|
|
45
|
+
* The type is deliberately narrower than `PinAction`: it structurally EXCLUDES
|
|
46
|
+
* `repin`. That exclusion is the mechanism (#3831) that keeps the retarget
|
|
47
|
+
* expansion — "a repin is an unpin leg then a pin leg, and a never-confirmed
|
|
48
|
+
* unpin aborts the pin leg" — defined in exactly ONE place
|
|
49
|
+
* (`gateway/status-pin-retarget.ts`). It used to be written twice, once in the
|
|
50
|
+
* driver with no per-leg persistence and once in the orchestrator with it; the
|
|
51
|
+
* driver copy was unreachable in production and silently carried the weaker
|
|
52
|
+
* semantics. A driver that cannot NAME a repin cannot re-grow a second copy of
|
|
53
|
+
* it — the compiler enforces what a comment could not.
|
|
54
|
+
*/
|
|
55
|
+
export type PinLegAction =
|
|
42
56
|
| { kind: 'noop'; reason: string }
|
|
43
57
|
/** Pin an EXISTING message. Caller pins, then records the message_id
|
|
44
58
|
* back into PinState. No new message is sent — this pins a message the
|
|
@@ -48,6 +62,9 @@ export type PinAction =
|
|
|
48
62
|
* or a TERMINAL failure; a never-confirmed failure keeps the claim so the
|
|
49
63
|
* still-pinned message can be retried (`isUnpinTerminalError`, #3664). */
|
|
50
64
|
| { kind: 'unpin'; messageId: number }
|
|
65
|
+
|
|
66
|
+
export type PinAction =
|
|
67
|
+
| PinLegAction
|
|
51
68
|
/**
|
|
52
69
|
* RETARGET — the caller still wants something pinned for this key, but a
|
|
53
70
|
* DIFFERENT message than the one we claim (the surface was re-posted: an
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GOLDEN SUITE — every status/progress card variant, pinned byte-for-byte.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: card rendering now flows through ONE layout core
|
|
5
|
+
* (`card-layout.ts`). That is the point — but it also means a change made for
|
|
6
|
+
* one card silently reshapes the other twenty. This suite is the alarm: it
|
|
7
|
+
* renders all variants in `card-variants.ts` through the real renderers and
|
|
8
|
+
* compares the exact wire text against a checked-in golden file. Any diff, in
|
|
9
|
+
* any card, fails.
|
|
10
|
+
*
|
|
11
|
+
* Regenerate deliberately (and read the diff in the PR):
|
|
12
|
+
* UPDATE_CARD_GOLDEN=1 npx vitest run telegram-plugin/tests/card-golden.test.ts
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
|
|
19
|
+
import { CARD_VARIANTS, GOLDEN_DELIMITER, renderGoldenDocument } from './card-variants.js'
|
|
20
|
+
|
|
21
|
+
const GOLDEN_PATH = join(dirname(fileURLToPath(import.meta.url)), 'card-variants.golden.txt')
|
|
22
|
+
|
|
23
|
+
describe('card golden suite', () => {
|
|
24
|
+
it('every card variant renders exactly its pinned golden text', () => {
|
|
25
|
+
const actual = renderGoldenDocument()
|
|
26
|
+
if (process.env.UPDATE_CARD_GOLDEN === '1') {
|
|
27
|
+
writeFileSync(GOLDEN_PATH, actual, 'utf8')
|
|
28
|
+
}
|
|
29
|
+
const expected = readFileSync(GOLDEN_PATH, 'utf8')
|
|
30
|
+
// Compare per-variant first: a one-card regression should name that card,
|
|
31
|
+
// not dump a 21-card diff.
|
|
32
|
+
const split = (doc: string): Map<string, string> => {
|
|
33
|
+
const out = new Map<string, string>()
|
|
34
|
+
for (const chunk of doc.split(GOLDEN_DELIMITER).slice(1)) {
|
|
35
|
+
const nl = chunk.indexOf('\n')
|
|
36
|
+
out.set(chunk.slice(0, nl).trim(), chunk.slice(nl + 1))
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
const exp = split(expected)
|
|
41
|
+
const act = split(actual)
|
|
42
|
+
for (const [name, body] of act) {
|
|
43
|
+
expect(exp.has(name), `golden has no entry for "${name}" — regenerate it`).toBe(true)
|
|
44
|
+
expect(body, `card "${name}" no longer renders its golden text`).toBe(exp.get(name))
|
|
45
|
+
}
|
|
46
|
+
// And the whole document, so a REMOVED or reordered variant also fails.
|
|
47
|
+
expect(actual).toBe(expected)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('the golden covers every variant in the catalogue and nothing else', () => {
|
|
51
|
+
const golden = readFileSync(GOLDEN_PATH, 'utf8')
|
|
52
|
+
const namesInGolden = golden
|
|
53
|
+
.split(GOLDEN_DELIMITER)
|
|
54
|
+
.slice(1)
|
|
55
|
+
.map((c) => c.slice(0, c.indexOf('\n')).trim())
|
|
56
|
+
expect(namesInGolden).toEqual(CARD_VARIANTS.map((v) => v.name))
|
|
57
|
+
// Guards against a fixture that silently stops rendering (e.g. a renderer
|
|
58
|
+
// starting to return null) and thus stops asserting anything real.
|
|
59
|
+
expect(namesInGolden.length).toBeGreaterThanOrEqual(21)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('no variant renders empty or null', () => {
|
|
63
|
+
for (const v of CARD_VARIANTS) {
|
|
64
|
+
const text = v.render()
|
|
65
|
+
expect(text, `variant "${v.name}" rendered null`).not.toBeNull()
|
|
66
|
+
expect((text ?? '').trim().length, `variant "${v.name}" rendered empty`).toBeGreaterThan(0)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|