switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,574 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createSendGate, type Clock } from './send-gate.js'
3
+ import { isFloodWaitActiveError } from './retry-api-call.js'
4
+
5
+ /**
6
+ * Deterministic tests for #3084 PR 2 — priority shedding, degraded mode, and
7
+ * restart-proof flood windows (part3-design §2/§3/§7). Same fake-clock pattern
8
+ * as send-gate.test.ts; every assertion checks an OUTCOME (a send happened /
9
+ * was shed / expired / failed fast; a window survived a round-trip), not a
10
+ * code path.
11
+ */
12
+ class FakeClock implements Clock {
13
+ private cur = 0
14
+ private seq = 0
15
+ private timers: { at: number; id: number; resolve: () => void }[] = []
16
+
17
+ now(): number {
18
+ return this.cur
19
+ }
20
+
21
+ sleep(ms: number): Promise<void> {
22
+ return new Promise<void>((resolve) => {
23
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
24
+ })
25
+ }
26
+
27
+ async advance(ms: number): Promise<void> {
28
+ // Flush first so any async chain that hasn't yet registered its next sleep
29
+ // (e.g. a critical send suspended on the serialize mutex's microtask) gets
30
+ // a chance to register before we walk time forward.
31
+ await flush()
32
+ const target = this.cur + ms
33
+ for (;;) {
34
+ const due = this.timers
35
+ .filter((t) => t.at <= target)
36
+ .sort((a, b) => a.at - b.at || a.id - b.id)
37
+ if (due.length === 0) break
38
+ const t = due[0]
39
+ this.timers = this.timers.filter((x) => x !== t)
40
+ this.cur = t.at
41
+ t.resolve()
42
+ await flush()
43
+ }
44
+ this.cur = target
45
+ }
46
+ }
47
+
48
+ function flush(): Promise<void> {
49
+ return new Promise<void>((r) => setImmediate(r))
50
+ }
51
+
52
+ function recorder(clock: FakeClock) {
53
+ const calls: { label: string; at: number }[] = []
54
+ const fn = (label: string) => async () => {
55
+ calls.push({ label, at: clock.now() })
56
+ return label
57
+ }
58
+ return { calls, fn }
59
+ }
60
+
61
+ // A future window well beyond the fail-fast ceiling.
62
+ const HOUR = 3_600_000
63
+
64
+ describe('send-gate PR2: cosmetic shedding', () => {
65
+ it('sheds a cosmetic send when a flood window covers its scope', async () => {
66
+ const clock = new FakeClock()
67
+ const { calls, fn } = recorder(clock)
68
+ const gate = createSendGate({
69
+ enabled: true,
70
+ clock,
71
+ initialWindows: [{ scopeKey: 'global', untilTs: HOUR }],
72
+ })
73
+
74
+ const res = await gate.gate(fn('cosmetic'), { priorityClass: 'cosmetic' })
75
+
76
+ // Shed resolves undefined; fn never ran; the shed counter moved.
77
+ expect(res).toBeUndefined()
78
+ expect(calls).toHaveLength(0)
79
+ expect(gate.stats().global.shed).toBe(1)
80
+ expect(gate.stats().global.sent).toBe(0)
81
+ })
82
+
83
+ it('sheds a cosmetic send when no token is immediately free', async () => {
84
+ const clock = new FakeClock()
85
+ const { calls, fn } = recorder(clock)
86
+ // Per-chat bucket burst 1, so the 2nd immediate cosmetic has no token.
87
+ const gate = createSendGate({
88
+ enabled: true,
89
+ clock,
90
+ perChatBurst: 1,
91
+ perChatPerSec: 1,
92
+ })
93
+
94
+ await gate.gate(fn('a'), { chat_id: '5', priorityClass: 'cosmetic' })
95
+ const shed = await gate.gate(fn('b'), { chat_id: '5', priorityClass: 'cosmetic' })
96
+
97
+ expect(calls.map((c) => c.label)).toEqual(['a'])
98
+ expect(shed).toBeUndefined()
99
+ expect(gate.stats().global.shed).toBe(1)
100
+ })
101
+
102
+ it('does NOT shed a cosmetic send when a token is free and no window is open', async () => {
103
+ const clock = new FakeClock()
104
+ const { calls, fn } = recorder(clock)
105
+ const gate = createSendGate({ enabled: true, clock })
106
+
107
+ await gate.gate(fn('a'), { priorityClass: 'cosmetic' })
108
+
109
+ expect(calls.map((c) => c.label)).toEqual(['a'])
110
+ expect(gate.stats().global.shed).toBe(0)
111
+ expect(gate.stats().global.sent).toBe(1)
112
+ })
113
+
114
+ it('sheds a cosmetic EDIT while the message-edit window is open', async () => {
115
+ const clock = new FakeClock()
116
+ const { calls, fn } = recorder(clock)
117
+ const gate = createSendGate({
118
+ enabled: true,
119
+ clock,
120
+ // H1: msg-edit scope is keyed `${chat_id}:${messageId}`.
121
+ initialWindows: [{ scopeKey: 'msg-edit:5:42', untilTs: HOUR }],
122
+ })
123
+
124
+ const res = await gate.gate(fn('edit'), {
125
+ chat_id: '5',
126
+ messageId: 42,
127
+ editPayload: 'v1',
128
+ priorityClass: 'cosmetic',
129
+ })
130
+
131
+ expect(res).toBeUndefined()
132
+ expect(calls).toHaveLength(0)
133
+ expect(gate.stats().global.shed).toBe(1)
134
+ })
135
+ })
136
+
137
+ describe('send-gate PR2: useful TTL expiry', () => {
138
+ it('drops a useful send that cannot be admitted before its TTL', async () => {
139
+ const clock = new FakeClock()
140
+ const { calls, fn } = recorder(clock)
141
+ // Global bucket blocked by a 5-minute window; useful TTL is 120s < window.
142
+ const gate = createSendGate({
143
+ enabled: true,
144
+ clock,
145
+ usefulTtlMs: 120_000,
146
+ initialWindows: [{ scopeKey: 'global', untilTs: 300_000 }],
147
+ })
148
+
149
+ const p = gate.gate(fn('useful'), { priorityClass: 'useful' })
150
+ // Walk past the TTL but not past the window.
151
+ await clock.advance(130_000)
152
+ const res = await p
153
+
154
+ expect(res).toBeUndefined()
155
+ expect(calls).toHaveLength(0)
156
+ expect(gate.stats().global.expired).toBe(1)
157
+ expect(gate.stats().global.sent).toBe(0)
158
+ })
159
+
160
+ it('admits a useful send that clears before its TTL', async () => {
161
+ const clock = new FakeClock()
162
+ const { calls, fn } = recorder(clock)
163
+ // Window clears at 30s, inside the 120s useful TTL.
164
+ const gate = createSendGate({
165
+ enabled: true,
166
+ clock,
167
+ usefulTtlMs: 120_000,
168
+ initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
169
+ })
170
+
171
+ const p = gate.gate(fn('useful'), { priorityClass: 'useful' })
172
+ await clock.advance(31_000)
173
+ const res = await p
174
+
175
+ expect(res).toBe('useful')
176
+ expect(calls.map((c) => c.label)).toEqual(['useful'])
177
+ expect(gate.stats().global.expired).toBe(0)
178
+ expect(gate.stats().global.sent).toBe(1)
179
+ })
180
+ })
181
+
182
+ describe('send-gate PR2: critical degraded mode', () => {
183
+ it('fails fast with a structured FLOOD_WAIT_ACTIVE when the window exceeds the ceiling', async () => {
184
+ const clock = new FakeClock()
185
+ const { calls, fn } = recorder(clock)
186
+ const gate = createSendGate({
187
+ enabled: true,
188
+ clock,
189
+ criticalFailFastMs: 60_000,
190
+ // 10-minute window >> 60s ceiling.
191
+ initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
192
+ })
193
+
194
+ let caught: unknown
195
+ try {
196
+ await gate.gate(fn('critical'), { priorityClass: 'critical' })
197
+ } catch (err) {
198
+ caught = err
199
+ }
200
+
201
+ expect(isFloodWaitActiveError(caught)).toBe(true)
202
+ const e = caught as { untilTs: number; retryAfterSec: number; error_code: number }
203
+ expect(e.error_code).toBe(429)
204
+ expect(e.untilTs).toBe(600_000)
205
+ expect(e.retryAfterSec).toBe(600)
206
+ expect(calls).toHaveLength(0) // never probed the API
207
+ expect(gate.stats().global.failedFast).toBe(1)
208
+ })
209
+
210
+ it('waits out a SHORT window (<= ceiling) then sends the critical, single in-flight with jitter', async () => {
211
+ const clock = new FakeClock()
212
+ const { calls, fn } = recorder(clock)
213
+ const gate = createSendGate({
214
+ enabled: true,
215
+ clock,
216
+ criticalFailFastMs: 60_000,
217
+ criticalJitterMaxMs: 100,
218
+ jitter: () => 0.5, // deterministic 50ms jitter
219
+ // 30s window, under the 60s fail-fast ceiling.
220
+ initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
221
+ })
222
+
223
+ const p = gate.gate(fn('critical'), { priorityClass: 'critical' })
224
+ await clock.advance(31_000)
225
+ const res = await p
226
+
227
+ expect(res).toBe('critical')
228
+ expect(calls).toHaveLength(1)
229
+ // Sent AFTER the window cleared (>= 30s), never during it.
230
+ expect(calls[0]!.at).toBeGreaterThanOrEqual(30_000)
231
+ expect(gate.stats().global.failedFast).toBe(0)
232
+ expect(gate.stats().global.sent).toBe(1)
233
+ })
234
+
235
+ it('never sheds a critical send under mere token pressure (no window)', async () => {
236
+ const clock = new FakeClock()
237
+ const { calls, fn } = recorder(clock)
238
+ const gate = createSendGate({
239
+ enabled: true,
240
+ clock,
241
+ perChatBurst: 1,
242
+ perChatPerSec: 1,
243
+ })
244
+
245
+ await gate.gate(fn('a'), { chat_id: '9', priorityClass: 'critical' })
246
+ const p = gate.gate(fn('b'), { chat_id: '9', priorityClass: 'critical' })
247
+ await clock.advance(1000) // one token refills
248
+ const res = await p
249
+
250
+ expect(res).toBe('b')
251
+ expect(calls.map((c) => c.label)).toEqual(['a', 'b'])
252
+ expect(gate.stats().global.shed).toBe(0)
253
+ })
254
+ })
255
+
256
+ describe('send-gate PR2: flag-off passthrough of the new machinery', () => {
257
+ it('never sheds / expires / fails fast when disabled, even with an open window', async () => {
258
+ const clock = new FakeClock()
259
+ const { calls, fn } = recorder(clock)
260
+ const gate = createSendGate({
261
+ enabled: false,
262
+ clock,
263
+ initialWindows: [{ scopeKey: 'global', untilTs: HOUR }],
264
+ })
265
+
266
+ // Cosmetic (would shed), useful (would risk TTL), critical (would fail fast)
267
+ // — all must run immediately at t=0 with zero counter movement.
268
+ const c = await gate.gate(fn('cosmetic'), { priorityClass: 'cosmetic' })
269
+ const u = await gate.gate(fn('useful'), { priorityClass: 'useful' })
270
+ const cr = await gate.gate(fn('critical'), { priorityClass: 'critical' })
271
+
272
+ expect([c, u, cr]).toEqual(['cosmetic', 'useful', 'critical'])
273
+ expect(calls.every((x) => x.at === 0)).toBe(true)
274
+ const s = gate.stats().global
275
+ expect([s.shed, s.expired, s.failedFast, s.sent]).toEqual([0, 0, 0, 0])
276
+ })
277
+ })
278
+
279
+ describe('send-gate PR2: edit call-site wiring (progress/answer-stream shape)', () => {
280
+ it('coalesces rapid cosmetic edits to one message into a single last-write-wins send', async () => {
281
+ const clock = new FakeClock()
282
+ const { calls, fn } = recorder(clock)
283
+ // Buckets ample; behaviour driven by the per-message edit floor.
284
+ const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
285
+
286
+ // v1 sends immediately (cold message). v2, v3 arrive within the floor and
287
+ // coalesce — only the LAST (v3) is sent when the floor clears. This is the
288
+ // exact shape the wired progress-card / answer-stream edit sites produce:
289
+ // messageId + editPayload + priorityClass:'cosmetic'.
290
+ const p1 = gate.gate(fn('v1'), { messageId: 99, editPayload: 'v1', priorityClass: 'cosmetic' })
291
+ await flush()
292
+ const p2 = gate.gate(fn('v2'), { messageId: 99, editPayload: 'v2', priorityClass: 'cosmetic' })
293
+ const p3 = gate.gate(fn('v3'), { messageId: 99, editPayload: 'v3', priorityClass: 'cosmetic' })
294
+ await clock.advance(1600)
295
+ await Promise.all([p1, p2, p3])
296
+
297
+ // Two API calls total: v1 (cold) and v3 (coalesced winner). v2 dropped.
298
+ expect(calls.map((c) => c.label)).toEqual(['v1', 'v3'])
299
+ expect(gate.stats().global.coalesced).toBe(1)
300
+ expect(gate.stats().global.sent).toBe(2)
301
+ })
302
+ })
303
+
304
+ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
305
+ it('two chats sharing message_id 100 do NOT collide — both send, no edit lost', async () => {
306
+ const clock = new FakeClock()
307
+ const { calls, fn } = recorder(clock)
308
+ const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
309
+
310
+ // Chat A: v1 (cold → sends now), then v2 within the floor → queued for A.
311
+ const pA1 = gate.gate(fn('A-v1'), { chat_id: 'A', messageId: 100, editPayload: 'A-v1' })
312
+ await flush()
313
+ const pA2 = gate.gate(fn('A-v2'), { chat_id: 'A', messageId: 100, editPayload: 'A-v2' })
314
+ // Chat B edits ITS OWN card, also message_id 100 — must be a SEPARATE state.
315
+ const pB1 = gate.gate(fn('B-v1'), { chat_id: 'B', messageId: 100, editPayload: 'B-v1' })
316
+
317
+ await clock.advance(1600)
318
+ await Promise.all([pA1, pA2, pB1])
319
+
320
+ // With the id-only bug, B-v1 overwrote A's pending (A-v2) → A-v2 silently
321
+ // dropped. Keyed by chat_id+messageId, all three survive.
322
+ expect(calls.map((c) => c.label).sort()).toEqual(['A-v1', 'A-v2', 'B-v1'])
323
+ expect(gate.stats().global.sent).toBe(3)
324
+ expect(gate.stats().global.dropped).toBe(0)
325
+ })
326
+
327
+ it('chat B cold edit is NOT floored by chat A holding the same message_id', async () => {
328
+ const clock = new FakeClock()
329
+ const { calls, fn } = recorder(clock)
330
+ const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
331
+
332
+ // A sends (starts A's floor). B's first edit to the same id must fire at
333
+ // t=0 — B has its own floor, not A's.
334
+ const pA = gate.gate(fn('A'), { chat_id: 'A', messageId: 100, editPayload: 'A' })
335
+ await flush()
336
+ const pB = gate.gate(fn('B'), { chat_id: 'B', messageId: 100, editPayload: 'B' })
337
+ await flush()
338
+ await Promise.all([pA, pB])
339
+
340
+ expect(calls.map((c) => c.label).sort()).toEqual(['A', 'B'])
341
+ expect(calls.every((c) => c.at === 0)).toBe(true)
342
+ })
343
+
344
+ it('a 429 on chat A message 100 does NOT shed chat B message 100 cosmetic edits', async () => {
345
+ const clock = new FakeClock()
346
+ const { calls, fn } = recorder(clock)
347
+ const gate = createSendGate({
348
+ enabled: true,
349
+ clock,
350
+ // Only chat A's msg-edit scope is under a flood window.
351
+ initialWindows: [{ scopeKey: 'msg-edit:A:100', untilTs: HOUR }],
352
+ })
353
+
354
+ // Chat A cosmetic edit → shed (its scope window is open).
355
+ const rA = await gate.gate(fn('A-edit'), {
356
+ chat_id: 'A',
357
+ messageId: 100,
358
+ editPayload: 'A-edit',
359
+ priorityClass: 'cosmetic',
360
+ })
361
+ // Chat B cosmetic edit to the SAME message_id → must NOT shed (different scope).
362
+ const rB = await gate.gate(fn('B-edit'), {
363
+ chat_id: 'B',
364
+ messageId: 100,
365
+ editPayload: 'B-edit',
366
+ priorityClass: 'cosmetic',
367
+ })
368
+
369
+ expect(rA).toBeUndefined() // A shed
370
+ expect(rB).toBe('B-edit') // B sent
371
+ expect(calls.map((c) => c.label)).toEqual(['B-edit'])
372
+ expect(gate.stats().global.shed).toBe(1)
373
+ })
374
+ })
375
+
376
+ describe('send-gate PR2: M2 critical wait re-evaluates the window each iteration', () => {
377
+ it('a window extended past the ceiling MID-WAIT converts a critical to fail-fast, not a hang', async () => {
378
+ const clock = new FakeClock()
379
+ const { calls, fn } = recorder(clock)
380
+ const gate = createSendGate({
381
+ enabled: true,
382
+ clock,
383
+ criticalFailFastMs: 60_000,
384
+ jitter: () => 0, // deterministic, no jitter sleep
385
+ // A SHORT 30s window (<= ceiling) → the critical waits it out.
386
+ initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
387
+ })
388
+
389
+ // Attach the rejection handler synchronously (before advancing time) so the
390
+ // eventual fail-fast rejection is never an unhandled rejection.
391
+ const p = gate.gate(fn('critical'), { priorityClass: 'critical' }).catch((e) => e)
392
+ await flush()
393
+ // While it waits, a competing 429 EXTENDS the global window to +10min.
394
+ gate.openFloodWindow('global', 600_000)
395
+ // Walk past the ORIGINAL short window; the loop re-checks the ceiling and,
396
+ // seeing the extended window, fails fast instead of blocking ~10 minutes.
397
+ await clock.advance(31_000)
398
+
399
+ const caught = await p
400
+ expect(isFloodWaitActiveError(caught)).toBe(true)
401
+ expect((caught as { untilTs: number }).untilTs).toBe(600_000)
402
+ expect(calls).toHaveLength(0) // never probed the API
403
+ expect(gate.stats().global.failedFast).toBe(1)
404
+ expect(gate.stats().global.sent).toBe(0)
405
+ })
406
+ })
407
+
408
+ describe('send-gate F4: a CRITICAL edit honours the fail-fast ceiling (no unbounded block)', () => {
409
+ it('fails fast with a structured FLOOD_WAIT_ACTIVE instead of blocking unbounded on the edit driver', async () => {
410
+ const clock = new FakeClock()
411
+ const { calls, fn } = recorder(clock)
412
+ const gate = createSendGate({
413
+ enabled: true,
414
+ clock,
415
+ criticalFailFastMs: 60_000,
416
+ // 10-minute ban >> the 60s ceiling. On the pre-fix edit path this window
417
+ // routed a `critical` card edit through the driver's UNBOUNDED admit and
418
+ // blocked for the whole ban; the fix must fail fast like the non-edit path.
419
+ initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
420
+ })
421
+
422
+ let caught: unknown
423
+ try {
424
+ // messageId + editPayload → edit path; priorityClass:'critical' is the case
425
+ // handleEdit previously ignored (only 'cosmetic' was special-cased).
426
+ await gate.gate(fn('critical-edit'), {
427
+ messageId: 42,
428
+ editPayload: 'v1',
429
+ priorityClass: 'critical',
430
+ })
431
+ } catch (err) {
432
+ caught = err
433
+ }
434
+
435
+ // Fail fast (structured flood-wait), NOT an unbounded block that never
436
+ // settles — the multi-hour reply wedge the send-gate exists to eliminate.
437
+ expect(isFloodWaitActiveError(caught)).toBe(true)
438
+ const e = caught as { untilTs: number; retryAfterSec: number; error_code: number }
439
+ expect(e.error_code).toBe(429)
440
+ expect(e.untilTs).toBe(600_000)
441
+ expect(e.retryAfterSec).toBe(600)
442
+ expect(calls).toHaveLength(0) // never probed the API
443
+ expect(gate.stats().global.failedFast).toBe(1)
444
+ expect(gate.stats().global.sent).toBe(0)
445
+ })
446
+
447
+ it('waits out a SHORT window (<= ceiling) then sends the critical edit — mirrors the non-edit path', async () => {
448
+ const clock = new FakeClock()
449
+ const { calls, fn } = recorder(clock)
450
+ const gate = createSendGate({
451
+ enabled: true,
452
+ clock,
453
+ criticalFailFastMs: 60_000,
454
+ // 30s window, under the 60s ceiling → the critical edit waits it out and
455
+ // then sends (not shed, not failed fast).
456
+ initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
457
+ })
458
+
459
+ const p = gate.gate(fn('critical-edit'), {
460
+ messageId: 7,
461
+ editPayload: 'v1',
462
+ priorityClass: 'critical',
463
+ })
464
+ await clock.advance(31_000)
465
+ const res = await p
466
+
467
+ expect(res).toBe('critical-edit')
468
+ expect(calls).toHaveLength(1)
469
+ expect(calls[0]!.at).toBeGreaterThanOrEqual(30_000)
470
+ expect(gate.stats().global.failedFast).toBe(0)
471
+ expect(gate.stats().global.sent).toBe(1)
472
+ })
473
+ })
474
+
475
+ describe('send-gate F2: a CRITICAL edit coalescing onto a non-critical driver still fail-fasts', () => {
476
+ it('a critical edit that coalesces onto a running useful driver rejects FLOOD_WAIT_ACTIVE (does NOT ride the unbounded admit)', async () => {
477
+ const clock = new FakeClock()
478
+ const { calls, fn } = recorder(clock)
479
+ const gate = createSendGate({
480
+ enabled: true,
481
+ clock,
482
+ criticalFailFastMs: 60_000,
483
+ // Large edit floor so a queued useful edit SITS in the driver's floor
484
+ // sleep long enough for a later critical edit to coalesce onto it BEFORE
485
+ // the driver dequeues — the exact race the fix closes.
486
+ editFloorMs: 10_000,
487
+ })
488
+
489
+ // 1. Warm the message so its edit floor is armed (a cold message would fire
490
+ // the first edit at t=0 with no floor sleep, leaving no window to coalesce
491
+ // into). This send happens BEFORE any ban.
492
+ const pWarm = gate.gate(fn('warm'), { messageId: 42, editPayload: 'warm' })
493
+ await flush()
494
+ await pWarm
495
+ expect(calls.map((c) => c.label)).toEqual(['warm'])
496
+
497
+ // 2. A 10-minute flood ban (>> the 60s ceiling) opens on the global scope.
498
+ gate.openFloodWindow('global', 600_000)
499
+
500
+ // 3. A USEFUL edit to the same message starts the driver and enters the
501
+ // floor sleep (floor not yet cleared). It is NOT critical, so the
502
+ // driver-start opts capture priorityClass='useful'.
503
+ const pUseful = gate
504
+ .gate(fn('useful'), { messageId: 42, editPayload: 'u', priorityClass: 'useful' })
505
+ .catch((e) => e)
506
+ await flush()
507
+
508
+ // 4. A CRITICAL edit to the SAME message arrives mid-ban and COALESCES onto
509
+ // the pending useful edit (shared promise). With the driver-start-opts
510
+ // bug this critical work rode the unbounded admit and blocked for the
511
+ // whole 10-minute ban; the fix upgrades the pending edit's priority so
512
+ // the driver reads `critical` and fails fast.
513
+ const pCritical = gate
514
+ .gate(fn('critical'), { messageId: 42, editPayload: 'c', priorityClass: 'critical' })
515
+ .catch((e) => e)
516
+ await flush()
517
+
518
+ // 5. Walk PAST the edit floor but NOT past the ban. On the fixed code the
519
+ // driver wakes, sees the coalesced critical class, and fails fast.
520
+ await clock.advance(10_001)
521
+
522
+ const caughtCritical = await pCritical
523
+ const caughtUseful = await pUseful
524
+
525
+ // The coalesced (shared) promise rejects with a structured flood-wait —
526
+ // it did NOT block unbounded for the remaining ~10 minutes of the ban.
527
+ expect(isFloodWaitActiveError(caughtCritical)).toBe(true)
528
+ expect(isFloodWaitActiveError(caughtUseful)).toBe(true)
529
+ const e = caughtCritical as { untilTs: number; retryAfterSec: number; error_code: number }
530
+ expect(e.error_code).toBe(429)
531
+ expect(e.untilTs).toBe(600_000)
532
+ // Only the warm edit ever probed the API; the coalesced edit never did.
533
+ expect(calls.map((c) => c.label)).toEqual(['warm'])
534
+ expect(gate.stats().global.failedFast).toBe(1)
535
+ expect(gate.stats().global.sent).toBe(1)
536
+ })
537
+ })
538
+
539
+ describe('send-gate PR2: opening a window from a 429, and persistence hook', () => {
540
+ it('opens scope windows via onWindowOpen when a non-edit send throws FLOOD_WAIT_ACTIVE', async () => {
541
+ const clock = new FakeClock()
542
+ const opened: { scopeKey: string; untilTs: number }[] = []
543
+ const gate = createSendGate({
544
+ enabled: true,
545
+ clock,
546
+ onWindowOpen: (scopeKey, untilTs) => opened.push({ scopeKey, untilTs }),
547
+ })
548
+
549
+ const floodErr = Object.assign(new Error('FLOOD_WAIT_ACTIVE'), {
550
+ retryAfterSec: 100,
551
+ untilTs: 100_000,
552
+ error_code: 429 as const,
553
+ parameters: { retry_after: 100 },
554
+ original: null,
555
+ })
556
+
557
+ await expect(
558
+ gate.gate(
559
+ async () => {
560
+ throw floodErr
561
+ },
562
+ { chat_id: '7', chatType: 'supergroup', messageId: 3, priorityClass: 'critical' },
563
+ ),
564
+ ).rejects.toBe(floodErr)
565
+
566
+ const scopes = opened.map((o) => o.scopeKey).sort()
567
+ // H1: msg-edit scope carries the chat_id (msg-edit:7:3), not the bare id.
568
+ expect(scopes).toEqual(['chat:7', 'global', 'group:7', 'msg-edit:7:3'])
569
+ // After the window opens, a later cosmetic on the same chat sheds.
570
+ const shed = await gate.gate(async () => 'x', { chat_id: '7', priorityClass: 'cosmetic' })
571
+ expect(shed).toBeUndefined()
572
+ expect(gate.stats().global.shed).toBe(1)
573
+ })
574
+ })