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
@@ -261,22 +261,37 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
261
261
  new URL('../gateway/gateway.ts', import.meta.url),
262
262
  'utf8',
263
263
  )
264
+ // #2996 P4-A: the turn_flush backstop (redact → normalize → scrub on
265
+ // `capturedText`) moved VERBATIM into stream-render.ts with
266
+ // handleSessionEvent. The reply path's identical `...(text))` wrapper lives
267
+ // in outbound-send-path.ts (`normalizeOutboundBody`, #2996 P2). Parity is
268
+ // pinned across the two extracted homes.
269
+ const streamSrc = readFileSync(
270
+ new URL('../gateway/stream-render.ts', import.meta.url),
271
+ 'utf8',
272
+ )
273
+ const replyModuleSrc = readFileSync(
274
+ new URL('../gateway/outbound-send-path.ts', import.meta.url),
275
+ 'utf8',
276
+ )
264
277
 
265
278
  it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
266
279
  // #2996: the reply-path entry pipeline moved into outbound-send-path.ts
267
280
  // (`normalizeOutboundBody`). The reply path delegates to it via
268
281
  // `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`; the
269
282
  // redact→normalize→scrub ordering is now pinned in the module source.
270
- const replyDelegates = gatewaySrc.indexOf(
271
- `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
272
- gatewaySrc.indexOf('async function executeReply('),
273
- )
274
- expect(replyDelegates).toBeGreaterThan(0)
275
-
276
283
  const moduleSrc = readFileSync(
277
284
  new URL('../gateway/outbound-send-path.ts', import.meta.url),
278
285
  'utf8',
279
286
  )
287
+ // #2996 P2: the reply orchestration itself now lives in the module
288
+ // (`sendReply`); the delegation line is pinned there.
289
+ const replyDelegates = moduleSrc.indexOf(
290
+ `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
291
+ moduleSrc.indexOf('export async function sendReply('),
292
+ )
293
+ expect(replyDelegates).toBeGreaterThan(0)
294
+
280
295
  const start = moduleSrc.indexOf('export function normalizeOutboundBody(')
281
296
  const redactIdx = moduleSrc.indexOf('redact(text, site)', start)
282
297
  const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
@@ -288,13 +303,13 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
288
303
  })
289
304
 
290
305
  it('turn-flush backstop: applies the SAME normalization in the SAME slot (REDS if the line is removed)', () => {
291
- const redactIdx = gatewaySrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
306
+ const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
292
307
  // The normalization call the reply path uses, verbatim, on the turn_flush
293
308
  // variable. This indexOf is what returns -1 (→ assertion fails) if the
294
309
  // `stripExcessBold(normalizePunctuation(capturedText))` line is deleted
295
310
  // from the turn_flush branch.
296
- const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
297
- const scrubIdx = gatewaySrc.indexOf('scrubVoice(capturedText)', redactIdx)
311
+ const normIdx = streamSrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
312
+ const scrubIdx = streamSrc.indexOf('scrubVoice(capturedText)', redactIdx)
298
313
  expect(redactIdx).toBeGreaterThan(0)
299
314
  expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the turn_flush redact
300
315
  expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub — mirrors reply
@@ -303,8 +318,8 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
303
318
  it('both send sites share the identical `stripExcessBold(normalizePunctuation(` wrapper', () => {
304
319
  // Parity, structurally: the exact normalization wrapper the reply path uses
305
320
  // is the one the turn_flush branch uses — same call, not a lookalike.
306
- expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(text))')
307
- expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
321
+ expect(replyModuleSrc).toContain('stripExcessBold(normalizePunctuation(text))')
322
+ expect(streamSrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
308
323
  })
309
324
 
310
325
  // Behavioural coverage (kept from the original suite): reconstruct the
@@ -676,3 +691,168 @@ describe('selectFlushDeliveryText — deliver the terminal answer, strip only na
676
691
  }
677
692
  })
678
693
  })
694
+
695
+ // ── #3237: STRUCTURAL block-provenance discriminator ───────────────────────
696
+ // The opener heuristic cannot tell a narration preamble from a real answer
697
+ // paragraph that merely opens with "Let me explain…". The structural flag
698
+ // (`followedByToolUse`, = `!lastInMessage`) can: a narration preamble is
699
+ // followed by a tool_use in its message; a terminal answer paragraph is not.
700
+ describe('selectFlushDeliveryText — structural provenance (followedByToolUse) #3237', () => {
701
+ // The exact real-answer shape from #3237: a genuine two-paragraph answer
702
+ // whose FIRST block opens with a narration phrase, written as plain text with
703
+ // NO tool calls (the model forgot the reply tool → flush path). Structure
704
+ // says neither block was followed by a tool_use, so nothing is narration.
705
+ const p1 =
706
+ 'Let me explain how the flush path works: it fires at turn_end when the ' +
707
+ 'model never called the reply tool, joining the captured assistant text ' +
708
+ 'blocks with a paragraph break so the answer renders with real separation.'
709
+ const p2 =
710
+ 'The terminal block is always delivered, so the answer is never fully ' +
711
+ 'lost — but the opening paragraph could be dropped by the opener-only strip.'
712
+
713
+ it('#3237 regression: a real 2-paragraph answer opening with "Let me explain…" is NOT truncated when structure says neither block was followed by a tool_use', () => {
714
+ // With NO structural signal (legacy string[]) the opener strip DROPS p1.
715
+ expect(selectFlushDeliveryText([p1, p2])).toBe(p2) // documents the bug
716
+ // With structure present ([false,false]) the full answer is delivered.
717
+ const out = selectFlushDeliveryText([p1, p2], [false, false])
718
+ expect(out).toBe(`${p1}\n\n${p2}`)
719
+ expect(out).toContain('Let me explain how the flush path works')
720
+ })
721
+
722
+ it('duplicate-reply fix preserved: a narration block FOLLOWED by a tool_use is stripped, terminal answer delivered', () => {
723
+ const narration = 'Let me pull the numbers together before I answer.'
724
+ const realAnswer = 'Revenue was 4.2M, up 12% year over year.'
725
+ // followedByToolUse[0]=true (the model drafted then acted), [1]=false (terminal).
726
+ const out = selectFlushDeliveryText([narration, realAnswer], [true, false])
727
+ expect(out).toBe(realAnswer)
728
+ expect(out).not.toContain('Let me pull the numbers')
729
+ })
730
+
731
+ it('structure alone drives the strip — a preamble the opener regex would MISS is still stripped when it was followed by a tool_use', () => {
732
+ // "Here are the figures" matches no NARRATION_OPENER, but a tool_use
733
+ // followed it → structural narration → stripped.
734
+ const preamble = 'Here are the figures you asked about.'
735
+ const realAnswer = 'The three services are all green.'
736
+ const out = selectFlushDeliveryText([preamble, realAnswer], [true, false])
737
+ expect(out).toBe(realAnswer)
738
+ expect(out).not.toContain('Here are the figures')
739
+ })
740
+
741
+ it('structure WINS over the opener heuristic — a "Let me…" opener NOT followed by a tool_use is kept (never truncated)', () => {
742
+ const first = 'Let me walk you through the three steps in order.'
743
+ const second = 'Step one is to branch off fresh main.'
744
+ // Opener-only would strip `first`; structure ([false,false]) keeps it.
745
+ expect(selectFlushDeliveryText([first, second], [false, false])).toBe(
746
+ `${first}\n\n${second}`,
747
+ )
748
+ })
749
+
750
+ it('per-block fallback: an UNDEFINED entry falls back to the opener heuristic for that block only', () => {
751
+ const narration = 'Let me check that.'
752
+ const realAnswer = 'Done — all green.'
753
+ // meta[0] undefined → fall back to isNarrationBlock("Let me check that.") = true → stripped.
754
+ const out = selectFlushDeliveryText([narration, realAnswer], [undefined, false])
755
+ expect(out).toBe(realAnswer)
756
+ })
757
+
758
+ it('flag-absent back-compat: omitting the meta array reproduces today\'s opener-strip EXACTLY', () => {
759
+ // Every legacy case behaves identically with no meta argument.
760
+ expect(selectFlushDeliveryText(['Let me check.', 'the answer'])).toBe('the answer')
761
+ expect(selectFlushDeliveryText(['Checking now…', 'The build is green.'])).toBe(
762
+ 'The build is green.',
763
+ )
764
+ expect(selectFlushDeliveryText([p1, p2])).toBe(p2) // opener strip drops p1
765
+ const first = 'B'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
766
+ const last = 'C'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
767
+ expect(selectFlushDeliveryText([first, last])).toBe(`${first}\n\n${last}`)
768
+ })
769
+
770
+ it('meta stays ALIGNED across the empty-block filter (zip-before-filter)', () => {
771
+ // An empty block between narration and answer must not shift the meta index.
772
+ const narration = 'Let me look that up.'
773
+ const realAnswer = 'The value is 42.'
774
+ // blocks[1] is empty (dropped); meta must still map [narration→true, ''→_, answer→false].
775
+ const out = selectFlushDeliveryText([narration, ' ', realAnswer], [true, false, false])
776
+ expect(out).toBe(realAnswer)
777
+ expect(out).not.toContain('Let me look that up')
778
+ })
779
+
780
+ it('cross-message shape: narration (msg1, tool_use after) + answer (msg2, terminal) → narration stripped', () => {
781
+ // lastInMessage semantics are per-message; a narration that is the last text
782
+ // of msg1 but has a tool_use after it in msg1 is followedByToolUse=true.
783
+ const narration = 'Let me search the codebase for the symbol.'
784
+ const realAnswer = 'It is defined in session-tail.ts.'
785
+ const out = selectFlushDeliveryText([narration, realAnswer], [true, false])
786
+ expect(out).toBe(realAnswer)
787
+ })
788
+
789
+ it('decideTurnFlush plumbs capturedBlockMeta through to the strip (#3237 end-to-end)', () => {
790
+ // Real-answer case: structure keeps both paragraphs.
791
+ const keep = decideTurnFlush({
792
+ chatId: 'chat1',
793
+ replyCalled: false,
794
+ capturedText: [p1, p2],
795
+ capturedBlockMeta: [false, false],
796
+ })
797
+ expect(keep.kind).toBe('flush')
798
+ if (keep.kind === 'flush') expect(keep.text).toBe(`${p1}\n\n${p2}`)
799
+
800
+ // Narration case: structure strips the preamble.
801
+ const strip = decideTurnFlush({
802
+ chatId: 'chat1',
803
+ replyCalled: false,
804
+ capturedText: ['Let me pull the numbers.', 'Revenue was 4.2M.'],
805
+ capturedBlockMeta: [true, false],
806
+ })
807
+ expect(strip.kind).toBe('flush')
808
+ if (strip.kind === 'flush') expect(strip.text).toBe('Revenue was 4.2M.')
809
+ })
810
+
811
+ // Regression for the post-#3338 review finding: a SUBSTANTIAL real-content
812
+ // block (≥ FLUSH_SUBSTANTIVE_MIN_CHARS, NOT narration-shaped) that happens to
813
+ // be followed by a tool_use must be KEPT — the model can write a real answer
814
+ // paragraph, then call a memory/verify tool, then a short wrap-up. Trusting
815
+ // followedByToolUse ALONE (the pre-fix behaviour) dropped the real answer and
816
+ // delivered only the wrap-up. The structural strip is gated by the substantive
817
+ // floor, so the substantial block survives.
818
+ it('substantial real-content block followed by a tool_use is KEPT (structural flag gated by the substantive floor)', () => {
819
+ const realAnswer =
820
+ 'The migration completed cleanly across all five shards: each shard was ' +
821
+ 'drained, the schema applied under an advisory lock, and traffic cut back ' +
822
+ 'over with zero dropped requests. The new index brought the p99 query time ' +
823
+ 'down from 840ms to 60ms, and the old columns are now safe to remove in ' +
824
+ 'the next release once the read path is confirmed off them entirely.'
825
+ expect(realAnswer.length).toBeGreaterThanOrEqual(FLUSH_SUBSTANTIVE_MIN_CHARS)
826
+ // Not narration-shaped: no first-person "about to do X" opener, no
827
+ // trailing ellipsis/colon — so only the structural flag could strip it.
828
+ const wrapUp = 'Saved that to memory.'
829
+ // meta[0]=true (a memory tool_use followed the real answer), [1]=false.
830
+ const out = selectFlushDeliveryText([realAnswer, wrapUp], [true, false])
831
+ expect(out).toBe(`${realAnswer}\n\n${wrapUp}`)
832
+ expect(out).toContain('The migration completed cleanly')
833
+
834
+ // End-to-end through decideTurnFlush.
835
+ const decision = decideTurnFlush({
836
+ chatId: 'chat1',
837
+ replyCalled: false,
838
+ capturedText: [realAnswer, wrapUp],
839
+ capturedBlockMeta: [true, false],
840
+ })
841
+ expect(decision.kind).toBe('flush')
842
+ if (decision.kind === 'flush') {
843
+ expect(decision.text).toBe(`${realAnswer}\n\n${wrapUp}`)
844
+ }
845
+ })
846
+
847
+ // A SHORT block followed by a tool_use is still stripped via the length floor
848
+ // (the pre-#3237 "short preamble the opener misses" behaviour is preserved):
849
+ // it is below FLUSH_SUBSTANTIVE_MIN_CHARS, so the gated strip still fires.
850
+ it('short block followed by a tool_use is still stripped (length-floor branch of the gate)', () => {
851
+ const shortPreamble = 'Here are the figures you asked about.'
852
+ expect(shortPreamble.length).toBeLessThan(FLUSH_SUBSTANTIVE_MIN_CHARS)
853
+ const realAnswer = 'The three services are all green.'
854
+ const out = selectFlushDeliveryText([shortPreamble, realAnswer], [true, false])
855
+ expect(out).toBe(realAnswer)
856
+ expect(out).not.toContain('Here are the figures')
857
+ })
858
+ })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * S1/S2/S4 WIRING (fable red-team 2026-07-17, fixed in #3299 @ a6e09dfb).
3
+ *
4
+ * The behavioural suite `turn-flush-suppression.test.ts` proves the scoped
5
+ * suppression predicate in isolation, and `answer-ready-flush.test.ts` proves
6
+ * the quiescence controller's reset/re-arm semantics — but neither says
7
+ * whether the GATEWAY actually calls them, or what it does with the answer.
8
+ * That decision-vs-delivery gap is exactly how the original S1 bug shipped:
9
+ * every routine was individually correct while the gateway's call site
10
+ * counted ANY chat-wide outbound and then CLOSED the obligation, silently
11
+ * dropping the user's real answer.
12
+ *
13
+ * gateway.ts is a side-effecting IIFE that cannot be imported in a unit test
14
+ * (same constraint as activity-card-wiring.test.ts /
15
+ * silence-liveness-wiring.test.ts), so these are STRUCTURAL assertions that
16
+ * pin the load-bearing call sites. They COMPLEMENT the behavioural suites —
17
+ * the outcomes are proven at routine level; this guards the wiring those
18
+ * routines depend on. Each test names the regression it would catch.
19
+ */
20
+ import { describe, it, expect } from 'vitest'
21
+ import { readFileSync } from 'node:fs'
22
+ import { resolve } from 'node:path'
23
+
24
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
25
+ // #2996 P4-A: the turn-flush / turn_end / `thinking`-arm code moved VERBATIM
26
+ // into stream-render.ts with handleSessionEvent. Windows that used to live in
27
+ // the gateway switch are grepped from the extracted module now.
28
+ const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
29
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
30
+
31
+ function between(src: string, startMarker: string, endMarker: string): string {
32
+ const after = src.split(startMarker)[1] ?? ''
33
+ return after.split(endMarker)[0] ?? ''
34
+ }
35
+
36
+ /** Strip // comment lines so prose quoting the OLD (buggy) shape can't
37
+ * satisfy — or trip — an assertion about the CODE. */
38
+ function codeOnly(src: string): string {
39
+ return src
40
+ .split('\n')
41
+ .filter((l) => !l.trim().startsWith('//'))
42
+ .filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
43
+ .join('\n')
44
+ }
45
+
46
+ describe('S1 wiring — turn-flush suppression call site', () => {
47
+ // The whole suppression branch: predicate call through the end of the
48
+ // `if (suppress)` early-return.
49
+ const branch = between(
50
+ streamSrc,
51
+ 'const { shouldSuppressTurnFlush }',
52
+ '// #3276 guard 5',
53
+ )
54
+
55
+ it('the gateway asks the SCOPED predicate, not a chat-wide outbound count', () => {
56
+ expect(branch.length).toBeGreaterThan(100)
57
+ const code = codeOnly(branch)
58
+ // The oracle is the thread/length-scoped history query…
59
+ expect(code).toMatch(/hasSubstantiveOutbound:\s*hasOutboundDeliveredSince/)
60
+ // …with an EXPLICIT null thread for chat-root turns (undefined would
61
+ // match rows in ANY topic and re-open the cross-topic false positive)…
62
+ expect(code).toMatch(/threadId:\s*backstopThreadId\s*\?\?\s*null/)
63
+ // …and the captured answer's length driving the substantive floor.
64
+ expect(code).toMatch(/answerLength:\s*capturedText\.length/)
65
+ // The OLD chat-wide predicate must never come back anywhere in the branch.
66
+ expect(code).not.toMatch(/getRecentOutboundCount/)
67
+ })
68
+
69
+ it('suppression leaves the obligation OPEN (noteTurnEnded) — never closes it', () => {
70
+ const code = codeOnly(branch)
71
+ // A false-positive suppression must stay recoverable: the sweep/liveness
72
+ // floor arbitrates. Closing here made the drop PERMANENT (the S1 bug).
73
+ expect(code).toMatch(/obligationLedger\.noteTurnEnded\(turn\.turnId/)
74
+ expect(code).not.toMatch(/obligationLedger\.close\(/)
75
+ })
76
+
77
+ it('the old predicate is gone from the whole gateway turn-flush path', () => {
78
+ // getRecentOutboundCount must not be consulted anywhere in gateway.ts —
79
+ // any reintroduction re-opens the chat-wide suppression class.
80
+ expect(codeOnly(gatewayAndStreamSrc)).not.toMatch(/getRecentOutboundCount/)
81
+ })
82
+ })
83
+
84
+ describe('S2 wiring — thinking events re-arm the answer-ready quiescence flush', () => {
85
+ it("the 'thinking' event arm calls resetAnswerReadyFlushTimeout()", () => {
86
+ // Without this, "prose → >1s thinking pause → trailing NO_REPLY" lets the
87
+ // quiescence timer fire mid-pause and deliver a turn the model intended
88
+ // silent. Scope to the thinking case arm only.
89
+ const arm = between(streamSrc, "case 'thinking': {", "case 'tool_use': {")
90
+ expect(arm.length).toBeGreaterThan(50)
91
+ expect(codeOnly(arm)).toMatch(/resetAnswerReadyFlushTimeout\(\)/)
92
+ })
93
+ })
94
+
95
+ describe('S4 wiring — flushed answers quote-anchor to the inbound they answer', () => {
96
+ it('deliverAnswer anchors the FIRST chunk with reply_parameters + allow_sending_without_reply', () => {
97
+ const sendChunk = between(
98
+ gatewaySrc,
99
+ 'const sendChunk = async (chunkIndex: number',
100
+ 'buildSendOpts:',
101
+ )
102
+ expect(sendChunk.length).toBeGreaterThan(50)
103
+ const code = codeOnly(sendChunk)
104
+ // First chunk only, and only when the turn has an inbound to anchor to.
105
+ expect(code).toMatch(/chunkIndex === 0 && args\.replyToMessageId != null/)
106
+ // allow_sending_without_reply keeps the send alive if the user deleted
107
+ // their message — without it the whole flush would fail on a dangling id.
108
+ expect(code).toMatch(/allow_sending_without_reply:\s*true/)
109
+ expect(code).toMatch(/reply_parameters/)
110
+ })
111
+
112
+ it('the turn-flush call site passes the turn’s inbound id (bare for synthesized turns)', () => {
113
+ // The anchor must be the message this TURN answers — turn.sourceMessageId
114
+ // is null for cron/handback turns, which therefore still send bare.
115
+ expect(codeOnly(gatewayAndStreamSrc)).toMatch(/replyToMessageId:\s*turn\.sourceMessageId/)
116
+ })
117
+ })
@@ -48,7 +48,10 @@ import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
48
48
  const gatewaySrc =
49
49
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
50
50
  '\n' +
51
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
51
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
52
+ '\n' +
53
+ // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
54
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
52
55
 
53
56
  function sliceAccessApproveBlock(): string {
54
57
  const fn =
@@ -102,7 +105,10 @@ describe('handleVaultRequestAccessCallback — posture branch', () => {
102
105
  expect(approveBlock).toMatch(/passphrase-for-access-approve/)
103
106
  // Pinned: the queued-drain path passes the typed passphrase via
104
107
  // the new attestation shape `{ kind: 'passphrase', passphrase }`.
105
- expect(gatewaySrc).toMatch(/performVaultAccessApproval\(ctx, stagedAccess, item\.stageId, item\.senderId, \{ kind: ['"]passphrase['"], passphrase \}\)/)
108
+ // P7 PR-8: the queued-drain call site moved into interceptVault
109
+ // (inbound-interceptors.ts) — ctx is `p.ctx` and the handler resolves via
110
+ // the lazy deps accessor. Same attestation shape pinned.
111
+ expect(gatewaySrc).toMatch(/performVaultAccessApproval\((?:p\.)?ctx, stagedAccess, item\.stageId, item\.senderId, \{ kind: ['"]passphrase['"], passphrase \}\)/)
106
112
  })
107
113
  })
108
114
 
@@ -414,8 +414,8 @@ describe('approve vs deny shape invariants', () => {
414
414
  // grant/save-outcome inbound must carry that topic so the resumed turn's
415
415
  // reply lands back in it — not General. The carrier is two-fold and BOTH
416
416
  // halves are load-bearing:
417
- // - top-level `threadId` → the gateway's per-topic busy-key / deliver-
418
- // until-acked keying (markClaudeBusyForInbound reads it).
417
+ // - top-level `threadId` → the gateway's per-topic deliver-until-acked
418
+ // keying (chatKeyForInbound reads it).
419
419
  // - `meta.message_thread_id` (stringified) → rendered into the
420
420
  // `<channel message_thread_id="…">` XML, which session-tail's
421
421
  // parseChannelMeta re-extracts to set currentTurn.sessionThreadId, which
@@ -29,7 +29,10 @@ import { resolve } from "node:path";
29
29
  const gatewaySrc =
30
30
  readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
31
31
  "\n" +
32
- readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
32
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8") +
33
+ "\n" +
34
+ // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
35
+ readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
33
36
 
34
37
  function extractPerformBlock(): string {
35
38
  const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
@@ -28,13 +28,21 @@ import { readFileSync } from "node:fs";
28
28
  import { resolve } from "node:path";
29
29
 
30
30
  // #2996 Phase 5: the callback-query handler families moved verbatim to
31
- // gateway/callback-query-handlers.ts; these pins read the gateway source
32
- // COMBINED with that module so the wiring assertions keep covering the
33
- // same runtime source text.
31
+ // gateway/callback-query-handlers.ts, and the agent-facing card-STAGING
32
+ // execute* tool handlers (vault_request_save & co) moved verbatim to
33
+ // gateway/card-tool-handlers.ts (#2996 P5-tail); these pins read the gateway
34
+ // source COMBINED with those modules so the wiring assertions keep covering
35
+ // the same runtime source text.
34
36
  const gatewaySrc =
35
37
  readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
36
38
  "\n" +
37
- readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
39
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8") +
40
+ "\n" +
41
+ readFileSync(resolve(__dirname, "..", "gateway", "card-tool-handlers.ts"), "utf-8") +
42
+ "\n" +
43
+ // P7 PR-8 (#2996): the vault pending-op intercept (incl. the rename-staged-
44
+ // key validation) moved verbatim into gateway/inbound-interceptors.ts.
45
+ readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
38
46
 
39
47
  /** The exported regex literal — what the gateway actually validates against. */
40
48
  function extractVaultKeyRegex(): RegExp {
@@ -49,7 +57,10 @@ function extractVaultKeyRegex(): RegExp {
49
57
  }
50
58
 
51
59
  /** Either inline literal with `/`, OR a reference to VAULT_KEY_REGEX. */
52
- const ACCEPTS_SLASH = /\[A-Za-z0-9_(\/|\\\/|\.)+-\]|VAULT_KEY_REGEX/;
60
+ // `vaultKeyRegex` is the InboundInterceptorDeps field gateway.ts binds to the
61
+ // shared VAULT_KEY_REGEX constant (P7 PR-8) — same runtime regex, new name at
62
+ // the moved call site.
63
+ const ACCEPTS_SLASH = /\[A-Za-z0-9_(\/|\\\/|\.)+-\]|VAULT_KEY_REGEX|vaultKeyRegex/;
53
64
 
54
65
  describe("vault-key regex accepts canonical slash-namespaced keys (#1047)", () => {
55
66
  it("vault_request_save validation includes '/' in the charclass", () => {
@@ -24,13 +24,16 @@ const bridgeSrc = readFileSync(
24
24
  'utf-8',
25
25
  )
26
26
  // #2996 Phase 5: the callback-query handler families moved verbatim to
27
- // gateway/callback-query-handlers.ts; these pins read the gateway source
28
- // COMBINED with that module so the wiring assertions keep covering the
29
- // same runtime source text.
27
+ // gateway/callback-query-handlers.ts, and the agent-facing card-STAGING
28
+ // execute* tool handlers moved verbatim to gateway/card-tool-handlers.ts
29
+ // (#2996 P5-tail); these pins read the gateway source COMBINED with those
30
+ // modules so the wiring assertions keep covering the same runtime source text.
30
31
  const gatewaySrc =
31
32
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
32
33
  '\n' +
33
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
34
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
35
+ '\n' +
36
+ readFileSync(resolve(__dirname, '..', 'gateway', 'card-tool-handlers.ts'), 'utf-8')
34
37
 
35
38
  describe('vault_request_access (#1012)', () => {
36
39
  it('bridge advertises the tool to MCP clients', () => {
@@ -64,7 +67,9 @@ describe('vault_request_access (#1012)', () => {
64
67
  it('gateway routes vault_request_access in executeToolCall', () => {
65
68
  // fails when: the switch arm is dropped. Tool would be accepted
66
69
  // by ALLOWED_TOOLS but fall through to the `unknown tool` branch.
67
- expect(gatewaySrc).toMatch(/case\s+'vault_request_access':\s*\n\s*return\s+executeVaultRequestAccess/)
70
+ // #2996 P5-tail: the dispatcher arm now routes through the extracted
71
+ // card-tool-handlers factory instance.
72
+ expect(gatewaySrc).toMatch(/case\s+'vault_request_access':\s*\n\s*return\s+cardToolHandlers\.executeVaultRequestAccess/)
68
73
  })
69
74
 
70
75
  it('gateway dispatches vra: callback prefix', () => {
@@ -29,7 +29,10 @@ import { resolve } from 'node:path'
29
29
  const gatewaySrc =
30
30
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
31
31
  '\n' +
32
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
32
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
33
+ '\n' +
34
+ // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
35
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
33
36
 
34
37
  describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
35
38
  it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
@@ -30,7 +30,12 @@ import { join, dirname } from 'node:path'
30
30
  const __dir = dirname(fileURLToPath(import.meta.url))
31
31
  const pluginDir = join(__dir, '..')
32
32
 
33
- const gatewaySrc = readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8')
33
+ const gatewaySrc =
34
+ readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8') +
35
+ '\n' +
36
+ // P7 PR-8 (#2996): the vault pending-op intercept (incl. the unlock branch)
37
+ // moved verbatim into gateway/inbound-interceptors.ts (interceptVault).
38
+ readFileSync(join(pluginDir, 'gateway', 'inbound-interceptors.ts'), 'utf8')
34
39
 
35
40
  // ─── helpers ─────────────────────────────────────────────────────────────────
36
41
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Outcome pins for the voice inbound handler extracted from gateway.ts
3
+ * (switchroom#2996 P6 cluster E). Drives the engine-selection + transcription
4
+ * fallback branch against injected access / host-capability / transcribe
5
+ * surfaces + a mock coalescing dispatch. Asserts the EXACT envelope forwarded
6
+ * to handleInboundCoalesced for each path (local/cloud transcript, caption
7
+ * prefix, disabled, and every fallback to the legacy "(voice message)").
8
+ */
9
+
10
+ import { describe, it, expect, vi, afterEach } from 'vitest'
11
+ import { handleVoiceMessage, type VoiceHandlerDeps } from '../gateway/voice-message-handler.js'
12
+
13
+ const VOICE_META = { kind: 'voice', file_id: 'v1', size: 100, mime: 'audio/ogg' }
14
+
15
+ function makeDeps(over: Partial<VoiceHandlerDeps> = {}) {
16
+ const handleInboundCoalesced = vi.fn(async () => {})
17
+ const deps: VoiceHandlerDeps = {
18
+ loadAccess: () => ({ voice_in: { enabled: true, provider: 'openai', api_key: 'k', language: 'en' } }),
19
+ loadHostCapabilities: () => null,
20
+ maybeTranscribeVoiceLocal: vi.fn(async () => 'LOCAL TEXT'),
21
+ maybeTranscribeVoice: vi.fn(async () => 'CLOUD TEXT'),
22
+ handleInboundCoalesced,
23
+ ...over,
24
+ }
25
+ return { deps, handleInboundCoalesced }
26
+ }
27
+
28
+ function ctxWith(caption?: string): any {
29
+ return { message: { voice: { file_id: 'v1', file_size: 100, mime_type: 'audio/ogg' }, caption }, chat: { id: 42 } }
30
+ }
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks()
34
+ delete process.env.SWITCHROOM_VOICE_ENGINE
35
+ })
36
+
37
+ describe('handleVoiceMessage — cloud path', () => {
38
+ it('transcribes via cloud and forwards a [voice transcript] envelope', async () => {
39
+ delete process.env.SWITCHROOM_VOICE_ENGINE
40
+ const { deps, handleInboundCoalesced } = makeDeps()
41
+ await handleVoiceMessage(ctxWith(), deps)
42
+ expect(deps.maybeTranscribeVoice).toHaveBeenCalledWith('v1', 'audio/ogg', 'en', 'k')
43
+ expect(deps.maybeTranscribeVoiceLocal).not.toHaveBeenCalled()
44
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
45
+ expect.anything(), '[voice transcript] CLOUD TEXT', undefined, VOICE_META,
46
+ )
47
+ })
48
+
49
+ it('prefixes the caption to the transcript when present', async () => {
50
+ const { deps, handleInboundCoalesced } = makeDeps()
51
+ await handleVoiceMessage(ctxWith('note:'), deps)
52
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('note:\n\n[voice transcript] CLOUD TEXT')
53
+ })
54
+ })
55
+
56
+ describe('handleVoiceMessage — local path', () => {
57
+ it('routes to the local sidecar when the engine env is local', async () => {
58
+ process.env.SWITCHROOM_VOICE_ENGINE = 'local'
59
+ // voice_in.provider need not be openai for the local path.
60
+ const { deps, handleInboundCoalesced } = makeDeps({
61
+ loadAccess: () => ({ voice_in: { enabled: true, language: 'de' } }),
62
+ })
63
+ await handleVoiceMessage(ctxWith(), deps)
64
+ expect(deps.maybeTranscribeVoiceLocal).toHaveBeenCalledWith('v1', 'audio/ogg', 'de')
65
+ expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
66
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('[voice transcript] LOCAL TEXT')
67
+ })
68
+
69
+ it('reads the engine from host-capabilities when env is unset', async () => {
70
+ delete process.env.SWITCHROOM_VOICE_ENGINE
71
+ const { deps } = makeDeps({
72
+ loadAccess: () => ({ voice_in: { enabled: true } }),
73
+ loadHostCapabilities: () => ({ voice: { engine: 'local' } }),
74
+ })
75
+ await handleVoiceMessage(ctxWith(), deps)
76
+ expect(deps.maybeTranscribeVoiceLocal).toHaveBeenCalled()
77
+ })
78
+ })
79
+
80
+ describe('handleVoiceMessage — fallbacks to the legacy envelope', () => {
81
+ it('falls back when voice_in is disabled', async () => {
82
+ const { deps, handleInboundCoalesced } = makeDeps({ loadAccess: () => ({}) })
83
+ await handleVoiceMessage(ctxWith(), deps)
84
+ expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
85
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
86
+ expect.anything(), '(voice message)', undefined, VOICE_META,
87
+ )
88
+ })
89
+
90
+ it('falls back to the legacy envelope on transcription failure', async () => {
91
+ const { deps, handleInboundCoalesced } = makeDeps({ maybeTranscribeVoice: vi.fn(async () => null) })
92
+ await handleVoiceMessage(ctxWith(), deps)
93
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
94
+ expect.anything(), '(voice message)', undefined, VOICE_META,
95
+ )
96
+ })
97
+
98
+ it('uses the caption as the legacy envelope when present', async () => {
99
+ const { deps, handleInboundCoalesced } = makeDeps({ loadAccess: () => ({}) })
100
+ await handleVoiceMessage(ctxWith('hi there'), deps)
101
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('hi there')
102
+ })
103
+
104
+ it('does not use the cloud path when provider is not openai', async () => {
105
+ delete process.env.SWITCHROOM_VOICE_ENGINE
106
+ const { deps } = makeDeps({ loadAccess: () => ({ voice_in: { enabled: true } }) })
107
+ await handleVoiceMessage(ctxWith(), deps)
108
+ expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
109
+ expect(deps.maybeTranscribeVoiceLocal).not.toHaveBeenCalled()
110
+ })
111
+ })