switchroom 0.19.18 → 0.19.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The edit-flood fuse (#3620) — the chokepoint that no outbound call can
3
+ * bypass.
4
+ *
5
+ * These tests assert the INVARIANT, not the code path: an outbound caller
6
+ * that knows nothing about the send gate (no keying, no priority class, no
7
+ * `robustApiCall`) still cannot exceed the ceiling, because the fuse lives in
8
+ * the grammY transformer stack that every call to the bot's `Api` object
9
+ * traverses.
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import {
13
+ createEditFloodFuse, installEditFloodFuse, EDIT_FLOOD_FUSE_DEFAULTS,
14
+ type FuseInstallable,
15
+ } from '../edit-flood-fuse.js'
16
+ import { SEND_GATE_DEFAULTS, type Clock } from '../send-gate.js'
17
+
18
+ const CHAT = '1001'
19
+
20
+ class FakeClock implements Clock {
21
+ private cur = 0
22
+ private seq = 0
23
+ private timers: { at: number; id: number; resolve: () => void }[] = []
24
+
25
+ now(): number { return this.cur }
26
+
27
+ sleep(ms: number): Promise<void> {
28
+ return new Promise<void>((resolve) => {
29
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
30
+ })
31
+ }
32
+
33
+ async advance(ms: number): Promise<void> {
34
+ const target = this.cur + ms
35
+ for (;;) {
36
+ await flush()
37
+ const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
38
+ if (due.length === 0) break
39
+ const t = due[0]!
40
+ this.timers = this.timers.filter((x) => x !== t)
41
+ this.cur = t.at
42
+ t.resolve()
43
+ await flush()
44
+ }
45
+ this.cur = target
46
+ await flush()
47
+ }
48
+ }
49
+
50
+ function flush(): Promise<void> {
51
+ return new Promise((r) => setImmediate(r))
52
+ }
53
+
54
+ describe('edit-flood fuse — a runaway edit stream cannot exceed the ceiling', () => {
55
+ it('200 unkeyed edits to ONE message admit at most the per-message ceiling per window', async () => {
56
+ const clock = new FakeClock()
57
+ const landed: string[] = []
58
+ const fuse = createEditFloodFuse({ clock, perMessageMaxPerWindow: 20, perMessageWindowMs: 60_000 })
59
+
60
+ // A caller that does everything wrong: raw, unkeyed, as fast as it can.
61
+ const inflight: Promise<unknown>[] = []
62
+ for (let i = 1; i <= 200; i++) {
63
+ inflight.push(fuse.apply(
64
+ 'editMessageText',
65
+ { chat_id: CHAT, message_id: 42, text: `frame ${i}` },
66
+ async () => { landed.push(`frame ${i}`); return true },
67
+ ))
68
+ await clock.advance(50) // 20 edits/sec offered — 1200/min
69
+ }
70
+ // 200 offers spread over 10s of fake time. Snapshot INSIDE the window —
71
+ // that is where the ceiling claim lives.
72
+ await clock.advance(1_000)
73
+ const inWindow = landed.length
74
+ // Then settle: the one surviving waiter is dropped at `maxDeferMs`.
75
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
76
+ await Promise.all(inflight)
77
+
78
+ // The whole burst happened inside ONE 60s window, so the ceiling is the
79
+ // ceiling — not 200, not 60.
80
+ expect(inWindow).toBeLessThanOrEqual(20)
81
+ expect(inWindow).toBeGreaterThan(0)
82
+ const s = fuse.stats()
83
+ expect(s.dropped + s.superseded).toBeGreaterThan(0)
84
+ })
85
+
86
+ it('drops the STALE frames, not the newest: the last edit to land is the newest offered', async () => {
87
+ const clock = new FakeClock()
88
+ const landed: string[] = []
89
+ const fuse = createEditFloodFuse({ clock, perMessageMaxPerWindow: 3, perMessageWindowMs: 10_000, maxDeferMs: 60_000 })
90
+
91
+ const inflight: Promise<unknown>[] = []
92
+ for (let i = 1; i <= 30; i++) {
93
+ inflight.push(fuse.apply(
94
+ 'editMessageText',
95
+ { chat_id: CHAT, message_id: 7, text: `v${i}` },
96
+ async () => { landed.push(`v${i}`); return true },
97
+ ))
98
+ await clock.advance(100)
99
+ }
100
+ // Let the window slide so the surviving (newest) frame gets its slot.
101
+ await clock.advance(30_000)
102
+ await Promise.all(inflight)
103
+
104
+ expect(landed.length).toBeLessThanOrEqual(6) // 3 per 10s window over ~33s
105
+ // Last-write-wins at the chokepoint: the final frame that reached the API
106
+ // is the newest one that was offered, never a stale intermediate.
107
+ expect(landed[landed.length - 1]).toBe('v30')
108
+ expect(fuse.stats().superseded).toBeGreaterThan(0)
109
+ })
110
+
111
+ it('N concurrent producers (parallel sub-agents) share ONE budget — they do not each get their own', async () => {
112
+ const clock = new FakeClock()
113
+ const landed: string[] = []
114
+ const fuse = createEditFloodFuse({
115
+ clock, perMessageMaxPerWindow: 50, perChatEditMaxPerWindow: 12, perChatWindowMs: 60_000,
116
+ })
117
+
118
+ // 8 producers, each editing its OWN card in the SAME chat — the shape of
119
+ // parallel sub-agents each driving a worker feed. Per-message budgets
120
+ // would let all 8 through; the per-CHAT budget is what must bind.
121
+ const producers = 8
122
+ const perProducer = 20
123
+ const inflight: Promise<unknown>[] = []
124
+ for (let round = 0; round < perProducer; round++) {
125
+ for (let p = 0; p < producers; p++) {
126
+ inflight.push(fuse.apply(
127
+ 'editMessageText',
128
+ { chat_id: CHAT, message_id: 100 + p, text: `p${p} r${round}` },
129
+ async () => { landed.push(`p${p} r${round}`); return true },
130
+ ))
131
+ }
132
+ await clock.advance(200)
133
+ }
134
+ await clock.advance(1_000)
135
+ const inWindow = landed.length
136
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
137
+ await Promise.all(inflight)
138
+
139
+ // 160 offered across 8 producers in a 5s span; the shared per-chat ceiling
140
+ // holds regardless of how many producers there are.
141
+ expect(inWindow).toBeLessThanOrEqual(12)
142
+ })
143
+
144
+ it('never drops a non-edit send — a reply is paced, not lost', async () => {
145
+ const clock = new FakeClock()
146
+ const landed: number[] = []
147
+ const fuse = createEditFloodFuse({
148
+ clock, perChatSendMaxPerWindow: 2, perChatWindowMs: 10_000, maxDeferMs: 60_000,
149
+ })
150
+
151
+ const inflight: Promise<unknown>[] = []
152
+ for (let i = 0; i < 6; i++) {
153
+ inflight.push(fuse.apply(
154
+ 'sendMessage',
155
+ { chat_id: CHAT, text: `reply ${i}` },
156
+ async () => { landed.push(i); return true },
157
+ ))
158
+ }
159
+ await clock.advance(120_000)
160
+ await Promise.all(inflight)
161
+
162
+ // Every single reply reached the API — paced, never dropped.
163
+ expect(landed).toHaveLength(6)
164
+ expect(fuse.stats().dropped).toBe(0)
165
+ })
166
+ })
167
+
168
+ describe('edit-flood fuse — supersede is scoped to ONE message', () => {
169
+ it('a queued edit to card A is not killed by an edit to card B in the same chat', async () => {
170
+ const clock = new FakeClock()
171
+ const landed: string[] = []
172
+ // The per-CHAT edit tier is the binding one here (per-message is wide).
173
+ const fuse = createEditFloodFuse({
174
+ clock, perMessageMaxPerWindow: 100, perChatEditMaxPerWindow: 1,
175
+ perChatWindowMs: 10_000, maxDeferMs: 120_000,
176
+ })
177
+
178
+ const inflight = [1, 2, 3].map((m) => fuse.apply(
179
+ 'editMessageText', { chat_id: CHAT, message_id: m, text: `card ${m}` },
180
+ async () => { landed.push(`card ${m}`); return true },
181
+ ))
182
+ await clock.advance(60_000)
183
+ await Promise.all(inflight)
184
+
185
+ // Last-write-wins means "a newer frame of THIS card replaces an older frame
186
+ // of THIS card". Killing card 2's queued edit because card 3 arrived would
187
+ // not be coalescing, it would be data loss — card 2 would never repaint.
188
+ expect(landed.sort()).toEqual(['card 1', 'card 2', 'card 3'])
189
+ expect(fuse.stats().superseded).toBe(0)
190
+ expect(fuse.stats().dropped).toBe(0)
191
+ })
192
+ })
193
+
194
+ describe('edit-flood fuse — AIMD: a soft 429 tightens the sustained rate', () => {
195
+ it('halves the ceiling after an observed 429 and restores it after the tighten window', async () => {
196
+ const clock = new FakeClock()
197
+ const fuse = createEditFloodFuse({
198
+ clock, perMessageMaxPerWindow: 20, perMessageWindowMs: 60_000,
199
+ tightenFactor: 0.5, tightenMs: 600_000,
200
+ })
201
+ expect(fuse.stats().perMessageCeiling).toBe(20)
202
+ expect(fuse.stats().tightened).toBe(false)
203
+
204
+ // A 429 arrives the way grammY surfaces it: a thrown error carrying the
205
+ // code and retry_after. The fuse must not swallow it...
206
+ const flood = Object.assign(new Error('Too Many Requests: retry after 3'), {
207
+ error_code: 429, parameters: { retry_after: 3 },
208
+ })
209
+ await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => { throw flood }))
210
+ .rejects.toThrow(/Too Many Requests/)
211
+
212
+ // ...but it must tighten the sustained rate, which nothing in the stack
213
+ // did before: retry-api-call sleeps `retry_after` and resumes at FULL rate.
214
+ expect(fuse.stats().floodObserved).toBe(1)
215
+ expect(fuse.stats().tightened).toBe(true)
216
+ expect(fuse.stats().perMessageCeiling).toBe(10)
217
+
218
+ // The tightened ceiling is what actually binds, not just what stats says.
219
+ const landed: number[] = []
220
+ const inflight: Promise<unknown>[] = []
221
+ for (let i = 0; i < 40; i++) {
222
+ inflight.push(fuse.apply(
223
+ 'editMessageText', { chat_id: CHAT, message_id: 5, text: `t${i}` },
224
+ async () => { landed.push(i); return true },
225
+ ))
226
+ await clock.advance(10)
227
+ }
228
+ await clock.advance(1_000)
229
+ const inWindow = landed.length
230
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
231
+ await Promise.all(inflight)
232
+ expect(inWindow).toBeLessThanOrEqual(10)
233
+
234
+ // Additive-ish recovery: once the tighten window lapses the full ceiling
235
+ // is back (no permanent degradation from one transient 429).
236
+ await clock.advance(600_001)
237
+ expect(fuse.stats().tightened).toBe(false)
238
+ expect(fuse.stats().perMessageCeiling).toBe(20)
239
+ })
240
+
241
+ it('does NOT tighten on an ordinary error whose text merely contains "429"', async () => {
242
+ const clock = new FakeClock()
243
+ const fuse = createEditFloodFuse({ clock })
244
+ const notAFlood = new Error('Bad Request: message 429 not found')
245
+ await expect(fuse.apply('editMessageText', { chat_id: CHAT, message_id: 429, text: 'x' },
246
+ async () => { throw notAFlood })).rejects.toThrow(/not found/)
247
+ // A false positive here would halve every ceiling for ten minutes on a
248
+ // routine deleted-message error.
249
+ expect(fuse.stats().floodObserved).toBe(0)
250
+ expect(fuse.stats().tightened).toBe(false)
251
+ })
252
+
253
+ it('recognises a 429 that arrives as a raw ApiResponse rather than a throw', async () => {
254
+ const clock = new FakeClock()
255
+ const fuse = createEditFloodFuse({ clock })
256
+ await fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => ({
257
+ ok: false, error_code: 429, parameters: { retry_after: 5 },
258
+ }))
259
+ expect(fuse.stats().floodObserved).toBe(1)
260
+ expect(fuse.stats().tightened).toBe(true)
261
+ })
262
+ })
263
+
264
+ describe('edit-flood fuse — structural: installed where nothing can bypass it', () => {
265
+ it('installs as a grammY transformer, so a RAW bot.api call is fused too', async () => {
266
+ const clock = new FakeClock()
267
+ const landed: string[] = []
268
+ // A stand-in for grammY's Api transformer stack: `use` registers a
269
+ // transformer and `call` runs the chain, exactly as grammY does.
270
+ const chain: ((prev: (m: string, p: unknown, s?: unknown) => Promise<unknown>, m: string, p: unknown, s?: unknown) => Promise<unknown>)[] = []
271
+ const bot: FuseInstallable = { api: { config: { use: (t) => { chain.push(t) } } } }
272
+ const raw = async (_m: string, p: unknown) => {
273
+ landed.push(String((p as { text: string }).text))
274
+ return true
275
+ }
276
+ const call = (m: string, p: unknown) =>
277
+ chain.reduce<(mm: string, pp: unknown, ss?: unknown) => Promise<unknown>>(
278
+ (prev, t) => (mm, pp, ss) => t(prev, mm, pp, ss), raw,
279
+ )(m, p)
280
+
281
+ const fuse = installEditFloodFuse(bot, { clock, perMessageMaxPerWindow: 4, perMessageWindowMs: 60_000 })
282
+ expect(chain).toHaveLength(1)
283
+
284
+ const inflight: Promise<unknown>[] = []
285
+ for (let i = 0; i < 50; i++) {
286
+ inflight.push(call('editMessageText', { chat_id: CHAT, message_id: 9, text: `raw ${i}` }))
287
+ await clock.advance(20)
288
+ }
289
+ await clock.advance(1_000)
290
+ const inWindow = landed.length
291
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
292
+ await Promise.all(inflight)
293
+
294
+ // The caller never touched the send gate, never keyed anything, and is
295
+ // still bounded. That is the property the send gate alone cannot give.
296
+ expect(inWindow).toBeLessThanOrEqual(4)
297
+ expect(fuse.stats().dropped + fuse.stats().superseded).toBeGreaterThan(0)
298
+ })
299
+
300
+ it('passes non-message methods straight through (getUpdates must never be paced)', async () => {
301
+ const clock = new FakeClock()
302
+ const fuse = createEditFloodFuse({ clock, perChatSendMaxPerWindow: 1, perChatWindowMs: 600_000 })
303
+ let calls = 0
304
+ for (let i = 0; i < 100; i++) {
305
+ await fuse.apply('getUpdates', {}, async () => { calls++; return [] })
306
+ }
307
+ expect(calls).toBe(100)
308
+ expect(fuse.stats().deferred).toBe(0)
309
+ })
310
+
311
+ // Review 2026-07-26 (R1). This test used to be titled "defaults sit above
312
+ // every legitimate cadence in the codebase" while asserting
313
+ // `perMessageMaxPerWindow < 24` — i.e. it asserted the OPPOSITE of its own
314
+ // name, and green-lit a false claim in the PR description. The defaults are
315
+ // fine; the story about them was wrong. Retitled and re-pointed at the
316
+ // property that is actually true and actually load-bearing.
317
+ it('defaults are a BINDING ceiling below the send gate\'s own permitted cadence', () => {
318
+ // The send gate's 1500ms edit floor permits 40 edits/min/message and its
319
+ // cosmetic budget permits 30/min. Telegram's per-group limit is ~20/min
320
+ // and edits count against it — so the in-repo "legitimate" cadences are
321
+ // themselves above what Telegram tolerates. The fuse therefore sits BELOW
322
+ // them on purpose and IS the binding constraint on a hot card.
323
+ const gateFloorPerMin = 60_000 / SEND_GATE_DEFAULTS.editFloorMs
324
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.perMessageMaxPerWindow).toBeLessThan(gateFloorPerMin)
325
+ // A per-chat ceiling below the per-message ceiling would make the
326
+ // per-message tier unreachable and route every over-budget frame through
327
+ // the drop-capable chat tier instead of the supersede-capable message one.
328
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.perChatEditMaxPerWindow)
329
+ .toBeGreaterThanOrEqual(EDIT_FLOOD_FUSE_DEFAULTS.perMessageMaxPerWindow)
330
+ // A defer that could outlast the window it is waiting on would drop frames
331
+ // that were about to get room anyway.
332
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs)
333
+ .toBeLessThan(EDIT_FLOOD_FUSE_DEFAULTS.perMessageWindowMs)
334
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.perMessageWindowMs).toBe(60_000)
335
+ })
336
+
337
+ it('fuses editMessageChecklist — the checklist tools drive one message id in a loop', async () => {
338
+ const clock = new FakeClock()
339
+ const landed: number[] = []
340
+ const fuse = createEditFloodFuse({ clock, perMessageMaxPerWindow: 3, perMessageWindowMs: 60_000 })
341
+ const inflight: Promise<unknown>[] = []
342
+ for (let i = 0; i < 40; i++) {
343
+ inflight.push(fuse.apply('editMessageChecklist',
344
+ { chat_id: CHAT, message_id: 77, checklist: { title: `v${i}` } },
345
+ async () => { landed.push(i); return true }))
346
+ await clock.advance(50)
347
+ }
348
+ await clock.advance(1_000)
349
+ const inWindow = landed.length
350
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
351
+ await Promise.all(inflight)
352
+ // Pre-fix this method was absent from EDIT_METHODS, so all 40 passed
353
+ // straight through — the exact shape of the flood the fuse exists to stop.
354
+ expect(inWindow).toBeLessThanOrEqual(3)
355
+ })
356
+
357
+ it('paces sendRichMessage — the plugin\'s PRIMARY send — without ever dropping it', async () => {
358
+ const clock = new FakeClock()
359
+ const landed: number[] = []
360
+ const fuse = createEditFloodFuse({
361
+ clock, perChatSendMaxPerWindow: 2, perChatWindowMs: 10_000, maxDeferMs: 60_000,
362
+ })
363
+ const inflight: Promise<unknown>[] = []
364
+ for (let i = 0; i < 6; i++) {
365
+ inflight.push(fuse.apply('sendRichMessage', { chat_id: CHAT, rich_message: { markdown: `r${i}` } },
366
+ async () => { landed.push(i); return { message_id: i } }))
367
+ }
368
+ // Pre-fix `sendRichMessage` was not in SEND_METHODS, so `deferred` stayed 0
369
+ // and the "non-edit sends are paced" property covered nothing this gateway
370
+ // actually sends.
371
+ await clock.advance(1)
372
+ expect(fuse.stats().deferred).toBeGreaterThan(0)
373
+ await clock.advance(120_000)
374
+ await Promise.all(inflight)
375
+ expect(landed).toHaveLength(6)
376
+ expect(fuse.stats().dropped).toBe(0)
377
+ })
378
+ })
379
+
380
+ describe('edit-flood fuse — a card\'s LAST pending frame is never silently dropped', () => {
381
+ it('releases (late) rather than drops an edit that nothing newer will repaint', async () => {
382
+ const clock = new FakeClock()
383
+ const landed: string[] = []
384
+ // Per-message tier wide open; the per-CHAT edit tier is saturated by a
385
+ // DIFFERENT card, which is the tier whose over-budget mode is `drop`.
386
+ const fuse = createEditFloodFuse({
387
+ clock, perMessageMaxPerWindow: 100, perChatEditMaxPerWindow: 1,
388
+ perChatWindowMs: 600_000, maxDeferMs: 5_000,
389
+ })
390
+ // Card A burns the chat's only edit slot for the next 10 minutes.
391
+ await fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1, text: 'A' },
392
+ async () => { landed.push('A'); return true })
393
+
394
+ // Card B's turn-final `finalize`: the ONE frame that paints its terminal
395
+ // state, with nothing behind it. Pre-fix this was dropped at the defer
396
+ // deadline and `true` was returned, so the send gate recorded a payload
397
+ // that never reached the screen and no-op-skipped every retry — card B
398
+ // stayed frozen mid-run for the rest of the session.
399
+ const finalize = fuse.apply('editMessageText', { chat_id: CHAT, message_id: 2, text: 'B done' },
400
+ async () => { landed.push('B done'); return true })
401
+ await clock.advance(10_000)
402
+ await finalize
403
+
404
+ expect(landed).toEqual(['A', 'B done'])
405
+ expect(fuse.stats().dropped).toBe(0)
406
+ })
407
+
408
+ it('still DROPS an over-budget frame when a newer frame for the same message is in flight', async () => {
409
+ const clock = new FakeClock()
410
+ const landed: string[] = []
411
+ const fuse = createEditFloodFuse({
412
+ clock, perMessageMaxPerWindow: 100, perChatEditMaxPerWindow: 1,
413
+ perChatWindowMs: 600_000, maxDeferMs: 5_000,
414
+ })
415
+ await fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1, text: 'A' },
416
+ async () => { landed.push('A'); return true })
417
+
418
+ // Two frames for card 2 — the older one is genuinely stale, so the drop is
419
+ // honest and the ceiling still holds. Without this the fix would have
420
+ // turned the per-chat ceiling into pure pacing.
421
+ const stale = fuse.apply('editMessageText', { chat_id: CHAT, message_id: 2, text: 'v1' },
422
+ async () => { landed.push('v1'); return true })
423
+ const fresh = fuse.apply('editMessageText', { chat_id: CHAT, message_id: 2, text: 'v2' },
424
+ async () => { landed.push('v2'); return true })
425
+ await clock.advance(10_000)
426
+ await Promise.all([stale, fresh])
427
+
428
+ expect(fuse.stats().dropped).toBeGreaterThan(0)
429
+ expect(landed).not.toContain('v1')
430
+ })
431
+ })