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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalogue of EVERY status/progress card variant the gateway can paint,
|
|
3
|
+
* each rendered through the REAL renderer with realistic inputs.
|
|
4
|
+
*
|
|
5
|
+
* One catalogue, two consumers, so they can never disagree:
|
|
6
|
+
* - `telegram-plugin/tests/card-golden.test.ts` — the golden suite. Every
|
|
7
|
+
* variant's exact wire text is pinned in `card-variants.golden.txt`, so a
|
|
8
|
+
* change to the shared card layout that alters ANY card fails loudly.
|
|
9
|
+
* - `scripts/card-previews.ts` — the human-readable preview doc.
|
|
10
|
+
*
|
|
11
|
+
* Adding a card variant means adding it HERE (and regenerating the golden with
|
|
12
|
+
* `UPDATE_CARD_GOLDEN=1 npx vitest run telegram-plugin/tests/card-golden.test.ts`),
|
|
13
|
+
* which is what keeps the golden suite exhaustive rather than a sample.
|
|
14
|
+
*
|
|
15
|
+
* These are RENDERER-level fixtures. The lifecycle behaviour that decides WHICH
|
|
16
|
+
* variant gets painted (dispatch → running → 2 workers → spill → done → failed
|
|
17
|
+
* → superseded) is covered end-to-end from the feed manager in
|
|
18
|
+
* `card-lifecycle-render.test.ts`; the two suites are complementary, not
|
|
19
|
+
* duplicates.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
renderActivityFeed,
|
|
23
|
+
renderActivityFeedWithNested,
|
|
24
|
+
renderCombinedWorkerFeed,
|
|
25
|
+
type CombinedWorkerRow,
|
|
26
|
+
type SessionActivityHeader,
|
|
27
|
+
} from '../tool-activity-summary.js'
|
|
28
|
+
import {
|
|
29
|
+
renderWorkerActivity,
|
|
30
|
+
WORKER_CARD_SUPERSEDED_BODY,
|
|
31
|
+
type WorkerActivityView,
|
|
32
|
+
} from '../worker-activity-feed.js'
|
|
33
|
+
|
|
34
|
+
/** Which renderer path a variant exercises, for the preview doc's inventory. */
|
|
35
|
+
export interface CardVariant {
|
|
36
|
+
/** Stable identity — the golden file keys on this, so renaming rewrites it. */
|
|
37
|
+
name: string
|
|
38
|
+
/** The call path, for the preview doc. */
|
|
39
|
+
fn: string
|
|
40
|
+
/** When a user actually sees this card. */
|
|
41
|
+
when: string
|
|
42
|
+
/**
|
|
43
|
+
* How the variant reaches the wire:
|
|
44
|
+
* - `shared` — composed by the one card layout core (`card-layout.ts`).
|
|
45
|
+
* - `constant` — a static notice with no live state to render (see the
|
|
46
|
+
* superseded card); its title line still comes from the shared composer.
|
|
47
|
+
*/
|
|
48
|
+
layout?: 'shared' | 'constant'
|
|
49
|
+
/** The real render. `null` means the renderer declined to emit anything. */
|
|
50
|
+
render: () => string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const agentHeader = (over: Partial<SessionActivityHeader> = {}): SessionActivityHeader => ({
|
|
54
|
+
label: 'Agent',
|
|
55
|
+
elapsedMs: 95_000,
|
|
56
|
+
toolCount: 12,
|
|
57
|
+
state: 'running',
|
|
58
|
+
model: 'claude-opus-4-8',
|
|
59
|
+
totalTokens: 41_200,
|
|
60
|
+
...over,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const AGENT_STEPS = [
|
|
64
|
+
'Searching memory for card render paths',
|
|
65
|
+
'Reading telegram-plugin/tool-activity-summary.ts',
|
|
66
|
+
'Reading telegram-plugin/worker-activity-feed.ts',
|
|
67
|
+
'Grepping for renderStatusCard callers',
|
|
68
|
+
'Drafting the inventory table',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const workerView = (over: Partial<WorkerActivityView> = {}): WorkerActivityView => ({
|
|
72
|
+
description: 'Audit every card render path and de-indent the worker card',
|
|
73
|
+
state: 'running',
|
|
74
|
+
elapsedMs: 143_000,
|
|
75
|
+
toolCount: 31,
|
|
76
|
+
lastTool: null,
|
|
77
|
+
latestSummary: '',
|
|
78
|
+
narrativeLines: [
|
|
79
|
+
'Fetching origin and branching off main',
|
|
80
|
+
'Reading telegram-plugin/status-no-truncate.ts',
|
|
81
|
+
'Mapping renderStatusCard callers',
|
|
82
|
+
'Writing the preview harness',
|
|
83
|
+
],
|
|
84
|
+
model: 'claude-opus-4-8',
|
|
85
|
+
totalTokens: 88_400,
|
|
86
|
+
...over,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const row = (
|
|
90
|
+
n: number,
|
|
91
|
+
desc: string,
|
|
92
|
+
hist: string[],
|
|
93
|
+
over: Partial<CombinedWorkerRow> = {},
|
|
94
|
+
): CombinedWorkerRow => ({
|
|
95
|
+
ordinal: n,
|
|
96
|
+
description: desc,
|
|
97
|
+
elapsedMs: 60_000 * n + 15_000,
|
|
98
|
+
toolCount: 7 * n,
|
|
99
|
+
totalTokens: 20_000 * n,
|
|
100
|
+
currentStep: hist[hist.length - 1] ?? 'starting…',
|
|
101
|
+
historyLines: hist,
|
|
102
|
+
model: 'claude-opus-4-8',
|
|
103
|
+
...over,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const H = (k: number, tag: string): string[] =>
|
|
107
|
+
Array.from({ length: k }, (_, i) => `${tag} step ${i + 1}`)
|
|
108
|
+
|
|
109
|
+
/** A 700-char step — long enough to blow the per-line clip and force the backstop. */
|
|
110
|
+
const LONG = 'A'.repeat(700)
|
|
111
|
+
|
|
112
|
+
export const CARD_VARIANTS: CardVariant[] = [
|
|
113
|
+
{
|
|
114
|
+
name: 'Agent card — running',
|
|
115
|
+
fn: 'renderActivityFeed → renderStatusCard',
|
|
116
|
+
when: 'Every turn that labels a tool. Pinned in the session chat, edited in place.',
|
|
117
|
+
render: () => renderActivityFeed(AGENT_STEPS, false, '', undefined, agentHeader()),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'Agent card — final (turn complete)',
|
|
121
|
+
fn: 'renderActivityFeed → renderStatusCard',
|
|
122
|
+
when: 'The terminal edit when the turn ends: every step struck, `✓ N steps` footer.',
|
|
123
|
+
render: () =>
|
|
124
|
+
renderActivityFeed(AGENT_STEPS, true, '', 17, agentHeader({ state: 'done', elapsedMs: 214_000 })),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'Agent card — steps only (no header)',
|
|
128
|
+
fn: 'renderActivityFeed → renderStatusCard (header omitted)',
|
|
129
|
+
when: 'Legacy/direct callers (`appendActivityLine`, `appendActivityLabel`) that pass no header.',
|
|
130
|
+
render: () => renderActivityFeed(AGENT_STEPS.slice(0, 3)),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'Agent card — with nested foreground sub-agent',
|
|
134
|
+
fn: 'renderActivityFeedWithNested → renderStatusCard (childSteps)',
|
|
135
|
+
when: 'A foreground Task/Agent runs inside the turn: parent lines all struck, the live `→` sits in the `↳` child block.',
|
|
136
|
+
render: () =>
|
|
137
|
+
renderActivityFeedWithNested(
|
|
138
|
+
AGENT_STEPS.slice(0, 3),
|
|
139
|
+
['Reading the repo conventions', 'Running the scoped test suite', 'Summarising findings'],
|
|
140
|
+
false,
|
|
141
|
+
' · 24s',
|
|
142
|
+
undefined,
|
|
143
|
+
agentHeader(),
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'Agent card — liveness early-open (no tools yet)',
|
|
148
|
+
fn: 'narrative-lane.ts → renderActivityFeedWithNested → renderStatusCard',
|
|
149
|
+
when: 'A silent turn passes the liveness threshold before any tool is labelled — the card opens on `Working…`.',
|
|
150
|
+
render: () =>
|
|
151
|
+
renderActivityFeedWithNested(['Working…'], [], false, ' · 32s', undefined, {
|
|
152
|
+
label: 'Agent',
|
|
153
|
+
elapsedMs: 32_000,
|
|
154
|
+
toolCount: 0,
|
|
155
|
+
state: 'running',
|
|
156
|
+
model: 'claude-opus-4-8',
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'Agent card — liveness finalised',
|
|
161
|
+
fn: 'narrative-lane.ts → renderActivityFeedWithNested → renderStatusCard',
|
|
162
|
+
when: 'The liveness-only card at turn end: `→ Working…` becomes a done record instead of freezing live.',
|
|
163
|
+
render: () =>
|
|
164
|
+
renderActivityFeedWithNested(['Working…'], [], true, '', undefined, {
|
|
165
|
+
label: 'Agent',
|
|
166
|
+
elapsedMs: 88_000,
|
|
167
|
+
toolCount: 0,
|
|
168
|
+
state: 'done',
|
|
169
|
+
model: 'claude-opus-4-8',
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'Agent card — post-answer background activity',
|
|
174
|
+
fn: 'narrative-lane.ts → renderActivityFeedWithNested → renderStatusCard',
|
|
175
|
+
when: 'The reply already went out and a background sub-agent is still running — the card surfaces below the reply.',
|
|
176
|
+
render: () =>
|
|
177
|
+
renderActivityFeedWithNested(['Working in background…'], [], false, ' · 1m10s', undefined, {
|
|
178
|
+
label: 'Agent',
|
|
179
|
+
elapsedMs: 130_000,
|
|
180
|
+
toolCount: 3,
|
|
181
|
+
state: 'running',
|
|
182
|
+
model: 'claude-opus-4-8',
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'Agent card — over-long steps (per-line clip)',
|
|
187
|
+
fn: 'renderStatusCard → cleanStepLine (STATUS_LINE_MAX)',
|
|
188
|
+
when: 'A step line far exceeds STATUS_LINE_MAX: each is clipped to 200 chars + `…` before escaping. (This is also why the card-wide `fitCardToBudget` backstop is unreachable on the agent card — 5 clipped lines cannot approach 32768. The shrink levels are genuinely exercised by the WORKERS spill variant.)',
|
|
189
|
+
render: () =>
|
|
190
|
+
renderActivityFeed(
|
|
191
|
+
[LONG, LONG, LONG, 'Finally a short newest step'],
|
|
192
|
+
false,
|
|
193
|
+
'',
|
|
194
|
+
undefined,
|
|
195
|
+
agentHeader(),
|
|
196
|
+
),
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'Worker card — just dispatched (no steps yet)',
|
|
200
|
+
fn: 'renderWorkerActivity → renderStatusCard (emptyPlaceholder)',
|
|
201
|
+
when: 'The first paint of a background worker, before it has produced a narrative line.',
|
|
202
|
+
render: () =>
|
|
203
|
+
renderWorkerActivity(
|
|
204
|
+
workerView({ narrativeLines: [], latestSummary: '', elapsedMs: 1_200, toolCount: 0 }),
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'Worker card — single worker running',
|
|
209
|
+
fn: 'renderWorkerActivity → renderStatusCard',
|
|
210
|
+
when: 'Exactly one background worker live in the chat. Pinned; edited in place.',
|
|
211
|
+
render: () => renderWorkerActivity(workerView(), ' · 18s'),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'Worker card — done (with result recap)',
|
|
215
|
+
fn: 'renderWorkerActivity → renderStatusCard (result block)',
|
|
216
|
+
when: 'The worker finished successfully: feed all-struck, rule, then the ✅ result paragraph.',
|
|
217
|
+
render: () =>
|
|
218
|
+
renderWorkerActivity(
|
|
219
|
+
workerView({
|
|
220
|
+
state: 'done',
|
|
221
|
+
elapsedMs: 902_000,
|
|
222
|
+
latestSummary:
|
|
223
|
+
'Inventoried 14 card variants, found renderCombinedWorkerFeed forks the header + budget logic, and de-indented the worker card. PR #0000.',
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'Worker card — failed',
|
|
229
|
+
fn: 'renderWorkerActivity → renderStatusCard (result block, ⚠️)',
|
|
230
|
+
when: 'The worker errored: header line 2 reads `failed`, result block carries the error recap.',
|
|
231
|
+
render: () =>
|
|
232
|
+
renderWorkerActivity(
|
|
233
|
+
workerView({
|
|
234
|
+
state: 'failed',
|
|
235
|
+
elapsedMs: 61_000,
|
|
236
|
+
latestSummary: 'Scoped test run failed: 3 assertions in card-type-distinguishability.test.ts.',
|
|
237
|
+
}),
|
|
238
|
+
),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'Worker card — incomplete (killed / timed out)',
|
|
242
|
+
fn: 'renderWorkerActivity → renderStatusCard (result suppressed by invariant)',
|
|
243
|
+
when: 'The worker was terminated before producing a result — the renderer refuses to fabricate a result block.',
|
|
244
|
+
render: () =>
|
|
245
|
+
renderWorkerActivity(
|
|
246
|
+
workerView({
|
|
247
|
+
state: 'incomplete',
|
|
248
|
+
elapsedMs: 1_805_000,
|
|
249
|
+
latestSummary: 'stray text that must not render',
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'WORKERS card — 2 workers',
|
|
255
|
+
fn: 'renderCombinedWorkerFeed → the shared card spec',
|
|
256
|
+
when: '2+ background workers live in the same chat/thread coalesce into ONE message. Depth = 5 each.',
|
|
257
|
+
render: () =>
|
|
258
|
+
renderCombinedWorkerFeed(
|
|
259
|
+
[
|
|
260
|
+
row(1, 'Audit every card render path', H(5, 'audit')),
|
|
261
|
+
row(2, 'Fix the hindsight recall floor', H(5, 'recall')),
|
|
262
|
+
],
|
|
263
|
+
{ maxRows: 6 },
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'WORKERS card — 4 workers',
|
|
268
|
+
fn: 'renderCombinedWorkerFeed → the shared card spec',
|
|
269
|
+
when: "Depth degrades to the floor of Ken's 6/5/4/3 curve.",
|
|
270
|
+
render: () =>
|
|
271
|
+
renderCombinedWorkerFeed(
|
|
272
|
+
[
|
|
273
|
+
row(1, 'Audit every card render path', H(4, 'audit')),
|
|
274
|
+
row(2, 'Fix the hindsight recall floor', H(4, 'recall')),
|
|
275
|
+
row(3, 'Release v0.19.24', H(4, 'release')),
|
|
276
|
+
row(4, 'Investigate the flaky UAT scenario', H(4, 'uat')),
|
|
277
|
+
],
|
|
278
|
+
{ maxRows: 6 },
|
|
279
|
+
),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'WORKERS card — 8 workers (spill)',
|
|
283
|
+
fn: 'renderCombinedWorkerFeed → fitCardToBudget (row-count shrink levels)',
|
|
284
|
+
when: 'Beyond the design fan-out the NEWEST rows collapse into a single `+M more working…` line.',
|
|
285
|
+
render: () =>
|
|
286
|
+
renderCombinedWorkerFeed(
|
|
287
|
+
Array.from({ length: 8 }, (_, i) => row(i + 1, `Background task number ${i + 1}`, H(3, `t${i + 1}`))),
|
|
288
|
+
{ maxRows: 6 },
|
|
289
|
+
),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'WORKERS card — survivors keep their ordinals',
|
|
293
|
+
fn: 'renderCombinedWorkerFeed → the shared card spec',
|
|
294
|
+
when: 'Worker 1 finished; 2 and 3 keep their numbers, so the card shows `2.`/`3.` with no `1.`',
|
|
295
|
+
render: () =>
|
|
296
|
+
renderCombinedWorkerFeed(
|
|
297
|
+
[
|
|
298
|
+
row(2, 'Fix the hindsight recall floor', H(4, 'recall')),
|
|
299
|
+
row(3, 'Release v0.19.24', H(4, 'release')),
|
|
300
|
+
],
|
|
301
|
+
{ maxRows: 6 },
|
|
302
|
+
),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'WORKERS card — a worker with no history yet',
|
|
306
|
+
fn: 'renderCombinedWorkerFeed → section placeholder',
|
|
307
|
+
when: 'A freshly dispatched worker joins a group that already has a live card.',
|
|
308
|
+
render: () =>
|
|
309
|
+
renderCombinedWorkerFeed(
|
|
310
|
+
[
|
|
311
|
+
row(1, 'Audit every card render path', H(5, 'audit')),
|
|
312
|
+
row(2, 'Freshly dispatched worker', [], { currentStep: '', historyLines: undefined }),
|
|
313
|
+
],
|
|
314
|
+
{ maxRows: 6 },
|
|
315
|
+
),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'Worker card — superseded (group message rotated)',
|
|
319
|
+
layout: 'constant',
|
|
320
|
+
fn: 'WORKER_CARD_SUPERSEDED_BODY (static notice; title line via renderCardTitleLine)',
|
|
321
|
+
when: 'The shared worker message hit its lifetime cap and a fresh card was opened; the retired message is finalised to this static note so it does not read as a stuck worker.',
|
|
322
|
+
render: () => WORKER_CARD_SUPERSEDED_BODY,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'Agent card — rolling-window overflow',
|
|
326
|
+
fn: 'renderActivityFeed → renderStatusCard → emitSection',
|
|
327
|
+
when: 'More steps than the rolling window: the oldest collapse to a `✓ +N earlier…` header.',
|
|
328
|
+
render: () =>
|
|
329
|
+
renderActivityFeed(
|
|
330
|
+
Array.from({ length: 14 }, (_, i) => `Doing thing number ${i + 1}`),
|
|
331
|
+
false,
|
|
332
|
+
'',
|
|
333
|
+
undefined,
|
|
334
|
+
agentHeader(),
|
|
335
|
+
),
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: 'Agent card — nested sub-agent with child overflow',
|
|
339
|
+
fn: 'renderActivityFeedWithNested → renderStatusCard',
|
|
340
|
+
when: 'The foreground sub-agent has produced more steps than the window: a `↳ +N earlier…` header appears in the child block.',
|
|
341
|
+
render: () =>
|
|
342
|
+
renderActivityFeedWithNested(
|
|
343
|
+
AGENT_STEPS.slice(0, 2),
|
|
344
|
+
Array.from({ length: 11 }, (_, i) => `Child step ${i + 1}`),
|
|
345
|
+
false,
|
|
346
|
+
'',
|
|
347
|
+
undefined,
|
|
348
|
+
agentHeader(),
|
|
349
|
+
),
|
|
350
|
+
},
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
/** Section delimiter in the golden file. Chosen so no card body can contain it. */
|
|
354
|
+
export const GOLDEN_DELIMITER = '\n===== CARD ====='
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The whole catalogue as ONE deterministic string — what the golden file holds.
|
|
358
|
+
* Every variant contributes its name and its exact rendered bytes.
|
|
359
|
+
*/
|
|
360
|
+
export function renderGoldenDocument(): string {
|
|
361
|
+
return (
|
|
362
|
+
CARD_VARIANTS.map(
|
|
363
|
+
(v) => `${GOLDEN_DELIMITER} ${v.name}\n${v.render() ?? '(null — renderer returned nothing)'}`,
|
|
364
|
+
).join('\n') + '\n'
|
|
365
|
+
)
|
|
366
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fuse must survive a restart and must react in PROPORTION to the ban
|
|
3
|
+
* (#3856).
|
|
4
|
+
*
|
|
5
|
+
* ── The two bugs these tests guard ───────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* 1. **All state was in process memory.** `tightenLevel` / `tightenedUntil`
|
|
8
|
+
* were module-local variables. The gateway restarting during a flood ban —
|
|
9
|
+
* the single most likely thing to happen during a multi-hour outage —
|
|
10
|
+
* brought the fuse back FULLY UNTIGHTENED, at exactly the rate that earned
|
|
11
|
+
* the ban, while the window was still open. Every other outbound path in
|
|
12
|
+
* the tree already consults `flood-wait.json`; the fuse did not.
|
|
13
|
+
*
|
|
14
|
+
* 2. **`noteFlood()` ignored the magnitude.** Every 429 was worth exactly one
|
|
15
|
+
* level of multiplicative decrease, so a 3-second burst nudge and the
|
|
16
|
+
* 15908-second (4.4h) ban of 2026-07-27 produced an identical response.
|
|
17
|
+
* The tightening also expired after a flat 10 minutes — ~25× shorter than
|
|
18
|
+
* that ban, so the fuse would have been back at full rate for hours while
|
|
19
|
+
* still banned.
|
|
20
|
+
*
|
|
21
|
+
* Every test asserts an OUTCOME (the live ceiling, or how many calls reached
|
|
22
|
+
* the wire), never that a branch ran.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
createEditFloodFuse,
|
|
32
|
+
editFloodFuseConfigFromEnv,
|
|
33
|
+
EDIT_FLOOD_FUSE_DEFAULTS,
|
|
34
|
+
} from '../edit-flood-fuse.js'
|
|
35
|
+
import { floodStatePath, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
|
|
36
|
+
import type { Clock } from '../send-gate.js'
|
|
37
|
+
|
|
38
|
+
const CHAT = '1001'
|
|
39
|
+
|
|
40
|
+
class FakeClock implements Clock {
|
|
41
|
+
private cur = 0
|
|
42
|
+
private seq = 0
|
|
43
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
44
|
+
now(): number { return this.cur }
|
|
45
|
+
sleep(ms: number): Promise<void> {
|
|
46
|
+
return new Promise<void>((resolve) => {
|
|
47
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
async advance(ms: number): Promise<void> {
|
|
51
|
+
const target = this.cur + ms
|
|
52
|
+
for (;;) {
|
|
53
|
+
await flush()
|
|
54
|
+
const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
|
|
55
|
+
if (due.length === 0) break
|
|
56
|
+
const t = due[0]!
|
|
57
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
58
|
+
this.cur = t.at
|
|
59
|
+
t.resolve()
|
|
60
|
+
await flush()
|
|
61
|
+
}
|
|
62
|
+
this.cur = target
|
|
63
|
+
await flush()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
|
67
|
+
|
|
68
|
+
/** A 429 in the shape grammY throws it. */
|
|
69
|
+
function flood(retryAfterSec: number): Error & Record<string, unknown> {
|
|
70
|
+
const e = new Error('Too Many Requests: retry after ' + retryAfterSec) as Error &
|
|
71
|
+
Record<string, unknown>
|
|
72
|
+
e.error_code = 429
|
|
73
|
+
e.parameters = { retry_after: retryAfterSec }
|
|
74
|
+
return e
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('restart durability — the persisted window outranks process memory', () => {
|
|
78
|
+
it('a FRESH fuse is already at its tightest when a ban is open on disk', async () => {
|
|
79
|
+
const clock = new FakeClock()
|
|
80
|
+
// Simulate the restart: brand-new fuse, zero in-memory history, but the
|
|
81
|
+
// marker on disk still says 12247s of ban to go.
|
|
82
|
+
const fuse = createEditFloodFuse({
|
|
83
|
+
clock, floodWaitRemainingMs: () => 12_247_000,
|
|
84
|
+
perMessageMaxPerWindow: 20, cosmeticPerMessageMaxPerWindow: 16,
|
|
85
|
+
maxTightenLevel: 4, tightenFactor: 0.5,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const s = fuse.stats()
|
|
89
|
+
expect(s.persistedFloodOpen).toBe(true)
|
|
90
|
+
expect(s.tightenLevel).toBe(4)
|
|
91
|
+
// 0.5^4 = 1/16 of the base ceiling — the fuse did NOT come back at full rate.
|
|
92
|
+
expect(s.cosmeticPerMessageCeiling).toBe(1)
|
|
93
|
+
expect(s.perMessageCeiling).toBe(1)
|
|
94
|
+
// And it BINDS: 30 cosmetic edits offered, at most the tightened ceiling
|
|
95
|
+
// reaches the wire. Without the persisted read this is 16.
|
|
96
|
+
let landed = 0
|
|
97
|
+
const calls = Array.from({ length: 30 }, () =>
|
|
98
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 7 }, async () => {
|
|
99
|
+
landed++
|
|
100
|
+
return true
|
|
101
|
+
}))
|
|
102
|
+
await clock.advance(31_000)
|
|
103
|
+
await Promise.all(calls)
|
|
104
|
+
expect(landed).toBeLessThanOrEqual(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('untightens once the window on disk closes — it is a floor, not a latch', async () => {
|
|
108
|
+
const clock = new FakeClock()
|
|
109
|
+
let remaining = 60_000
|
|
110
|
+
const fuse = createEditFloodFuse({
|
|
111
|
+
clock, floodWaitRemainingMs: () => remaining,
|
|
112
|
+
floodProbeIntervalMs: 0, // re-read every call, for determinism here
|
|
113
|
+
perMessageMaxPerWindow: 20, maxTightenLevel: 4, tightenFactor: 0.5,
|
|
114
|
+
})
|
|
115
|
+
expect(fuse.stats().tightenLevel).toBe(4)
|
|
116
|
+
remaining = 0
|
|
117
|
+
// The fuse earned nothing itself, so it returns all the way to base — the
|
|
118
|
+
// persisted window pins the level while open and never mutates it.
|
|
119
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
120
|
+
expect(fuse.stats().perMessageCeiling).toBe(20)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('FAILS OPEN: an unreadable marker must never gag the bot', async () => {
|
|
124
|
+
const clock = new FakeClock()
|
|
125
|
+
const fuse = createEditFloodFuse({
|
|
126
|
+
clock,
|
|
127
|
+
floodWaitRemainingMs: () => {
|
|
128
|
+
throw new Error('EACCES: flood-wait.json owned by another uid')
|
|
129
|
+
},
|
|
130
|
+
perChatSendMaxPerWindow: 25,
|
|
131
|
+
})
|
|
132
|
+
expect(fuse.stats().persistedFloodOpen).toBe(false)
|
|
133
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
134
|
+
let landed = 0
|
|
135
|
+
await fuse.apply('sendMessage', { chat_id: CHAT, text: 'the answer' }, async () => {
|
|
136
|
+
landed++
|
|
137
|
+
return true
|
|
138
|
+
})
|
|
139
|
+
expect(landed).toBe(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('re-reads the marker at most once per floodProbeIntervalMs', async () => {
|
|
143
|
+
const clock = new FakeClock()
|
|
144
|
+
let reads = 0
|
|
145
|
+
const fuse = createEditFloodFuse({
|
|
146
|
+
clock, floodProbeIntervalMs: 1_000,
|
|
147
|
+
floodWaitRemainingMs: () => { reads++; return 0 },
|
|
148
|
+
perChatTotalMaxPerWindow: 1_000, perChatSendMaxPerWindow: 1_000,
|
|
149
|
+
perTokenMaxPerWindow: 1_000,
|
|
150
|
+
})
|
|
151
|
+
for (let i = 0; i < 50; i++) {
|
|
152
|
+
await fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => true)
|
|
153
|
+
}
|
|
154
|
+
// A file read on every admission would make the fuse the latency problem
|
|
155
|
+
// it exists to prevent.
|
|
156
|
+
expect(reads).toBeLessThanOrEqual(2)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('reads the REAL breaker file that robustApiCall and the sweep use', async () => {
|
|
160
|
+
// Not a mock: write the actual on-disk marker and let the production probe
|
|
161
|
+
// read it, so the two halves cannot drift apart.
|
|
162
|
+
const dir = mkdtempSync(join(tmpdir(), 'fuse-flood-'))
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(
|
|
165
|
+
floodStatePath(dir),
|
|
166
|
+
JSON.stringify({ untilTs: Date.now() + 12_247_000, retryAfterSec: 12247, recordedTs: Date.now() }),
|
|
167
|
+
'utf-8',
|
|
168
|
+
)
|
|
169
|
+
const fuse = createEditFloodFuse({
|
|
170
|
+
floodWaitRemainingMs: makeFloodWaitProbe(floodStatePath(dir)),
|
|
171
|
+
maxTightenLevel: 4, tightenFactor: 0.5, perMessageMaxPerWindow: 20,
|
|
172
|
+
})
|
|
173
|
+
expect(fuse.stats().persistedFloodOpen).toBe(true)
|
|
174
|
+
expect(fuse.stats().perMessageCeiling).toBe(1)
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true })
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('editFloodFuseConfigFromEnv wires the probe from TELEGRAM_STATE_DIR', () => {
|
|
181
|
+
const dir = mkdtempSync(join(tmpdir(), 'fuse-env-'))
|
|
182
|
+
try {
|
|
183
|
+
writeFileSync(
|
|
184
|
+
floodStatePath(dir),
|
|
185
|
+
JSON.stringify({ untilTs: Date.now() + 600_000, retryAfterSec: 600, recordedTs: Date.now() }),
|
|
186
|
+
'utf-8',
|
|
187
|
+
)
|
|
188
|
+
const cfg = editFloodFuseConfigFromEnv({ TELEGRAM_STATE_DIR: dir })
|
|
189
|
+
expect(cfg.floodWaitRemainingMs).toBeTypeOf('function')
|
|
190
|
+
expect(cfg.floodWaitRemainingMs!()).toBeGreaterThan(0)
|
|
191
|
+
// The wiring is what makes it durable in production — a fuse built from
|
|
192
|
+
// this config is tightened without anyone remembering to pass a probe.
|
|
193
|
+
expect(createEditFloodFuse(cfg).stats().persistedFloodOpen).toBe(true)
|
|
194
|
+
} finally {
|
|
195
|
+
rmSync(dir, { recursive: true, force: true })
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('is a no-op when TELEGRAM_STATE_DIR is unset (dev / one-shot contexts)', () => {
|
|
200
|
+
const cfg = editFloodFuseConfigFromEnv({})
|
|
201
|
+
expect(cfg.floodWaitRemainingMs).toBeUndefined()
|
|
202
|
+
expect(createEditFloodFuse(cfg).stats().persistedFloodOpen).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('severity scaling — a 4.4h ban is not one nudge\'s worth of signal', () => {
|
|
207
|
+
const mk = (clock: FakeClock) =>
|
|
208
|
+
createEditFloodFuse({
|
|
209
|
+
clock, maxTightenLevel: 4, tightenFactor: 0.5, tightenMs: 600_000,
|
|
210
|
+
perMessageMaxPerWindow: 32,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
async function observe(fuse: ReturnType<typeof mk>, retryAfterSec: number): Promise<void> {
|
|
214
|
+
await expect(
|
|
215
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
216
|
+
throw flood(retryAfterSec)
|
|
217
|
+
}),
|
|
218
|
+
).rejects.toThrow()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
it.each([
|
|
222
|
+
[3, 1], // routine burst nudge
|
|
223
|
+
[30, 2], // sustained overrate
|
|
224
|
+
[120, 3], // a real ban
|
|
225
|
+
[3713, 4], // the 2026-07-25 incident — straight to maximum
|
|
226
|
+
[15908, 4], // the 2026-07-27 incident
|
|
227
|
+
])('retry_after=%is tightens by %i level(s)', async (retryAfterSec, expected) => {
|
|
228
|
+
const clock = new FakeClock()
|
|
229
|
+
const fuse = mk(clock)
|
|
230
|
+
await observe(fuse, retryAfterSec)
|
|
231
|
+
expect(fuse.stats().tightenLevel).toBe(expected)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('the ban-magnitude case reaches the floor in ONE 429, not four', async () => {
|
|
235
|
+
// This is the assertion that fails on the pre-fix code: there, a single
|
|
236
|
+
// 15908s ban moved the ceiling from 32 to 16 and the stream carried on at
|
|
237
|
+
// half rate into a four-hour outage.
|
|
238
|
+
const clock = new FakeClock()
|
|
239
|
+
const fuse = mk(clock)
|
|
240
|
+
expect(fuse.stats().perMessageCeiling).toBe(32)
|
|
241
|
+
await observe(fuse, 15_908)
|
|
242
|
+
expect(fuse.stats().perMessageCeiling).toBe(2) // 32 * 0.5^4
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('holds the tightening for AT LEAST the ban\'s own duration', async () => {
|
|
246
|
+
// A flat 10-minute tightenMs expired ~25× over during the 4.4h ban.
|
|
247
|
+
const clock = new FakeClock()
|
|
248
|
+
const fuse = mk(clock)
|
|
249
|
+
await observe(fuse, 15_908)
|
|
250
|
+
await clock.advance(15_907_000) // one second before the ban lifts
|
|
251
|
+
expect(fuse.stats().tightenLevel).toBeGreaterThan(0)
|
|
252
|
+
expect(fuse.stats().perMessageCeiling).toBeLessThan(32)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('a 3s nudge still decays on the normal 10-minute schedule', async () => {
|
|
256
|
+
const clock = new FakeClock()
|
|
257
|
+
const fuse = mk(clock)
|
|
258
|
+
await observe(fuse, 3)
|
|
259
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
260
|
+
// Held for tightenMs PLUS the stated penalty (3s) — still ~10 minutes.
|
|
261
|
+
await clock.advance(600_001)
|
|
262
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
263
|
+
await clock.advance(3_001)
|
|
264
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
265
|
+
expect(fuse.stats().perMessageCeiling).toBe(32)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('recovers the magnitude from a 429 that arrives as a raw ApiResponse', async () => {
|
|
269
|
+
const clock = new FakeClock()
|
|
270
|
+
const fuse = mk(clock)
|
|
271
|
+
await fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () =>
|
|
272
|
+
({ ok: false, error_code: 429, parameters: { retry_after: 15_908 } }))
|
|
273
|
+
expect(fuse.stats().tightenLevel).toBe(4)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('recovers the magnitude from the message text when there is no parameters block', async () => {
|
|
277
|
+
const clock = new FakeClock()
|
|
278
|
+
const fuse = mk(clock)
|
|
279
|
+
await expect(
|
|
280
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
281
|
+
throw new Error('Too Many Requests: retry after 15908')
|
|
282
|
+
}),
|
|
283
|
+
).rejects.toThrow()
|
|
284
|
+
expect(fuse.stats().tightenLevel).toBe(4)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('an UNQUANTIFIED flood is treated as the mildest case, not the worst', async () => {
|
|
288
|
+
// Inflating an unmeasured signal into a maximal response would let one
|
|
289
|
+
// ambiguous error message throttle the bot to 1/window.
|
|
290
|
+
const clock = new FakeClock()
|
|
291
|
+
const fuse = mk(clock)
|
|
292
|
+
await expect(
|
|
293
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
294
|
+
throw new Error('Too Many Requests')
|
|
295
|
+
}),
|
|
296
|
+
).rejects.toThrow()
|
|
297
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('still does NOT tighten on an ordinary error that merely contains "429"', async () => {
|
|
301
|
+
const clock = new FakeClock()
|
|
302
|
+
const fuse = mk(clock)
|
|
303
|
+
await expect(
|
|
304
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
305
|
+
throw new Error('Bad Request: message 429 not found')
|
|
306
|
+
}),
|
|
307
|
+
).rejects.toThrow()
|
|
308
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
309
|
+
expect(fuse.stats().floodObserved).toBe(0)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('the default probe interval is bounded', () => {
|
|
313
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.floodProbeIntervalMs).toBeGreaterThan(0)
|
|
314
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.floodProbeIntervalMs).toBeLessThanOrEqual(5_000)
|
|
315
|
+
})
|
|
316
|
+
})
|