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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- 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
|
+
})
|