switchroom 0.18.31 → 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.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +4 -2
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +693 -253
- package/dist/host-control/main.js +5 -3
- package/dist/vault/approvals/kernel-server.js +4 -2
- package/dist/vault/broker/server.js +4 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +43 -5
- package/telegram-plugin/dist/gateway/gateway.js +25868 -25038
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +7195 -8837
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/subagent-watcher.ts +111 -28
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +27 -3
- package/telegram-plugin/worker-activity-feed.ts +24 -0
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -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 =
|
|
43
|
-
const redactIdx =
|
|
44
|
-
const previewIdx =
|
|
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
|
|
@@ -1,37 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Machine-authoritative inbound routing (#2794 cutover; kill switch
|
|
3
|
+
* removed in #2996 P1).
|
|
3
4
|
*
|
|
4
|
-
* `handleInbound`
|
|
5
|
+
* `handleInbound` dispatches the machine's captured `inbound` effects
|
|
5
6
|
* through `dispatchEffects` as the AUTHORITATIVE deliver-vs-buffer routing.
|
|
6
|
-
* These tests pin the
|
|
7
|
+
* These tests pin the delivery outcomes that routing rests on: fresh turn →
|
|
8
|
+
* setTurnStarted + deliverToBridge (real send); mid-turn → buffer (no send,
|
|
9
|
+
* drainable); steer mid-turn → deliverToBridge without setTurnStarted.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Plus the `onDeliverResult` observer: the gateway's machine-deliver path
|
|
12
|
+
* branches on the send outcome (delivered → steer-ack + delivery-confirm
|
|
13
|
+
* tracking; miss → durable-buffer + restart notice), so the callback
|
|
14
|
+
* contract (fires once per deliverToBridge, with the real ok, and never
|
|
15
|
+
* breaks delivery when the observer throws) is load-bearing.
|
|
12
16
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
17
|
+
* The `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` kill-switch subprocess suite
|
|
18
|
+
* and its fixture probe were deleted in #2996 P1 with the switch itself.
|
|
26
19
|
*/
|
|
27
20
|
|
|
28
21
|
import { describe, expect, it, vi } from 'vitest'
|
|
29
|
-
import {
|
|
30
|
-
import { join } from 'node:path'
|
|
31
|
-
import {
|
|
32
|
-
dispatchEffects,
|
|
33
|
-
isDispatchEnabled,
|
|
34
|
-
} from '../gateway/inbound-delivery-machine-dispatch'
|
|
22
|
+
import { dispatchEffects } from '../gateway/inbound-delivery-machine-dispatch'
|
|
35
23
|
import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
|
|
36
24
|
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
|
|
37
25
|
import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
|
|
@@ -41,9 +29,6 @@ import {
|
|
|
41
29
|
type ChatKey,
|
|
42
30
|
type State,
|
|
43
31
|
} from '../gateway/inbound-delivery-machine'
|
|
44
|
-
import {
|
|
45
|
-
isDeliveryCutoverEnabled,
|
|
46
|
-
} from '../gateway/inbound-delivery-machine-shadow'
|
|
47
32
|
|
|
48
33
|
const KEY = '111:_' as ChatKey
|
|
49
34
|
|
|
@@ -87,13 +72,7 @@ function aliveIdle(): State {
|
|
|
87
72
|
return transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
|
|
88
73
|
}
|
|
89
74
|
|
|
90
|
-
describe('
|
|
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
|
-
|
|
75
|
+
describe('machine-authoritative routing — delivery outcomes', () => {
|
|
97
76
|
it('fresh-turn inbound: machine effects execute setTurnStarted + deliverToBridge', () => {
|
|
98
77
|
const state = aliveIdle()
|
|
99
78
|
const msg = ipcMsg()
|
|
@@ -240,79 +219,71 @@ describe('onDeliverResult observer contract (the flip’s post-send branch sourc
|
|
|
240
219
|
})
|
|
241
220
|
})
|
|
242
221
|
|
|
243
|
-
describe('machine deliver path —
|
|
244
|
-
//
|
|
245
|
-
// the
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
222
|
+
describe('machine deliver path — steer vs fresh-turn setTurnStarted contract', () => {
|
|
223
|
+
// The gateway's machine-deliver wiring keys "was this a fresh turn?" off
|
|
224
|
+
// the setTurnStarted effect: a mid-turn steer emits deliverToBridge
|
|
225
|
+
// WITHOUT setTurnStarted (it amends the running turn, it does not open
|
|
226
|
+
// one). A machine change that started emitting setTurnStarted for steers
|
|
227
|
+
// would mis-classify steers as fresh turns at the live call site.
|
|
228
|
+
it('steer mid-turn emits NO setTurnStarted even on a send-miss', () => {
|
|
229
|
+
const s1 = transition(aliveIdle(), {
|
|
230
|
+
kind: 'inbound',
|
|
231
|
+
key: KEY,
|
|
232
|
+
msg: machineMsg(ipcMsg()),
|
|
233
|
+
at: 2000,
|
|
234
|
+
}).state
|
|
235
|
+
const { effects } = transition(s1, {
|
|
236
|
+
kind: 'inbound',
|
|
237
|
+
key: KEY,
|
|
238
|
+
msg: machineMsg(ipcMsg(), true),
|
|
239
|
+
at: 3000,
|
|
240
|
+
})
|
|
241
|
+
expect(effects.some((e) => e.kind === 'setTurnStarted')).toBe(false)
|
|
254
242
|
let stamped: string | null = null
|
|
255
|
-
let delivered =
|
|
243
|
+
let delivered = true
|
|
256
244
|
const { ctx } = makeCtx({ ipcServer: { sendToAgent: vi.fn(() => false) } as never })
|
|
257
245
|
dispatchEffects(effects, {
|
|
258
246
|
...ctx,
|
|
259
247
|
onSetTurnStarted: (k) => {
|
|
260
248
|
stamped = k
|
|
261
|
-
busyKeys.add(k)
|
|
262
249
|
},
|
|
263
250
|
onDeliverResult: (_k, ok) => {
|
|
264
251
|
delivered = ok
|
|
265
252
|
},
|
|
266
253
|
})
|
|
254
|
+
expect(stamped).toBeNull()
|
|
267
255
|
expect(delivered).toBe(false)
|
|
268
|
-
|
|
269
|
-
if (stamped != null) busyKeys.delete(stamped)
|
|
270
|
-
return { stamped }
|
|
271
|
-
}
|
|
256
|
+
})
|
|
272
257
|
|
|
273
|
-
it('fresh
|
|
274
|
-
const busyKeys = new Set<string>()
|
|
258
|
+
it('fresh turn fires setTurnStarted exactly once, before the send', () => {
|
|
275
259
|
const { effects } = transition(aliveIdle(), {
|
|
276
260
|
kind: 'inbound',
|
|
277
261
|
key: KEY,
|
|
278
262
|
msg: machineMsg(ipcMsg()),
|
|
279
263
|
at: 2000,
|
|
280
264
|
})
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
265
|
+
const order: string[] = []
|
|
266
|
+
const sendToAgent = vi.fn(() => {
|
|
267
|
+
order.push('send')
|
|
268
|
+
return true
|
|
300
269
|
})
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
270
|
+
const { ctx } = makeCtx({ ipcServer: { sendToAgent } as never })
|
|
271
|
+
dispatchEffects(effects, {
|
|
272
|
+
...ctx,
|
|
273
|
+
onSetTurnStarted: () => order.push('setTurnStarted'),
|
|
274
|
+
})
|
|
275
|
+
expect(order).toEqual(['setTurnStarted', 'send'])
|
|
306
276
|
})
|
|
307
277
|
})
|
|
308
278
|
|
|
309
279
|
describe('carve-out routing anchors (gateway keys off these machine facts)', () => {
|
|
310
280
|
it('PIN: bridge-dead inbound emits the exact `inbound_bridge_dead_buffer` trace stage', () => {
|
|
311
281
|
// gateway.ts's machineBridgeDead carve-out matches this string verbatim
|
|
312
|
-
// to route bridge-dead inbounds to the imperative
|
|
313
|
-
// silently reroutes them to the machine's buffer+persist
|
|
314
|
-
//
|
|
315
|
-
// pin makes the rename loud. Deleted
|
|
282
|
+
// to route bridge-dead inbounds to the imperative delivery body. A
|
|
283
|
+
// rename here silently reroutes them to the machine's buffer+persist
|
|
284
|
+
// (losing the shouldTrackDelivery drop semantics + restart notice) —
|
|
285
|
+
// this pin makes the rename loud. Deleted with the carve-out when the
|
|
286
|
+
// machine grows bridge_dead send semantics (#2794 PR4 remainder).
|
|
316
287
|
const dead = initialState() // bridge_dead is the initial global state
|
|
317
288
|
const { effects } = transition(dead, {
|
|
318
289
|
kind: 'inbound',
|
|
@@ -349,70 +320,3 @@ describe('carve-out routing anchors (gateway keys off these machine facts)', ()
|
|
|
349
320
|
expect(effects.some((e) => e.kind === 'deliverToBridge')).toBe(false)
|
|
350
321
|
})
|
|
351
322
|
})
|
|
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
|
-
})
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The turn-in-flight GATE reads the delivery state machine
|
|
3
|
+
* (`isMachineInTurn`) — since #2996 P1 the machine is the SOLE authority
|
|
4
|
+
* (the legacy `claudeBusyKeys` set was deleted).
|
|
4
5
|
*
|
|
5
|
-
* The bug
|
|
6
|
-
* per-delivery
|
|
7
|
-
* exactly one. When a turn-end
|
|
8
|
-
* key) the set
|
|
9
|
-
* subsequent inbound
|
|
10
|
-
* framework-fallback force-
|
|
6
|
+
* The bug the machine gate closes (gymbro/clerk, 2026-05-28): the legacy
|
|
7
|
+
* set was per-delivery — every delivery `.add`ed a key, but turn-end
|
|
8
|
+
* `.delete`d exactly one. When a turn-end was missed (or fired under a
|
|
9
|
+
* non-matching key) the set kept an orphan, `size > 0` read true forever,
|
|
10
|
+
* and EVERY subsequent inbound buffered as "held mid-turn" until the 5-min
|
|
11
|
+
* framework-fallback force-drained it.
|
|
11
12
|
*
|
|
12
13
|
* The machine cannot accumulate orphans: global state holds ONE
|
|
13
14
|
* `activeTurn`, so any matching turnEnd returns it to idle, and the TTL
|
|
@@ -19,7 +20,6 @@ import { describe, expect, it, beforeEach } from 'vitest'
|
|
|
19
20
|
import {
|
|
20
21
|
shadowEmit,
|
|
21
22
|
isMachineInTurn,
|
|
22
|
-
isDeliveryCutoverEnabled,
|
|
23
23
|
__shadowResetForTests,
|
|
24
24
|
} from '../gateway/inbound-delivery-machine-shadow.js'
|
|
25
25
|
import { TURN_TTL_MS, type ChatKey } from '../gateway/inbound-delivery-machine.js'
|
|
@@ -31,13 +31,9 @@ function inbound(key: ChatKey, at: number, msgId = 1) {
|
|
|
31
31
|
shadowEmit({ kind: 'inbound', key, msg: { msgId, isSteering: false, payload: null }, at })
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
describe('
|
|
34
|
+
describe('machine turn-in-flight gate accessors (sole authority, #2996 P1)', () => {
|
|
35
35
|
beforeEach(() => __shadowResetForTests())
|
|
36
36
|
|
|
37
|
-
it('enabled by default (shadow on, no kill-switch in test env)', () => {
|
|
38
|
-
expect(isDeliveryCutoverEnabled()).toBe(true)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
37
|
it('reads idle before any turn (bridge alive)', () => {
|
|
42
38
|
shadowEmit({ kind: 'bridgeUp', at: 1000 })
|
|
43
39
|
expect(isMachineInTurn()).toBe(false)
|
|
@@ -18,13 +18,12 @@
|
|
|
18
18
|
* 3. Invariant #1 at the dispatch layer: an inbound is delivered XOR
|
|
19
19
|
* (buffered AND persisted) — never both, never neither.
|
|
20
20
|
*
|
|
21
|
-
* It does NOT invoke the imperative
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* protocol — do NOT cite this file as that proof.
|
|
21
|
+
* It does NOT invoke the remaining imperative delivery body (the twin
|
|
22
|
+
* carve-outs for `!`-interrupt / bridge_dead, `dispatchPermissionVerdict`,
|
|
23
|
+
* the silence-poke ladder), so it is NOT a proof that machine path and
|
|
24
|
+
* imperative path produce identical I/O. (#2996 P1 deleted the legacy
|
|
25
|
+
* gate/claudeBusyKeys twins after the cutover bake; the carve-out body's
|
|
26
|
+
* eventual deletion — the #2794 PR4 remainder — needs its own evidence.)
|
|
28
27
|
*
|
|
29
28
|
* What it DOES gate: if a future edit adds an effect kind the
|
|
30
29
|
* dispatcher doesn't execute, the "no unhandled effect" assertion fails
|