switchroom 0.19.26 → 0.19.28

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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Outcome tests for #3891 — `finalizeCallback` must transit the ONE retry /
3
+ * flood policy, and a failed card repaint must not leave a live-looking
4
+ * keyboard.
5
+ *
6
+ * These are deliberately NOT unit tests of a helper. Each one drives the real
7
+ * `createRetryApiCall` + the real `makeFloodWaitRecorder` against a real temp
8
+ * state dir and asserts the observable artefact an operator or `switchroom
9
+ * doctor` would later read:
10
+ *
11
+ * - `429-ledger.json` gains an episode for a 429 earned by a BUTTON TAP.
12
+ * - `flood-wait.json` gains the window.
13
+ * - after a repaint that fails post-retry, the card's keyboard is gone
14
+ * (or, if it cannot be removed, the operator is told in-channel).
15
+ *
16
+ * Pre-fix (both legs calling `ctx.*` raw) every one of these fails: the
17
+ * ledger file is never created, and the keyboard survives untouched.
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
21
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { GrammyError } from 'grammy'
25
+ import {
26
+ finalizeCallback,
27
+ DEAD_CARD_NOTICE,
28
+ type FinalizeCallbackContext,
29
+ type FinalizeApiCall,
30
+ } from '../inline-keyboard-callbacks.js'
31
+ import { createRetryApiCall } from '../retry-api-call.js'
32
+ import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
33
+ import {
34
+ readFlood429Ledger,
35
+ flood429LedgerPathFromFloodState,
36
+ } from '../flood-429-ledger.js'
37
+
38
+ /** Synthetic chat id — never a real operator chat (see check-no-pii-secrets). */
39
+ const CHAT_ID = -1009900112233
40
+ const MSG_ID = 4242
41
+
42
+ let stateDir: string
43
+ let floodStatePath: string
44
+
45
+ beforeEach(() => {
46
+ stateDir = mkdtempSync(join(tmpdir(), 'sr-3891-'))
47
+ floodStatePath = join(stateDir, 'flood-wait.json')
48
+ })
49
+ afterEach(() => {
50
+ rmSync(stateDir, { recursive: true, force: true })
51
+ })
52
+
53
+ /** A Telegram 429 exactly as grammy surfaces it. */
54
+ function flood429(retryAfter: number, method: string): GrammyError {
55
+ return new GrammyError(
56
+ `Call to '${method}' failed!`,
57
+ { ok: false, error_code: 429, description: 'Too Many Requests: retry after ' + retryAfter, parameters: { retry_after: retryAfter } },
58
+ method,
59
+ {},
60
+ )
61
+ }
62
+
63
+ /** A non-429, non-benign edit failure — the shape seen in the clerk incident. */
64
+ function hardEditFailure(method = 'editMessageText'): GrammyError {
65
+ return new GrammyError(
66
+ `Call to '${method}' failed!`,
67
+ { ok: false, error_code: 400, description: "Bad Request: can't parse entities" },
68
+ method,
69
+ {},
70
+ )
71
+ }
72
+
73
+ /**
74
+ * The production wiring, verbatim in shape: `createRetryApiCall` with BOTH
75
+ * breaker hooks, writing to a temp state dir. No sleeping (fake sleep) so the
76
+ * flood-wait retry path runs at test speed.
77
+ */
78
+ function realPolicy(): FinalizeApiCall {
79
+ const record = makeFloodWaitRecorder(floodStatePath)
80
+ const probe = makeFloodWaitProbe(floodStatePath)
81
+ return createRetryApiCall({
82
+ log: () => {},
83
+ sleep: async () => {},
84
+ onFloodWait: (retryAfterSec) => record(retryAfterSec),
85
+ floodWaitRemainingMs: probe,
86
+ })
87
+ }
88
+
89
+ interface Capture {
90
+ acks: number
91
+ edits: number
92
+ markupEdits: Array<Record<string, unknown>>
93
+ replies: string[]
94
+ }
95
+
96
+ function mkCtx(cap: Capture, behaviour: {
97
+ ack?: () => void
98
+ edit?: () => void
99
+ markup?: () => void
100
+ reply?: () => void
101
+ } = {}): FinalizeCallbackContext {
102
+ return {
103
+ answerCallbackQuery: async () => { cap.acks++; behaviour.ack?.(); return true },
104
+ editMessageText: async () => { cap.edits++; behaviour.edit?.(); return { message_id: MSG_ID } },
105
+ editMessageReplyMarkup: async (opts) => {
106
+ cap.markupEdits.push(opts ?? {})
107
+ behaviour.markup?.()
108
+ return true
109
+ },
110
+ reply: async (text) => { cap.replies.push(text); behaviour.reply?.(); return { message_id: MSG_ID + 1 } },
111
+ callbackQuery: { message: { message_id: MSG_ID, chat: { id: CHAT_ID } } },
112
+ }
113
+ }
114
+
115
+ const mkCap = (): Capture => ({ acks: 0, edits: 0, markupEdits: [], replies: [] })
116
+
117
+ describe('#3891 — tap-path 429s reach the flood ledger', () => {
118
+ it('records a 429 earned by answerCallbackQuery to the 429 ledger AND the window file', async () => {
119
+ const cap = mkCap()
120
+ let ackAttempts = 0
121
+ const ctx = mkCtx(cap, {
122
+ ack: () => { ackAttempts++; if (ackAttempts === 1) throw flood429(7, 'answerCallbackQuery') },
123
+ })
124
+
125
+ await finalizeCallback(ctx, {
126
+ ackText: 'Approved',
127
+ newText: 'resolved',
128
+ apiCall: realPolicy(),
129
+ log: () => {},
130
+ })
131
+ // The ack leg is fire-and-forget; let its retry chain settle.
132
+ await new Promise((r) => setTimeout(r, 0))
133
+
134
+ const ledgerPath = flood429LedgerPathFromFloodState(floodStatePath)
135
+ expect(existsSync(ledgerPath)).toBe(true)
136
+ const episodes = readFlood429Ledger(ledgerPath)
137
+ expect(episodes).toHaveLength(1)
138
+ expect(episodes[0]?.peakRetryAfterSec).toBe(7)
139
+ expect(episodes[0]?.count).toBe(1)
140
+ // The window file the send gate / doctor / watchdog all read.
141
+ expect(existsSync(floodStatePath)).toBe(true)
142
+ // And the tap still succeeded on retry — the policy is not a silencer.
143
+ expect(ackAttempts).toBe(2)
144
+ })
145
+
146
+ it('records a 429 earned by the card repaint, and the repaint still lands on retry', async () => {
147
+ const cap = mkCap()
148
+ let editAttempts = 0
149
+ const ctx = mkCtx(cap, {
150
+ edit: () => { editAttempts++; if (editAttempts === 1) throw flood429(11, 'editMessageText') },
151
+ })
152
+
153
+ await finalizeCallback(ctx, {
154
+ ackText: 'Approved',
155
+ newText: 'resolved',
156
+ apiCall: realPolicy(),
157
+ log: () => {},
158
+ })
159
+
160
+ const episodes = readFlood429Ledger(flood429LedgerPathFromFloodState(floodStatePath))
161
+ expect(episodes).toHaveLength(1)
162
+ expect(episodes[0]?.peakRetryAfterSec).toBe(11)
163
+ expect(editAttempts).toBe(2)
164
+ // Repaint landed on the retry, so no ladder rung was needed.
165
+ expect(cap.markupEdits).toHaveLength(0)
166
+ expect(cap.replies).toHaveLength(0)
167
+ })
168
+
169
+ it('scopes the flood window to the tapped chat, not `global`', async () => {
170
+ const seen: Array<{ chat_id?: string; verb?: string; priorityClass?: string }> = []
171
+ const apiCall: FinalizeApiCall = async (fn, opts) => {
172
+ seen.push({ chat_id: opts?.chat_id, verb: opts?.verb, priorityClass: opts?.priorityClass })
173
+ return fn()
174
+ }
175
+ const cap = mkCap()
176
+ await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'x', apiCall, log: () => {} })
177
+ await new Promise((r) => setTimeout(r, 0))
178
+
179
+ expect(seen).toHaveLength(2)
180
+ for (const s of seen) {
181
+ expect(s.chat_id).toBe(String(CHAT_ID))
182
+ // `critical` is the one class the send gate never sheds. A finalize
183
+ // repaint is the operator's ONLY confirmation their tap resolved a
184
+ // human-in-the-loop decision; shedding it is the incident itself.
185
+ expect(s.priorityClass).toBe('critical')
186
+ }
187
+ expect(seen.map((s) => s.verb)).toEqual(['answerCallbackQuery', 'editMessageText'])
188
+ })
189
+
190
+ it('survives a callbackQuery with no chat (degrades to an unscoped window, never throws)', async () => {
191
+ const seen: Array<string | undefined> = []
192
+ const apiCall: FinalizeApiCall = async (fn, opts) => { seen.push(opts?.chat_id); return fn() }
193
+ const cap = mkCap()
194
+ const ctx = { ...mkCtx(cap), callbackQuery: undefined }
195
+ await expect(
196
+ finalizeCallback(ctx, { ackText: 'ok', newText: 'x', apiCall, log: () => {} }),
197
+ ).resolves.toBeUndefined()
198
+ expect(seen.every((s) => s === undefined)).toBe(true)
199
+ })
200
+ })
201
+
202
+ describe('#3891 — a failed repaint never leaves a live-looking card', () => {
203
+ it('strips the keyboard when the body repaint fails post-retry', async () => {
204
+ const cap = mkCap()
205
+ const logs: string[] = []
206
+ const ctx = mkCtx(cap, { edit: () => { throw hardEditFailure() } })
207
+
208
+ await finalizeCallback(ctx, {
209
+ ackText: 'Approved',
210
+ newText: '**bad _markdown',
211
+ apiCall: realPolicy(),
212
+ log: (l) => logs.push(l),
213
+ })
214
+
215
+ // THE outcome: the card is no longer tappable.
216
+ expect(cap.markupEdits).toHaveLength(1)
217
+ expect(cap.markupEdits[0]?.reply_markup).toEqual({ inline_keyboard: [] })
218
+ // Not silent, either.
219
+ expect(logs.some((l) => l.includes('editMessageText failed'))).toBe(true)
220
+ expect(logs.some((l) => l.includes('no longer tappable'))).toBe(true)
221
+ // No need to shout in-channel — rung 2 fixed it.
222
+ expect(cap.replies).toHaveLength(0)
223
+ })
224
+
225
+ it('tells the operator in-channel when the keyboard cannot be stripped either', async () => {
226
+ const cap = mkCap()
227
+ const logs: string[] = []
228
+ const ctx = mkCtx(cap, {
229
+ edit: () => { throw hardEditFailure() },
230
+ markup: () => { throw hardEditFailure('editMessageReplyMarkup') },
231
+ })
232
+
233
+ await finalizeCallback(ctx, {
234
+ ackText: 'Approved',
235
+ newText: 'resolved',
236
+ apiCall: realPolicy(),
237
+ log: (l) => logs.push(l),
238
+ })
239
+
240
+ expect(cap.replies).toEqual([DEAD_CARD_NOTICE])
241
+ // The notice must name the buttons as stale — that is the whole point.
242
+ expect(DEAD_CARD_NOTICE).toContain('STALE')
243
+ })
244
+
245
+ it('a repaint failure does NOT block the model wake-up (invariant 3 holds)', async () => {
246
+ const cap = mkCap()
247
+ let synthFired = false
248
+ const ctx = mkCtx(cap, {
249
+ edit: () => { throw hardEditFailure() },
250
+ markup: () => { throw hardEditFailure('editMessageReplyMarkup') },
251
+ reply: () => { throw hardEditFailure('sendMessage') },
252
+ })
253
+
254
+ await finalizeCallback(ctx, {
255
+ ackText: 'Approved',
256
+ newText: 'resolved',
257
+ apiCall: realPolicy(),
258
+ synthInbound: () => { synthFired = true },
259
+ log: () => {},
260
+ })
261
+
262
+ expect(synthFired).toBe(true)
263
+ })
264
+
265
+ it('does NOT run the ladder when the repaint succeeds', async () => {
266
+ const cap = mkCap()
267
+ await finalizeCallback(mkCtx(cap), {
268
+ ackText: 'Approved',
269
+ newText: 'resolved',
270
+ apiCall: realPolicy(),
271
+ log: () => {},
272
+ })
273
+ expect(cap.edits).toBe(1)
274
+ expect(cap.markupEdits).toHaveLength(0)
275
+ expect(cap.replies).toHaveLength(0)
276
+ })
277
+
278
+ it('treats the two BENIGN edit failures as done — the retry policy swallows them, no ladder', async () => {
279
+ // MESSAGE_TO_EDIT_NOT_FOUND: the operator deleted the card. There is no
280
+ // keyboard left to disarm, so shouting about a stale card would be noise.
281
+ const cap = mkCap()
282
+ const ctx = mkCtx(cap, {
283
+ edit: () => {
284
+ throw new GrammyError(
285
+ "Call to 'editMessageText' failed!",
286
+ { ok: false, error_code: 400, description: 'Bad Request: message to edit not found' },
287
+ 'editMessageText',
288
+ {},
289
+ )
290
+ },
291
+ })
292
+ await finalizeCallback(ctx, {
293
+ ackText: 'Approved', newText: 'resolved', apiCall: realPolicy(), log: () => {},
294
+ })
295
+ expect(cap.markupEdits).toHaveLength(0)
296
+ expect(cap.replies).toHaveLength(0)
297
+ })
298
+ })
@@ -16,6 +16,8 @@ import { finalizeCallback, type FinalizeCallbackContext } from '../inline-keyboa
16
16
  interface Capture {
17
17
  acks: Array<{ text?: string; show_alert?: boolean }>
18
18
  edits: Array<{ text: string | { markdown: string }; opts: Record<string, unknown> }>
19
+ markupEdits: Array<Record<string, unknown>>
20
+ replies: string[]
19
21
  ackThrows?: Error
20
22
  editThrows?: Error
21
23
  }
@@ -32,13 +34,30 @@ function mkCtx(cap: Capture): FinalizeCallbackContext {
32
34
  if (cap.editThrows) throw cap.editThrows
33
35
  return { message_id: 1 }
34
36
  },
37
+ editMessageReplyMarkup: async (opts) => {
38
+ cap.markupEdits.push(opts ?? {})
39
+ return true
40
+ },
41
+ reply: async (text) => {
42
+ cap.replies.push(text)
43
+ return { message_id: 2 }
44
+ },
35
45
  }
36
46
  }
37
47
 
48
+ /**
49
+ * Passthrough stand-in for the gateway's `robustApiCall` (#3891). The helper
50
+ * now REQUIRES the retry/flood seam; these three-invariant cases are about the
51
+ * invariants, not the policy, so they hand in a transparent one. The policy's
52
+ * own outcomes are pinned in `finalize-callback-flood-policy.test.ts`.
53
+ */
54
+ const passthrough = <T>(fn: () => Promise<T>): Promise<T> => fn()
55
+
38
56
  describe('finalizeCallback — three-invariant contract', () => {
39
57
  it('invariant 1: acks the callback with the supplied toast text', async () => {
40
- const cap: Capture = { acks: [], edits: [] }
58
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
41
59
  await finalizeCallback(mkCtx(cap), {
60
+ apiCall: passthrough,
42
61
  ackText: 'Approved',
43
62
  newText: 'Original prompt\n\n✓ Approved by @op',
44
63
  })
@@ -48,8 +67,9 @@ describe('finalizeCallback — three-invariant contract', () => {
48
67
  })
49
68
 
50
69
  it('invariant 1: alert=true renders as full modal (show_alert: true)', async () => {
51
- const cap: Capture = { acks: [], edits: [] }
70
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
52
71
  await finalizeCallback(mkCtx(cap), {
72
+ apiCall: passthrough,
53
73
  ackText: 'Vault grant revoked',
54
74
  alert: true,
55
75
  newText: '...',
@@ -58,8 +78,9 @@ describe('finalizeCallback — three-invariant contract', () => {
58
78
  })
59
79
 
60
80
  it('invariant 2: strips reply_markup AND rich-edits the body in one atomic call', async () => {
61
- const cap: Capture = { acks: [], edits: [] }
81
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
62
82
  await finalizeCallback(mkCtx(cap), {
83
+ apiCall: passthrough,
63
84
  ackText: 'Approved',
64
85
  newText: '**✓ Approved**\n\nGrant minted at 22:38 UTC',
65
86
  })
@@ -75,8 +96,8 @@ describe('finalizeCallback — three-invariant contract', () => {
75
96
  })
76
97
 
77
98
  it('invariant 2: literalText edits a plain string (no rich wrapper, no parse_mode)', async () => {
78
- const cap: Capture = { acks: [], edits: [] }
79
- await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain', literalText: true })
99
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
100
+ await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain', literalText: true, apiCall: passthrough })
80
101
  expect(cap.edits[0]?.text).toBe('plain')
81
102
  expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
82
103
  })
@@ -91,8 +112,11 @@ describe('finalizeCallback — three-invariant contract', () => {
91
112
  order.push('edit-end')
92
113
  return { message_id: 1 }
93
114
  },
115
+ editMessageReplyMarkup: async () => { order.push('markup'); return true },
116
+ reply: async () => { order.push('reply'); return { message_id: 2 } },
94
117
  }
95
118
  await finalizeCallback(ctx, {
119
+ apiCall: passthrough,
96
120
  ackText: 'ok',
97
121
  newText: '...',
98
122
  synthInbound: () => { order.push('synth') },
@@ -108,8 +132,9 @@ describe('finalizeCallback — three-invariant contract', () => {
108
132
 
109
133
  it('invariant 3: async synthInbound is awaited', async () => {
110
134
  let synthResolved = false
111
- const cap: Capture = { acks: [], edits: [] }
135
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
112
136
  await finalizeCallback(mkCtx(cap), {
137
+ apiCall: passthrough,
113
138
  ackText: 'ok',
114
139
  newText: '...',
115
140
  synthInbound: async () => {
@@ -122,9 +147,10 @@ describe('finalizeCallback — three-invariant contract', () => {
122
147
 
123
148
  it('invariant 3: synthInbound errors are caught + logged, never propagated', async () => {
124
149
  const logs: string[] = []
125
- const cap: Capture = { acks: [], edits: [] }
150
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
126
151
  await expect(
127
152
  finalizeCallback(mkCtx(cap), {
153
+ apiCall: passthrough,
128
154
  ackText: 'ok',
129
155
  newText: '...',
130
156
  synthInbound: () => { throw new Error('inject_inbound IPC closed') },
@@ -143,9 +169,12 @@ describe('finalizeCallback — three-invariant contract', () => {
143
169
  const cap: Capture = {
144
170
  acks: [],
145
171
  edits: [],
172
+ markupEdits: [],
173
+ replies: [],
146
174
  editThrows: new Error('Bad Request: message to edit not found'),
147
175
  }
148
176
  await finalizeCallback(mkCtx(cap), {
177
+ apiCall: passthrough,
149
178
  ackText: 'Approved',
150
179
  newText: '...',
151
180
  synthInbound: () => { synthFired = true },
@@ -163,9 +192,12 @@ describe('finalizeCallback — three-invariant contract', () => {
163
192
  const cap: Capture = {
164
193
  acks: [],
165
194
  edits: [],
195
+ markupEdits: [],
196
+ replies: [],
166
197
  ackThrows: new Error('query is too old'),
167
198
  }
168
199
  await finalizeCallback(mkCtx(cap), {
200
+ apiCall: passthrough,
169
201
  ackText: 'Approved',
170
202
  newText: 'edited body',
171
203
  synthInbound: () => { synthFired = true },
@@ -181,8 +213,9 @@ describe('finalizeCallback — three-invariant contract', () => {
181
213
  })
182
214
 
183
215
  it('synthInbound is optional — surfaces with no model in the loop just ack + edit', async () => {
184
- const cap: Capture = { acks: [], edits: [] }
216
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
185
217
  await finalizeCallback(mkCtx(cap), {
218
+ apiCall: passthrough,
186
219
  ackText: 'Dismissed',
187
220
  newText: '✗ Dismissed',
188
221
  })
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The real 2026-07 Telegram 429 stream, verbatim.
3
+ *
4
+ * Every `telegram gateway: 429 …` line one production agent's gateway emitted
5
+ * between 2026-07-12 and 2026-07-27, extracted from `gateway-supervisor.log`
6
+ * (plus its rotated `.gz`) as
7
+ * `[<iso>] … (flood ban of <n>s | rate limited, waiting <n>s)`.
8
+ * 170 observations; nothing sampled, synthesised, or reordered. Timestamps and
9
+ * `retry_after` values only — no ids, no chat content.
10
+ *
11
+ * This is the fixture the flood-pressure classifier is proved against, because
12
+ * the shape is the whole problem:
13
+ *
14
+ * - 72 of the 170 are short rate nudges, the largest being 5s. They are
15
+ * invisible to the operator: `retryApiCall` sleeps them and the send lands.
16
+ * - The smallest `retry_after` belonging to a real ban is 282s. There is no
17
+ * observed traffic between 5s and 282s, so the benign band is wide.
18
+ * - 87 of the remaining observations are ONE ban (2026-07-27T20:19:56Z,
19
+ * `retry_after=15908`) re-observed every ~5s as it counted down. Any signal
20
+ * built on event COUNT explodes here, during the outage it should have
21
+ * predicted.
22
+ *
23
+ * The four real bans:
24
+ * 2026-07-12T05:06:46Z retry_after=21397 (5.9h)
25
+ * 2026-07-13T03:17:47Z retry_after=27951 (7.8h)
26
+ * 2026-07-25T23:43:07Z retry_after=3713 (62min)
27
+ * 2026-07-27T20:19:56Z retry_after=15908 (4.4h) ← the incident
28
+ *
29
+ * Generated once from the live logs; do not edit by hand.
30
+ */
31
+
32
+ /** One observed 429: ISO timestamp + Telegram's `parameters.retry_after`. */
33
+ export interface RawFlood429 {
34
+ iso: string
35
+ ts: number
36
+ retryAfterSec: number
37
+ }
38
+
39
+ const RAW: ReadonlyArray<{ iso: string; retryAfterSec: number }> = [
40
+ { iso: "2026-07-12T00:43:42.969Z", retryAfterSec: 3 },
41
+ { iso: "2026-07-12T00:43:52.754Z", retryAfterSec: 3 },
42
+ { iso: "2026-07-12T05:06:46.641Z", retryAfterSec: 21397 },
43
+ { iso: "2026-07-12T23:39:24.579Z", retryAfterSec: 436 },
44
+ { iso: "2026-07-13T00:37:18.647Z", retryAfterSec: 563 },
45
+ { iso: "2026-07-13T00:37:19.162Z", retryAfterSec: 563 },
46
+ { iso: "2026-07-13T01:42:00.795Z", retryAfterSec: 283 },
47
+ { iso: "2026-07-13T01:42:01.269Z", retryAfterSec: 282 },
48
+ { iso: "2026-07-13T01:42:01.741Z", retryAfterSec: 282 },
49
+ { iso: "2026-07-13T02:39:43.308Z", retryAfterSec: 425 },
50
+ { iso: "2026-07-13T03:17:47.764Z", retryAfterSec: 27951 },
51
+ { iso: "2026-07-14T01:00:55.498Z", retryAfterSec: 3 },
52
+ { iso: "2026-07-14T01:01:05.283Z", retryAfterSec: 3 },
53
+ { iso: "2026-07-14T01:42:51.141Z", retryAfterSec: 3 },
54
+ { iso: "2026-07-14T01:43:00.941Z", retryAfterSec: 3 },
55
+ { iso: "2026-07-14T10:17:08.494Z", retryAfterSec: 3 },
56
+ { iso: "2026-07-14T10:17:18.394Z", retryAfterSec: 3 },
57
+ { iso: "2026-07-15T06:47:31.287Z", retryAfterSec: 3 },
58
+ { iso: "2026-07-15T11:28:14.054Z", retryAfterSec: 3 },
59
+ { iso: "2026-07-15T11:28:23.913Z", retryAfterSec: 3 },
60
+ { iso: "2026-07-16T01:48:28.656Z", retryAfterSec: 3 },
61
+ { iso: "2026-07-16T01:48:38.477Z", retryAfterSec: 3 },
62
+ { iso: "2026-07-16T02:37:16.659Z", retryAfterSec: 3 },
63
+ { iso: "2026-07-16T02:37:26.496Z", retryAfterSec: 3 },
64
+ { iso: "2026-07-16T07:39:14.389Z", retryAfterSec: 3 },
65
+ { iso: "2026-07-16T07:39:24.206Z", retryAfterSec: 3 },
66
+ { iso: "2026-07-16T19:29:32.965Z", retryAfterSec: 3 },
67
+ { iso: "2026-07-16T19:29:42.817Z", retryAfterSec: 3 },
68
+ { iso: "2026-07-16T21:32:57.539Z", retryAfterSec: 5 },
69
+ { iso: "2026-07-17T00:04:44.818Z", retryAfterSec: 3 },
70
+ { iso: "2026-07-17T00:04:54.594Z", retryAfterSec: 3 },
71
+ { iso: "2026-07-17T02:45:40.820Z", retryAfterSec: 3 },
72
+ { iso: "2026-07-17T02:45:50.608Z", retryAfterSec: 3 },
73
+ { iso: "2026-07-17T06:10:38.674Z", retryAfterSec: 3 },
74
+ { iso: "2026-07-17T06:10:48.510Z", retryAfterSec: 3 },
75
+ { iso: "2026-07-17T07:55:18.855Z", retryAfterSec: 3 },
76
+ { iso: "2026-07-17T07:55:28.640Z", retryAfterSec: 3 },
77
+ { iso: "2026-07-17T09:21:15.798Z", retryAfterSec: 3 },
78
+ { iso: "2026-07-18T22:35:20.897Z", retryAfterSec: 3 },
79
+ { iso: "2026-07-19T01:25:52.829Z", retryAfterSec: 3 },
80
+ { iso: "2026-07-19T01:26:02.649Z", retryAfterSec: 3 },
81
+ { iso: "2026-07-19T03:29:55.399Z", retryAfterSec: 3 },
82
+ { iso: "2026-07-19T03:30:05.203Z", retryAfterSec: 3 },
83
+ { iso: "2026-07-19T06:05:47.796Z", retryAfterSec: 3 },
84
+ { iso: "2026-07-19T06:05:57.632Z", retryAfterSec: 3 },
85
+ { iso: "2026-07-19T10:21:12.347Z", retryAfterSec: 3 },
86
+ { iso: "2026-07-19T10:21:22.178Z", retryAfterSec: 3 },
87
+ { iso: "2026-07-19T10:21:50.826Z", retryAfterSec: 3 },
88
+ { iso: "2026-07-19T10:22:00.651Z", retryAfterSec: 3 },
89
+ { iso: "2026-07-19T10:22:10.486Z", retryAfterSec: 3 },
90
+ { iso: "2026-07-19T10:47:10.609Z", retryAfterSec: 3 },
91
+ { iso: "2026-07-19T10:47:20.439Z", retryAfterSec: 3 },
92
+ { iso: "2026-07-19T10:47:30.277Z", retryAfterSec: 3 },
93
+ { iso: "2026-07-20T01:40:22.311Z", retryAfterSec: 3 },
94
+ { iso: "2026-07-20T01:40:32.183Z", retryAfterSec: 3 },
95
+ { iso: "2026-07-20T02:26:37.436Z", retryAfterSec: 3 },
96
+ { iso: "2026-07-20T02:26:47.514Z", retryAfterSec: 3 },
97
+ { iso: "2026-07-21T11:14:47.910Z", retryAfterSec: 3 },
98
+ { iso: "2026-07-21T11:14:57.729Z", retryAfterSec: 3 },
99
+ { iso: "2026-07-21T22:59:02.412Z", retryAfterSec: 3 },
100
+ { iso: "2026-07-21T22:59:12.189Z", retryAfterSec: 3 },
101
+ { iso: "2026-07-23T19:58:39.060Z", retryAfterSec: 3 },
102
+ { iso: "2026-07-24T08:22:21.126Z", retryAfterSec: 3 },
103
+ { iso: "2026-07-24T09:00:54.528Z", retryAfterSec: 3 },
104
+ { iso: "2026-07-24T09:01:04.413Z", retryAfterSec: 3 },
105
+ { iso: "2026-07-24T21:47:04.852Z", retryAfterSec: 3 },
106
+ { iso: "2026-07-24T21:49:20.213Z", retryAfterSec: 3 },
107
+ { iso: "2026-07-24T21:49:30.004Z", retryAfterSec: 3 },
108
+ { iso: "2026-07-24T21:49:39.786Z", retryAfterSec: 3 },
109
+ { iso: "2026-07-25T05:24:04.941Z", retryAfterSec: 3 },
110
+ { iso: "2026-07-25T22:50:39.203Z", retryAfterSec: 3 },
111
+ { iso: "2026-07-25T22:50:48.981Z", retryAfterSec: 3 },
112
+ { iso: "2026-07-25T23:43:07.861Z", retryAfterSec: 3713 },
113
+ { iso: "2026-07-26T14:07:07.428Z", retryAfterSec: 3 },
114
+ { iso: "2026-07-26T14:07:20.567Z", retryAfterSec: 3 },
115
+ { iso: "2026-07-26T14:07:30.355Z", retryAfterSec: 3 },
116
+ { iso: "2026-07-26T22:43:03.042Z", retryAfterSec: 3 },
117
+ { iso: "2026-07-27T05:50:52.944Z", retryAfterSec: 3 },
118
+ { iso: "2026-07-27T10:04:58.523Z", retryAfterSec: 3 },
119
+ { iso: "2026-07-27T10:05:12.142Z", retryAfterSec: 3 },
120
+ { iso: "2026-07-27T10:05:21.953Z", retryAfterSec: 3 },
121
+ { iso: "2026-07-27T16:22:32.052Z", retryAfterSec: 3 },
122
+ { iso: "2026-07-27T20:19:56.704Z", retryAfterSec: 15908 },
123
+ { iso: "2026-07-27T20:53:27.532Z", retryAfterSec: 13897 },
124
+ { iso: "2026-07-27T20:53:31.923Z", retryAfterSec: 13893 },
125
+ { iso: "2026-07-27T20:53:36.919Z", retryAfterSec: 13888 },
126
+ { iso: "2026-07-27T20:53:41.904Z", retryAfterSec: 13883 },
127
+ { iso: "2026-07-27T20:53:46.908Z", retryAfterSec: 13878 },
128
+ { iso: "2026-07-27T20:53:51.904Z", retryAfterSec: 13873 },
129
+ { iso: "2026-07-27T20:53:56.914Z", retryAfterSec: 13868 },
130
+ { iso: "2026-07-27T20:54:01.911Z", retryAfterSec: 13863 },
131
+ { iso: "2026-07-27T20:54:06.907Z", retryAfterSec: 13858 },
132
+ { iso: "2026-07-27T20:54:11.932Z", retryAfterSec: 13853 },
133
+ { iso: "2026-07-27T20:54:16.925Z", retryAfterSec: 13848 },
134
+ { iso: "2026-07-27T20:54:21.929Z", retryAfterSec: 13843 },
135
+ { iso: "2026-07-27T20:54:26.932Z", retryAfterSec: 13838 },
136
+ { iso: "2026-07-27T20:54:31.929Z", retryAfterSec: 13833 },
137
+ { iso: "2026-07-27T20:54:36.940Z", retryAfterSec: 13828 },
138
+ { iso: "2026-07-27T20:54:41.914Z", retryAfterSec: 13823 },
139
+ { iso: "2026-07-27T20:54:46.919Z", retryAfterSec: 13818 },
140
+ { iso: "2026-07-27T20:54:51.949Z", retryAfterSec: 13813 },
141
+ { iso: "2026-07-27T20:54:56.911Z", retryAfterSec: 13808 },
142
+ { iso: "2026-07-27T20:55:01.932Z", retryAfterSec: 13803 },
143
+ { iso: "2026-07-27T20:55:06.918Z", retryAfterSec: 13798 },
144
+ { iso: "2026-07-27T20:55:11.949Z", retryAfterSec: 13793 },
145
+ { iso: "2026-07-27T20:55:17.011Z", retryAfterSec: 13788 },
146
+ { iso: "2026-07-27T20:55:21.997Z", retryAfterSec: 13783 },
147
+ { iso: "2026-07-27T20:55:26.949Z", retryAfterSec: 13778 },
148
+ { iso: "2026-07-27T20:55:31.941Z", retryAfterSec: 13773 },
149
+ { iso: "2026-07-27T20:55:36.943Z", retryAfterSec: 13768 },
150
+ { iso: "2026-07-27T20:55:41.924Z", retryAfterSec: 13763 },
151
+ { iso: "2026-07-27T20:55:46.927Z", retryAfterSec: 13758 },
152
+ { iso: "2026-07-27T20:55:51.928Z", retryAfterSec: 13753 },
153
+ { iso: "2026-07-27T20:55:56.922Z", retryAfterSec: 13748 },
154
+ { iso: "2026-07-27T20:56:01.937Z", retryAfterSec: 13743 },
155
+ { iso: "2026-07-27T20:56:06.922Z", retryAfterSec: 13738 },
156
+ { iso: "2026-07-27T20:56:11.950Z", retryAfterSec: 13733 },
157
+ { iso: "2026-07-27T20:56:16.950Z", retryAfterSec: 13728 },
158
+ { iso: "2026-07-27T20:56:21.954Z", retryAfterSec: 13723 },
159
+ { iso: "2026-07-27T20:56:26.955Z", retryAfterSec: 13718 },
160
+ { iso: "2026-07-27T20:56:31.952Z", retryAfterSec: 13713 },
161
+ { iso: "2026-07-27T20:56:36.954Z", retryAfterSec: 13708 },
162
+ { iso: "2026-07-27T20:56:41.952Z", retryAfterSec: 13703 },
163
+ { iso: "2026-07-27T20:56:46.938Z", retryAfterSec: 13698 },
164
+ { iso: "2026-07-27T20:56:51.937Z", retryAfterSec: 13693 },
165
+ { iso: "2026-07-27T20:56:56.942Z", retryAfterSec: 13688 },
166
+ { iso: "2026-07-27T20:57:01.984Z", retryAfterSec: 13683 },
167
+ { iso: "2026-07-27T20:57:06.955Z", retryAfterSec: 13678 },
168
+ { iso: "2026-07-27T20:57:11.959Z", retryAfterSec: 13673 },
169
+ { iso: "2026-07-27T20:57:16.976Z", retryAfterSec: 13668 },
170
+ { iso: "2026-07-27T20:57:21.964Z", retryAfterSec: 13663 },
171
+ { iso: "2026-07-27T20:57:26.962Z", retryAfterSec: 13658 },
172
+ { iso: "2026-07-27T20:57:31.968Z", retryAfterSec: 13653 },
173
+ { iso: "2026-07-27T20:57:36.960Z", retryAfterSec: 13648 },
174
+ { iso: "2026-07-27T20:57:41.981Z", retryAfterSec: 13643 },
175
+ { iso: "2026-07-27T20:57:46.972Z", retryAfterSec: 13638 },
176
+ { iso: "2026-07-27T20:57:51.976Z", retryAfterSec: 13633 },
177
+ { iso: "2026-07-27T20:57:56.966Z", retryAfterSec: 13628 },
178
+ { iso: "2026-07-27T20:58:01.971Z", retryAfterSec: 13623 },
179
+ { iso: "2026-07-27T20:58:07.023Z", retryAfterSec: 13618 },
180
+ { iso: "2026-07-27T20:58:12.081Z", retryAfterSec: 13613 },
181
+ { iso: "2026-07-27T20:58:16.975Z", retryAfterSec: 13608 },
182
+ { iso: "2026-07-27T20:58:21.974Z", retryAfterSec: 13603 },
183
+ { iso: "2026-07-27T20:58:26.994Z", retryAfterSec: 13598 },
184
+ { iso: "2026-07-27T20:58:31.979Z", retryAfterSec: 13593 },
185
+ { iso: "2026-07-27T20:58:36.977Z", retryAfterSec: 13588 },
186
+ { iso: "2026-07-27T20:58:41.983Z", retryAfterSec: 13583 },
187
+ { iso: "2026-07-27T20:58:46.966Z", retryAfterSec: 13578 },
188
+ { iso: "2026-07-27T20:58:51.982Z", retryAfterSec: 13573 },
189
+ { iso: "2026-07-27T20:58:56.984Z", retryAfterSec: 13568 },
190
+ { iso: "2026-07-27T20:59:01.983Z", retryAfterSec: 13563 },
191
+ { iso: "2026-07-27T20:59:06.982Z", retryAfterSec: 13558 },
192
+ { iso: "2026-07-27T20:59:11.987Z", retryAfterSec: 13553 },
193
+ { iso: "2026-07-27T20:59:16.995Z", retryAfterSec: 13548 },
194
+ { iso: "2026-07-27T20:59:21.982Z", retryAfterSec: 13543 },
195
+ { iso: "2026-07-27T20:59:26.987Z", retryAfterSec: 13538 },
196
+ { iso: "2026-07-27T20:59:31.991Z", retryAfterSec: 13533 },
197
+ { iso: "2026-07-27T20:59:36.994Z", retryAfterSec: 13528 },
198
+ { iso: "2026-07-27T20:59:41.993Z", retryAfterSec: 13523 },
199
+ { iso: "2026-07-27T20:59:47.005Z", retryAfterSec: 13518 },
200
+ { iso: "2026-07-27T20:59:51.997Z", retryAfterSec: 13513 },
201
+ { iso: "2026-07-27T20:59:56.996Z", retryAfterSec: 13508 },
202
+ { iso: "2026-07-27T21:00:02.004Z", retryAfterSec: 13503 },
203
+ { iso: "2026-07-27T21:00:07.009Z", retryAfterSec: 13498 },
204
+ { iso: "2026-07-27T21:00:11.996Z", retryAfterSec: 13493 },
205
+ { iso: "2026-07-27T21:00:17.016Z", retryAfterSec: 13488 },
206
+ { iso: "2026-07-27T21:00:22.002Z", retryAfterSec: 13483 },
207
+ { iso: "2026-07-27T21:00:27.001Z", retryAfterSec: 13478 },
208
+ { iso: "2026-07-27T21:00:32.002Z", retryAfterSec: 13473 },
209
+ { iso: "2026-07-27T21:00:36.999Z", retryAfterSec: 13468 },
210
+ ]
211
+
212
+ export const REAL_429_STREAM: ReadonlyArray<RawFlood429> = RAW.map((e) => ({
213
+ ...e,
214
+ ts: Date.parse(e.iso),
215
+ }))
216
+
217
+ /** Convenience: `Date.parse` of an ISO instant, for assertion timestamps. */
218
+ export function at(iso: string): number {
219
+ return Date.parse(iso)
220
+ }