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
@@ -131,10 +131,17 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
131
131
  return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
132
132
  }
133
133
 
134
- it('executeReply finalizes (clearActivitySummary) before the chunk loop, gated on substantive', () => {
134
+ // #2996 step 1: the chunk send loop was relocated verbatim from executeReply
135
+ // into outbound-send-path.ts's `sendReplyChunks`, invoked here as
136
+ // `await sendReplyChunks(chunkSendDeps, …)`. This guard's INTENT — the
137
+ // lever-2 card finalize runs BEFORE the reply send — is unchanged; the send
138
+ // marker is now the delegation call rather than the inline `for` loop.
139
+ const SEND_MARKER = 'sendReplyChunks('
140
+
141
+ it('executeReply finalizes (clearActivitySummary) before the reply send, gated on substantive', () => {
135
142
  const src = executeReplySrc()
136
143
  const clearIdx = src.indexOf('clearActivitySummary(')
137
- const loopIdx = src.indexOf('for (let i = 0; i < chunks.length')
144
+ const loopIdx = src.indexOf(SEND_MARKER)
138
145
  expect(clearIdx).toBeGreaterThan(-1)
139
146
  expect(loopIdx).toBeGreaterThan(-1)
140
147
  expect(clearIdx).toBeLessThan(loopIdx)
@@ -148,8 +155,8 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
148
155
  // An ack (non-substantive) falls through and never finalizes early, so the
149
156
  // reopen path keeps owning the card (the #2141 ack-then-work feed).
150
157
  const replySrc = executeReplySrc()
151
- // The pre-loop clearActivitySummary must be the substantive-gated one.
152
- const preLoop = replySrc.split('for (let i = 0; i < chunks.length')[0] ?? ''
158
+ // The pre-send clearActivitySummary must be the substantive-gated one.
159
+ const preLoop = replySrc.split(SEND_MARKER)[0] ?? ''
153
160
  const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
154
161
  expect(clears).toHaveLength(1)
155
162
  })
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Subprocess probe for the SWITCHROOM_DELIVERY_MACHINE_CUTOVER kill switch.
3
+ *
4
+ * The cutover env var is read at MODULE LOAD in both
5
+ * `inbound-delivery-machine-dispatch.ts` and
6
+ * `inbound-delivery-machine-shadow.ts`, so an in-process test can't flip it
7
+ * after import (and bun's `vi` shim has no resetModules/stubEnv). The
8
+ * cutover-flip test spawns this script under `bun` with the env it wants
9
+ * and asserts the JSON verdict printed on stdout.
10
+ *
11
+ * Output: one JSON line —
12
+ * {
13
+ * dispatchEnabled, cutoverEnabled, shadowEnabled,
14
+ * sendCalls, buffered, setTurnStartedCalls, deliverResults
15
+ * }
16
+ * after driving a fresh-turn inbound effect set through dispatchEffects
17
+ * against a recording fake ctx.
18
+ */
19
+
20
+ import { dispatchEffects, isDispatchEnabled } from '../../gateway/inbound-delivery-machine-dispatch'
21
+ import {
22
+ isDeliveryCutoverEnabled,
23
+ __shadowEnabledForTests,
24
+ } from '../../gateway/inbound-delivery-machine-shadow'
25
+ import { createPendingInboundBuffer } from '../../gateway/pending-inbound-buffer'
26
+ import { createPendingPermissionBuffer } from '../../gateway/pending-permission-decisions'
27
+ import { initialState, transition, type ChatKey } from '../../gateway/inbound-delivery-machine'
28
+
29
+ const KEY = '111:_' as ChatKey
30
+ const msg = { type: 'inbound', chatId: '111', messageId: 42, text: 'hello' }
31
+
32
+ const alive = transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
33
+ const { effects } = transition(alive, {
34
+ kind: 'inbound',
35
+ key: KEY,
36
+ msg: { msgId: 42, isSteering: false, payload: msg },
37
+ at: 2000,
38
+ })
39
+
40
+ let sendCalls = 0
41
+ const buffer = createPendingInboundBuffer()
42
+ let setTurnStartedCalls = 0
43
+ const deliverResults: boolean[] = []
44
+
45
+ dispatchEffects(effects, {
46
+ selfAgent: 'probe-agent',
47
+ ipcServer: {
48
+ sendToAgent: () => {
49
+ sendCalls++
50
+ return true
51
+ },
52
+ } as never,
53
+ pendingInboundBuffer: buffer,
54
+ inboundSpool: null,
55
+ pendingPermissionBuffer: createPendingPermissionBuffer(),
56
+ log: () => {},
57
+ onSetTurnStarted: () => {
58
+ setTurnStartedCalls++
59
+ },
60
+ onDeliverResult: (_k, ok) => {
61
+ deliverResults.push(ok)
62
+ },
63
+ })
64
+
65
+ process.stdout.write(
66
+ JSON.stringify({
67
+ dispatchEnabled: isDispatchEnabled(),
68
+ cutoverEnabled: isDeliveryCutoverEnabled(),
69
+ shadowEnabled: __shadowEnabledForTests(),
70
+ sendCalls,
71
+ buffered: buffer.drain('probe-agent').length,
72
+ setTurnStartedCalls,
73
+ deliverResults,
74
+ }) + '\n',
75
+ )
@@ -35,8 +35,12 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
35
35
  })
36
36
 
37
37
  it('reply: scrubs at entry, before the stderr preview log', () => {
38
+ // #2996: the entry pipeline (normalize → redact → punctuation/bold →
39
+ // voice-scrub) is extracted into outbound-send-path.ts. The reply path now
40
+ // delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
41
+ // — the injected redactor still runs at entry, before the stderr preview.
38
42
  const start = src.indexOf('async function executeReply(')
39
- const redactIdx = src.indexOf(`redactOutboundText(text, 'reply')`, start)
43
+ const redactIdx = src.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
40
44
  const previewIdx = src.indexOf('reply: invoked chatId=', start)
41
45
  expect(start).toBeGreaterThan(0)
42
46
  expect(redactIdx).toBeGreaterThan(start)
@@ -0,0 +1,418 @@
1
+ /**
2
+ * PR3c cutover flip (#2794 / #2996 item 1) — the inbound-routing flip.
3
+ *
4
+ * `handleInbound` now dispatches the machine's captured `inbound` effects
5
+ * through `dispatchEffects` as the AUTHORITATIVE deliver-vs-buffer routing.
6
+ * These tests pin the two contract halves the flip rests on:
7
+ *
8
+ * 1. DEFAULT ON — with no env override, both the cutover gate
9
+ * (`isDeliveryCutoverEnabled`) and the dispatcher (`isDispatchEnabled`)
10
+ * are enabled, and the machine's effect sequences execute real I/O
11
+ * (fresh turn → setTurnStarted + deliverToBridge; mid-turn → buffer).
12
+ *
13
+ * 2. KILL SWITCH — `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` restores
14
+ * legacy behavior: the gate reads legacy claudeBusyKeys (gate returns
15
+ * false from `isDeliveryCutoverEnabled`) and `dispatchEffects` is a
16
+ * total no-op, so the imperative twin in gateway.ts is the only
17
+ * executor. Env is read at module load, so the kill-switch tests use
18
+ * vi.resetModules + a fresh dynamic import.
19
+ *
20
+ * Plus the `onDeliverResult` observer added for the flip: the gateway's
21
+ * machine-deliver path branches on the send outcome (delivered → busy-mark
22
+ * + delivery-confirm tracking; miss → release + durable-buffer + restart
23
+ * notice), so the callback contract (fires once per deliverToBridge, with
24
+ * the real ok, and never breaks delivery when the observer throws) is
25
+ * load-bearing.
26
+ */
27
+
28
+ import { describe, expect, it, vi } from 'vitest'
29
+ import { spawnSync } from 'node:child_process'
30
+ import { join } from 'node:path'
31
+ import {
32
+ dispatchEffects,
33
+ isDispatchEnabled,
34
+ } from '../gateway/inbound-delivery-machine-dispatch'
35
+ import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
36
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
37
+ import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
38
+ import {
39
+ initialState,
40
+ transition,
41
+ type ChatKey,
42
+ type State,
43
+ } from '../gateway/inbound-delivery-machine'
44
+ import {
45
+ isDeliveryCutoverEnabled,
46
+ } from '../gateway/inbound-delivery-machine-shadow'
47
+
48
+ const KEY = '111:_' as ChatKey
49
+
50
+ function machineMsg(payload: unknown, isSteering = false) {
51
+ return { msgId: 42, isSteering, payload }
52
+ }
53
+
54
+ function ipcMsg(): Record<string, unknown> {
55
+ return {
56
+ type: 'inbound',
57
+ chatId: '111',
58
+ messageId: 42,
59
+ text: 'hello',
60
+ meta: undefined,
61
+ }
62
+ }
63
+
64
+ function makeCtx(overrides?: Partial<DispatchCtx>): {
65
+ ctx: DispatchCtx
66
+ logs: string[]
67
+ sendToAgent: ReturnType<typeof vi.fn>
68
+ inbound: ReturnType<typeof createPendingInboundBuffer>
69
+ } {
70
+ const logs: string[] = []
71
+ const sendToAgent = vi.fn(() => true)
72
+ const inbound = createPendingInboundBuffer()
73
+ const ctx: DispatchCtx = {
74
+ selfAgent: 'test-agent',
75
+ ipcServer: { sendToAgent } as never,
76
+ pendingInboundBuffer: inbound,
77
+ inboundSpool: null,
78
+ pendingPermissionBuffer: createPendingPermissionBuffer(),
79
+ log: (line: string) => logs.push(line),
80
+ ...overrides,
81
+ }
82
+ return { ctx, logs, sendToAgent, inbound }
83
+ }
84
+
85
+ /** Drive the pure machine to bridge-alive-idle. */
86
+ function aliveIdle(): State {
87
+ return transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
88
+ }
89
+
90
+ describe('cutover default — ON without env override', () => {
91
+ it('dispatcher and gate are enabled by default (no kill-switch in test env)', () => {
92
+ expect(process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER).toBeUndefined()
93
+ expect(isDispatchEnabled()).toBe(true)
94
+ expect(isDeliveryCutoverEnabled()).toBe(true)
95
+ })
96
+
97
+ it('fresh-turn inbound: machine effects execute setTurnStarted + deliverToBridge', () => {
98
+ const state = aliveIdle()
99
+ const msg = ipcMsg()
100
+ const { effects } = transition(state, {
101
+ kind: 'inbound',
102
+ key: KEY,
103
+ msg: machineMsg(msg),
104
+ at: 2000,
105
+ })
106
+ expect(effects.map((e) => e.kind)).toEqual([
107
+ 'setTurnStarted',
108
+ 'deliverToBridge',
109
+ 'logTrace',
110
+ ])
111
+
112
+ const { ctx, sendToAgent } = makeCtx()
113
+ const marked: Array<[ChatKey, number]> = []
114
+ let deliveredOk: boolean | null = null
115
+ dispatchEffects(effects, {
116
+ ...ctx,
117
+ onSetTurnStarted: (k, at) => marked.push([k, at]),
118
+ onDeliverResult: (_k, ok) => {
119
+ deliveredOk = ok
120
+ },
121
+ })
122
+ // Busy-key mirror stamped BEFORE the send (machine effect order).
123
+ expect(marked).toEqual([[KEY, 2000]])
124
+ expect(sendToAgent).toHaveBeenCalledTimes(1)
125
+ expect(sendToAgent).toHaveBeenCalledWith('test-agent', msg)
126
+ expect(deliveredOk).toBe(true)
127
+ })
128
+
129
+ it('mid-turn non-steering inbound: machine effects buffer (no bridge send)', () => {
130
+ // idle → fresh turn for KEY, then a second non-steering inbound mid-turn.
131
+ const s1 = transition(aliveIdle(), {
132
+ kind: 'inbound',
133
+ key: KEY,
134
+ msg: machineMsg(ipcMsg()),
135
+ at: 2000,
136
+ }).state
137
+ const held = ipcMsg()
138
+ const { effects } = transition(s1, {
139
+ kind: 'inbound',
140
+ key: KEY,
141
+ msg: machineMsg(held),
142
+ at: 3000,
143
+ })
144
+ expect(effects.map((e) => e.kind)).toEqual([
145
+ 'bufferInbound',
146
+ 'persistInbound',
147
+ 'logTrace',
148
+ ])
149
+
150
+ const { ctx, sendToAgent, inbound } = makeCtx()
151
+ dispatchEffects(effects, ctx)
152
+ expect(sendToAgent).not.toHaveBeenCalled()
153
+ const drained = inbound.drain('test-agent')
154
+ expect(drained).toHaveLength(1)
155
+ expect(drained[0]).toBe(held)
156
+ })
157
+
158
+ it('steering inbound mid-turn: delivered to bridge, NO setTurnStarted', () => {
159
+ const s1 = transition(aliveIdle(), {
160
+ kind: 'inbound',
161
+ key: KEY,
162
+ msg: machineMsg(ipcMsg()),
163
+ at: 2000,
164
+ }).state
165
+ const steer = ipcMsg()
166
+ const { effects } = transition(s1, {
167
+ kind: 'inbound',
168
+ key: KEY,
169
+ msg: machineMsg(steer, true),
170
+ at: 3000,
171
+ })
172
+ expect(effects.map((e) => e.kind)).toEqual(['deliverToBridge', 'logTrace'])
173
+
174
+ const { ctx, sendToAgent } = makeCtx()
175
+ const marked: unknown[] = []
176
+ dispatchEffects(effects, { ...ctx, onSetTurnStarted: (k) => marked.push(k) })
177
+ expect(sendToAgent).toHaveBeenCalledWith('test-agent', steer)
178
+ expect(marked).toEqual([])
179
+ })
180
+ })
181
+
182
+ describe('onDeliverResult observer contract (the flip’s post-send branch source)', () => {
183
+ it('reports ok=false when sendToAgent returns false (send-miss branch)', () => {
184
+ const state = aliveIdle()
185
+ const { effects } = transition(state, {
186
+ kind: 'inbound',
187
+ key: KEY,
188
+ msg: machineMsg(ipcMsg()),
189
+ at: 2000,
190
+ })
191
+ const { ctx } = makeCtx({ ipcServer: { sendToAgent: vi.fn(() => false) } as never })
192
+ const results: boolean[] = []
193
+ dispatchEffects(effects, { ...ctx, onDeliverResult: (_k, ok) => results.push(ok) })
194
+ expect(results).toEqual([false])
195
+ })
196
+
197
+ it('reports ok=false when the send throws', () => {
198
+ const state = aliveIdle()
199
+ const { effects } = transition(state, {
200
+ kind: 'inbound',
201
+ key: KEY,
202
+ msg: machineMsg(ipcMsg()),
203
+ at: 2000,
204
+ })
205
+ const { ctx } = makeCtx({
206
+ ipcServer: {
207
+ sendToAgent: vi.fn(() => {
208
+ throw new Error('socket gone')
209
+ }),
210
+ } as never,
211
+ })
212
+ const results: boolean[] = []
213
+ dispatchEffects(effects, { ...ctx, onDeliverResult: (_k, ok) => results.push(ok) })
214
+ expect(results).toEqual([false])
215
+ })
216
+
217
+ it('a throwing observer never breaks delivery or enrolment', () => {
218
+ const state = aliveIdle()
219
+ const msg = ipcMsg()
220
+ const { effects } = transition(state, {
221
+ kind: 'inbound',
222
+ key: KEY,
223
+ msg: machineMsg(msg),
224
+ at: 2000,
225
+ })
226
+ const enrolled: unknown[] = []
227
+ const { ctx, sendToAgent } = makeCtx()
228
+ expect(() =>
229
+ dispatchEffects(effects, {
230
+ ...ctx,
231
+ onDeliverResult: () => {
232
+ throw new Error('observer bug')
233
+ },
234
+ onUserInboundDelivered: (m) => enrolled.push(m),
235
+ }),
236
+ ).not.toThrow()
237
+ expect(sendToAgent).toHaveBeenCalledTimes(1)
238
+ // Enrolment still happened after the observer threw.
239
+ expect(enrolled).toEqual([msg])
240
+ })
241
+ })
242
+
243
+ describe('machine deliver path — send-miss busy-key handling (review fix, PR #3012)', () => {
244
+ // Mirrors the gateway's machine-deliver wiring: onSetTurnStarted stamps
245
+ // the busy mirror AND records the key; the miss branch releases ONLY a
246
+ // key THIS dispatch stamped. A steer-miss must keep the original turn's
247
+ // key (the twin never reserves for steers), else the wiped key fires a
248
+ // false machine_over_holds parity drift and the pending-restart gate
249
+ // (claudeBusyKeys.size) could green-light a mid-turn restart.
250
+ function runMissHarness(
251
+ effects: ReturnType<typeof transition>['effects'],
252
+ busyKeys: Set<string>,
253
+ ): { stamped: string | null } {
254
+ let stamped: string | null = null
255
+ let delivered = false
256
+ const { ctx } = makeCtx({ ipcServer: { sendToAgent: vi.fn(() => false) } as never })
257
+ dispatchEffects(effects, {
258
+ ...ctx,
259
+ onSetTurnStarted: (k) => {
260
+ stamped = k
261
+ busyKeys.add(k)
262
+ },
263
+ onDeliverResult: (_k, ok) => {
264
+ delivered = ok
265
+ },
266
+ })
267
+ expect(delivered).toBe(false)
268
+ // Gateway miss branch (guarded release):
269
+ if (stamped != null) busyKeys.delete(stamped)
270
+ return { stamped }
271
+ }
272
+
273
+ it('fresh-turn miss: the key THIS dispatch stamped is released', () => {
274
+ const busyKeys = new Set<string>()
275
+ const { effects } = transition(aliveIdle(), {
276
+ kind: 'inbound',
277
+ key: KEY,
278
+ msg: machineMsg(ipcMsg()),
279
+ at: 2000,
280
+ })
281
+ const { stamped } = runMissHarness(effects, busyKeys)
282
+ expect(stamped).toBe(KEY)
283
+ expect(busyKeys.has(KEY)).toBe(false)
284
+ })
285
+
286
+ it('steer miss: no setTurnStarted fired, the ORIGINAL turn\'s key survives', () => {
287
+ // Original fresh turn holds the busy key.
288
+ const busyKeys = new Set<string>([KEY])
289
+ const s1 = transition(aliveIdle(), {
290
+ kind: 'inbound',
291
+ key: KEY,
292
+ msg: machineMsg(ipcMsg()),
293
+ at: 2000,
294
+ }).state
295
+ const { effects } = transition(s1, {
296
+ kind: 'inbound',
297
+ key: KEY,
298
+ msg: machineMsg(ipcMsg(), true),
299
+ at: 3000,
300
+ })
301
+ expect(effects.some((e) => e.kind === 'setTurnStarted')).toBe(false)
302
+ const { stamped } = runMissHarness(effects, busyKeys)
303
+ expect(stamped).toBeNull()
304
+ // The in-flight turn's key was NOT wiped by the steer's send-miss.
305
+ expect(busyKeys.has(KEY)).toBe(true)
306
+ })
307
+ })
308
+
309
+ describe('carve-out routing anchors (gateway keys off these machine facts)', () => {
310
+ it('PIN: bridge-dead inbound emits the exact `inbound_bridge_dead_buffer` trace stage', () => {
311
+ // gateway.ts's machineBridgeDead carve-out matches this string verbatim
312
+ // to route bridge-dead inbounds to the imperative twin. A rename here
313
+ // silently reroutes them to the machine's buffer+persist (losing the
314
+ // twin's shouldTrackDelivery drop semantics + restart notice) — this
315
+ // pin makes the rename loud. Deleted in PR4 with the twin.
316
+ const dead = initialState() // bridge_dead is the initial global state
317
+ const { effects } = transition(dead, {
318
+ kind: 'inbound',
319
+ key: KEY,
320
+ msg: machineMsg(ipcMsg()),
321
+ at: 2000,
322
+ })
323
+ expect(effects.map((e) => e.kind)).toEqual(['bufferInbound', 'persistInbound', 'logTrace'])
324
+ expect(effects.find((e) => e.kind === 'logTrace')).toMatchObject({
325
+ stage: 'inbound_bridge_dead_buffer',
326
+ })
327
+ })
328
+
329
+ it('interrupt-while-in-turn carve-out: a mid-turn non-steering inbound buffers (gateway must reroute interrupts to the twin)', () => {
330
+ // The machine has no interrupt event — a `!`-interrupt body looks like a
331
+ // plain mid-turn inbound and would BUFFER (stranding it: the SIGINT'd
332
+ // turn may never emit turn_complete). gateway.ts's machineAuthoritative
333
+ // predicate therefore excludes `machineBuffers && isInterrupt` and falls
334
+ // back to the twin's deliver carve-out. This pins the machine fact the
335
+ // predicate rests on.
336
+ const s1 = transition(aliveIdle(), {
337
+ kind: 'inbound',
338
+ key: KEY,
339
+ msg: machineMsg(ipcMsg()),
340
+ at: 2000,
341
+ }).state
342
+ const { effects } = transition(s1, {
343
+ kind: 'inbound',
344
+ key: KEY,
345
+ msg: machineMsg(ipcMsg(), false),
346
+ at: 3000,
347
+ })
348
+ expect(effects.some((e) => e.kind === 'bufferInbound')).toBe(true)
349
+ expect(effects.some((e) => e.kind === 'deliverToBridge')).toBe(false)
350
+ })
351
+ })
352
+
353
+ // The env var is read at MODULE LOAD in the dispatch + shadow modules, and
354
+ // bun's `vi` shim has no resetModules/stubEnv — so the kill-switch/default
355
+ // assertions run the fixture probe in a SUBPROCESS with a controlled env
356
+ // (same pattern as bridge-anonymous-refuse.test.ts).
357
+ describe('kill switch — SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0 restores legacy (subprocess)', () => {
358
+ const PROBE = join(__dirname, 'fixtures', 'cutover-killswitch-probe.ts')
359
+
360
+ function bunBin(): string | null {
361
+ const r = spawnSync('which', ['bun'], { encoding: 'utf-8' })
362
+ const p = r.status === 0 ? r.stdout.trim() : ''
363
+ return p !== '' ? p : null
364
+ }
365
+
366
+ function runProbe(env: Record<string, string | undefined>) {
367
+ const bun = bunBin()
368
+ if (bun == null) return null
369
+ const r = spawnSync(bun, ['run', PROBE], {
370
+ encoding: 'utf-8',
371
+ env: { ...process.env, ...env },
372
+ timeout: 30_000,
373
+ })
374
+ expect(r.status).toBe(0)
375
+ const line = r.stdout.trim().split('\n').pop() ?? ''
376
+ return JSON.parse(line) as {
377
+ dispatchEnabled: boolean
378
+ cutoverEnabled: boolean
379
+ shadowEnabled: boolean
380
+ sendCalls: number
381
+ buffered: number
382
+ setTurnStartedCalls: number
383
+ deliverResults: boolean[]
384
+ }
385
+ }
386
+
387
+ it('default (no env): machine authoritative — dispatcher executes the deliver', () => {
388
+ const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: undefined })
389
+ if (v == null) return // bun not on PATH — covered by the in-process default tests above
390
+ expect(v.dispatchEnabled).toBe(true)
391
+ expect(v.cutoverEnabled).toBe(true)
392
+ expect(v.sendCalls).toBe(1)
393
+ expect(v.setTurnStartedCalls).toBe(1)
394
+ expect(v.deliverResults).toEqual([true])
395
+ expect(v.buffered).toBe(0)
396
+ })
397
+
398
+ it('=0: dispatcher is a total no-op and the gate reverts to legacy; shadow stays on', () => {
399
+ const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: '0' })
400
+ if (v == null) return
401
+ expect(v.dispatchEnabled).toBe(false)
402
+ expect(v.cutoverEnabled).toBe(false)
403
+ // Shadow trace continues for the bake; only authoritative reads flip off.
404
+ expect(v.shadowEnabled).toBe(true)
405
+ expect(v.sendCalls).toBe(0)
406
+ expect(v.buffered).toBe(0)
407
+ expect(v.setTurnStartedCalls).toBe(0)
408
+ expect(v.deliverResults).toEqual([])
409
+ })
410
+
411
+ it('=1 (explicit on): identical to the default', () => {
412
+ const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: '1' })
413
+ if (v == null) return
414
+ expect(v.dispatchEnabled).toBe(true)
415
+ expect(v.cutoverEnabled).toBe(true)
416
+ expect(v.sendCalls).toBe(1)
417
+ })
418
+ })