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.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +527 -2880
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- 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
|
|
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('
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
'
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
187
|
+
describe('dispatchEffects — inbound-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
|
-
|
|
303
|
+
describe('dispatchEffects — turn/poke callbacks', () => {
|
|
215
304
|
const k = 'c1:_' as never
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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 (
|