switchroom 0.18.30 → 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.
Files changed (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,144 @@
1
+ /**
2
+ * #3278 — read-back confirmation probe primitive (outbound-send-path.ts).
3
+ *
4
+ * `classifyReadBackError` maps a raw grammy `editMessageText` FAILURE into the
5
+ * confirmation tri-state, and `createBackstopReadBackProbe` combines a chunk's
6
+ * per-id probes into the chunk verdict `runBackstopDelivery` consumes. These
7
+ * assert OUTCOMES: which error shape means present vs absent vs ambiguous, and
8
+ * that a length-resplit chunk (>1 id) is confirmed only if EVERY id is present.
9
+ */
10
+ import { describe, test, expect, vi } from 'vitest'
11
+ import { GrammyError } from 'grammy'
12
+ import {
13
+ classifyReadBackError,
14
+ createBackstopReadBackProbe,
15
+ createBackstopReadBack,
16
+ } from '../gateway/outbound-send-path.js'
17
+ import type { ReadBackResult } from '../gateway/backstop-delivery.js'
18
+
19
+ // GrammyError's constructor takes (message, payload, method, parameters).
20
+ function grammy(code: number, description: string): GrammyError {
21
+ return new GrammyError(
22
+ `Call to 'editMessageText' failed! (${code}: ${description})`,
23
+ { ok: false, error_code: code, description },
24
+ 'editMessageText',
25
+ {},
26
+ )
27
+ }
28
+
29
+ describe('#3278 classifyReadBackError — raw edit-failure → confirmation tri-state', () => {
30
+ test('400 "message to edit not found" ⇒ absent (positive drop → safe re-send)', () => {
31
+ expect(classifyReadBackError(grammy(400, 'Bad Request: message to edit not found'))).toBe('absent')
32
+ })
33
+
34
+ test('400 "message is not modified" ⇒ exists (a no-op edit → message present)', () => {
35
+ expect(classifyReadBackError(grammy(400, 'Bad Request: message is not modified'))).toBe('exists')
36
+ })
37
+
38
+ test('429 flood ⇒ ambiguous (never re-send)', () => {
39
+ expect(classifyReadBackError(grammy(429, 'Too Many Requests: retry after 30'))).toBe('ambiguous')
40
+ })
41
+
42
+ test('5xx / non-grammy network error ⇒ ambiguous', () => {
43
+ expect(classifyReadBackError(grammy(500, 'Internal Server Error'))).toBe('ambiguous')
44
+ expect(classifyReadBackError(new Error('fetch failed: ECONNRESET'))).toBe('ambiguous')
45
+ })
46
+
47
+ test('an unrelated 400 (e.g. thread not found) is ambiguous, NOT absent', () => {
48
+ // A wrong-classification here would demote-and-re-send a message that may
49
+ // well exist — the never-demote-on-ambiguous rule guards against duplicates.
50
+ expect(classifyReadBackError(grammy(400, 'Bad Request: message thread not found'))).toBe('ambiguous')
51
+ })
52
+ })
53
+
54
+ describe('#3278 createBackstopReadBackProbe — per-chunk id combine', () => {
55
+ test('single id present ⇒ exists; probes the id with the SAME rich body', async () => {
56
+ const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
57
+ const richMessage = vi.fn((s: string) => ({ rich: s }))
58
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage })
59
+ const verdict = await readBack(0, [111], 'answer')
60
+ expect(verdict).toBe('exists')
61
+ expect(richMessage).toHaveBeenCalledWith('answer')
62
+ expect(probeId).toHaveBeenCalledTimes(1)
63
+ expect(probeId).toHaveBeenCalledWith(111, { rich: 'answer' })
64
+ })
65
+
66
+ test('resplit chunk (2 ids): one absent ⇒ absent (whole chunk re-sent)', async () => {
67
+ const probeId = vi.fn(async (id: number): Promise<ReadBackResult> => (id === 12 ? 'absent' : 'exists'))
68
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
69
+ expect(await readBack(1, [11, 12], 'B')).toBe('absent')
70
+ expect(probeId).toHaveBeenCalledTimes(2) // every id of the chunk is probed
71
+ })
72
+
73
+ test('resplit chunk (2 ids): all present ⇒ exists', async () => {
74
+ const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
75
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
76
+ expect(await readBack(0, [1, 2], 'A')).toBe('exists')
77
+ })
78
+
79
+ test('empty id set ⇒ ambiguous and NEVER probes (no confirm, no demote)', async () => {
80
+ const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
81
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
82
+ expect(await readBack(0, [], 'A')).toBe('ambiguous')
83
+ expect(probeId).not.toHaveBeenCalled()
84
+ })
85
+ })
86
+
87
+ const SHED = Symbol.for('switchroom.send-gate.shed')
88
+
89
+ describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () => {
90
+ function wiring(overrides: Partial<Parameters<typeof createBackstopReadBack>[0]> = {}) {
91
+ return {
92
+ editMessageText: vi.fn(async () => true), // edit applied → exists
93
+ gate: vi.fn(async (fn: () => Promise<unknown>) => fn()), // passthrough gate
94
+ isShed: (r: unknown) => r === SHED,
95
+ richMessage: (s: string) => ({ rich: s }),
96
+ chatId: '1001',
97
+ threadId: undefined as number | undefined,
98
+ ...overrides,
99
+ }
100
+ }
101
+
102
+ test('edit RESOLVES ⇒ exists; edits to the identical rich body (no visible mutation)', async () => {
103
+ const w = wiring()
104
+ const readBack = createBackstopReadBack(w)
105
+ expect(await readBack(0, [55], 'answer')).toBe('exists')
106
+ // Probed the landed id with the SAME rich body the chunk was sent with.
107
+ expect(w.editMessageText).toHaveBeenCalledWith(55, { rich: 'answer' }, expect.anything())
108
+ // Routed through the send gate (pacing) rather than a raw call.
109
+ expect(w.gate).toHaveBeenCalledTimes(1)
110
+ })
111
+
112
+ test('400 message-not-found ⇒ absent (safe re-send)', async () => {
113
+ const editMessageText = vi.fn(async () => {
114
+ throw new GrammyError('boom', { ok: false, error_code: 400, description: 'Bad Request: message to edit not found' }, 'editMessageText', {})
115
+ })
116
+ const readBack = createBackstopReadBack(wiring({ editMessageText }))
117
+ expect(await readBack(0, [55], 'answer')).toBe('absent')
118
+ })
119
+
120
+ test('400 not-modified ⇒ exists (a no-op edit on a present message)', async () => {
121
+ const editMessageText = vi.fn(async () => {
122
+ throw new GrammyError('boom', { ok: false, error_code: 400, description: 'Bad Request: message is not modified' }, 'editMessageText', {})
123
+ })
124
+ const readBack = createBackstopReadBack(wiring({ editMessageText }))
125
+ expect(await readBack(0, [55], 'answer')).toBe('exists')
126
+ })
127
+
128
+ test('429 flood ⇒ ambiguous (never re-send)', async () => {
129
+ const editMessageText = vi.fn(async () => {
130
+ throw new GrammyError('boom', { ok: false, error_code: 429, description: 'Too Many Requests' }, 'editMessageText', {})
131
+ })
132
+ const readBack = createBackstopReadBack(wiring({ editMessageText }))
133
+ expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
134
+ })
135
+
136
+ test('gate SHED (SEND_GATE_SHED sentinel) ⇒ ambiguous, NOT a false exists', async () => {
137
+ // Under a flood window the cosmetic probe sheds — the gate resolves the
138
+ // sentinel, not the edit. Must be ambiguous so the chunk is never demoted.
139
+ const gate = vi.fn(async () => SHED)
140
+ const editMessageText = vi.fn(async () => true)
141
+ const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
142
+ expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
143
+ })
144
+ })
@@ -36,6 +36,13 @@ const gatewaySrc = readFileSync(
36
36
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
37
37
  'utf-8',
38
38
  )
39
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
40
+ // (`sendReply`); the post-send block assertions read there. The
41
+ // `releaseTurnBufferGate` helper itself stays in gateway.ts.
42
+ const sendPathSrc = readFileSync(
43
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
44
+ 'utf-8',
45
+ )
39
46
 
40
47
  describe('buffer-gate release decoupled from final-answer classification', () => {
41
48
  // Extract the helper's docstring (everything between the matching
@@ -102,8 +109,8 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
102
109
  it('executeReply calls releaseTurnBufferGate OUTSIDE the isFinalAnswerReply branch', () => {
103
110
  // Slice the executeReply post-send block (between the anchor
104
111
  // comments and the next exported function).
105
- const post = gatewaySrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
106
- const slice = post.split('\nasync function ')[0] ?? ''
112
+ const post = sendPathSrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
113
+ const slice = post.split('\nexport async function ')[0] ?? ''
107
114
  // The narrow `isFinalAnswerReply`-gated finalize MUST stay (it
108
115
  // emits the 👍 reaction on the final-answer happy path).
109
116
  expect(slice).toMatch(/isFinalAnswerReply\(/)
@@ -131,9 +138,12 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
131
138
  // helper is narrow on purpose. If future code adds new
132
139
  // callsites that aren't turn-terminal, the steer-vs-queue
133
140
  // semantics could drift.
134
- const callMatches = gatewaySrc.match(/releaseTurnBufferGate\(/g) ?? []
135
- // Definition + 2 callsites = 3:
136
- // - executeReply's post-send block (reply-finalize, the original).
141
+ // #2996 P2: the reply-finalize callsite moved (verbatim) into
142
+ // outbound-send-path.ts `sendReply`; gateway.ts keeps the definition,
143
+ // the halt callsite, and the deps-injection reference.
144
+ const callMatches = (gatewaySrc + sendPathSrc).match(/releaseTurnBufferGate\(/g) ?? []
145
+ // Definition + deps-interface member + 2 callsites = 4:
146
+ // - sendReply's post-send block (reply-finalize, the original).
137
147
  // - executeHaltNow (#3020): an interrupt-cancelled turn never reaches
138
148
  // reply-finalize (the C-c killed it and no replacement inbound
139
149
  // follows), so the halt IS that turn's terminal — releasing there is
@@ -142,6 +152,6 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
142
152
  // with executeStreamReply.)
143
153
  // If this count grows the test catches it; reviewer must justify
144
154
  // any new callsite.
145
- expect(callMatches.length).toBe(3)
155
+ expect(callMatches.length).toBe(4)
146
156
  })
147
157
  })
@@ -115,9 +115,9 @@ describe("agent button taps use the turn-gate (mid-turn → buffer)", () => {
115
115
  });
116
116
 
117
117
  it("buffers + spools on bridge-offline (existing behaviour intact)", () => {
118
- // The idle-branch send missed → the reservation is released in lockstep
119
- // and the tap is spooled to the pending-inbound buffer for replay.
120
- expect(body).toMatch(/claudeBusyKeys\.delete\(reservedBusyKey\)/);
118
+ // The idle-branch send missed → the tap is spooled to the
119
+ // pending-inbound buffer for replay. (#2996 P1: the legacy busy-key
120
+ // reservation/release pair was deleted with the claudeBusyKeys set.)
121
121
  expect(body).toMatch(/return 'buffered-bridge-offline'/);
122
122
  // The bridge-offline buffer push is the LAST push (after the send miss),
123
123
  // so it always spools before returning the offline outcome.
@@ -0,0 +1,358 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
+
3
+ // The dispatcher's resume closure reaches for the durable outbound-text oracle
4
+ // (history.hasOutboundWithText) to reconcile crash-idempotency. Mock it so these
5
+ // stay pure vitest unit tests (no bun:sqlite / real DB) and we can control the
6
+ // "did this chunk's text land durably?" answer per scenario.
7
+ let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
8
+ vi.mock('../history.js', () => ({
9
+ hasOutboundWithText: (chatId: string, text: string, threadId: number | null, since: number) =>
10
+ mockOracle(chatId, text, threadId, since),
11
+ }))
12
+
13
+ import {
14
+ BackstopDeliveryLedger,
15
+ runBackstopDelivery,
16
+ type ReadBackResult,
17
+ } from '../gateway/backstop-delivery.js'
18
+ import { ObligationLedger, type Obligation } from '../gateway/obligation-ledger.js'
19
+ import {
20
+ buildCapturedDeliverySnapshot,
21
+ hydrateResumeLedger,
22
+ createCapturedResumeDispatcher,
23
+ type CapturedResumeArgs,
24
+ } from '../gateway/captured-answer-resume.js'
25
+
26
+ /**
27
+ * #3282 — a partial turn-flush backstop that left an obligation OPEN used to be
28
+ * re-presented as a FRESH model generation, re-posting chunks that already
29
+ * landed (the user saw the head of the answer twice). These assert the
30
+ * deterministic OUTCOME of the fix — the represent is now a byte-identical
31
+ * captured-answer RESUME of ONLY the non-`landed-confirmed` tail:
32
+ * (a) 3-chunk answer, tail fails ⇒ resume sends EXACTLY the unlanded tail,
33
+ * chunk 0 is NEVER re-sent, and the chat ends with each chunk once;
34
+ * (b) a restart between landing and confirmation does NOT re-send a chunk the
35
+ * durable oracle proves landed;
36
+ * (c) a fully-landed answer's represent sends NOTHING.
37
+ * A test that would still pass if the resume re-sent a confirmed chunk is not a
38
+ * test — every scenario asserts the exact `sendChunk` index set.
39
+ */
40
+
41
+ beforeEach(() => {
42
+ mockOracle = () => false
43
+ })
44
+
45
+ function obligation(over: Partial<Obligation> = {}): Obligation {
46
+ return {
47
+ originTurnId: '#t1',
48
+ chatId: '900',
49
+ threadId: undefined,
50
+ messageId: 42,
51
+ text: 'the question',
52
+ openedAt: 1000,
53
+ representCount: 0,
54
+ ...over,
55
+ }
56
+ }
57
+
58
+ /** Simulate the ORIGINAL partial backstop delivery into `ledger`, then snapshot
59
+ * it — the exact input the represent resume consumes. `landPlan(i)` returns the
60
+ * per-chunk behavior: 'ok' (lands + confirms), 'throw' (send fails → unsent),
61
+ * or 'unconfirmed' (lands but read-back ambiguous → landed-unconfirmed). */
62
+ async function seedPartial(
63
+ ledger: BackstopDeliveryLedger,
64
+ turnId: string,
65
+ chunks: string[],
66
+ landPlan: (i: number) => 'ok' | 'throw' | 'unconfirmed',
67
+ ): Promise<void> {
68
+ await runBackstopDelivery(
69
+ ledger,
70
+ turnId,
71
+ chunks,
72
+ null,
73
+ {
74
+ sendChunk: async (i: number) => {
75
+ if (landPlan(i) === 'throw') throw new Error('FLOOD_WAIT_ACTIVE')
76
+ return [5000 + i]
77
+ },
78
+ readBack: async (i: number): Promise<ReadBackResult> =>
79
+ landPlan(i) === 'unconfirmed' ? 'ambiguous' : 'exists',
80
+ },
81
+ 1, // a single attempt → a genuine partial (no in-fire retry masking it)
82
+ )
83
+ }
84
+
85
+ /** A fake `deliverAnswer` that faithfully replicates the gateway's RESUME path:
86
+ * run the injected `hydrate`, then `runBackstopDelivery` over the captured
87
+ * chunks with a `sendChunk` that records which indices are (re-)sent. */
88
+ function makeResumeDeliverAnswer(ledger: BackstopDeliveryLedger, sentIdx: number[]) {
89
+ return async (a: CapturedResumeArgs): Promise<{ delivered: boolean; sentIds: number[] }> => {
90
+ a.resume.hydrate(ledger, a.turnId)
91
+ const res = await runBackstopDelivery(
92
+ ledger,
93
+ a.turnId,
94
+ a.resume.snapshot.chunks,
95
+ a.cardMessageId,
96
+ {
97
+ sendChunk: async (i: number) => {
98
+ sentIdx.push(i)
99
+ return [7000 + i]
100
+ },
101
+ readBack: async (): Promise<ReadBackResult> => 'exists',
102
+ },
103
+ 3,
104
+ )
105
+ return { delivered: res.delivered, sentIds: res.sentIds }
106
+ }
107
+ }
108
+
109
+ async function flush(dispatcher: { inFlight: () => string[] }): Promise<void> {
110
+ for (let i = 0; i < 50 && dispatcher.inFlight().length > 0; i++) {
111
+ await new Promise((r) => setTimeout(r, 0))
112
+ }
113
+ }
114
+
115
+ describe('#3282 buildCapturedDeliverySnapshot — captures per-chunk landed state', () => {
116
+ it('captures landed chunks (ids + confirmed flag); an unsent chunk is absent', async () => {
117
+ const ledger = new BackstopDeliveryLedger()
118
+ const chunks = ['c0', 'c1', 'c2']
119
+ // chunk 0 confirmed, chunk 1 landed-unconfirmed, chunk 2 never sent.
120
+ await seedPartial(ledger, '#s', chunks, (i) => (i === 0 ? 'ok' : i === 1 ? 'unconfirmed' : 'throw'))
121
+ const snap = buildCapturedDeliverySnapshot(ledger, '#s', chunks)
122
+ expect(snap.chunks).toEqual(chunks)
123
+ const byIndex = Object.fromEntries(snap.chunkStates.map((s) => [s.index, s]))
124
+ expect(byIndex[0]).toMatchObject({ index: 0, confirmed: true })
125
+ expect(byIndex[0].messageIds.length).toBeGreaterThan(0)
126
+ expect(byIndex[1]).toMatchObject({ index: 1, confirmed: false })
127
+ expect(byIndex[2]).toBeUndefined() // never landed → not in the snapshot
128
+ })
129
+ })
130
+
131
+ describe('#3282 hydrateResumeLedger — rebuilds ledger + oracle reconcile', () => {
132
+ it('confirmed chunk ⇒ excluded from the resume send set; unsent chunk ⇒ included', () => {
133
+ const l = new BackstopDeliveryLedger()
134
+ const snap = {
135
+ chunks: ['c0', 'c1', 'c2'],
136
+ chunkStates: [{ index: 0, messageIds: [10], confirmed: true }],
137
+ }
138
+ hydrateResumeLedger(l, '#h', snap, () => false)
139
+ expect(l.hasConfirmedChunk('#h', 0)).toBe(true)
140
+ // chunk 0 confirmed → not resent; chunks 1,2 unsent → the resume tail.
141
+ expect(l.unsentIndices('#h', 3)).toEqual([1, 2])
142
+ })
143
+
144
+ it('landed-unconfirmed chunk ⇒ re-PROBED (landedUnconfirmed), not re-sent', () => {
145
+ const l = new BackstopDeliveryLedger()
146
+ const snap = {
147
+ chunks: ['c0', 'c1'],
148
+ chunkStates: [{ index: 0, messageIds: [10], confirmed: false }],
149
+ }
150
+ hydrateResumeLedger(l, '#h', snap, () => false)
151
+ expect(l.landedUnconfirmedIndices('#h', 2)).toEqual([0]) // will be re-probed
152
+ expect(l.unsentIndices('#h', 2)).toEqual([1]) // only chunk 1 is unsent
153
+ })
154
+
155
+ it('oracle proves a landed-unconfirmed chunk landed ⇒ CONFIRMED (crash-idempotency A5)', () => {
156
+ const l = new BackstopDeliveryLedger()
157
+ const snap = {
158
+ chunks: ['c0', 'c1'],
159
+ chunkStates: [{ index: 0, messageIds: [10], confirmed: false }],
160
+ }
161
+ // The durable outbound row for c0 exists → confirm without re-probe/re-send.
162
+ hydrateResumeLedger(l, '#h', snap, (txt) => txt === 'c0')
163
+ expect(l.hasConfirmedChunk('#h', 0)).toBe(true)
164
+ expect(l.landedUnconfirmedIndices('#h', 2)).toEqual([])
165
+ expect(l.unsentIndices('#h', 2)).toEqual([1])
166
+ })
167
+
168
+ it('a chunkState with no message ids is left unsent (never fabricates a landing)', () => {
169
+ const l = new BackstopDeliveryLedger()
170
+ const snap = { chunks: ['c0'], chunkStates: [{ index: 0, messageIds: [], confirmed: false }] }
171
+ hydrateResumeLedger(l, '#h', snap, () => false)
172
+ expect(l.unsentIndices('#h', 1)).toEqual([0])
173
+ })
174
+ })
175
+
176
+ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome', () => {
177
+ it('(a) partial [0 confirmed, 1+2 unsent] ⇒ resume sends EXACTLY [1,2]; chunk 0 never re-sent; each chunk once', async () => {
178
+ const chunks = ['c0', 'c1', 'c2']
179
+ // Original delivery: chunk 0 lands+confirms, chunks 1,2 fail (unsent).
180
+ const orig = new BackstopDeliveryLedger()
181
+ await seedPartial(orig, '#t1', chunks, (i) => (i === 0 ? 'ok' : 'throw'))
182
+ const snapshot = buildCapturedDeliverySnapshot(orig, '#t1', chunks)
183
+ orig.clear('#t1') // the turn-flush GCs the in-memory ledger (state is durable)
184
+
185
+ // The represent runs on a FRESH ledger (as after a sweep / restart).
186
+ const resumeLedger = new BackstopDeliveryLedger()
187
+ const sentIdx: number[] = []
188
+ const oblLedger = new ObligationLedger(2)
189
+ const supersedes: Array<{ ids: number[] }> = []
190
+ const o = obligation({ capturedDelivery: snapshot })
191
+ oblLedger.openIfAbsent(o)
192
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
193
+
194
+ const dispatcher = createCapturedResumeDispatcher({
195
+ deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
196
+ obligationLedger: oblLedger,
197
+ backstopDeliveryLedger: resumeLedger,
198
+ flushedTurnSupersede: { record: (_c, _t, rec) => supersedes.push({ ids: rec.messageIds }) },
199
+ historyEnabled: false,
200
+ })
201
+ dispatcher.dispatch(oblLedger.list()[0])
202
+ await flush(dispatcher)
203
+
204
+ // The core anti-regression assertion: ONLY the unlanded tail is (re-)sent.
205
+ expect(sentIdx.sort()).toEqual([1, 2])
206
+ expect(sentIdx).not.toContain(0) // chunk 0 (already landed) NEVER re-posted
207
+ expect(oblLedger.isOpen('#t1')).toBe(false) // fully delivered ⇒ closed
208
+ // §4 — the supersede record covers the WHOLE delivered set (original landed
209
+ // chunk 0's id 5000 + the resumed tail's ids), keyed by turn so a late reply
210
+ // corrects the complete answer in place.
211
+ expect(supersedes.at(-1)?.ids).toEqual([5000, 7001, 7002])
212
+ })
213
+
214
+ it('(b) restart between landing and confirmation ⇒ oracle-confirmed chunk NOT re-sent', async () => {
215
+ const chunks = ['c0', 'c1']
216
+ // Snapshot has chunk 0 landed-unconfirmed (crashed before read-back), chunk 1 unsent.
217
+ const snapshot = {
218
+ chunks,
219
+ chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }],
220
+ }
221
+ const resumeLedger = new BackstopDeliveryLedger()
222
+ const sentIdx: number[] = []
223
+ const oblLedger = new ObligationLedger(2)
224
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
225
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
226
+
227
+ // The durable oracle proves chunk 0's text landed → confirm, don't re-send.
228
+ mockOracle = (_c, txt) => txt === 'c0'
229
+
230
+ const dispatcher = createCapturedResumeDispatcher({
231
+ deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
232
+ obligationLedger: oblLedger,
233
+ backstopDeliveryLedger: resumeLedger,
234
+ flushedTurnSupersede: { record: () => {} },
235
+ historyEnabled: true,
236
+ })
237
+ dispatcher.dispatch(oblLedger.list()[0])
238
+ await flush(dispatcher)
239
+
240
+ expect(sentIdx).toEqual([1]) // chunk 0 reconciled-confirmed, only chunk 1 sent
241
+ expect(sentIdx).not.toContain(0)
242
+ expect(oblLedger.isOpen('#t1')).toBe(false)
243
+ })
244
+
245
+ it('(c) fully-landed answer ⇒ resume sends NOTHING and closes the obligation', async () => {
246
+ const chunks = ['c0', 'c1']
247
+ const snapshot = {
248
+ chunks,
249
+ chunkStates: [
250
+ { index: 0, messageIds: [5000], confirmed: true },
251
+ { index: 1, messageIds: [5001], confirmed: true },
252
+ ],
253
+ }
254
+ const resumeLedger = new BackstopDeliveryLedger()
255
+ const sentIdx: number[] = []
256
+ const oblLedger = new ObligationLedger(2)
257
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
258
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
259
+
260
+ const dispatcher = createCapturedResumeDispatcher({
261
+ deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
262
+ obligationLedger: oblLedger,
263
+ backstopDeliveryLedger: resumeLedger,
264
+ flushedTurnSupersede: { record: () => {} },
265
+ historyEnabled: false,
266
+ })
267
+ dispatcher.dispatch(oblLedger.list()[0])
268
+ await flush(dispatcher)
269
+
270
+ expect(sentIdx).toEqual([]) // nothing re-posted
271
+ expect(oblLedger.isOpen('#t1')).toBe(false)
272
+ })
273
+
274
+ it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
275
+ const chunks = ['c0', 'c1']
276
+ const snapshot = {
277
+ chunks,
278
+ chunkStates: [{ index: 0, messageIds: [5000], confirmed: true }],
279
+ }
280
+ const resumeLedger = new BackstopDeliveryLedger()
281
+ const oblLedger = new ObligationLedger(2)
282
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
283
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
284
+
285
+ // A resume whose tail send keeps throwing ⇒ not delivered ⇒ obligation stays OPEN.
286
+ const dispatcher = createCapturedResumeDispatcher({
287
+ deliverAnswer: async (a) => {
288
+ a.resume.hydrate(resumeLedger, a.turnId)
289
+ const res = await runBackstopDelivery(
290
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
291
+ { sendChunk: async () => { throw new Error('FLOOD_WAIT_ACTIVE') }, readBack: async () => 'exists' as ReadBackResult },
292
+ 2,
293
+ )
294
+ return { delivered: res.delivered, sentIds: res.sentIds }
295
+ },
296
+ obligationLedger: oblLedger,
297
+ backstopDeliveryLedger: resumeLedger,
298
+ flushedTurnSupersede: { record: () => {} },
299
+ historyEnabled: false,
300
+ })
301
+ dispatcher.dispatch(oblLedger.list()[0])
302
+ await flush(dispatcher)
303
+
304
+ expect(oblLedger.isOpen('#t1')).toBe(true) // left OPEN for the next paced sweep
305
+ expect(oblLedger.list()[0].representCount).toBe(1) // budget consumed → bounded ladder
306
+ })
307
+
308
+ it('de-dupes concurrent dispatches for the same origin (only one resume runs)', async () => {
309
+ const chunks = ['c0', 'c1']
310
+ const orig = new BackstopDeliveryLedger()
311
+ await seedPartial(orig, '#t1', chunks, (i) => (i === 0 ? 'ok' : 'throw'))
312
+ const snapshot = buildCapturedDeliverySnapshot(orig, '#t1', chunks)
313
+ orig.clear('#t1')
314
+
315
+ const resumeLedger = new BackstopDeliveryLedger()
316
+ let deliverCalls = 0
317
+ const oblLedger = new ObligationLedger(2)
318
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
319
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
320
+
321
+ const dispatcher = createCapturedResumeDispatcher({
322
+ deliverAnswer: async (a) => {
323
+ deliverCalls++
324
+ a.resume.hydrate(resumeLedger, a.turnId)
325
+ const res = await runBackstopDelivery(
326
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
327
+ { sendChunk: async (i: number) => [7000 + i], readBack: async () => 'exists' as ReadBackResult },
328
+ 3,
329
+ )
330
+ return { delivered: res.delivered, sentIds: res.sentIds }
331
+ },
332
+ obligationLedger: oblLedger,
333
+ backstopDeliveryLedger: resumeLedger,
334
+ flushedTurnSupersede: { record: () => {} },
335
+ historyEnabled: false,
336
+ })
337
+ const o = oblLedger.list()[0]
338
+ dispatcher.dispatch(o)
339
+ dispatcher.dispatch(o) // second, concurrent — must be a no-op
340
+ await flush(dispatcher)
341
+ expect(deliverCalls).toBe(1)
342
+ })
343
+
344
+ it('no captured snapshot ⇒ dispatch is a no-op (caller falls through to fresh generation)', async () => {
345
+ let delivered = false
346
+ const dispatcher = createCapturedResumeDispatcher({
347
+ deliverAnswer: async () => { delivered = true; return { delivered: true, sentIds: [] } },
348
+ obligationLedger: new ObligationLedger(2),
349
+ backstopDeliveryLedger: new BackstopDeliveryLedger(),
350
+ flushedTurnSupersede: { record: () => {} },
351
+ historyEnabled: false,
352
+ })
353
+ dispatcher.dispatch(obligation()) // no capturedDelivery
354
+ dispatcher.dispatch(obligation({ capturedDelivery: { chunks: [], chunkStates: [] } })) // empty
355
+ await flush(dispatcher)
356
+ expect(delivered).toBe(false)
357
+ })
358
+ })
@@ -48,6 +48,19 @@ const gatewaySrc = readFileSync(
48
48
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
49
49
  'utf-8',
50
50
  )
51
+ // #2996 P2: executeReply's body (the over-ping block + the lever-2 finalize)
52
+ // moved VERBATIM to outbound-send-path.ts `sendReply()`. The façade call-site
53
+ // assertions that used to grep the executeReply window grep the sendReply
54
+ // window there instead — the wiring contract they pin is unchanged.
55
+ const sendPathSrc = readFileSync(
56
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
57
+ 'utf-8',
58
+ )
59
+ /** The sendReply window: from the function decl to the next export. */
60
+ function sendReplyWindow(): string {
61
+ const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
62
+ return after.split('\nexport ')[0] ?? after
63
+ }
51
64
 
52
65
  /** Source of a named gateway function up to the next top-level definition. */
53
66
  function fnSrc(name: string): string {
@@ -243,15 +256,15 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
243
256
  // The relocation must NOT split the #2562 pair across the façade boundary:
244
257
  // the façade decides; the call-site thunk sets firstPingAt AND
245
258
  // firstPingWasSubstantive on two adjacent lines, no await between.
246
- expect(gatewaySrc).toMatch(
259
+ expect(sendPathSrc).toMatch(
247
260
  /turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
248
261
  )
249
262
  // No await anywhere in the executeReply over-ping block (the decide→apply→
250
263
  // pair-set synchronous chain). Bound to the block, BEFORE the Telegraph
251
264
  // block below (which legitimately awaits).
252
- const blockStart = gatewaySrc.indexOf('const applyOverPingDecision')
253
- const telegraphIdx = gatewaySrc.indexOf('// Telegraph publish (#579)', blockStart)
254
- const block = gatewaySrc.slice(blockStart, telegraphIdx)
265
+ const blockStart = sendPathSrc.indexOf('const applyOverPingDecision')
266
+ const telegraphIdx = sendPathSrc.indexOf('// Telegraph publish (#579)', blockStart)
267
+ const block = sendPathSrc.slice(blockStart, telegraphIdx)
255
268
  const blockCode = block
256
269
  .split('\n')
257
270
  .filter((l) => {
@@ -267,17 +280,14 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
267
280
  // The over-ping net exists ONLY in executeReply. The retired stream_reply
268
281
  // tool (executeStreamReply) had no decideOverPing / firstPingAt /
269
282
  // wasOverPingSuppressed and never called claimOrDowngradePing.
270
- const calls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
283
+ // #2996 P2: the single call now lives in the extracted sendReply body;
284
+ // gateway.ts must have ZERO (the wrapper delegates).
285
+ const gwCalls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
286
+ expect(gwCalls).toHaveLength(0)
287
+ const calls = [...sendPathSrc.matchAll(/\.claimOrDowngradePing\(/g)]
271
288
  expect(calls).toHaveLength(1)
272
- const callIdx = gatewaySrc.indexOf('.claimOrDowngradePing(')
273
- const execReplyIdx = gatewaySrc.indexOf('async function executeReply(')
274
- // Upper bound: the next top-level function after executeReply.
275
- const nextFnIdx = gatewaySrc.indexOf('\nasync function ', execReplyIdx + 1)
276
- expect(execReplyIdx).toBeGreaterThan(-1)
277
- expect(nextFnIdx).toBeGreaterThan(execReplyIdx)
278
- // The single call is inside executeReply.
279
- expect(callIdx).toBeGreaterThan(execReplyIdx)
280
- expect(callIdx).toBeLessThan(nextFnIdx)
289
+ // The single call is inside the sendReply window.
290
+ expect(sendReplyWindow()).toMatch(/\.claimOrDowngradePing\(/)
281
291
  })
282
292
  })
283
293
 
@@ -299,7 +309,8 @@ describe('façade delegates to the existing emission primitives (call-site liter
299
309
  it('finalizeCard wraps clearActivitySummary at the call sites (not CALLED inside the façade)', () => {
300
310
  expect(facadeSrc).not.toMatch(/import .*clearActivitySummary/)
301
311
  expect(facadeCode).not.toMatch(/clearActivitySummary\(/)
302
- expect(gatewaySrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
312
+ // #2996 P2: the lever-2 call site moved (verbatim) into sendReply.
313
+ expect(sendPathSrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
303
314
  })
304
315
 
305
316
  it('claimOrDowngradePing — the call-site disabled thunk STILL contains a literal decideOverPing( (disabled-path proof)', () => {
@@ -308,9 +319,9 @@ describe('façade delegates to the existing emission primitives (call-site liter
308
319
  // its OWN literal `decideOverPing(` call inside the call-site thunk, VERBATIM
309
320
  // from PR-4b-base — so the disabled path is provably byte-identical (never
310
321
  // depends on the façade for the decision).
311
- const pingIdx = gatewaySrc.indexOf('claimOrDowngradePing(')
322
+ const pingIdx = sendPathSrc.indexOf('claimOrDowngradePing(')
312
323
  expect(pingIdx).toBeGreaterThan(-1)
313
- const window = gatewaySrc.slice(pingIdx, pingIdx + 1600)
324
+ const window = sendPathSrc.slice(pingIdx, pingIdx + 1600)
314
325
  expect(window).toMatch(/decideOverPing\(/)
315
326
  })
316
327
  })
@@ -385,8 +396,7 @@ describe('the drain sites route through the façade with producers preserved ver
385
396
 
386
397
  describe('the lever-2 finalize block routes through the façade', () => {
387
398
  it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
388
- const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
389
- const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
399
+ const body = sendReplyWindow()
390
400
  expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
391
401
  expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
392
402
  })