switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -51,19 +51,6 @@ function makeDeps(agentName: string | null) {
51
51
  ['chat1:thr1:msg1', 100],
52
52
  ['chat2:thr2:msg2', 200],
53
53
  ])
54
- // PR3b: claudeBusyKeys tracks turns actually handed to claude. In a
55
- // healthy registered-disconnect scenario both maps would carry the
56
- // same keys (delivery succeeded); the dangling-sweep tests below
57
- // override individual deps to exercise the orphaned-key path.
58
- const claudeBusyKeys = new Set<string>([
59
- 'chat1:thr1:msg1',
60
- 'chat2:thr2:msg2',
61
- ])
62
- // #2787: shadow timestamp map, kept in lockstep with claudeBusyKeys.
63
- const claudeBusyKeySince = new Map<string, number>([
64
- ['chat1:thr1:msg1', 100],
65
- ['chat2:thr2:msg2', 200],
66
- ])
67
54
  const activeDraftStreams = new Map<string, FakeStream>([
68
55
  ['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
69
56
  ['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
@@ -79,8 +66,6 @@ function makeDeps(agentName: string | null) {
79
66
  activeStatusReactions,
80
67
  activeReactionMsgIds,
81
68
  activeTurnStartedAt,
82
- claudeBusyKeys,
83
- claudeBusyKeySince,
84
69
  activeDraftStreams,
85
70
  clearActiveReactions,
86
71
  disposeProgressDriver,
@@ -202,8 +187,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
202
187
  ['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
203
188
  ]),
204
189
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
205
- claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
206
- claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
207
190
  activeDraftStreams: new Map<string, FakeStream>(),
208
191
  clearActiveReactions,
209
192
  disposeProgressDriver,
@@ -214,10 +197,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
214
197
 
215
198
  flushOnAgentDisconnect(deps)
216
199
 
217
- // PR3b: claudeBusyKeys swept alongside the activeTurnStartedAt
218
- // dangling entry — both maps mirror each other on registered
219
- // disconnects, so a key in one is always a key in the other.
220
- expect(deps.claudeBusyKeys.size).toBe(0)
221
200
  // The sweep fired and cleared the dangling entry.
222
201
  expect(deps.activeTurnStartedAt.size).toBe(0)
223
202
  expect(deps.activeReactionMsgIds.size).toBe(0)
@@ -261,8 +240,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
261
240
  activeStatusReactions: new Map<string, FakeCtrl>(),
262
241
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
263
242
  activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
264
- claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
265
- claudeBusyKeySince: new Map<string, number>([['real-turn:thr:msg', 100]]),
266
243
  activeDraftStreams: new Map<string, FakeStream>(),
267
244
  clearActiveReactions: vi.fn(),
268
245
  disposeProgressDriver: vi.fn(),
@@ -278,109 +255,11 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
278
255
  expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
279
256
  })
280
257
 
281
- // PR3b orphan-sweep regression: synthetic-inbound deliveries
282
- // (cron, reactions, vault, button-callback) bypass handleInbound's
283
- // fresh-turn branch and so never stamp activeTurnStartedAt. They
284
- // DO mark claudeBusyKeys. If their turn dies without turn_end, the
285
- // activeTurnStartedAt-keyed dangling sweep misses them — orphan
286
- // persists in claudeBusyKeys → fleet gate wedges. This test pins
287
- // the post-sweep claudeBusyKeys.clear() fix.
288
- it('sweeps claudeBusyKeys orphans that have NO activeTurnStartedAt entry (PR3b follow-up)', () => {
289
- const onDanglingTurnsSwept = vi.fn()
290
- const log = vi.fn()
291
- const deps = {
292
- agentName: 'clerk',
293
- activeStatusReactions: new Map<string, FakeCtrl>(),
294
- activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
295
- activeTurnStartedAt: new Map<string, number>(),
296
- // The orphan scenario: claude was handed a turn (e.g. cron
297
- // synthetic delivered), so claudeBusyKeys has it, but
298
- // activeTurnStartedAt was never set because cron bypasses
299
- // handleInbound's fresh-turn branch.
300
- claudeBusyKeys: new Set<string>(['cron-only-key:_']),
301
- claudeBusyKeySince: new Map<string, number>([['cron-only-key:_', 100]]),
302
- activeDraftStreams: new Map<string, FakeStream>(),
303
- clearActiveReactions: vi.fn(),
304
- disposeProgressDriver: vi.fn(),
305
- stopTurnTypingLoops: vi.fn(),
306
- onDanglingTurnsSwept,
307
- log,
308
- }
309
-
310
- flushOnAgentDisconnect(deps)
311
-
312
- // The orphan is cleared even though it never had an
313
- // activeTurnStartedAt entry.
314
- expect(deps.claudeBusyKeys.size).toBe(0)
315
- // The activeTurnStartedAt-keyed sweep wasn't fired (nothing in
316
- // that map to sweep) — so onDanglingTurnsSwept shouldn't fire
317
- // either. The orphan sweep is a separate observation.
318
- expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
319
- // But it logs the orphan-clear so operators can see it.
320
- expect(
321
- log.mock.calls.some((c: unknown[]) =>
322
- typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
323
- ),
324
- ).toBe(true)
325
- })
326
-
327
- it('orphan-sweep singular vs plural log message agrees with count', () => {
328
- // Tiny grammar regression: "1 entry" vs "2 entries".
329
- const log = vi.fn()
330
- const baseDeps = {
331
- agentName: 'clerk',
332
- activeStatusReactions: new Map<string, FakeCtrl>(),
333
- activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
334
- activeTurnStartedAt: new Map<string, number>(),
335
- activeDraftStreams: new Map<string, FakeStream>(),
336
- clearActiveReactions: vi.fn(),
337
- disposeProgressDriver: vi.fn(),
338
- stopTurnTypingLoops: vi.fn(),
339
- }
340
- // Singular form.
341
- flushOnAgentDisconnect({
342
- ...baseDeps,
343
- claudeBusyKeys: new Set<string>(['k1:_']),
344
- claudeBusyKeySince: new Map<string, number>([['k1:_', 100]]),
345
- log,
346
- })
347
- expect(log.mock.calls.some((c: unknown[]) =>
348
- typeof c[0] === 'string' && / 1 orphan claudeBusyKeys entry /.test(c[0]),
349
- )).toBe(true)
350
- // Plural form.
351
- log.mockClear()
352
- flushOnAgentDisconnect({
353
- ...baseDeps,
354
- claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
355
- claudeBusyKeySince: new Map<string, number>([['k1:_', 100], ['k2:1', 100]]),
356
- log,
357
- })
358
- expect(log.mock.calls.some((c: unknown[]) =>
359
- typeof c[0] === 'string' && / 2 orphan claudeBusyKeys entries /.test(c[0]),
360
- )).toBe(true)
361
- })
362
-
363
- it('does NOT fire orphan-sweep log when claudeBusyKeys is empty', () => {
364
- // Zero-noise discipline: every disconnect for a healthy idle
365
- // agent shouldn't produce a "0 orphan claudeBusyKeys" line.
366
- const log = vi.fn()
367
- flushOnAgentDisconnect({
368
- agentName: 'clerk',
369
- activeStatusReactions: new Map<string, FakeCtrl>(),
370
- activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
371
- activeTurnStartedAt: new Map<string, number>(),
372
- claudeBusyKeys: new Set<string>(),
373
- claudeBusyKeySince: new Map<string, number>(),
374
- activeDraftStreams: new Map<string, FakeStream>(),
375
- clearActiveReactions: vi.fn(),
376
- disposeProgressDriver: vi.fn(),
377
- stopTurnTypingLoops: vi.fn(),
378
- log,
379
- })
380
- expect(log.mock.calls.some((c: unknown[]) =>
381
- typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
382
- )).toBe(false)
383
- })
258
+ // (#2996 P1) The PR3b claudeBusyKeys orphan-sweep tests that lived here
259
+ // were deleted with the set itself — the delivery machine's bridgeDown
260
+ // event (emitted by the gateway alongside this flush) structurally
261
+ // clears the turn-in-flight gate for synthetic-delivered turns that
262
+ // died without a turn_end.
384
263
 
385
264
  it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
386
265
  // Backward-compat guard — existing callers that don't pass the new
@@ -390,8 +269,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
390
269
  activeStatusReactions: new Map<string, FakeCtrl>(),
391
270
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
392
271
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
393
- claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
394
- claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
395
272
  activeDraftStreams: new Map<string, FakeStream>(),
396
273
  clearActiveReactions: vi.fn(),
397
274
  disposeProgressDriver: vi.fn(),
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Registration-wiring pin for the #2996 P0a leaf move.
3
+ *
4
+ * P0a extracted the 62 top-level grammY registration statements — the 60
5
+ * `bot.use` / `bot.command` / `bot.on` / `bot.catch` calls PLUS the two
6
+ * interleaved handler-registering helpers (`registerOpsInfoCommands` and the
7
+ * terminal `installUnhandledMessageCatchAll`) — out of their previously
8
+ * module-scope-interspersed positions in gateway.ts and into a single
9
+ * `registerGatewayHandlers(bot, deps)` function, called exactly once at the
10
+ * same point in module evaluation as the old last registration.
11
+ *
12
+ * grammY middleware/handler ORDER is load-bearing — the admin-gate `bot.use`
13
+ * must run before any command, and the specific `message:*` handlers before
14
+ * the catch-all. This test is the deterministic guard that the move (and any
15
+ * future edit to the block) preserves the EXACT ordered registration
16
+ * sequence. It is outcome-asserting: it fails if a registration is dropped,
17
+ * reordered, duplicated, or added, and if any registration leaks back out to
18
+ * module top-level (which would change its evaluation order relative to the
19
+ * single call site).
20
+ *
21
+ * P0b (#2996) update: the single `registerGatewayHandlers(bot, {})` call moved
22
+ * OFF module top level into the boot function `initGatewayBot()` — because P0b
23
+ * defers `new Bot` construction to boot, so `bot` no longer exists at import.
24
+ * The call still fires exactly once, after construction and before the runner,
25
+ * preserving registration order. The call-site assertion below now pins that
26
+ * new location (zero top-level calls; exactly one inside initGatewayBot).
27
+ *
28
+ * The golden sequence below was captured from `origin/main` immediately
29
+ * BEFORE the move (parser-extracted top-level `bot.*` order) so the same
30
+ * assertion holds pre- and post-move — the ordered list is a behaviour
31
+ * invariant, not an artifact of the new structure.
32
+ *
33
+ * Source-parsing (not import) mirrors gateway-pending-command-wiring.test.ts:
34
+ * gateway.ts is not import-safe under vitest until the #2996 P0 importability
35
+ * seam lands, so we assert on the parsed source instead.
36
+ */
37
+
38
+ import { describe, it, expect } from 'vitest'
39
+ import { readFileSync } from 'node:fs'
40
+ import { fileURLToPath } from 'node:url'
41
+ import { dirname, resolve } from 'node:path'
42
+ import ts from 'typescript'
43
+
44
+ const __dirname = dirname(fileURLToPath(import.meta.url))
45
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
46
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
47
+
48
+ /**
49
+ * The frozen, ordered registration sequence. Order is significant — this is
50
+ * the exact top-level order captured from origin/main BEFORE the move.
51
+ * Format: `use` / `catch` (no name), `command:<name>`, `on:<event>`,
52
+ * `helper:<fn>` for the interleaved handler-registering helper calls.
53
+ */
54
+ const GOLDEN_REGISTRATIONS: readonly string[] = [
55
+ 'use',
56
+ 'command:start',
57
+ 'command:help',
58
+ 'command:status',
59
+ 'command:agents',
60
+ 'command:inject',
61
+ 'command:compact',
62
+ 'command:clear',
63
+ 'command:model',
64
+ 'command:effort',
65
+ 'command:agentstart',
66
+ 'command:agentstop',
67
+ 'command:stop',
68
+ 'command:restart',
69
+ 'command:new',
70
+ 'command:update',
71
+ 'command:upgradestatus',
72
+ 'command:upgrade',
73
+ 'command:audit',
74
+ 'command:approve',
75
+ 'command:deny',
76
+ 'command:folders',
77
+ 'command:pending',
78
+ 'command:interrupt',
79
+ 'command:connect',
80
+ 'command:auth',
81
+ 'command:vault',
82
+ 'command:topics',
83
+ 'command:logs',
84
+ 'command:memory',
85
+ 'command:issues',
86
+ 'command:usage',
87
+ 'helper:registerOpsInfoCommands',
88
+ 'on:callback_query:data',
89
+ 'on:message:text',
90
+ 'on:message:photo',
91
+ 'on:message:document',
92
+ 'on:message:voice',
93
+ 'on:message:audio',
94
+ 'on:message:video',
95
+ 'on:message:video_note',
96
+ 'on:message:sticker',
97
+ 'on:message:animation',
98
+ 'on:message:contact',
99
+ 'on:message:location',
100
+ 'on:message:venue',
101
+ 'on:message:poll',
102
+ 'on:message:web_app_data',
103
+ 'on:message:users_shared',
104
+ 'on:message:chat_shared',
105
+ 'on:message:dice',
106
+ 'on:message:game',
107
+ 'on:message:story',
108
+ 'on:message:paid_media',
109
+ 'on:message:successful_payment',
110
+ 'on:message:passport_data',
111
+ 'on:message:checklist_tasks_done',
112
+ 'on:message:checklist_tasks_added',
113
+ 'on:message:pinned_message',
114
+ 'helper:installUnhandledMessageCatchAll',
115
+ 'on:message_reaction',
116
+ 'catch',
117
+ ]
118
+
119
+ /**
120
+ * The interleaved handler-registering helper calls. Their position in the
121
+ * sequence is load-bearing: `installUnhandledMessageCatchAll` MUST come after
122
+ * every `message:*` handler (grammY no-double-handling; see
123
+ * catch-all-unhandled-message.test.ts) and before `message_reaction`.
124
+ */
125
+ const HELPER_REGISTRARS = new Set(['registerOpsInfoCommands', 'installUnhandledMessageCatchAll'])
126
+
127
+ const sourceFile = ts.createSourceFile(
128
+ GATEWAY_PATH,
129
+ GATEWAY_SRC,
130
+ ts.ScriptTarget.Latest,
131
+ true,
132
+ ts.ScriptKind.TS,
133
+ )
134
+
135
+ /** `bot.<verb>` for a CallExpression whose callee is `bot.<verb>`, else null. */
136
+ function botVerb(expr: ts.Expression): string | null {
137
+ if (!ts.isCallExpression(expr)) return null
138
+ const callee = expr.expression
139
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
140
+ if (callee.expression.text === 'bot') return callee.name.text
141
+ }
142
+ return null
143
+ }
144
+
145
+ /** Unwrap a possibly `as`-cast string literal to its text; else null. */
146
+ function stringArg(arg: ts.Expression | undefined): string | null {
147
+ if (!arg) return null
148
+ let node: ts.Expression = arg
149
+ if (ts.isAsExpression(node)) node = node.expression
150
+ if (ts.isStringLiteralLike(node)) return node.text
151
+ return null
152
+ }
153
+
154
+ /** Canonical signature for a registration statement, or null if not one. */
155
+ function registrationSig(stmt: ts.Statement): string | null {
156
+ if (!ts.isExpressionStatement(stmt)) return null
157
+ const expr = stmt.expression
158
+ if (!ts.isCallExpression(expr)) return null
159
+ // Interleaved handler-registering helper: `helperName(bot, ...)`.
160
+ if (ts.isIdentifier(expr.expression) && HELPER_REGISTRARS.has(expr.expression.text)) {
161
+ return `helper:${expr.expression.text}`
162
+ }
163
+ const verb = botVerb(expr)
164
+ if (verb === null) return null
165
+ if (verb === 'command' || verb === 'on') {
166
+ const name = stringArg(expr.arguments[0])
167
+ return name === null ? `${verb}:<non-literal>` : `${verb}:${name}`
168
+ }
169
+ return verb // use / catch / anything else with no name key
170
+ }
171
+
172
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
173
+ for (const s of sourceFile.statements) {
174
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
175
+ }
176
+ return undefined
177
+ }
178
+
179
+ describe('gateway #2996 P0a: registerGatewayHandlers is the single ordered registration unit', () => {
180
+ it('defines registerGatewayHandlers(bot, deps) at module top level', () => {
181
+ const fn = findFunction('registerGatewayHandlers')
182
+ expect(fn).toBeDefined()
183
+ expect(fn!.parameters.length).toBe(2)
184
+ expect(fn!.parameters[0].name.getText(sourceFile)).toBe('bot')
185
+ expect(fn!.parameters[1].name.getText(sourceFile)).toBe('deps')
186
+ })
187
+
188
+ it('registers the EXACT golden ordered sequence inside the function', () => {
189
+ const fn = findFunction('registerGatewayHandlers')
190
+ expect(fn?.body).toBeDefined()
191
+ const actual: string[] = []
192
+ for (const stmt of fn!.body!.statements) {
193
+ const sig = registrationSig(stmt)
194
+ if (sig !== null) actual.push(sig)
195
+ }
196
+ // Deep, ordered equality — fails on drop, reorder, duplicate, or add.
197
+ expect(actual).toEqual([...GOLDEN_REGISTRATIONS])
198
+ })
199
+
200
+ it('has no duplicate registrations', () => {
201
+ const fn = findFunction('registerGatewayHandlers')
202
+ const sigs: string[] = []
203
+ for (const stmt of fn!.body!.statements) {
204
+ const sig = registrationSig(stmt)
205
+ if (sig !== null) sigs.push(sig)
206
+ }
207
+ expect(new Set(sigs).size).toBe(sigs.length)
208
+ })
209
+
210
+ it('leaves ZERO bot.* registrations at module top level (all live in the function)', () => {
211
+ const leaked: string[] = []
212
+ for (const stmt of sourceFile.statements) {
213
+ const sig = registrationSig(stmt)
214
+ if (sig !== null) leaked.push(sig)
215
+ }
216
+ expect(leaked).toEqual([])
217
+ })
218
+
219
+ it('calls registerGatewayHandlers(bot, ...) exactly once, inside initGatewayBot (P0b), never at module top level', () => {
220
+ // P0b (#2996): the call moved off module top level into the boot function
221
+ // initGatewayBot(). Assert (a) ZERO top-level calls — proving the deferral —
222
+ // and (b) exactly one call in the whole file, with `bot` as its first arg,
223
+ // located inside initGatewayBot's body.
224
+ let topLevelCalls = 0
225
+ for (const stmt of sourceFile.statements) {
226
+ if (
227
+ ts.isExpressionStatement(stmt) &&
228
+ ts.isCallExpression(stmt.expression) &&
229
+ ts.isIdentifier(stmt.expression.expression) &&
230
+ stmt.expression.expression.text === 'registerGatewayHandlers'
231
+ ) {
232
+ topLevelCalls++
233
+ }
234
+ }
235
+ expect(topLevelCalls).toBe(0)
236
+
237
+ // Count every registerGatewayHandlers(...) call anywhere in the module, and
238
+ // record whether it is lexically inside the initGatewayBot function.
239
+ const initGatewayBot = findFunction('initGatewayBot')
240
+ expect(initGatewayBot).toBeDefined()
241
+ const initStart = initGatewayBot!.getStart(sourceFile)
242
+ const initEnd = initGatewayBot!.getEnd()
243
+
244
+ let totalCalls = 0
245
+ let callsInsideInit = 0
246
+ const visit = (node: ts.Node): void => {
247
+ if (
248
+ ts.isCallExpression(node) &&
249
+ ts.isIdentifier(node.expression) &&
250
+ node.expression.text === 'registerGatewayHandlers'
251
+ ) {
252
+ totalCalls++
253
+ // first arg is the bot singleton
254
+ expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
255
+ const start = node.getStart(sourceFile)
256
+ if (start >= initStart && node.getEnd() <= initEnd) callsInsideInit++
257
+ }
258
+ ts.forEachChild(node, visit)
259
+ }
260
+ visit(sourceFile)
261
+
262
+ expect(totalCalls).toBe(1)
263
+ expect(callsInsideInit).toBe(1)
264
+ })
265
+
266
+ it('registers the admin-gate middleware (bot.use) FIRST and the error boundary (bot.catch) LAST', () => {
267
+ const fn = findFunction('registerGatewayHandlers')
268
+ const sigs: string[] = []
269
+ for (const stmt of fn!.body!.statements) {
270
+ const sig = registrationSig(stmt)
271
+ if (sig !== null) sigs.push(sig)
272
+ }
273
+ expect(sigs[0]).toBe('use')
274
+ expect(sigs[sigs.length - 1]).toBe('catch')
275
+ // the catch-all-ish specific media handlers precede message_reaction, which
276
+ // precedes the error boundary
277
+ expect(sigs.indexOf('on:message:text')).toBeGreaterThan(sigs.indexOf('use'))
278
+ expect(sigs.indexOf('on:message_reaction')).toBeGreaterThan(sigs.indexOf('on:message:text'))
279
+ })
280
+
281
+ it('installs the terminal catch-all AFTER every message:* handler and before message_reaction', () => {
282
+ const fn = findFunction('registerGatewayHandlers')
283
+ const sigs: string[] = []
284
+ for (const stmt of fn!.body!.statements) {
285
+ const sig = registrationSig(stmt)
286
+ if (sig !== null) sigs.push(sig)
287
+ }
288
+ const catchAllIdx = sigs.indexOf('helper:installUnhandledMessageCatchAll')
289
+ expect(catchAllIdx).toBeGreaterThan(0)
290
+ // Every message:* handler precedes the catch-all (grammY no-double-handling).
291
+ const messageHandlers = sigs.filter(s => /^on:message(:|$)/.test(s) && s !== 'on:message_reaction')
292
+ expect(messageHandlers.length).toBeGreaterThan(0)
293
+ for (const mh of messageHandlers) {
294
+ expect(sigs.indexOf(mh)).toBeLessThan(catchAllIdx)
295
+ }
296
+ // message_reaction (a different update type) is registered AFTER the catch-all.
297
+ expect(sigs.indexOf('on:message_reaction')).toBeGreaterThan(catchAllIdx)
298
+ // ops-info commands register between the last /command and callback_query:data.
299
+ const opsIdx = sigs.indexOf('helper:registerOpsInfoCommands')
300
+ expect(opsIdx).toBeGreaterThan(sigs.indexOf('command:usage'))
301
+ expect(opsIdx).toBeLessThan(sigs.indexOf('on:callback_query:data'))
302
+ })
303
+ })
@@ -22,6 +22,13 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
22
22
  new URL('../gateway/gateway.ts', import.meta.url),
23
23
  'utf8',
24
24
  )
25
+ // #2996 P2: executeReply's body moved VERBATIM to outbound-send-path.ts
26
+ // (`sendReply`); the reply-path scrub-ordering assertion reads there. The
27
+ // injected redactor (`redactOutboundText`) still lives in gateway.ts.
28
+ const sendPathSrc = readFileSync(
29
+ new URL('../gateway/outbound-send-path.ts', import.meta.url),
30
+ 'utf8',
31
+ )
25
32
 
26
33
  it('imports the shared redactor', () => {
27
34
  expect(src).toMatch(/import \{ redact \} from '\.\.\/secret-detect\/redact\.js'/)
@@ -39,9 +46,9 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
39
46
  // voice-scrub) is extracted into outbound-send-path.ts. The reply path now
40
47
  // delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
41
48
  // — the injected redactor still runs at entry, before the stderr preview.
42
- const start = src.indexOf('async function executeReply(')
43
- const redactIdx = src.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
44
- const previewIdx = src.indexOf('reply: invoked chatId=', start)
49
+ const start = sendPathSrc.indexOf('export async function sendReply(')
50
+ const redactIdx = sendPathSrc.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
51
+ const previewIdx = sendPathSrc.indexOf('reply: invoked chatId=', start)
45
52
  expect(start).toBeGreaterThan(0)
46
53
  expect(redactIdx).toBeGreaterThan(start)
47
54
  expect(previewIdx).toBeGreaterThan(redactIdx) // mask BEFORE the preview is logged