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
|
@@ -17,7 +17,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
|
17
17
|
import {
|
|
18
18
|
__dispatchOneForTests,
|
|
19
19
|
dispatchEffects,
|
|
20
|
-
isDispatchEnabled,
|
|
21
20
|
} from '../gateway/inbound-delivery-machine-dispatch'
|
|
22
21
|
import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
|
|
23
22
|
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
|
|
@@ -50,12 +49,6 @@ function makeCtx(overrides?: Partial<DispatchCtx>): {
|
|
|
50
49
|
return { ctx, logs, sendToAgent, clientSend, inbound, perm }
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
describe('isDispatchEnabled', () => {
|
|
54
|
-
it('defaults to true when the kill-switch env-var is unset', () => {
|
|
55
|
-
expect(isDispatchEnabled()).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
52
|
describe('dispatchEffects — drainBuffer', () => {
|
|
60
53
|
it('redelivers buffered inbound to the registered client', () => {
|
|
61
54
|
const { ctx, clientSend, inbound } = makeCtx()
|
|
@@ -175,15 +168,6 @@ describe('dispatchEffects — bridgeUp composite (all three effects in order)',
|
|
|
175
168
|
})
|
|
176
169
|
})
|
|
177
170
|
|
|
178
|
-
describe('dispatchEffects — kill switch', () => {
|
|
179
|
-
// The kill switch is checked at module-load time so we can't toggle
|
|
180
|
-
// it inside the test process easily. We validate the contract that
|
|
181
|
-
// the helper exists and respects the env-var — see isDispatchEnabled.
|
|
182
|
-
it('exposes the kill-switch check', () => {
|
|
183
|
-
expect(typeof isDispatchEnabled()).toBe('boolean')
|
|
184
|
-
})
|
|
185
|
-
})
|
|
186
|
-
|
|
187
171
|
describe('dispatchEffects — inbound-routing effects (PR3c wiring)', () => {
|
|
188
172
|
const k = 'c1:_' as never
|
|
189
173
|
const ipcMsg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'telegram' } }
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* — before the permission-reply / `/auth` paste-back / interrupt-empty /
|
|
10
10
|
* secret-detect-drop intercepts — an intercepted message (e.g. an approval
|
|
11
11
|
* reply) drove the machine idle→`bridge_alive_in_turn`, then early-returned
|
|
12
|
-
* as an intercept: no delivery,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
12
|
+
* as an intercept: no delivery, and critically no `turnEnd`. The machine
|
|
13
|
+
* held the gate closed until the 5-min TTL tick force-cleared it, buffering
|
|
14
|
+
* every inbound (including `/usage`) meanwhile — the dangerous over-hold
|
|
15
|
+
* direction the bake-era gate-parity probe flagged.
|
|
16
16
|
*
|
|
17
17
|
* The behaviour lives inside the un-exported `handleInbound` closure, so —
|
|
18
18
|
* mirroring the other gateway-*.test.ts source-pins — we assert on source
|
|
@@ -227,39 +227,94 @@ describe('PendingUserNoticeGate — notice fires IFF the turn dies reply-less',
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// The single-turn norm: the error surfaced while topic A's turn was live, so
|
|
231
|
+
// the notice is keyed to A and topic A's turn end resolves it. `TOPIC_A` is a
|
|
232
|
+
// `statusKey(chatId, threadId)`-shaped value.
|
|
233
|
+
const TOPIC_A = '5000000002'
|
|
234
|
+
const TOPIC_B = '5000000009:42'
|
|
235
|
+
|
|
230
236
|
it('DROPS the notice when the turn recovered (delivered a reply)', () => {
|
|
231
237
|
const gate = new PendingUserNoticeGate()
|
|
232
|
-
gate.schedule(makeNotice())
|
|
233
|
-
expect(gate.resolveTurnEnd(true, 1_001_000)).toEqual([])
|
|
238
|
+
gate.schedule(makeNotice({ key: TOPIC_A }))
|
|
239
|
+
expect(gate.resolveTurnEnd(TOPIC_A, true, 1_001_000)).toEqual([])
|
|
234
240
|
// And it does not resurrect on a later failed turn end.
|
|
235
|
-
expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
|
|
241
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
|
|
236
242
|
})
|
|
237
243
|
|
|
238
244
|
it('SENDS the notice exactly once when the turn ends without a reply', () => {
|
|
239
245
|
const gate = new PendingUserNoticeGate()
|
|
240
|
-
|
|
241
|
-
gate.
|
|
242
|
-
const flushed = gate.resolveTurnEnd(false, 1_001_000)
|
|
246
|
+
gate.schedule(makeNotice({ key: TOPIC_A }))
|
|
247
|
+
const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_001_000)
|
|
243
248
|
expect(flushed).toHaveLength(1)
|
|
244
249
|
expect(flushed[0].chatIds).toEqual(['5000000002', '5000000003'])
|
|
245
250
|
// Exactly once — a second turn end flushes nothing.
|
|
246
|
-
expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
|
|
251
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
|
|
247
252
|
})
|
|
248
253
|
|
|
249
|
-
it('collapses an error burst within one turn to ONE pending notice per
|
|
254
|
+
it('collapses an error burst within one turn to ONE pending notice per topic', () => {
|
|
250
255
|
const gate = new PendingUserNoticeGate()
|
|
251
|
-
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
252
|
-
gate.schedule(makeNotice({ atMs: 1_000_500 }))
|
|
253
|
-
gate.schedule(makeNotice({ atMs: 1_001_000 }))
|
|
254
|
-
expect(gate.resolveTurnEnd(false, 1_002_000)).toHaveLength(1)
|
|
256
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
257
|
+
gate.schedule(makeNotice({ atMs: 1_000_500, key: TOPIC_A }))
|
|
258
|
+
gate.schedule(makeNotice({ atMs: 1_001_000, key: TOPIC_A }))
|
|
259
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toHaveLength(1)
|
|
255
260
|
})
|
|
256
261
|
|
|
257
262
|
it('expires an unresolved notice after the TTL (bias to silence over false failure)', () => {
|
|
258
263
|
const gate = new PendingUserNoticeGate()
|
|
259
|
-
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
264
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
260
265
|
const after = 1_000_000 + PENDING_USER_NOTICE_TTL_MS + 1
|
|
261
266
|
expect(gate.hasPending(after)).toBe(false)
|
|
262
|
-
expect(gate.resolveTurnEnd(false, after)).toEqual([])
|
|
267
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, after)).toEqual([])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ── #3294 — keyed by turn/topic so concurrent per-topic turns don't
|
|
271
|
+
// cross-resolve each other's pending notices. ─────────────────────────────
|
|
272
|
+
it("does NOT flush topic A's notice when a DIFFERENT topic's turn ends reply-less", () => {
|
|
273
|
+
const gate = new PendingUserNoticeGate()
|
|
274
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
275
|
+
// Topic B's turn ends WITHOUT a reply. Pre-#3294 this flushed A's notice
|
|
276
|
+
// (a false "couldn't complete" for a turn that may still recover). Now B's
|
|
277
|
+
// end resolves nothing keyed to A.
|
|
278
|
+
expect(gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)).toEqual([])
|
|
279
|
+
// A's notice is still pending, and A's OWN reply-less end flushes it.
|
|
280
|
+
expect(gate.hasPending(1_000_600)).toBe(true)
|
|
281
|
+
const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)
|
|
282
|
+
expect(flushed).toHaveLength(1)
|
|
283
|
+
expect(flushed[0].key).toBe(TOPIC_A)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("does NOT drop topic A's notice when a DIFFERENT topic's turn recovers", () => {
|
|
287
|
+
const gate = new PendingUserNoticeGate()
|
|
288
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
289
|
+
// Topic B's turn recovered (delivered a reply). It must not drop A's notice.
|
|
290
|
+
expect(gate.resolveTurnEnd(TOPIC_B, true, 1_000_500)).toEqual([])
|
|
291
|
+
// A's notice survives and still flushes on A's reply-less end.
|
|
292
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)).toHaveLength(1)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('keeps a per-topic pending notice for each concurrent topic (no cross-collapse)', () => {
|
|
296
|
+
const gate = new PendingUserNoticeGate()
|
|
297
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A, chatIds: ['5000000002'] }))
|
|
298
|
+
gate.schedule(makeNotice({ atMs: 1_000_100, key: TOPIC_B, chatIds: ['5000000009'] }))
|
|
299
|
+
// A's end flushes only A.
|
|
300
|
+
const a = gate.resolveTurnEnd(TOPIC_A, false, 1_000_200)
|
|
301
|
+
expect(a).toHaveLength(1)
|
|
302
|
+
expect(a[0].chatIds).toEqual(['5000000002'])
|
|
303
|
+
// B is still pending and flushes on B's end.
|
|
304
|
+
const b = gate.resolveTurnEnd(TOPIC_B, false, 1_000_300)
|
|
305
|
+
expect(b).toHaveLength(1)
|
|
306
|
+
expect(b[0].chatIds).toEqual(['5000000009'])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('an UNATTRIBUTED (undefined-key) notice resolves on any turn end (legacy single-turn behavior)', () => {
|
|
310
|
+
const gate = new PendingUserNoticeGate()
|
|
311
|
+
// No key — the error arrived with no live turn (between turns / post-poke).
|
|
312
|
+
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
313
|
+
// Any topic's reply-less end flushes it (preserves pre-#3294 behavior so a
|
|
314
|
+
// genuine death on a single-turn gateway still notifies the user).
|
|
315
|
+
const flushed = gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)
|
|
316
|
+
expect(flushed).toHaveLength(1)
|
|
317
|
+
expect(flushed[0].key).toBeUndefined()
|
|
263
318
|
})
|
|
264
319
|
})
|
|
265
320
|
|
|
@@ -12,6 +12,13 @@ import { describe, it, expect } from 'vitest'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { resolve } from 'node:path'
|
|
14
14
|
|
|
15
|
+
// #2996 P2: executeReply's body moved VERBATIM to outbound-send-path.ts
|
|
16
|
+
// (`sendReply`); the reply-path routing assertions read the window there.
|
|
17
|
+
const sendPathSrc = readFileSync(
|
|
18
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
19
|
+
'utf-8',
|
|
20
|
+
)
|
|
21
|
+
const sendReplyFn = sendPathSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
|
|
15
22
|
const gatewaySrc = readFileSync(
|
|
16
23
|
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
17
24
|
'utf-8',
|
|
@@ -54,7 +61,7 @@ describe('component 3 — turn-origin reply routing', () => {
|
|
|
54
61
|
})
|
|
55
62
|
|
|
56
63
|
it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
|
|
57
|
-
const fn =
|
|
64
|
+
const fn = sendReplyFn
|
|
58
65
|
expect(fn).toMatch(/TURN_ORIGIN_ROUTING_ENABLED/)
|
|
59
66
|
expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
|
|
60
67
|
// The resolution + reply-route telemetry go through resolveAnswerThreadWithLog,
|
|
@@ -85,8 +92,7 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
|
|
|
85
92
|
})
|
|
86
93
|
|
|
87
94
|
it('the reply path recovers origin from the quoted message_id when the model omits the echo', () => {
|
|
88
|
-
for (const
|
|
89
|
-
const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
|
|
95
|
+
for (const fn of [sendReplyFn]) {
|
|
90
96
|
// Echo first (authoritative), quoted message_id as the framework fallback.
|
|
91
97
|
expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
|
|
92
98
|
// Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
|
|
@@ -620,3 +620,43 @@ describe("ObligationLedger — escalation suppression predicate (Fix 4)", () =>
|
|
|
620
620
|
expect(snapshots[1]).toEqual([]);
|
|
621
621
|
});
|
|
622
622
|
});
|
|
623
|
+
|
|
624
|
+
// #3282 — the durable captured-answer snapshot attaches to the OPEN obligation
|
|
625
|
+
// so a partial backstop delivery is resumed (tail only) rather than regenerated.
|
|
626
|
+
describe("ObligationLedger — noteCapturedDelivery (#3282)", () => {
|
|
627
|
+
function input(id: string, openedAt: number) {
|
|
628
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: 1, text: "q", openedAt };
|
|
629
|
+
}
|
|
630
|
+
const snap = { chunks: ["c0", "c1"], chunkStates: [{ index: 0, messageIds: [10], confirmed: true }] };
|
|
631
|
+
|
|
632
|
+
it("attaches the snapshot to an OPEN obligation and persists it", () => {
|
|
633
|
+
const snapshots: Obligation[][] = [];
|
|
634
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
635
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
636
|
+
L.noteCapturedDelivery("c:3#1", snap);
|
|
637
|
+
expect(L.list()[0]!.capturedDelivery).toEqual(snap);
|
|
638
|
+
// The mutation persisted (onChange fired again after open).
|
|
639
|
+
expect(snapshots.at(-1)![0]!.capturedDelivery).toEqual(snap);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("is a no-op for an obligation that is not open (a delivered turn has none to resume)", () => {
|
|
643
|
+
const L = new ObligationLedger(2);
|
|
644
|
+
L.noteCapturedDelivery("c:3#missing", snap); // never opened
|
|
645
|
+
expect(L.isOpen("c:3#missing")).toBe(false);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("ignores an EMPTY snapshot (nothing to resume)", () => {
|
|
649
|
+
const L = new ObligationLedger(2);
|
|
650
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
651
|
+
L.noteCapturedDelivery("c:3#1", { chunks: [], chunkStates: [] });
|
|
652
|
+
expect(L.list()[0]!.capturedDelivery).toBeUndefined();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("close drops the snapshot with the obligation (bounded lifetime, A6)", () => {
|
|
656
|
+
const L = new ObligationLedger(2);
|
|
657
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
658
|
+
L.noteCapturedDelivery("c:3#1", snap);
|
|
659
|
+
expect(L.close("c:3#1")).toBe(true);
|
|
660
|
+
expect(L.isOpen("c:3#1")).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
@@ -118,6 +118,49 @@ describe("obligation-store", () => {
|
|
|
118
118
|
expect(loaded[0]!.representCount).toBe(1);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it("round-trips a valid capturedDelivery snapshot through persist → load (#3282)", () => {
|
|
122
|
+
const { fs } = memFs();
|
|
123
|
+
const capturedDelivery = {
|
|
124
|
+
chunks: ["c0", "c1"],
|
|
125
|
+
chunkStates: [
|
|
126
|
+
{ index: 0, messageIds: [10], confirmed: true },
|
|
127
|
+
{ index: 1, messageIds: [11, 12], confirmed: false },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
persistObligations(PATH, fs, [ob("c:3#715", { capturedDelivery })]);
|
|
131
|
+
const loaded = loadObligations(PATH, fs);
|
|
132
|
+
expect(loaded).toHaveLength(1);
|
|
133
|
+
expect(loaded[0]!.capturedDelivery).toEqual(capturedDelivery);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("STRIPS a malformed capturedDelivery (fail-open to fresh-generation represent) (#3282)", () => {
|
|
137
|
+
// A partial write / forward-incompatible shape must not crash the resume: the
|
|
138
|
+
// row survives (obligation preserved), the bad blob is dropped.
|
|
139
|
+
const bad = {
|
|
140
|
+
v: 1,
|
|
141
|
+
obligations: [
|
|
142
|
+
{ ...ob("c:3#715"), capturedDelivery: { chunks: "not-an-array", chunkStates: [] } },
|
|
143
|
+
{ ...ob("c:3#716"), capturedDelivery: { chunks: ["c0"], chunkStates: [{ index: 0 }] } },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
|
|
147
|
+
const loaded = loadObligations(PATH, fs);
|
|
148
|
+
expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:3#716"]);
|
|
149
|
+
expect(loaded[0]!.capturedDelivery).toBeUndefined();
|
|
150
|
+
expect(loaded[1]!.capturedDelivery).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("STRIPS an empty-chunks capturedDelivery (nothing to resume → fresh-gen, bounded) (#3282)", () => {
|
|
154
|
+
const bad = {
|
|
155
|
+
v: 1,
|
|
156
|
+
obligations: [{ ...ob("c:3#715"), capturedDelivery: { chunks: [], chunkStates: [] } }],
|
|
157
|
+
};
|
|
158
|
+
const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
|
|
159
|
+
const loaded = loadObligations(PATH, fs);
|
|
160
|
+
expect(loaded).toHaveLength(1);
|
|
161
|
+
expect(loaded[0]!.capturedDelivery).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
121
164
|
it("never throws on a write failure — degrades to in-memory (logs)", () => {
|
|
122
165
|
const logs: string[] = [];
|
|
123
166
|
const fs: ObligationStoreFsSeam = {
|
|
@@ -143,7 +143,8 @@ describe('resolution clears the durable store (Defect A)', () => {
|
|
|
143
143
|
|
|
144
144
|
describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
145
145
|
it('the periodic reaper runs the approval-card expiry sweep inside try/catch', () => {
|
|
146
|
-
|
|
146
|
+
// Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
|
|
147
|
+
const reaper = slice(GATEWAY, 'const pendingStateReaper = ', 12000)
|
|
147
148
|
// The sweep call must be guarded like the sibling sweepStaleTurnActiveMarker:
|
|
148
149
|
// an escaped throw inside this setInterval callback reaches uncaughtException
|
|
149
150
|
// and takes the whole gateway down.
|
|
@@ -101,14 +101,17 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
|
|
|
101
101
|
expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
it('turnInFlightMachineOnly
|
|
104
|
+
it('turnInFlightMachineOnly reads the delivery machine (sole gate, #2996 P1)', () => {
|
|
105
105
|
// #3262: the machine-in-turn signal moved here (read by the /model & /effort
|
|
106
|
-
// resolver without the approval hold).
|
|
107
|
-
// and the
|
|
108
|
-
//
|
|
106
|
+
// resolver without the approval hold). #2996 P1 deleted the legacy
|
|
107
|
+
// claudeBusyKeys branch and the gate-parity probe — the machine read is
|
|
108
|
+
// the ONLY busy source now; a reintroduced legacy branch would silently
|
|
109
|
+
// fork the busy read again.
|
|
109
110
|
const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
|
|
110
|
-
expect(fn).
|
|
111
|
-
|
|
111
|
+
expect(fn).toContain('return isMachineInTurn()')
|
|
112
|
+
// Code-shape pins (comments may still narrate the deleted legacy design):
|
|
113
|
+
expect(fn).not.toMatch(/claudeBusyKeys\.size/)
|
|
114
|
+
expect(fn).not.toMatch(/probeGateParity\(/)
|
|
112
115
|
// NOT gated by the approval hold — that stays in turnInFlightForGate.
|
|
113
116
|
expect(fn).not.toContain('hasPendingApproval')
|
|
114
117
|
})
|
|
@@ -21,12 +21,15 @@ import { describe, it, expect } from 'vitest'
|
|
|
21
21
|
import { readFileSync } from 'node:fs'
|
|
22
22
|
import { resolve } from 'node:path'
|
|
23
23
|
|
|
24
|
+
// #2996 P2: executeReply's body (incl. the file-send + result + history
|
|
25
|
+
// block this suite pins) moved VERBATIM to outbound-send-path.ts
|
|
26
|
+
// (`sendReply`); the slice reads there now. Assertions unchanged.
|
|
24
27
|
const gatewaySrc = readFileSync(
|
|
25
|
-
resolve(__dirname, '..', 'gateway', '
|
|
28
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
26
29
|
'utf-8',
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
// Slice
|
|
32
|
+
// Slice sendReply's file-send + result + history block: from the
|
|
30
33
|
// photo-precheck anchor to the recordOutbound call for replies.
|
|
31
34
|
const sliceStart = gatewaySrc.indexOf('const photoPrecheck = new Map')
|
|
32
35
|
const sliceEnd = gatewaySrc.indexOf('history recordOutbound (reply) failed')
|
|
@@ -43,8 +43,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
|
43
43
|
const fs = require('node:fs') as typeof import('node:fs')
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
45
|
const path = require('node:path') as typeof import('node:path')
|
|
46
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
47
|
+
// (`sendReply`); the post-send block anchors live there now.
|
|
46
48
|
const src = fs.readFileSync(
|
|
47
|
-
path.resolve(__dirname, '../gateway/
|
|
49
|
+
path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
|
|
48
50
|
'utf8',
|
|
49
51
|
)
|
|
50
52
|
// Find the executeReply post-send block — anchored on the
|
|
@@ -75,8 +77,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
|
75
77
|
const fs = require('node:fs') as typeof import('node:fs')
|
|
76
78
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
79
|
const path = require('node:path') as typeof import('node:path')
|
|
80
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
81
|
+
// (`sendReply`); the post-send block anchors live there now.
|
|
78
82
|
const src = fs.readFileSync(
|
|
79
|
-
path.resolve(__dirname, '../gateway/
|
|
83
|
+
path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
|
|
80
84
|
'utf8',
|
|
81
85
|
)
|
|
82
86
|
const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
|
|
@@ -145,8 +145,9 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
|
|
|
145
145
|
})
|
|
146
146
|
|
|
147
147
|
it('reaps awaitingAuthCodeAt in the TTL reaper alongside other pending-state maps', () => {
|
|
148
|
-
// The pendingStateReaper interval must sweep expired auth-code-context entries
|
|
149
|
-
|
|
148
|
+
// The pendingStateReaper interval must sweep expired auth-code-context entries.
|
|
149
|
+
// Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
|
|
150
|
+
const reaperIdx = src.indexOf('pendingStateReaper = ')
|
|
150
151
|
expect(reaperIdx).toBeGreaterThan(0)
|
|
151
152
|
const reaperBlock = src.slice(reaperIdx, reaperIdx + 800)
|
|
152
153
|
expect(reaperBlock).toMatch(/awaitingAuthCodeAt/)
|