switchroom 0.18.8 → 0.18.9

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 (36) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +2 -2
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
  6. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  7. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  8. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  9. package/telegram-plugin/gateway/gateway.ts +527 -2880
  10. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  11. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  13. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  14. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  15. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  16. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  17. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  18. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  20. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  21. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  22. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  23. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  24. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  26. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  27. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  29. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  30. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  31. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  32. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  33. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  34. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  35. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  36. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Machine→dispatcher coverage + self-consistency harness for the
3
+ * inbound-delivery state-machine cutover (#2794 / #2996 Phase 1).
4
+ *
5
+ * SCOPE — what this file proves, and what it deliberately does NOT:
6
+ *
7
+ * It drives realistic event schedules (the same shapes the gateway's
8
+ * `shadowEmit` call sites feed the machine — bridge flap, fresh turn,
9
+ * mid-turn buffering, steering, turn-end drain, permission verdicts
10
+ * across a bridge outage, stale-turn fallback) through the PURE
11
+ * `transition()` function, collects the returned effect sequence, then
12
+ * replays that sequence through the REAL `dispatchEffects()` against a
13
+ * recording fake-deps object. It asserts:
14
+ *
15
+ * 1. Every effect kind the machine can emit is handled by the
16
+ * dispatcher (no silent no-op, no `unwired`/`not-yet-cutover` gap).
17
+ * 2. The observable side-effects fire in the machine's returned order.
18
+ * 3. Invariant #1 at the dispatch layer: an inbound is delivered XOR
19
+ * (buffered AND persisted) — never both, never neither.
20
+ *
21
+ * It does NOT invoke the imperative twins (`markClaudeBusyForInbound`,
22
+ * the legacy `turnInFlightForGate` claudeBusyKeys branch,
23
+ * `dispatchPermissionVerdict`, the silence-poke ladder), so it is NOT a
24
+ * proof that machine path and imperative path produce identical I/O.
25
+ * Twin-equivalence evidence for PR4 (twin deletion) must come from the
26
+ * live shadow trace + gate-parity-probe bake per the RFC's cutover
27
+ * protocol — do NOT cite this file as that proof.
28
+ *
29
+ * What it DOES gate: if a future edit adds an effect kind the
30
+ * dispatcher doesn't execute, the "no unhandled effect" assertion fails
31
+ * loudly — keeping PR3c's live call-site flip a one-liner.
32
+ */
33
+
34
+ import { describe, expect, it } from 'vitest'
35
+ import {
36
+ transition,
37
+ initialState,
38
+ type Effect,
39
+ type Event,
40
+ type State,
41
+ type ChatKey,
42
+ type InboundMessage as MachineInbound,
43
+ } from '../gateway/inbound-delivery-machine'
44
+ import {
45
+ dispatchEffects,
46
+ type DispatchCtx,
47
+ } from '../gateway/inbound-delivery-machine-dispatch'
48
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
49
+ import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
50
+
51
+ // ── Recording fake deps ──────────────────────────────────────────────
52
+ // Records every observable dispatcher action in call order so the test
53
+ // can assert the effect→I/O mapping and its ordering.
54
+
55
+ interface Recorder {
56
+ ctx: DispatchCtx
57
+ ops: string[]
58
+ }
59
+
60
+ function makeRecorder(): Recorder {
61
+ const ops: string[] = []
62
+ const inbound = createPendingInboundBuffer()
63
+ const perm = createPendingPermissionBuffer()
64
+ const client = {
65
+ id: 'c1',
66
+ agentName: 'test-agent',
67
+ send: (m: unknown) => {
68
+ const msg = m as { type?: string; id?: string; requestId?: string }
69
+ if (msg.type === 'permission_decision') ops.push(`send:perm:${msg.requestId}`)
70
+ else ops.push(`send:inbound:${msg.id}`)
71
+ },
72
+ }
73
+ const spoolPuts: string[] = []
74
+ const ctx: DispatchCtx = {
75
+ selfAgent: 'test-agent',
76
+ ipcServer: { sendToAgent: () => true } as never,
77
+ pendingInboundBuffer: inbound,
78
+ inboundSpool: {
79
+ put: (_agent: string, m: { id?: string }) => {
80
+ spoolPuts.push(m.id ?? '?')
81
+ ops.push(`persist:${m.id}`)
82
+ return true
83
+ },
84
+ ack: () => {},
85
+ liveEntries: () => [],
86
+ } as never,
87
+ pendingPermissionBuffer: perm,
88
+ client: client as never,
89
+ log: () => {},
90
+ onUserInboundDelivered: (m) => ops.push(`enrol:${(m as { id?: string }).id}`),
91
+ onSetTurnStarted: (key, at) => ops.push(`setTurn:${key}:${at}`),
92
+ onClearTurnStarted: (key) => ops.push(`clearTurn:${key}`),
93
+ onNoteOutbound: (key) => ops.push(`noteOutbound:${key}`),
94
+ onFirePoke: (key, level) => ops.push(`poke:${key}:${level}`),
95
+ }
96
+ // bufferInbound goes through the real buffer; record via a wrapper.
97
+ const origPush = inbound.push.bind(inbound)
98
+ ;(inbound as { push: typeof inbound.push }).push = (agent, m) => {
99
+ ops.push(`buffer:${(m as { id?: string }).id}`)
100
+ return origPush(agent, m)
101
+ }
102
+ return { ctx, ops }
103
+ }
104
+
105
+ // A logging deps object that records the raw effect kinds a dispatch
106
+ // pass would execute, so we can assert "no unhandled effect kind".
107
+ function makeEffectKindProbe(): { ctx: DispatchCtx; seen: string[] } {
108
+ const seen: string[] = []
109
+ const ctx: DispatchCtx = {
110
+ selfAgent: 'test-agent',
111
+ ipcServer: { sendToAgent: () => true } as never,
112
+ pendingInboundBuffer: createPendingInboundBuffer(),
113
+ inboundSpool: { put: () => true, ack: () => {}, liveEntries: () => [] } as never,
114
+ pendingPermissionBuffer: createPendingPermissionBuffer(),
115
+ client: { id: 'c1', agentName: 'test-agent', send: () => {} } as never,
116
+ log: (line: string) => {
117
+ // The dispatcher must NEVER leave an effect unhandled. Two gap
118
+ // markers exist in the code; both are failures for a live cutover.
119
+ if (line.includes('not-yet-cutover') || line.includes('unwired')) {
120
+ seen.push(`GAP:${line.trim()}`)
121
+ }
122
+ },
123
+ onSetTurnStarted: () => {},
124
+ onClearTurnStarted: () => {},
125
+ onNoteOutbound: () => {},
126
+ onFirePoke: () => {},
127
+ }
128
+ return { ctx, seen }
129
+ }
130
+
131
+ // ── Event-schedule fixtures (mirror gateway shadowEmit call sites) ────
132
+
133
+ const K = (chat: string, thread?: string): ChatKey => `${chat}:${thread ?? '_'}` as ChatKey
134
+ const inMsg = (id: number, steer = false): MachineInbound => ({
135
+ msgId: id,
136
+ isSteering: steer,
137
+ payload: { type: 'inbound', id: `m${id}`, chat_id: '1', text: `t${id}`, meta: { source: 'telegram' } },
138
+ })
139
+ // Real user inbound — NO meta.source. This is the primary live case:
140
+ // shouldTrackDelivery (inbound-delivery-confirm.ts) only tracks
141
+ // no-source messages, so fixtures that all carry a source would mask a
142
+ // broken enrolment guard (the exact HIGH found in review of PR #3006).
143
+ const userMsg = (id: number): MachineInbound => ({
144
+ msgId: id,
145
+ isSteering: false,
146
+ payload: { type: 'inbound', id: `m${id}`, chat_id: '1', text: `t${id}`, meta: {} },
147
+ })
148
+ const verdict = (r: string) => ({
149
+ requestId: r,
150
+ behavior: 'allow' as const,
151
+ payload: { type: 'permission_decision', requestId: r, behavior: 'allow', updatedInput: {}, timestamp: 0 },
152
+ })
153
+
154
+ /** Named schedules that exercise every transition branch. */
155
+ const SCHEDULES: Record<string, Event[]> = {
156
+ fresh_turn_then_end: [
157
+ { kind: 'bridgeUp', at: 0 },
158
+ { kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
159
+ { kind: 'turnEnd', key: K('1'), at: 20, outboundEmitted: true },
160
+ ],
161
+ mid_turn_buffer: [
162
+ { kind: 'bridgeUp', at: 0 },
163
+ { kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
164
+ { kind: 'inbound', key: K('1'), msg: inMsg(2), at: 15 }, // buffered
165
+ { kind: 'turnEnd', key: K('1'), at: 20, outboundEmitted: true },
166
+ ],
167
+ steering_mid_turn: [
168
+ { kind: 'bridgeUp', at: 0 },
169
+ { kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
170
+ { kind: 'inbound', key: K('1'), msg: inMsg(2, true), at: 15 }, // steer → deliver
171
+ ],
172
+ bridge_dead_buffer_then_recover: [
173
+ { kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 }, // bridge dead → buffer+persist
174
+ { kind: 'bridgeUp', at: 20 }, // drain
175
+ ],
176
+ perm_verdict_alive: [
177
+ { kind: 'bridgeUp', at: 0 },
178
+ { kind: 'permVerdict', verdict: verdict('rA'), at: 5 },
179
+ ],
180
+ perm_verdict_dead_then_recover: [
181
+ { kind: 'permVerdict', verdict: verdict('rB'), at: 5 }, // dead → persist
182
+ { kind: 'bridgeUp', at: 10 }, // redeliver
183
+ ],
184
+ stale_turn_fallback: [
185
+ { kind: 'bridgeUp', at: 0 },
186
+ { kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
187
+ { kind: 'tick', now: 10 + 300_001 }, // past TURN_TTL, no outbound → firePoke
188
+ ],
189
+ fresh_turn_user_no_source: [
190
+ { kind: 'bridgeUp', at: 0 },
191
+ { kind: 'inbound', key: K('1'), msg: userMsg(1), at: 10 }, // real user inbound, no meta.source
192
+ ],
193
+ overlapping_turn_no_spurious_fallback: [
194
+ { kind: 'bridgeUp', at: 0 },
195
+ { kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
196
+ { kind: 'modelOutbound', key: K('1'), at: 10 + 300_000 }, // recent outbound
197
+ { kind: 'tick', now: 10 + 300_001 }, // suppressed by invariant #5
198
+ ],
199
+ }
200
+
201
+ // Run a schedule through the machine, returning the concatenated effect
202
+ // stream in machine order.
203
+ function runMachine(events: Event[]): { effects: Effect[]; finalState: State } {
204
+ let state = initialState()
205
+ const effects: Effect[] = []
206
+ for (const ev of events) {
207
+ const r = transition(state, ev)
208
+ state = r.state
209
+ effects.push(...r.effects)
210
+ }
211
+ return { effects, finalState: state }
212
+ }
213
+
214
+ describe('dispatch-equivalence: machine effects → dispatcher I/O', () => {
215
+ for (const [name, events] of Object.entries(SCHEDULES)) {
216
+ it(`'${name}': every emitted effect is handled by the dispatcher (no gap)`, () => {
217
+ const { effects } = runMachine(events)
218
+ const { ctx, seen } = makeEffectKindProbe()
219
+ dispatchEffects(effects, ctx)
220
+ expect(seen).toEqual([]) // no not-yet-cutover / unwired markers
221
+ })
222
+ }
223
+
224
+ it('fresh_turn_then_end replays in machine order: setTurn → send → noteOutbound/clearTurn', () => {
225
+ const { effects } = runMachine(SCHEDULES.fresh_turn_then_end)
226
+ const { ctx, ops } = makeRecorder()
227
+ dispatchEffects(effects, ctx)
228
+ // setTurnStarted precedes the deliver (downstream observer must see
229
+ // the turn marker up first), and turn-end clears + notes outbound.
230
+ expect(ops[0]).toBe('setTurn:1:_:10')
231
+ expect(ops[1]).toBe('send:inbound:m1')
232
+ expect(ops).toContain('clearTurn:1:_')
233
+ expect(ops).toContain('noteOutbound:1:_')
234
+ expect(ops.indexOf('setTurn:1:_:10')).toBeLessThan(ops.indexOf('send:inbound:m1'))
235
+ })
236
+
237
+ it('mid_turn_buffer: the second inbound is buffered+persisted, never delivered', () => {
238
+ const { effects } = runMachine(SCHEDULES.mid_turn_buffer)
239
+ const { ctx, ops } = makeRecorder()
240
+ dispatchEffects(effects, ctx)
241
+ // m1 delivered (fresh turn), m2 buffered+persisted (mid-turn).
242
+ expect(ops).toContain('send:inbound:m1')
243
+ expect(ops).toContain('buffer:m2')
244
+ expect(ops).toContain('persist:m2')
245
+ // Invariant #1 at the dispatch layer: buffer implies persist, same msg.
246
+ expect(ops.filter((o) => o === 'buffer:m2')).toHaveLength(1)
247
+ expect(ops.filter((o) => o === 'persist:m2')).toHaveLength(1)
248
+ // m2 is NOT delivered at receipt-time; if it is redelivered at all
249
+ // it happens via the turn-end drainBuffer, i.e. strictly AFTER the
250
+ // turn was cleared — never as an in-turn delivery.
251
+ const m2SendIdx = ops.indexOf('send:inbound:m2')
252
+ if (m2SendIdx !== -1) {
253
+ expect(m2SendIdx).toBeGreaterThan(ops.indexOf('clearTurn:1:_'))
254
+ }
255
+ expect(ops.indexOf('buffer:m2')).toBeLessThan(ops.indexOf('clearTurn:1:_'))
256
+ })
257
+
258
+ it('steering_mid_turn: a steer message is delivered mid-turn, not buffered', () => {
259
+ const { effects } = runMachine(SCHEDULES.steering_mid_turn)
260
+ const { ctx, ops } = makeRecorder()
261
+ dispatchEffects(effects, ctx)
262
+ expect(ops).toContain('send:inbound:m1')
263
+ expect(ops).toContain('send:inbound:m2')
264
+ expect(ops).not.toContain('buffer:m2')
265
+ })
266
+
267
+ it('bridge_dead_buffer_then_recover: buffer+persist while dead, drain on recover', () => {
268
+ const { effects } = runMachine(SCHEDULES.bridge_dead_buffer_then_recover)
269
+ const { ctx, ops } = makeRecorder()
270
+ dispatchEffects(effects, ctx)
271
+ // Buffered+persisted while dead; the drainBuffer effect at bridgeUp
272
+ // redelivers from the buffer → the recorded buffer push then a send.
273
+ expect(ops).toContain('buffer:m1')
274
+ expect(ops).toContain('persist:m1')
275
+ expect(ops).toContain('send:inbound:m1') // redelivered by drainBuffer
276
+ })
277
+
278
+ it('perm_verdict_alive: verdict delivered straight to the bridge', () => {
279
+ const { effects } = runMachine(SCHEDULES.perm_verdict_alive)
280
+ const { ctx, ops } = makeRecorder()
281
+ dispatchEffects(effects, ctx)
282
+ expect(ops).toContain('send:perm:rA')
283
+ })
284
+
285
+ it('perm_verdict_dead_then_recover: persisted while dead, redelivered on bridgeUp', () => {
286
+ const { effects } = runMachine(SCHEDULES.perm_verdict_dead_then_recover)
287
+ const { ctx, ops } = makeRecorder()
288
+ dispatchEffects(effects, ctx)
289
+ // Persisted while dead (no direct send at persist time), then the
290
+ // redeliverPersistedPermVerdicts effect at bridgeUp sends it.
291
+ expect(ops).toContain('send:perm:rB')
292
+ })
293
+
294
+ it('stale_turn_fallback: fires the fallback poke + clears the turn', () => {
295
+ const { effects } = runMachine(SCHEDULES.stale_turn_fallback)
296
+ const { ctx, ops } = makeRecorder()
297
+ dispatchEffects(effects, ctx)
298
+ expect(ops).toContain('poke:1:_:fallback')
299
+ expect(ops).toContain('clearTurn:1:_')
300
+ })
301
+
302
+ it('fresh_turn_user_no_source: a real user inbound (no meta.source) is delivered AND enrolled in the deliver-until-acked sweep', () => {
303
+ const { effects } = runMachine(SCHEDULES.fresh_turn_user_no_source)
304
+ const { ctx, ops } = makeRecorder()
305
+ dispatchEffects(effects, ctx)
306
+ expect(ops).toContain('send:inbound:m1')
307
+ // The enrolment callback MUST fire for no-source user inbounds —
308
+ // shouldTrackDelivery inside the gateway callback is the gate, not
309
+ // a dispatcher-side meta.source pre-filter (review HIGH, PR #3006).
310
+ expect(ops).toContain('enrol:m1')
311
+ })
312
+
313
+ it('overlapping_turn_no_spurious_fallback: recent outbound suppresses the poke', () => {
314
+ const { effects } = runMachine(SCHEDULES.overlapping_turn_no_spurious_fallback)
315
+ const { ctx, ops } = makeRecorder()
316
+ dispatchEffects(effects, ctx)
317
+ // Invariant #5: the model broke silence within OUTBOUND_RECENT_MS,
318
+ // so NO fallback poke fires.
319
+ expect(ops.some((o) => o.startsWith('poke:'))).toBe(false)
320
+ })
321
+ })
322
+
323
+ describe('dispatch-equivalence: effect-kind coverage', () => {
324
+ it('the union of all schedule effects covers every machine effect kind that maps to I/O', () => {
325
+ const allKinds = new Set<string>()
326
+ for (const events of Object.values(SCHEDULES)) {
327
+ for (const e of runMachine(events).effects) allKinds.add(e.kind)
328
+ }
329
+ // Every kind produced by the fixtures must be one the dispatcher
330
+ // executes. logTrace is pure telemetry; the rest are I/O.
331
+ const expected = [
332
+ 'deliverToBridge',
333
+ 'bufferInbound',
334
+ 'persistInbound',
335
+ 'drainBuffer',
336
+ 'setTurnStarted',
337
+ 'clearTurnStarted',
338
+ 'noteOutbound',
339
+ 'firePoke',
340
+ 'deliverPermVerdict',
341
+ 'persistPermVerdict',
342
+ 'redeliverPersistedPermVerdicts',
343
+ ]
344
+ for (const kind of expected) {
345
+ expect(allKinds.has(kind)).toBe(true)
346
+ }
347
+ })
348
+ })
@@ -22,7 +22,7 @@ import {
22
22
  import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
23
23
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
24
24
  import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
25
- import type { Effect } from '../gateway/inbound-delivery-machine'
25
+ import { createInboundSpool } from '../gateway/inbound-spool'
26
26
 
27
27
  function makeCtx(overrides?: Partial<DispatchCtx>): {
28
28
  ctx: DispatchCtx
@@ -184,57 +184,146 @@ describe('dispatchEffects — kill switch', () => {
184
184
  })
185
185
  })
186
186
 
187
- describe('dispatchOnenot-yet-cutover effects log without throwing', () => {
188
- const notYet: Effect['kind'][] = [
189
- 'deliverToBridge',
190
- 'bufferInbound',
191
- 'persistInbound',
192
- 'setTurnStarted',
193
- 'clearTurnStarted',
194
- 'noteOutbound',
195
- 'firePoke',
196
- 'deliverPermVerdict',
197
- 'persistPermVerdict',
198
- ]
199
-
200
- for (const kind of notYet) {
201
- it(`'${kind}' logs not-yet-cutover and does NOT touch primitives`, () => {
202
- const { ctx, sendToAgent, clientSend, logs } = makeCtx()
203
- // Construct a minimal valid effect for each kind. Most carry a
204
- // payload that the dispatcher ignores in this PR.
205
- const effect = synthesizeNotYetCutoverEffect(kind)
206
- expect(() => __dispatchOneForTests(effect, ctx)).not.toThrow()
207
- expect(sendToAgent).not.toHaveBeenCalled()
208
- expect(clientSend).not.toHaveBeenCalled()
209
- expect(logs.some((l) => l.includes(`not-yet-cutover effect=${kind}`))).toBe(true)
210
- })
211
- }
187
+ describe('dispatchEffectsinbound-routing effects (PR3c wiring)', () => {
188
+ const k = 'c1:_' as never
189
+ const ipcMsg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'telegram' } }
190
+
191
+ it('deliverToBridge sends the payload to the client', () => {
192
+ const { ctx, clientSend } = makeCtx()
193
+ __dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
194
+ expect(clientSend).toHaveBeenCalledTimes(1)
195
+ expect(clientSend).toHaveBeenCalledWith(ipcMsg)
196
+ })
197
+
198
+ it('deliverToBridge falls back to ipcServer.sendToAgent with no client', () => {
199
+ const { ctx, sendToAgent } = makeCtx({ client: undefined })
200
+ __dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
201
+ expect(sendToAgent).toHaveBeenCalledWith('test-agent', ipcMsg)
202
+ })
203
+
204
+ it('deliverToBridge enrols in the deliver-until-acked sweep', () => {
205
+ const onUserInboundDelivered = vi.fn()
206
+ const { ctx } = makeCtx({ onUserInboundDelivered })
207
+ __dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
208
+ expect(onUserInboundDelivered).toHaveBeenCalledWith(ipcMsg)
209
+ })
210
+
211
+ it('deliverToBridge DOES pass the enrolment callback for a real user inbound (NO meta.source)', () => {
212
+ // The primary live case: real user messages carry NO meta.source,
213
+ // and they are exactly the messages shouldTrackDelivery tracks. A
214
+ // dispatcher-side pre-filter on meta.source would skip them (the
215
+ // review HIGH on PR #3006) — the callback must fire unconditionally
216
+ // and self-gate inside the gateway.
217
+ const noSourceMsg = { type: 'inbound' as const, id: 'u1', chat_id: '1', text: 'hi', meta: {} }
218
+ const onUserInboundDelivered = vi.fn()
219
+ const { ctx, clientSend } = makeCtx({ onUserInboundDelivered })
220
+ __dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 2, isSteering: false, payload: noSourceMsg } }, ctx)
221
+ expect(clientSend).toHaveBeenCalledWith(noSourceMsg)
222
+ expect(onUserInboundDelivered).toHaveBeenCalledWith(noSourceMsg)
223
+ })
224
+
225
+ it('bufferInbound pushes onto the pending inbound buffer', () => {
226
+ const { ctx, inbound, clientSend } = makeCtx()
227
+ __dispatchOneForTests({ kind: 'bufferInbound', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
228
+ expect(inbound.depth('test-agent')).toBe(1)
229
+ expect(clientSend).not.toHaveBeenCalled()
230
+ })
231
+
232
+ it('persistInbound puts to the spool when one is attached', () => {
233
+ const put = vi.fn(() => true)
234
+ const { ctx } = makeCtx({ inboundSpool: { put } as never })
235
+ __dispatchOneForTests({ kind: 'persistInbound', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
236
+ expect(put).toHaveBeenCalledWith('test-agent', ipcMsg)
237
+ })
238
+
239
+ it('persistInbound is a safe no-op when no spool is attached', () => {
240
+ const { ctx } = makeCtx({ inboundSpool: null })
241
+ expect(() =>
242
+ __dispatchOneForTests({ kind: 'persistInbound', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx),
243
+ ).not.toThrow()
244
+ })
245
+
246
+ it('bufferInbound + persistInbound with a spool attached to the buffer does NOT double-spool (idempotent by spoolId)', () => {
247
+ // The machine emits bufferInbound AND persistInbound as a pair for a
248
+ // mid-turn / bridge-dead inbound. When the ctx's buffer is
249
+ // constructed with the SAME durable spool (the production wiring),
250
+ // buffer.push already spools — so the paired persistInbound's
251
+ // spool.put must dedupe by spoolId, leaving exactly ONE live entry.
252
+ const spoolPath = '/state/agent/telegram/inbound-spool.jsonl'
253
+ const files = new Map<string, string>()
254
+ const fs = {
255
+ appendFileSync: (p: string, d: string) => files.set(p, (files.get(p) ?? '') + d),
256
+ readFileSync: (p: string) => files.get(p) ?? '',
257
+ writeFileSync: (p: string, d: string) => files.set(p, d),
258
+ renameSync: (from: string, to: string) => {
259
+ files.set(to, files.get(from) ?? '')
260
+ files.delete(from)
261
+ },
262
+ existsSync: (p: string) => files.has(p),
263
+ statSizeSync: (p: string) => Buffer.byteLength(files.get(p) ?? ''),
264
+ }
265
+ const spool = createInboundSpool({ path: spoolPath, fs, log: () => {} })
266
+ const buffer = createPendingInboundBuffer({ spool, log: () => {} })
267
+ const { ctx } = makeCtx({ pendingInboundBuffer: buffer, inboundSpool: spool })
268
+ // Spool-identifiable message shape (spoolId reads chatId/messageId).
269
+ const durableMsg = {
270
+ type: 'inbound' as const,
271
+ chatId: 'c1',
272
+ messageId: 77,
273
+ user: 'u',
274
+ userId: 1,
275
+ ts: 1000,
276
+ text: 'hold me',
277
+ meta: {},
278
+ }
279
+ __dispatchOneForTests({ kind: 'bufferInbound', key: k, msg: { msgId: 77, isSteering: false, payload: durableMsg } }, ctx)
280
+ __dispatchOneForTests({ kind: 'persistInbound', key: k, msg: { msgId: 77, isSteering: false, payload: durableMsg } }, ctx)
281
+ expect(buffer.depth('test-agent')).toBe(1)
282
+ expect(spool.liveCount()).toBe(1) // no double-spool
283
+ })
284
+ })
285
+
286
+ describe('dispatchEffects — perm-verdict effects', () => {
287
+ const permEv = { type: 'permission_decision' as const, requestId: 'r1', behavior: 'allow' as const, updatedInput: {}, timestamp: 0 }
288
+
289
+ it('deliverPermVerdict sends the payload to the client', () => {
290
+ const { ctx, clientSend } = makeCtx()
291
+ __dispatchOneForTests({ kind: 'deliverPermVerdict', verdict: { requestId: 'r1', behavior: 'allow', payload: permEv } }, ctx)
292
+ expect(clientSend).toHaveBeenCalledWith(permEv)
293
+ })
294
+
295
+ it('persistPermVerdict pushes onto the pending permission buffer', () => {
296
+ const { ctx, perm, clientSend } = makeCtx()
297
+ __dispatchOneForTests({ kind: 'persistPermVerdict', verdict: { requestId: 'r1', behavior: 'allow', payload: permEv } }, ctx)
298
+ expect(clientSend).not.toHaveBeenCalled()
299
+ expect(perm.drain('test-agent')).toEqual([permEv])
300
+ })
212
301
  })
213
302
 
214
- function synthesizeNotYetCutoverEffect(kind: Effect['kind']): Effect {
303
+ describe('dispatchEffects — turn/poke callbacks', () => {
215
304
  const k = 'c1:_' as never
216
- const msg = { msgId: 1, isSteering: false, payload: null } as never
217
- const verdict = { requestId: 'r', behavior: 'allow' as const, payload: null } as never
218
- switch (kind) {
219
- case 'deliverToBridge':
220
- return { kind, key: k, msg }
221
- case 'bufferInbound':
222
- return { kind, key: k, msg }
223
- case 'persistInbound':
224
- return { kind, key: k, msg }
225
- case 'setTurnStarted':
226
- return { kind, key: k, at: 0 }
227
- case 'clearTurnStarted':
228
- return { kind, key: k }
229
- case 'noteOutbound':
230
- return { kind, key: k, at: 0 }
231
- case 'firePoke':
232
- return { kind, key: k, level: 'fallback' }
233
- case 'deliverPermVerdict':
234
- return { kind, verdict }
235
- case 'persistPermVerdict':
236
- return { kind, verdict }
237
- default:
238
- throw new Error(`unreachable: ${kind}`)
239
- }
240
- }
305
+
306
+ it('setTurnStarted / clearTurnStarted / noteOutbound / firePoke fire their callbacks', () => {
307
+ const onSetTurnStarted = vi.fn()
308
+ const onClearTurnStarted = vi.fn()
309
+ const onNoteOutbound = vi.fn()
310
+ const onFirePoke = vi.fn()
311
+ const { ctx } = makeCtx({ onSetTurnStarted, onClearTurnStarted, onNoteOutbound, onFirePoke })
312
+ __dispatchOneForTests({ kind: 'setTurnStarted', key: k, at: 7 }, ctx)
313
+ __dispatchOneForTests({ kind: 'clearTurnStarted', key: k }, ctx)
314
+ __dispatchOneForTests({ kind: 'noteOutbound', key: k, at: 9 }, ctx)
315
+ __dispatchOneForTests({ kind: 'firePoke', key: k, level: 'fallback' }, ctx)
316
+ expect(onSetTurnStarted).toHaveBeenCalledWith(k, 7)
317
+ expect(onClearTurnStarted).toHaveBeenCalledWith(k)
318
+ expect(onNoteOutbound).toHaveBeenCalledWith(k, 9)
319
+ expect(onFirePoke).toHaveBeenCalledWith(k, 'fallback')
320
+ })
321
+
322
+ it('logs an `unwired` trace (never silently no-ops) when a callback is absent', () => {
323
+ const { ctx, logs } = makeCtx()
324
+ __dispatchOneForTests({ kind: 'setTurnStarted', key: k, at: 0 }, ctx)
325
+ __dispatchOneForTests({ kind: 'firePoke', key: k, level: 'soft' }, ctx)
326
+ expect(logs.some((l) => l.includes('unwired effect=setTurnStarted'))).toBe(true)
327
+ expect(logs.some((l) => l.includes('unwired effect=firePoke'))).toBe(true)
328
+ })
329
+ })
@@ -19,7 +19,14 @@ import { describe, expect, it } from 'vitest'
19
19
  import { readFileSync } from 'node:fs'
20
20
  import { resolve } from 'node:path'
21
21
 
22
- const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
22
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
23
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
24
+ // COMBINED with that module so the wiring assertions keep covering the
25
+ // same runtime source text.
26
+ const gatewaySrc =
27
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
28
+ '\n' +
29
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
23
30
 
24
31
  function proposeCallbackBlock(): string {
25
32
  return (