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.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -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 =
|
|
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 =
|
|
297
|
-
const scrubIdx =
|
|
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(
|
|
307
|
-
expect(
|
|
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
|
-
|
|
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
|
|
418
|
-
//
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
+
})
|