switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -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.
@@ -129,30 +129,39 @@ describe("agent button taps use the turn-gate (mid-turn → buffer)", () => {
129
129
  });
130
130
 
131
131
  describe("the agent: callback handler routes through the helper", () => {
132
+ // #2996 P6: the agent-callback family moved to
133
+ // agent-button-callback-handler.ts (handleAgentButtonCallback); the
134
+ // structural pins now scrape the extracted module. The injected
135
+ // deliverButtonTapInbound + restarting-notice bindings stay in gateway.ts.
136
+ const moduleSrc = readFileSync(
137
+ resolve(__dirname, "..", "gateway", "agent-button-callback-handler.ts"),
138
+ "utf8",
139
+ );
132
140
  it("delivers the tap via deliverButtonTapInbound, not a raw sendToAgent", () => {
133
- // Locate the agent-callback handler block.
134
- const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
141
+ const anchor = moduleSrc.indexOf("const agentCb = parseAgentCallback(data)");
135
142
  expect(anchor, "agent-callback handler missing").toBeGreaterThan(0);
136
- const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
143
+ const handler = moduleSrc.slice(anchor);
137
144
  // The tap is routed through the turn-safe helper...
138
- expect(handler).toMatch(/await deliverButtonTapInbound\(selfAgentBtn, inboundMsg\)/);
145
+ expect(handler).toMatch(/await deps\.deliverButtonTapInbound\(selfAgentBtn, inboundMsg\)/);
139
146
  // ...and NOT via a raw ungated sendToAgent of the tap inbound (the
140
147
  // regressed path that stranded the tap mid-turn).
141
148
  expect(handler).not.toMatch(/ipcServer\.sendToAgent\(selfAgentBtn, inboundMsg\)/);
149
+ // The gateway binding still wires the real helper.
150
+ expect(gatewaySrc).toMatch(/deliverButtonTapInbound,/);
142
151
  });
143
152
 
144
153
  it("still shows the restart notice only on bridge-offline", () => {
145
- const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
146
- const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
154
+ const handler = moduleSrc;
147
155
  // The restart notice is gated on the bridge-offline outcome — a mid-turn
148
156
  // buffered tap must NOT nag the user (it will be actioned at idle).
149
157
  expect(handler).toMatch(/btnOutcome === 'buffered-bridge-offline'/);
150
- expect(handler).toMatch(/button-tap-restarting-notice/);
158
+ // The notice text + verb live in the gateway's injected binding.
159
+ expect(gatewaySrc).toMatch(/button-tap-restarting-notice/);
151
160
  });
152
161
 
153
162
  it("preserves the ack toast and single-use keyboard strip (UX unchanged)", () => {
154
- const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
155
- const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
163
+ const anchor = moduleSrc.indexOf("const agentCb = parseAgentCallback(data)");
164
+ const handler = moduleSrc.slice(anchor);
156
165
  // Ack toast fires FIRST (before delivery) so the tap always registers…
157
166
  const ackIdx = handler.indexOf("answerCallbackQuery({ text: ackText })");
158
167
  const deliverIdx = handler.indexOf("deliverButtonTapInbound(");
@@ -32,6 +32,8 @@ import {
32
32
  type CallbackQueryHandlersDeps,
33
33
  type PendingVaultRequestAccess,
34
34
  type PendingVaultRequestSave,
35
+ type PendingSecretRequest,
36
+ type ArmedSecretCapture,
35
37
  type PendingMentalModelPropose,
36
38
  type DeferredSecret,
37
39
  type PendingVaultOp,
@@ -200,6 +202,12 @@ function makeDeps(overrides: Partial<CallbackQueryHandlersDeps> = {}) {
200
202
  expire: () => () => {},
201
203
  log: () => () => {},
202
204
  }),
205
+ pendingSecretRequests: createSweepableCardStore<PendingSecretRequest>({
206
+ isExpired: () => false,
207
+ expire: () => () => {},
208
+ log: () => () => {},
209
+ }),
210
+ armedSecretCaptures: createSweepableStore<ArmedSecretCapture>(() => false),
203
211
  pendingMentalModelProposes: createSweepableCardStore<PendingMentalModelPropose>({
204
212
  isExpired: () => false,
205
213
  expire: () => () => {},
@@ -455,6 +463,98 @@ describe('handleVaultDeferCallback', () => {
455
463
  })
456
464
  })
457
465
 
466
+ // ── vsp:* — request_secret (agent asks operator to PROVIDE a secret) ─────
467
+
468
+ function stagedSecretRequest(over: Partial<PendingSecretRequest> = {}): PendingSecretRequest {
469
+ return {
470
+ agent: 'worker',
471
+ chat_id: '111',
472
+ card_message_id: 42,
473
+ key: 'openai/api_key',
474
+ reason: 'need it to call the API',
475
+ staged_at: Date.now(),
476
+ ...over,
477
+ }
478
+ }
479
+
480
+ describe('handleSecretRequestCallback', () => {
481
+ it('refuses a sender not on allowFrom (never touches state)', async () => {
482
+ const { deps } = makeDeps()
483
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest())
484
+ const h = createCallbackQueryHandlers(deps)
485
+ const { ctx, raw } = makeCtx({ senderId: '999' })
486
+ await h.handleSecretRequestCallback(ctx, 'vsp:provide:s1')
487
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
488
+ // Auth gate runs before any capture is armed.
489
+ expect(deps.armedSecretCaptures.has('111')).toBe(false)
490
+ })
491
+
492
+ // Race pin (#2996 P5 mandate — verdict-during-expiry): a tap that lands after
493
+ // the card's TTL sweep already dropped the pending request must degrade to
494
+ // the expired toast, NOT arm a capture or wake the agent.
495
+ it('a tap on an already-expired/unknown stage toasts "This request expired." and changes nothing', async () => {
496
+ const { deps, injected } = makeDeps()
497
+ const h = createCallbackQueryHandlers(deps)
498
+ const { ctx, raw } = makeCtx()
499
+ await h.handleSecretRequestCallback(ctx, 'vsp:provide:gone')
500
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'This request expired.' })
501
+ expect(deps.armedSecretCaptures.has('111')).toBe(false)
502
+ expect(injected).toHaveLength(0)
503
+ })
504
+
505
+ it('provide: arms the capture keyed by chat_id and swaps the card to the send-now prompt', async () => {
506
+ const { deps } = makeDeps()
507
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest({ threadId: 7 }))
508
+ const h = createCallbackQueryHandlers(deps)
509
+ const { ctx, raw } = makeCtx()
510
+ await h.handleSecretRequestCallback(ctx, 'vsp:provide:s1')
511
+ const armed = deps.armedSecretCaptures.get('111')
512
+ expect(armed?.key).toBe('openai/api_key')
513
+ expect(armed?.stageId).toBe('s1')
514
+ expect(armed?.threadId).toBe(7)
515
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
516
+ text: 'Send the value now — it auto-deletes.',
517
+ })
518
+ // Card edited to the "send the value" prompt with the keyboard stripped.
519
+ expect(raw.api.editMessageText).toHaveBeenCalled()
520
+ // The pending request survives until the value actually arrives.
521
+ expect(deps.pendingSecretRequests.has('s1')).toBe(true)
522
+ })
523
+
524
+ it('decline: drops the stage, clears any armed capture, edits the card, wakes the agent', async () => {
525
+ const { deps, injected } = makeDeps()
526
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest())
527
+ deps.armedSecretCaptures.set('111', {
528
+ key: 'openai/api_key',
529
+ agent: 'worker',
530
+ stageId: 's1',
531
+ armed_at: Date.now(),
532
+ })
533
+ const h = createCallbackQueryHandlers(deps)
534
+ const { ctx, raw } = makeCtx()
535
+ await h.handleSecretRequestCallback(ctx, 'vsp:decline:s1')
536
+ expect(deps.pendingSecretRequests.has('s1')).toBe(false)
537
+ expect(deps.pendingCardStore.remove).toHaveBeenCalledWith('s1')
538
+ expect(deps.armedSecretCaptures.has('111')).toBe(false)
539
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Declined.' })
540
+ expect(raw.api.editMessageText).toHaveBeenCalled()
541
+ expect(injected).toHaveLength(1)
542
+ expect(injected[0]!.agent).toBe('worker')
543
+ expect(injected[0]!.text).toContain('openai/api_key')
544
+ })
545
+
546
+ it('an unknown action just acknowledges without mutating state', async () => {
547
+ const { deps, injected } = makeDeps()
548
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest())
549
+ const h = createCallbackQueryHandlers(deps)
550
+ const { ctx, raw } = makeCtx()
551
+ await h.handleSecretRequestCallback(ctx, 'vsp:bogus:s1')
552
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith()
553
+ expect(deps.pendingSecretRequests.has('s1')).toBe(true)
554
+ expect(injected).toHaveLength(0)
555
+ })
556
+ })
557
+
458
558
  // ── mmp:* — mental-model proposal ───────────────────────────────────────
459
559
 
460
560
  function stagedPropose(over: Partial<PendingMentalModelPropose> = {}): PendingMentalModelPropose {
@@ -748,6 +848,7 @@ describe('authorization gate parity', () => {
748
848
  ['vrd', (h, ctx) => h.handleVaultRecentDenialCallback(ctx, 'vrd:worker:k')],
749
849
  ['vra', (h, ctx) => h.handleVaultRequestAccessCallback(ctx, 'vra:approve:s1')],
750
850
  ['vrs', (h, ctx) => h.handleVaultRequestSaveCallback(ctx, 'vrs:save:s1')],
851
+ ['vsp', (h, ctx) => h.handleSecretRequestCallback(ctx, 'vsp:provide:s1')],
751
852
  ['vd', (h, ctx) => h.handleVaultDeferCallback(ctx, 'vd:unlock:111:7')],
752
853
  ['vg', (h, ctx) => h.handleVaultGrantCallback(ctx, 'vg:cancel')],
753
854
  ['mmp', (h, ctx) => h.handleMentalModelProposeCallback(ctx, 'mmp:approve:m1')],
@@ -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
+ })