@vellumai/assistant 0.4.16 → 0.4.18
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/Dockerfile +6 -6
- package/README.md +1 -2
- package/eslint.config.mjs +2 -2
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +128 -120
- package/src/__tests__/account-registry.test.ts +121 -110
- package/src/__tests__/active-skill-tools.test.ts +200 -172
- package/src/__tests__/actor-token-service.test.ts +341 -274
- package/src/__tests__/agent-loop-thinking.test.ts +28 -19
- package/src/__tests__/agent-loop.test.ts +798 -378
- package/src/__tests__/anthropic-provider.test.ts +405 -247
- package/src/__tests__/app-builder-tool-scripts.test.ts +97 -97
- package/src/__tests__/app-bundler.test.ts +112 -79
- package/src/__tests__/app-executors.test.ts +205 -178
- package/src/__tests__/app-git-history.test.ts +90 -73
- package/src/__tests__/app-git-service.test.ts +67 -53
- package/src/__tests__/app-open-proxy.test.ts +29 -25
- package/src/__tests__/approval-conversation-turn.test.ts +100 -81
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +45 -17
- package/src/__tests__/approval-message-composer.test.ts +119 -119
- package/src/__tests__/approval-primitive.test.ts +264 -233
- package/src/__tests__/approval-routes-http.test.ts +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +250 -178
- package/src/__tests__/asset-search-tool.test.ts +251 -191
- package/src/__tests__/assistant-attachment-directive.test.ts +187 -142
- package/src/__tests__/assistant-attachments.test.ts +254 -186
- package/src/__tests__/assistant-event-hub.test.ts +105 -63
- package/src/__tests__/assistant-event.test.ts +66 -58
- package/src/__tests__/assistant-events-sse-hardening.test.ts +113 -73
- package/src/__tests__/assistant-feature-flag-guard.test.ts +78 -52
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +48 -45
- package/src/__tests__/assistant-feature-flags-integration.test.ts +118 -77
- package/src/__tests__/assistant-id-boundary-guard.test.ts +158 -104
- package/src/__tests__/attachments-store.test.ts +240 -183
- package/src/__tests__/attachments.test.ts +70 -62
- package/src/__tests__/audit-log-rotation.test.ts +50 -35
- package/src/__tests__/browser-fill-credential.test.ts +169 -101
- package/src/__tests__/browser-manager.test.ts +97 -75
- package/src/__tests__/browser-runtime-check.test.ts +16 -15
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +12 -10
- package/src/__tests__/browser-skill-endstate.test.ts +97 -72
- package/src/__tests__/bundle-scanner.test.ts +47 -22
- package/src/__tests__/bundled-asset.test.ts +74 -47
- package/src/__tests__/call-constants.test.ts +19 -19
- package/src/__tests__/call-controller.test.ts +1073 -751
- package/src/__tests__/call-conversation-messages.test.ts +90 -65
- package/src/__tests__/call-domain.test.ts +149 -121
- package/src/__tests__/call-pointer-message-composer.test.ts +113 -83
- package/src/__tests__/call-pointer-messages.test.ts +213 -154
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +9 -10
- package/src/__tests__/call-recovery.test.ts +232 -212
- package/src/__tests__/call-routes-http.test.ts +328 -279
- package/src/__tests__/call-start-guardian-guard.test.ts +32 -30
- package/src/__tests__/call-state-machine.test.ts +62 -51
- package/src/__tests__/call-state.test.ts +89 -75
- package/src/__tests__/call-store.test.ts +387 -316
- package/src/__tests__/callback-handoff-copy.test.ts +84 -82
- package/src/__tests__/canonical-guardian-store.test.ts +331 -280
- package/src/__tests__/channel-approval-routes.test.ts +1643 -1126
- package/src/__tests__/channel-approval.test.ts +139 -137
- package/src/__tests__/channel-approvals.test.ts +226 -182
- package/src/__tests__/channel-delivery-store.test.ts +232 -194
- package/src/__tests__/channel-guardian.test.ts +6 -3
- package/src/__tests__/channel-invite-transport.test.ts +107 -92
- package/src/__tests__/channel-policy.test.ts +42 -38
- package/src/__tests__/channel-readiness-service.test.ts +119 -102
- package/src/__tests__/channel-reply-delivery.test.ts +147 -118
- package/src/__tests__/channel-retry-sweep.test.ts +153 -110
- package/src/__tests__/checker.test.ts +3309 -1850
- package/src/__tests__/clarification-resolver.test.ts +91 -79
- package/src/__tests__/classifier.test.ts +64 -54
- package/src/__tests__/claude-code-skill-regression.test.ts +42 -37
- package/src/__tests__/claude-code-tool-profiles.test.ts +31 -29
- package/src/__tests__/clawhub.test.ts +92 -82
- package/src/__tests__/cli.test.ts +30 -30
- package/src/__tests__/clipboard.test.ts +53 -46
- package/src/__tests__/commit-guarantee.test.ts +59 -52
- package/src/__tests__/commit-message-enrichment-service.test.ts +203 -75
- package/src/__tests__/compaction.benchmark.test.ts +33 -31
- package/src/__tests__/computer-use-session-compaction.test.ts +60 -50
- package/src/__tests__/computer-use-session-lifecycle.test.ts +145 -117
- package/src/__tests__/computer-use-session-working-dir.test.ts +62 -48
- package/src/__tests__/computer-use-skill-baseline.test.ts +22 -19
- package/src/__tests__/computer-use-skill-endstate.test.ts +45 -31
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +121 -88
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +65 -42
- package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +33 -18
- package/src/__tests__/computer-use-tools.test.ts +121 -98
- package/src/__tests__/config-schema.test.ts +443 -347
- package/src/__tests__/config-watcher.test.ts +96 -81
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +148 -133
- package/src/__tests__/conflict-intent-tokenization.test.ts +96 -78
- package/src/__tests__/conflict-policy.test.ts +151 -80
- package/src/__tests__/conflict-store.test.ts +203 -157
- package/src/__tests__/connection-policy.test.ts +89 -59
- package/src/__tests__/contacts-tools.test.ts +247 -178
- package/src/__tests__/context-memory-e2e.test.ts +306 -214
- package/src/__tests__/context-token-estimator.test.ts +114 -74
- package/src/__tests__/context-window-manager.test.ts +269 -167
- package/src/__tests__/contradiction-checker.test.ts +161 -135
- package/src/__tests__/conversation-attention-store.test.ts +350 -290
- package/src/__tests__/conversation-attention-telegram.test.ts +156 -114
- package/src/__tests__/conversation-pairing.test.ts +220 -113
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/conversation-store.test.ts +390 -235
- package/src/__tests__/credential-broker-browser-fill.test.ts +325 -250
- package/src/__tests__/credential-broker-server-use.test.ts +283 -243
- package/src/__tests__/credential-broker.test.ts +128 -74
- package/src/__tests__/credential-host-pattern-match.test.ts +64 -44
- package/src/__tests__/credential-metadata-store.test.ts +360 -311
- package/src/__tests__/credential-policy-validate.test.ts +81 -65
- package/src/__tests__/credential-resolve.test.ts +212 -145
- package/src/__tests__/credential-security-e2e.test.ts +144 -103
- package/src/__tests__/credential-security-invariants.test.ts +253 -208
- package/src/__tests__/credential-selection.test.ts +254 -146
- package/src/__tests__/credential-vault-unit.test.ts +531 -341
- package/src/__tests__/credential-vault.test.ts +761 -484
- package/src/__tests__/daemon-assistant-events.test.ts +91 -66
- package/src/__tests__/daemon-lifecycle.test.ts +258 -190
- package/src/__tests__/daemon-server-session-init.test.ts +257 -191
- package/src/__tests__/date-context.test.ts +314 -249
- package/src/__tests__/db-migration-rollback.test.ts +259 -130
- package/src/__tests__/db-schedule-syntax-migration.test.ts +78 -41
- package/src/__tests__/delete-managed-skill-tool.test.ts +77 -53
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -135
- package/src/__tests__/dictation-mode-detection.test.ts +77 -55
- package/src/__tests__/dictation-profile-store.test.ts +70 -56
- package/src/__tests__/dictation-text-processing.test.ts +53 -35
- package/src/__tests__/diff.test.ts +102 -98
- package/src/__tests__/domain-normalize.test.ts +54 -54
- package/src/__tests__/domain-policy.test.ts +71 -55
- package/src/__tests__/dynamic-page-surface.test.ts +31 -33
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +69 -69
- package/src/__tests__/edit-engine.test.ts +56 -56
- package/src/__tests__/elevenlabs-client.test.ts +117 -91
- package/src/__tests__/elevenlabs-config.test.ts +32 -31
- package/src/__tests__/email-classifier.test.ts +15 -12
- package/src/__tests__/email-cli.test.ts +121 -108
- package/src/__tests__/emit-signal-routing-intent.test.ts +76 -69
- package/src/__tests__/encrypted-store.test.ts +180 -154
- package/src/__tests__/entity-extractor.test.ts +108 -87
- package/src/__tests__/entity-search.test.ts +664 -258
- package/src/__tests__/ephemeral-permissions.test.ts +224 -188
- package/src/__tests__/event-bus.test.ts +81 -77
- package/src/__tests__/extract-email.test.ts +51 -0
- package/src/__tests__/file-edit-tool.test.ts +62 -44
- package/src/__tests__/file-ops-service.test.ts +131 -114
- package/src/__tests__/file-read-tool.test.ts +48 -31
- package/src/__tests__/file-write-tool.test.ts +43 -37
- package/src/__tests__/filesystem-tools.test.ts +238 -209
- package/src/__tests__/followup-tools.test.ts +237 -162
- package/src/__tests__/forbidden-legacy-symbols.test.ts +19 -20
- package/src/__tests__/frontmatter.test.ts +96 -81
- package/src/__tests__/fuzzy-match-property.test.ts +75 -81
- package/src/__tests__/fuzzy-match.test.ts +71 -65
- package/src/__tests__/gateway-client-managed-outbound.test.ts +76 -57
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -369
- package/src/__tests__/gateway-only-guard.test.ts +54 -56
- package/src/__tests__/gemini-image-service.test.ts +113 -100
- package/src/__tests__/gemini-provider.test.ts +297 -220
- package/src/__tests__/get-weather.test.ts +188 -114
- package/src/__tests__/gmail-integration.test.ts +47 -46
- package/src/__tests__/guardian-action-conversation-turn.test.ts +226 -171
- package/src/__tests__/guardian-action-copy-generator.test.ts +111 -93
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -151
- package/src/__tests__/guardian-action-followup-store.test.ts +199 -167
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +297 -250
- package/src/__tests__/guardian-action-late-reply.test.ts +462 -316
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +23 -18
- package/src/__tests__/guardian-action-store.test.ts +158 -109
- package/src/__tests__/guardian-action-sweep.test.ts +114 -100
- package/src/__tests__/guardian-actions-endpoint.test.ts +440 -256
- package/src/__tests__/guardian-control-plane-policy.test.ts +497 -331
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +217 -215
- package/src/__tests__/guardian-dispatch.test.ts +316 -256
- package/src/__tests__/guardian-grant-minting.test.ts +247 -178
- package/src/__tests__/guardian-outbound-http.test.ts +337 -209
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +99 -96
- package/src/__tests__/guardian-question-copy.test.ts +17 -17
- package/src/__tests__/guardian-question-mode.test.ts +134 -100
- package/src/__tests__/guardian-routing-invariants.test.ts +679 -613
- package/src/__tests__/guardian-routing-state.test.ts +256 -209
- package/src/__tests__/guardian-verification-intent-routing.test.ts +94 -88
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -41
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +0 -1
- package/src/__tests__/handle-user-message-secret-resume.test.ts +43 -21
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +92 -76
- package/src/__tests__/handlers-cu-observation-blob.test.ts +103 -70
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +77 -51
- package/src/__tests__/handlers-slack-config.test.ts +63 -54
- package/src/__tests__/handlers-task-submit-slash.test.ts +18 -18
- package/src/__tests__/handlers-telegram-config.test.ts +662 -329
- package/src/__tests__/handlers-twitter-config.test.ts +525 -298
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +270 -195
- package/src/__tests__/headless-browser-interactions.test.ts +444 -280
- package/src/__tests__/headless-browser-navigate.test.ts +116 -79
- package/src/__tests__/headless-browser-read-tools.test.ts +123 -86
- package/src/__tests__/headless-browser-snapshot.test.ts +71 -56
- package/src/__tests__/heartbeat-service.test.ts +76 -58
- package/src/__tests__/history-repair-observability.test.ts +14 -14
- package/src/__tests__/history-repair.test.ts +171 -167
- package/src/__tests__/home-base-bootstrap.test.ts +30 -27
- package/src/__tests__/hooks-blocking.test.ts +86 -37
- package/src/__tests__/hooks-cli.test.ts +104 -68
- package/src/__tests__/hooks-config.test.ts +81 -43
- package/src/__tests__/hooks-discovery.test.ts +106 -96
- package/src/__tests__/hooks-integration.test.ts +78 -72
- package/src/__tests__/hooks-manager.test.ts +99 -61
- package/src/__tests__/hooks-runner.test.ts +94 -71
- package/src/__tests__/hooks-settings.test.ts +69 -64
- package/src/__tests__/hooks-templates.test.ts +85 -54
- package/src/__tests__/hooks-ts-runner.test.ts +82 -45
- package/src/__tests__/hooks-watch.test.ts +32 -22
- package/src/__tests__/host-file-edit-tool.test.ts +190 -148
- package/src/__tests__/host-file-read-tool.test.ts +86 -63
- package/src/__tests__/host-file-write-tool.test.ts +98 -64
- package/src/__tests__/host-shell-tool.test.ts +342 -233
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -152
- package/src/__tests__/ingress-member-store.test.ts +163 -159
- package/src/__tests__/ingress-reconcile.test.ts +183 -142
- package/src/__tests__/ingress-routes-http.test.ts +441 -356
- package/src/__tests__/ingress-url-consistency.test.ts +125 -64
- package/src/__tests__/integration-status.test.ts +93 -73
- package/src/__tests__/intent-routing.test.ts +148 -118
- package/src/__tests__/invite-redemption-service.test.ts +163 -121
- package/src/__tests__/ipc-blob-store.test.ts +104 -91
- package/src/__tests__/ipc-contract-inventory.test.ts +27 -15
- package/src/__tests__/ipc-contract.test.ts +24 -23
- package/src/__tests__/ipc-protocol.test.ts +52 -46
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +61 -50
- package/src/__tests__/ipc-snapshot.test.ts +1135 -1056
- package/src/__tests__/ipc-validate.test.ts +240 -179
- package/src/__tests__/key-migration.test.ts +123 -90
- package/src/__tests__/keychain.test.ts +150 -123
- package/src/__tests__/lifecycle-docs-guard.test.ts +65 -64
- package/src/__tests__/llm-usage-store.test.ts +112 -87
- package/src/__tests__/managed-skill-lifecycle.test.ts +147 -108
- package/src/__tests__/managed-store.test.ts +411 -360
- package/src/__tests__/mcp-cli.test.ts +189 -123
- package/src/__tests__/mcp-health-check.test.ts +26 -21
- package/src/__tests__/media-generate-image.test.ts +122 -99
- package/src/__tests__/media-reuse-story.e2e.test.ts +282 -214
- package/src/__tests__/media-visibility-policy.test.ts +86 -38
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +146 -100
- package/src/__tests__/memory-lifecycle-e2e.test.ts +385 -297
- package/src/__tests__/memory-query-builder.test.ts +32 -33
- package/src/__tests__/memory-recall-quality.test.ts +761 -407
- package/src/__tests__/memory-regressions.experimental.test.ts +443 -380
- package/src/__tests__/memory-regressions.test.ts +3725 -2642
- package/src/__tests__/memory-retrieval-budget.test.ts +7 -8
- package/src/__tests__/memory-retrieval.benchmark.test.ts +144 -109
- package/src/__tests__/memory-upsert-concurrency.test.ts +292 -201
- package/src/__tests__/messaging-send-tool.test.ts +36 -29
- package/src/__tests__/migration-cli-flows.test.ts +69 -53
- package/src/__tests__/migration-ordering.test.ts +103 -86
- package/src/__tests__/mime-builder.test.ts +55 -32
- package/src/__tests__/mock-signup-server.test.ts +384 -246
- package/src/__tests__/model-intents.test.ts +61 -37
- package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +9 -12
- package/src/__tests__/no-is-trusted-guard.test.ts +24 -21
- package/src/__tests__/non-member-access-request.test.ts +294 -249
- package/src/__tests__/notification-broadcaster.test.ts +99 -81
- package/src/__tests__/notification-decision-fallback.test.ts +223 -178
- package/src/__tests__/notification-decision-strategy.test.ts +375 -337
- package/src/__tests__/notification-deep-link.test.ts +67 -61
- package/src/__tests__/notification-guardian-path.test.ts +248 -206
- package/src/__tests__/notification-routing-intent.test.ts +166 -93
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/notification-thread-candidate-validation.test.ts +78 -75
- package/src/__tests__/notification-thread-candidates.test.ts +64 -61
- package/src/__tests__/oauth-callback-registry.test.ts +40 -30
- package/src/__tests__/oauth-connect-handler.test.ts +109 -89
- package/src/__tests__/oauth-scope-policy.test.ts +63 -55
- package/src/__tests__/oauth2-gateway-transport.test.ts +252 -174
- package/src/__tests__/onboarding-starter-tasks.test.ts +93 -89
- package/src/__tests__/onboarding-template-contract.test.ts +93 -94
- package/src/__tests__/openai-provider.test.ts +366 -274
- package/src/__tests__/pairing-concurrent.test.ts +18 -12
- package/src/__tests__/pairing-routes.test.ts +45 -41
- package/src/__tests__/parallel-tool.benchmark.test.ts +108 -58
- package/src/__tests__/parser.test.ts +316 -226
- package/src/__tests__/path-classifier.test.ts +24 -25
- package/src/__tests__/path-policy.test.ts +187 -147
- package/src/__tests__/phone.test.ts +36 -36
- package/src/__tests__/platform-move-helper.test.ts +48 -40
- package/src/__tests__/platform-socket-path.test.ts +23 -24
- package/src/__tests__/platform-workspace-migration.test.ts +464 -414
- package/src/__tests__/platform.test.ts +61 -53
- package/src/__tests__/playbook-execution.test.ts +397 -265
- package/src/__tests__/playbook-tools.test.ts +267 -196
- package/src/__tests__/prebuilt-home-base-seed.test.ts +30 -27
- package/src/__tests__/pricing.test.ts +316 -136
- package/src/__tests__/profile-compiler.test.ts +206 -188
- package/src/__tests__/provider-commit-message-generator.test.ts +114 -106
- package/src/__tests__/provider-error-scenarios.test.ts +212 -158
- package/src/__tests__/provider-fail-open-selection.test.ts +51 -44
- package/src/__tests__/provider-registry-ollama.test.ts +13 -9
- package/src/__tests__/provider-streaming.benchmark.test.ts +232 -183
- package/src/__tests__/proxy-approval-callback.test.ts +180 -119
- package/src/__tests__/public-ingress-urls.test.ts +112 -94
- package/src/__tests__/qdrant-manager.test.ts +147 -98
- package/src/__tests__/ratelimit.test.ts +152 -82
- package/src/__tests__/recording-handler.test.ts +273 -151
- package/src/__tests__/recording-intent-fallback.test.ts +94 -75
- package/src/__tests__/recording-intent-handler.test.ts +422 -292
- package/src/__tests__/recording-intent.test.ts +578 -379
- package/src/__tests__/recording-state-machine.test.ts +530 -316
- package/src/__tests__/recurrence-engine-rruleset.test.ts +150 -92
- package/src/__tests__/recurrence-engine.test.ts +81 -41
- package/src/__tests__/recurrence-types.test.ts +63 -44
- package/src/__tests__/relay-server.test.ts +2131 -1602
- package/src/__tests__/reminder-store.test.ts +158 -80
- package/src/__tests__/reminder.test.ts +113 -109
- package/src/__tests__/remote-skill-policy.test.ts +96 -72
- package/src/__tests__/request-file-tool.test.ts +74 -67
- package/src/__tests__/response-tier.test.ts +131 -74
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -70
- package/src/__tests__/runtime-events-sse-parity.test.ts +167 -145
- package/src/__tests__/runtime-events-sse.test.ts +67 -51
- package/src/__tests__/sandbox-diagnostics.test.ts +66 -56
- package/src/__tests__/sandbox-host-parity.test.ts +377 -301
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +213 -161
- package/src/__tests__/schedule-store.test.ts +268 -205
- package/src/__tests__/schedule-tools.test.ts +702 -524
- package/src/__tests__/scheduler-recurrence.test.ts +240 -130
- package/src/__tests__/scoped-approval-grants.test.ts +258 -168
- package/src/__tests__/scoped-grant-security-matrix.test.ts +160 -146
- package/src/__tests__/script-proxy-certs.test.ts +38 -35
- package/src/__tests__/script-proxy-connect-tunnel.test.ts +71 -46
- package/src/__tests__/script-proxy-decision-trace.test.ts +161 -84
- package/src/__tests__/script-proxy-http-forwarder.test.ts +146 -129
- package/src/__tests__/script-proxy-injection-runtime.test.ts +139 -113
- package/src/__tests__/script-proxy-mitm-handler.test.ts +226 -142
- package/src/__tests__/script-proxy-policy-runtime.test.ts +126 -86
- package/src/__tests__/script-proxy-policy.test.ts +308 -153
- package/src/__tests__/script-proxy-rewrite-specificity.test.ts +74 -62
- package/src/__tests__/script-proxy-router.test.ts +111 -77
- package/src/__tests__/script-proxy-session-manager.test.ts +156 -113
- package/src/__tests__/script-proxy-session-runtime.test.ts +28 -24
- package/src/__tests__/secret-allowlist.test.ts +105 -90
- package/src/__tests__/secret-ingress-handler.test.ts +41 -30
- package/src/__tests__/secret-onetime-send.test.ts +67 -50
- package/src/__tests__/secret-prompt-log-hygiene.test.ts +35 -31
- package/src/__tests__/secret-response-routing.test.ts +50 -41
- package/src/__tests__/secret-scanner-executor.test.ts +152 -111
- package/src/__tests__/secret-scanner.test.ts +495 -413
- package/src/__tests__/secure-keys.test.ts +132 -121
- package/src/__tests__/send-endpoint-busy.test.ts +313 -232
- package/src/__tests__/send-notification-tool.test.ts +43 -42
- package/src/__tests__/sensitive-output-placeholders.test.ts +72 -64
- package/src/__tests__/sequence-store.test.ts +335 -167
- package/src/__tests__/server-history-render.test.ts +341 -202
- package/src/__tests__/session-abort-tool-results.test.ts +133 -70
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/session-confirmation-signals.test.ts +252 -160
- package/src/__tests__/session-conflict-gate.test.ts +775 -585
- package/src/__tests__/session-error.test.ts +222 -191
- package/src/__tests__/session-evictor.test.ts +79 -62
- package/src/__tests__/session-init.benchmark.test.ts +170 -108
- package/src/__tests__/session-load-history-repair.test.ts +273 -139
- package/src/__tests__/session-messaging-secret-redirect.test.ts +130 -90
- package/src/__tests__/session-pre-run-repair.test.ts +106 -59
- package/src/__tests__/session-profile-injection.test.ts +198 -130
- package/src/__tests__/session-provider-retry-repair.test.ts +223 -141
- package/src/__tests__/session-queue.test.ts +624 -321
- package/src/__tests__/session-runtime-assembly.test.ts +425 -329
- package/src/__tests__/session-runtime-workspace.test.ts +69 -61
- package/src/__tests__/session-skill-tools.test.ts +973 -678
- package/src/__tests__/session-slash-known.test.ts +185 -133
- package/src/__tests__/session-slash-queue.test.ts +147 -81
- package/src/__tests__/session-slash-unknown.test.ts +135 -90
- package/src/__tests__/session-surfaces-task-progress.test.ts +122 -87
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +338 -177
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +63 -40
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +60 -37
- package/src/__tests__/session-tool-setup-tools-disabled.test.ts +28 -26
- package/src/__tests__/session-undo.test.ts +43 -30
- package/src/__tests__/session-workspace-cache-state.test.ts +108 -67
- package/src/__tests__/session-workspace-injection.test.ts +245 -117
- package/src/__tests__/session-workspace-tool-tracking.test.ts +260 -93
- package/src/__tests__/shared-filesystem-errors.test.ts +47 -47
- package/src/__tests__/shell-credential-ref.test.ts +126 -90
- package/src/__tests__/shell-identity.test.ts +134 -111
- package/src/__tests__/shell-parser-fuzz.test.ts +263 -179
- package/src/__tests__/shell-parser-property.test.ts +435 -288
- package/src/__tests__/shell-tool-proxy-mode.test.ts +142 -70
- package/src/__tests__/size-guard.test.ts +42 -44
- package/src/__tests__/skill-feature-flags-integration.test.ts +79 -52
- package/src/__tests__/skill-feature-flags.test.ts +75 -47
- package/src/__tests__/skill-include-graph.test.ts +143 -148
- package/src/__tests__/skill-load-feature-flag.test.ts +94 -59
- package/src/__tests__/skill-load-tool.test.ts +371 -199
- package/src/__tests__/skill-projection-feature-flag.test.ts +131 -88
- package/src/__tests__/skill-projection.benchmark.test.ts +93 -65
- package/src/__tests__/skill-script-runner-host.test.ts +460 -250
- package/src/__tests__/skill-script-runner-sandbox.test.ts +168 -108
- package/src/__tests__/skill-script-runner.test.ts +115 -74
- package/src/__tests__/skill-tool-factory.test.ts +140 -96
- package/src/__tests__/skill-tool-manifest.test.ts +306 -210
- package/src/__tests__/skill-version-hash.test.ts +70 -56
- package/src/__tests__/skills.test.ts +0 -1
- package/src/__tests__/slack-channel-config.test.ts +127 -84
- package/src/__tests__/slack-skill.test.ts +60 -47
- package/src/__tests__/slash-commands-catalog.test.ts +37 -31
- package/src/__tests__/slash-commands-parser.test.ts +71 -64
- package/src/__tests__/slash-commands-resolver.test.ts +143 -107
- package/src/__tests__/slash-commands-rewrite.test.ts +22 -22
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/speaker-identification.test.ts +28 -25
- package/src/__tests__/starter-bundle.test.ts +27 -23
- package/src/__tests__/starter-task-flow.test.ts +67 -52
- package/src/__tests__/subagent-manager-notify.test.ts +154 -108
- package/src/__tests__/subagent-tools.test.ts +311 -270
- package/src/__tests__/subagent-types.test.ts +40 -40
- package/src/__tests__/surface-mutex-cleanup.test.ts +42 -30
- package/src/__tests__/swarm-dag-pathological.test.ts +122 -111
- package/src/__tests__/swarm-orchestrator.test.ts +135 -101
- package/src/__tests__/swarm-plan-validator.test.ts +125 -73
- package/src/__tests__/swarm-recursion.test.ts +58 -46
- package/src/__tests__/swarm-router-planner.test.ts +99 -74
- package/src/__tests__/swarm-session-integration.test.ts +148 -91
- package/src/__tests__/swarm-tool.test.ts +65 -45
- package/src/__tests__/swarm-worker-backend.test.ts +59 -45
- package/src/__tests__/swarm-worker-runner.test.ts +133 -118
- package/src/__tests__/system-prompt.test.ts +290 -256
- package/src/__tests__/task-compiler.test.ts +176 -120
- package/src/__tests__/task-management-tools.test.ts +561 -456
- package/src/__tests__/task-memory-cleanup.test.ts +627 -362
- package/src/__tests__/task-runner.test.ts +117 -94
- package/src/__tests__/task-scheduler.test.ts +113 -84
- package/src/__tests__/task-tools.test.ts +349 -264
- package/src/__tests__/terminal-sandbox.test.ts +138 -108
- package/src/__tests__/terminal-tools.test.ts +350 -305
- package/src/__tests__/thread-seed-composer.test.ts +307 -180
- package/src/__tests__/tool-approval-handler.test.ts +238 -137
- package/src/__tests__/tool-audit-listener.test.ts +69 -69
- package/src/__tests__/tool-domain-event-publisher.test.ts +142 -132
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +153 -146
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +136 -105
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +355 -239
- package/src/__tests__/tool-executor-redaction.test.ts +112 -109
- package/src/__tests__/tool-executor-shell-integration.test.ts +130 -79
- package/src/__tests__/tool-executor.test.ts +1274 -674
- package/src/__tests__/tool-grant-request-escalation.test.ts +401 -283
- package/src/__tests__/tool-metrics-listener.test.ts +97 -85
- package/src/__tests__/tool-notification-listener.test.ts +42 -25
- package/src/__tests__/tool-permission-simulate-handler.test.ts +137 -113
- package/src/__tests__/tool-policy.test.ts +44 -25
- package/src/__tests__/tool-profiling-listener.test.ts +99 -93
- package/src/__tests__/tool-result-truncation.test.ts +5 -4
- package/src/__tests__/tool-trace-listener.test.ts +131 -111
- package/src/__tests__/top-level-renderer.test.ts +62 -58
- package/src/__tests__/top-level-scanner.test.ts +68 -64
- package/src/__tests__/trace-emitter.test.ts +56 -56
- package/src/__tests__/trust-context-guards.test.ts +65 -65
- package/src/__tests__/trust-store.test.ts +1239 -806
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -275
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -373
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +264 -241
- package/src/__tests__/trusted-contact-multichannel.test.ts +182 -142
- package/src/__tests__/trusted-contact-verification.test.ts +251 -231
- package/src/__tests__/turn-commit.test.ts +259 -200
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-provider.test.ts +140 -126
- package/src/__tests__/twilio-rest.test.ts +22 -18
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +188 -162
- package/src/__tests__/twilio-routes-twiml.test.ts +55 -55
- package/src/__tests__/twilio-routes.test.ts +389 -281
- package/src/__tests__/twitter-auth-handler.test.ts +184 -139
- package/src/__tests__/twitter-cli-error-shaping.test.ts +88 -73
- package/src/__tests__/twitter-cli-routing.test.ts +146 -99
- package/src/__tests__/twitter-oauth-client.test.ts +82 -65
- package/src/__tests__/update-bulletin-format.test.ts +69 -66
- package/src/__tests__/update-bulletin-state.test.ts +66 -60
- package/src/__tests__/update-bulletin.test.ts +150 -114
- package/src/__tests__/update-template-contract.test.ts +15 -10
- package/src/__tests__/url-safety.test.ts +288 -265
- package/src/__tests__/user-reference.test.ts +32 -32
- package/src/__tests__/view-image-tool.test.ts +118 -96
- package/src/__tests__/voice-invite-redemption.test.ts +111 -106
- package/src/__tests__/voice-quality.test.ts +117 -102
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +204 -146
- package/src/__tests__/voice-session-bridge.test.ts +351 -216
- package/src/__tests__/weather-skill-regression.test.ts +170 -120
- package/src/__tests__/web-fetch.test.ts +664 -526
- package/src/__tests__/web-search.test.ts +379 -213
- package/src/__tests__/work-item-output.test.ts +90 -53
- package/src/__tests__/workspace-git-service.test.ts +437 -356
- package/src/__tests__/workspace-heartbeat-service.test.ts +125 -91
- package/src/__tests__/workspace-lifecycle.test.ts +98 -64
- package/src/__tests__/workspace-policy.test.ts +139 -71
- package/src/commands/__tests__/cc-command-registry.test.ts +142 -134
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +48 -39
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +44 -4
- package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +0 -1
- package/src/config/bundled-skills/messaging/SKILL.md +9 -7
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +15 -5
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -5
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +34 -32
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/env.ts +38 -29
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/memory/db-connection.ts +16 -10
- package/src/messaging/providers/gmail/adapter.ts +10 -3
- package/src/messaging/providers/gmail/client.ts +280 -72
- package/src/runtime/auth/__tests__/context.test.ts +75 -65
- package/src/runtime/auth/__tests__/credential-service.test.ts +137 -114
- package/src/runtime/auth/__tests__/guard-tests.test.ts +84 -90
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +40 -40
- package/src/runtime/auth/__tests__/middleware.test.ts +80 -74
- package/src/runtime/auth/__tests__/policy.test.ts +9 -9
- package/src/runtime/auth/__tests__/route-policy.test.ts +76 -65
- package/src/runtime/auth/__tests__/scopes.test.ts +68 -60
- package/src/runtime/auth/__tests__/subject.test.ts +54 -54
- package/src/runtime/auth/__tests__/token-service.test.ts +115 -108
- package/src/runtime/auth/scopes.ts +3 -0
- package/src/runtime/auth/token-service.ts +78 -48
- package/src/runtime/auth/types.ts +2 -1
- package/src/runtime/http-server.ts +2 -1
- package/src/security/secure-keys.ts +103 -53
- package/src/sequence/reply-matcher.ts +10 -6
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/browser/__tests__/auth-cache.test.ts +69 -63
- package/src/tools/browser/__tests__/auth-detector.test.ts +218 -157
- package/src/tools/browser/__tests__/jit-auth.test.ts +83 -99
- package/src/tools/ui-surface/definitions.ts +2 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -1,61 +1,70 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import { join } from
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
beforeEach,
|
|
7
|
+
describe,
|
|
8
|
+
expect,
|
|
9
|
+
mock,
|
|
10
|
+
spyOn,
|
|
11
|
+
test,
|
|
12
|
+
} from "bun:test";
|
|
4
13
|
|
|
5
|
-
import {
|
|
6
|
-
import { eq } from 'drizzle-orm';
|
|
14
|
+
import { eq } from "drizzle-orm";
|
|
7
15
|
|
|
8
16
|
// ---------------------------------------------------------------------------
|
|
9
17
|
// Test isolation: in-memory SQLite via temp directory
|
|
10
18
|
// ---------------------------------------------------------------------------
|
|
11
19
|
|
|
12
|
-
const testDir = mkdtempSync(join(tmpdir(),
|
|
20
|
+
const testDir = mkdtempSync(join(tmpdir(), "channel-approval-routes-test-"));
|
|
13
21
|
|
|
14
|
-
mock.module(
|
|
22
|
+
mock.module("../util/platform.js", () => ({
|
|
15
23
|
getRootDir: () => testDir,
|
|
16
24
|
getDataDir: () => testDir,
|
|
17
|
-
isMacOS: () => process.platform ===
|
|
18
|
-
isLinux: () => process.platform ===
|
|
19
|
-
isWindows: () => process.platform ===
|
|
20
|
-
getSocketPath: () => join(testDir,
|
|
21
|
-
getPidPath: () => join(testDir,
|
|
22
|
-
getDbPath: () => join(testDir,
|
|
23
|
-
getLogPath: () => join(testDir,
|
|
25
|
+
isMacOS: () => process.platform === "darwin",
|
|
26
|
+
isLinux: () => process.platform === "linux",
|
|
27
|
+
isWindows: () => process.platform === "win32",
|
|
28
|
+
getSocketPath: () => join(testDir, "test.sock"),
|
|
29
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
30
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
31
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
24
32
|
ensureDataDir: () => {},
|
|
25
33
|
}));
|
|
26
34
|
|
|
27
|
-
mock.module(
|
|
28
|
-
getLogger: () =>
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
mock.module("../util/logger.js", () => ({
|
|
36
|
+
getLogger: () =>
|
|
37
|
+
new Proxy({} as Record<string, unknown>, {
|
|
38
|
+
get: () => () => {},
|
|
39
|
+
}),
|
|
31
40
|
}));
|
|
32
41
|
|
|
33
42
|
// Mock security check to always pass
|
|
34
|
-
mock.module(
|
|
43
|
+
mock.module("../security/secret-ingress.js", () => ({
|
|
35
44
|
checkIngressForSecrets: () => ({ blocked: false }),
|
|
36
45
|
}));
|
|
37
46
|
|
|
38
47
|
// Mock render to return the raw content as text
|
|
39
|
-
mock.module(
|
|
48
|
+
mock.module("../daemon/handlers.js", () => ({
|
|
40
49
|
renderHistoryContent: (content: unknown) => ({
|
|
41
|
-
text: typeof content ===
|
|
50
|
+
text: typeof content === "string" ? content : JSON.stringify(content),
|
|
42
51
|
}),
|
|
43
52
|
}));
|
|
44
53
|
|
|
45
54
|
// Mock ingress member store to return an active member for all lookups.
|
|
46
55
|
// The ingress ACL is always-on and requires member records, but approval
|
|
47
56
|
// route tests focus on approval orchestration, not ACL enforcement.
|
|
48
|
-
mock.module(
|
|
57
|
+
mock.module("../memory/ingress-member-store.js", () => ({
|
|
49
58
|
findMember: () => ({
|
|
50
|
-
id:
|
|
51
|
-
assistantId:
|
|
52
|
-
sourceChannel:
|
|
53
|
-
externalUserId:
|
|
59
|
+
id: "member-test-default",
|
|
60
|
+
assistantId: "self",
|
|
61
|
+
sourceChannel: "telegram",
|
|
62
|
+
externalUserId: "telegram-user-default",
|
|
54
63
|
externalChatId: null,
|
|
55
64
|
displayName: null,
|
|
56
65
|
username: null,
|
|
57
|
-
status:
|
|
58
|
-
policy:
|
|
66
|
+
status: "active",
|
|
67
|
+
policy: "allow",
|
|
59
68
|
inviteId: null,
|
|
60
69
|
createdBySessionId: null,
|
|
61
70
|
revokedReason: null,
|
|
@@ -66,35 +75,42 @@ mock.module('../memory/ingress-member-store.js', () => ({
|
|
|
66
75
|
}),
|
|
67
76
|
updateLastSeen: () => {},
|
|
68
77
|
}));
|
|
69
|
-
import type { Session } from
|
|
78
|
+
import type { Session } from "../daemon/session.js";
|
|
70
79
|
import {
|
|
71
80
|
createCanonicalGuardianDelivery,
|
|
72
81
|
createCanonicalGuardianRequest,
|
|
73
82
|
getCanonicalGuardianRequest,
|
|
74
|
-
} from
|
|
75
|
-
import * as channelDeliveryStore from
|
|
83
|
+
} from "../memory/canonical-guardian-store.js";
|
|
84
|
+
import * as channelDeliveryStore from "../memory/channel-delivery-store.js";
|
|
76
85
|
import {
|
|
77
86
|
createApprovalRequest,
|
|
78
87
|
createBinding,
|
|
79
88
|
getAllPendingApprovalsByGuardianChat,
|
|
80
|
-
} from
|
|
81
|
-
import { getDb, initializeDb, resetDb } from
|
|
82
|
-
import {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
} from "../memory/channel-guardian-store.js";
|
|
90
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
91
|
+
import {
|
|
92
|
+
conversations,
|
|
93
|
+
externalConversationBindings,
|
|
94
|
+
} from "../memory/schema.js";
|
|
95
|
+
import { initAuthSigningKey } from "../runtime/auth/token-service.js";
|
|
96
|
+
import * as gatewayClient from "../runtime/gateway-client.js";
|
|
97
|
+
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
86
98
|
import {
|
|
87
99
|
_setTestPollMaxWait,
|
|
88
100
|
handleChannelInbound,
|
|
89
101
|
sweepExpiredGuardianApprovals,
|
|
90
|
-
} from
|
|
102
|
+
} from "../runtime/routes/channel-routes.js";
|
|
91
103
|
|
|
92
104
|
initializeDb();
|
|
93
|
-
initAuthSigningKey(Buffer.from(
|
|
105
|
+
initAuthSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long"));
|
|
94
106
|
|
|
95
107
|
afterAll(() => {
|
|
96
108
|
resetDb();
|
|
97
|
-
try {
|
|
109
|
+
try {
|
|
110
|
+
rmSync(testDir, { recursive: true });
|
|
111
|
+
} catch {
|
|
112
|
+
/* best effort */
|
|
113
|
+
}
|
|
98
114
|
});
|
|
99
115
|
|
|
100
116
|
// ---------------------------------------------------------------------------
|
|
@@ -104,11 +120,13 @@ afterAll(() => {
|
|
|
104
120
|
function ensureConversation(conversationId: string): void {
|
|
105
121
|
const db = getDb();
|
|
106
122
|
try {
|
|
107
|
-
db.insert(conversations)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
db.insert(conversations)
|
|
124
|
+
.values({
|
|
125
|
+
id: conversationId,
|
|
126
|
+
createdAt: Date.now(),
|
|
127
|
+
updatedAt: Date.now(),
|
|
128
|
+
})
|
|
129
|
+
.run();
|
|
112
130
|
} catch {
|
|
113
131
|
// already exists
|
|
114
132
|
}
|
|
@@ -116,17 +134,17 @@ function ensureConversation(conversationId: string): void {
|
|
|
116
134
|
|
|
117
135
|
function resetTables(): void {
|
|
118
136
|
const db = getDb();
|
|
119
|
-
db.run(
|
|
120
|
-
db.run(
|
|
121
|
-
db.run(
|
|
122
|
-
db.run(
|
|
123
|
-
db.run(
|
|
124
|
-
db.run(
|
|
125
|
-
db.run(
|
|
126
|
-
db.run(
|
|
127
|
-
db.run(
|
|
128
|
-
db.run(
|
|
129
|
-
db.run(
|
|
137
|
+
db.run("DELETE FROM scoped_approval_grants");
|
|
138
|
+
db.run("DELETE FROM canonical_guardian_deliveries");
|
|
139
|
+
db.run("DELETE FROM canonical_guardian_requests");
|
|
140
|
+
db.run("DELETE FROM channel_guardian_approval_requests");
|
|
141
|
+
db.run("DELETE FROM channel_guardian_verification_challenges");
|
|
142
|
+
db.run("DELETE FROM channel_guardian_bindings");
|
|
143
|
+
db.run("DELETE FROM conversation_keys");
|
|
144
|
+
db.run("DELETE FROM message_runs");
|
|
145
|
+
db.run("DELETE FROM channel_inbound_events");
|
|
146
|
+
db.run("DELETE FROM messages");
|
|
147
|
+
db.run("DELETE FROM conversations");
|
|
130
148
|
channelDeliveryStore.resetAllRunDeliveryClaims();
|
|
131
149
|
pendingInteractions.clear();
|
|
132
150
|
}
|
|
@@ -143,9 +161,13 @@ function registerPendingInteraction(
|
|
|
143
161
|
input?: Record<string, unknown>;
|
|
144
162
|
riskLevel?: string;
|
|
145
163
|
persistentDecisionsAllowed?: boolean;
|
|
146
|
-
allowlistOptions?: Array<{
|
|
164
|
+
allowlistOptions?: Array<{
|
|
165
|
+
label: string;
|
|
166
|
+
description: string;
|
|
167
|
+
pattern: string;
|
|
168
|
+
}>;
|
|
147
169
|
scopeOptions?: Array<{ label: string; scope: string }>;
|
|
148
|
-
executionTarget?:
|
|
170
|
+
executionTarget?: "sandbox" | "host";
|
|
149
171
|
},
|
|
150
172
|
): ReturnType<typeof mock> {
|
|
151
173
|
const handleConfirmationResponse = mock(() => {});
|
|
@@ -156,16 +178,20 @@ function registerPendingInteraction(
|
|
|
156
178
|
pendingInteractions.register(requestId, {
|
|
157
179
|
session: mockSession,
|
|
158
180
|
conversationId,
|
|
159
|
-
kind:
|
|
181
|
+
kind: "confirmation",
|
|
160
182
|
confirmationDetails: {
|
|
161
183
|
toolName,
|
|
162
|
-
input: opts?.input ?? { command:
|
|
163
|
-
riskLevel: opts?.riskLevel ??
|
|
184
|
+
input: opts?.input ?? { command: "rm -rf /tmp/test" },
|
|
185
|
+
riskLevel: opts?.riskLevel ?? "high",
|
|
164
186
|
allowlistOptions: opts?.allowlistOptions ?? [
|
|
165
|
-
{
|
|
187
|
+
{
|
|
188
|
+
label: "rm -rf /tmp/test",
|
|
189
|
+
description: "rm -rf /tmp/test",
|
|
190
|
+
pattern: "rm -rf /tmp/test",
|
|
191
|
+
},
|
|
166
192
|
],
|
|
167
193
|
scopeOptions: opts?.scopeOptions ?? [
|
|
168
|
-
{ label:
|
|
194
|
+
{ label: "everywhere", scope: "everywhere" },
|
|
169
195
|
],
|
|
170
196
|
persistentDecisionsAllowed: opts?.persistentDecisionsAllowed,
|
|
171
197
|
executionTarget: opts?.executionTarget,
|
|
@@ -175,35 +201,30 @@ function registerPendingInteraction(
|
|
|
175
201
|
return handleConfirmationResponse;
|
|
176
202
|
}
|
|
177
203
|
|
|
178
|
-
/** Legacy bearer token constant — retained for use in X-Gateway-Origin
|
|
179
|
-
* headers in test request construction. Gateway-origin proof is now
|
|
180
|
-
* handled by JWT auth; these headers are ignored by the handler. */
|
|
181
|
-
const TEST_BEARER_TOKEN = 'token';
|
|
182
|
-
|
|
183
204
|
function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
184
205
|
const body: Record<string, unknown> = {
|
|
185
|
-
sourceChannel:
|
|
186
|
-
conversationExternalId:
|
|
187
|
-
actorExternalId:
|
|
206
|
+
sourceChannel: "telegram",
|
|
207
|
+
conversationExternalId: "chat-123",
|
|
208
|
+
actorExternalId: "telegram-user-default",
|
|
188
209
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
189
|
-
content:
|
|
190
|
-
replyCallbackUrl:
|
|
210
|
+
content: "hello",
|
|
211
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
191
212
|
...overrides,
|
|
192
213
|
};
|
|
193
|
-
if (!Object.hasOwn(overrides,
|
|
194
|
-
body.interface =
|
|
214
|
+
if (!Object.hasOwn(overrides, "interface")) {
|
|
215
|
+
body.interface =
|
|
216
|
+
typeof body.sourceChannel === "string" ? body.sourceChannel : "telegram";
|
|
195
217
|
}
|
|
196
|
-
return new Request(
|
|
197
|
-
method:
|
|
218
|
+
return new Request("http://localhost/channels/inbound", {
|
|
219
|
+
method: "POST",
|
|
198
220
|
headers: {
|
|
199
|
-
|
|
200
|
-
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
221
|
+
"Content-Type": "application/json",
|
|
201
222
|
},
|
|
202
223
|
body: JSON.stringify(body),
|
|
203
224
|
});
|
|
204
225
|
}
|
|
205
226
|
|
|
206
|
-
const noopProcessMessage = mock(async () => ({ messageId:
|
|
227
|
+
const noopProcessMessage = mock(async () => ({ messageId: "msg-1" }));
|
|
207
228
|
|
|
208
229
|
beforeEach(() => {
|
|
209
230
|
resetTables();
|
|
@@ -214,26 +235,26 @@ beforeEach(() => {
|
|
|
214
235
|
// 1. Stale callback handling without matching pending approval
|
|
215
236
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
216
237
|
|
|
217
|
-
describe(
|
|
218
|
-
test(
|
|
219
|
-
ensureConversation(
|
|
238
|
+
describe("stale callback handling without matching pending approval", () => {
|
|
239
|
+
test("ignores stale callback payloads even when pending approvals exist", async () => {
|
|
240
|
+
ensureConversation("conv-1");
|
|
220
241
|
|
|
221
242
|
// Register a pending interaction for this conversation
|
|
222
|
-
registerPendingInteraction(
|
|
243
|
+
registerPendingInteraction("req-abc", "conv-1", "shell");
|
|
223
244
|
|
|
224
245
|
const req = makeInboundRequest({
|
|
225
|
-
content:
|
|
246
|
+
content: "approve",
|
|
226
247
|
// Callback data references a DIFFERENT requestId than the one pending
|
|
227
|
-
callbackData:
|
|
248
|
+
callbackData: "apr:req-different:approve_once",
|
|
228
249
|
});
|
|
229
250
|
|
|
230
251
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
231
|
-
const body = await res.json() as Record<string, unknown>;
|
|
252
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
232
253
|
|
|
233
254
|
// Callback payloads without a matching pending approval are treated as
|
|
234
255
|
// stale and ignored.
|
|
235
256
|
expect(body.accepted).toBe(true);
|
|
236
|
-
expect(body.approval).toBe(
|
|
257
|
+
expect(body.approval).toBe("stale_ignored");
|
|
237
258
|
});
|
|
238
259
|
});
|
|
239
260
|
|
|
@@ -241,73 +262,91 @@ describe('stale callback handling without matching pending approval', () => {
|
|
|
241
262
|
// 2. Callback data triggers decision handling
|
|
242
263
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
243
264
|
|
|
244
|
-
describe(
|
|
265
|
+
describe("inbound callback metadata triggers decision handling", () => {
|
|
245
266
|
beforeEach(() => {
|
|
246
267
|
createBinding({
|
|
247
|
-
assistantId:
|
|
248
|
-
channel:
|
|
249
|
-
guardianExternalUserId:
|
|
250
|
-
guardianDeliveryChatId:
|
|
251
|
-
guardianPrincipalId:
|
|
268
|
+
assistantId: "self",
|
|
269
|
+
channel: "telegram",
|
|
270
|
+
guardianExternalUserId: "telegram-user-default",
|
|
271
|
+
guardianDeliveryChatId: "chat-123",
|
|
272
|
+
guardianPrincipalId: "telegram-user-default",
|
|
252
273
|
});
|
|
253
274
|
});
|
|
254
275
|
|
|
255
276
|
test('callback data "apr:<requestId>:approve_once" is parsed and applied', async () => {
|
|
256
|
-
const deliverSpy = spyOn(
|
|
277
|
+
const deliverSpy = spyOn(
|
|
278
|
+
gatewayClient,
|
|
279
|
+
"deliverChannelReply",
|
|
280
|
+
).mockResolvedValue(undefined);
|
|
257
281
|
|
|
258
282
|
// Establish the conversation to get a conversationId mapping
|
|
259
|
-
const initReq = makeInboundRequest({ content:
|
|
283
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
260
284
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
261
285
|
|
|
262
286
|
const db = getDb();
|
|
263
|
-
const events = db.$client
|
|
287
|
+
const events = db.$client
|
|
288
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
289
|
+
.all() as Array<{ conversation_id: string }>;
|
|
264
290
|
const conversationId = events[0]?.conversation_id;
|
|
265
291
|
expect(conversationId).toBeTruthy();
|
|
266
292
|
ensureConversation(conversationId!);
|
|
267
293
|
|
|
268
294
|
// Register a pending interaction for this conversation
|
|
269
|
-
const sessionMock = registerPendingInteraction(
|
|
295
|
+
const sessionMock = registerPendingInteraction(
|
|
296
|
+
"req-cb-1",
|
|
297
|
+
conversationId!,
|
|
298
|
+
"shell",
|
|
299
|
+
);
|
|
270
300
|
|
|
271
301
|
// Send a callback data message
|
|
272
302
|
const req = makeInboundRequest({
|
|
273
|
-
content:
|
|
274
|
-
callbackData:
|
|
303
|
+
content: "",
|
|
304
|
+
callbackData: "apr:req-cb-1:approve_once",
|
|
275
305
|
});
|
|
276
306
|
|
|
277
307
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
278
|
-
const body = await res.json() as Record<string, unknown>;
|
|
308
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
279
309
|
|
|
280
310
|
expect(body.accepted).toBe(true);
|
|
281
|
-
expect(body.approval).toBe(
|
|
282
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
311
|
+
expect(body.approval).toBe("decision_applied");
|
|
312
|
+
expect(sessionMock).toHaveBeenCalledWith("req-cb-1", "allow");
|
|
283
313
|
|
|
284
314
|
deliverSpy.mockRestore();
|
|
285
315
|
});
|
|
286
316
|
|
|
287
317
|
test('callback data "apr:<requestId>:reject" applies a rejection', async () => {
|
|
288
|
-
const deliverSpy = spyOn(
|
|
318
|
+
const deliverSpy = spyOn(
|
|
319
|
+
gatewayClient,
|
|
320
|
+
"deliverChannelReply",
|
|
321
|
+
).mockResolvedValue(undefined);
|
|
289
322
|
|
|
290
|
-
const initReq = makeInboundRequest({ content:
|
|
323
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
291
324
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
292
325
|
|
|
293
326
|
const db = getDb();
|
|
294
|
-
const events = db.$client
|
|
327
|
+
const events = db.$client
|
|
328
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
329
|
+
.all() as Array<{ conversation_id: string }>;
|
|
295
330
|
const conversationId = events[0]?.conversation_id;
|
|
296
331
|
ensureConversation(conversationId!);
|
|
297
332
|
|
|
298
|
-
const sessionMock = registerPendingInteraction(
|
|
333
|
+
const sessionMock = registerPendingInteraction(
|
|
334
|
+
"req-cb-2",
|
|
335
|
+
conversationId!,
|
|
336
|
+
"shell",
|
|
337
|
+
);
|
|
299
338
|
|
|
300
339
|
const req = makeInboundRequest({
|
|
301
|
-
content:
|
|
302
|
-
callbackData:
|
|
340
|
+
content: "",
|
|
341
|
+
callbackData: "apr:req-cb-2:reject",
|
|
303
342
|
});
|
|
304
343
|
|
|
305
344
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
306
|
-
const body = await res.json() as Record<string, unknown>;
|
|
345
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
307
346
|
|
|
308
347
|
expect(body.accepted).toBe(true);
|
|
309
|
-
expect(body.approval).toBe(
|
|
310
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
348
|
+
expect(body.approval).toBe("decision_applied");
|
|
349
|
+
expect(sessionMock).toHaveBeenCalledWith("req-cb-2", "deny");
|
|
311
350
|
|
|
312
351
|
deliverSpy.mockRestore();
|
|
313
352
|
});
|
|
@@ -317,61 +356,79 @@ describe('inbound callback metadata triggers decision handling', () => {
|
|
|
317
356
|
// 3. Plain text triggers decision handling
|
|
318
357
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
319
358
|
|
|
320
|
-
describe(
|
|
359
|
+
describe("inbound text matching approval phrases triggers decision handling", () => {
|
|
321
360
|
beforeEach(() => {
|
|
322
361
|
createBinding({
|
|
323
|
-
assistantId:
|
|
324
|
-
channel:
|
|
325
|
-
guardianExternalUserId:
|
|
326
|
-
guardianDeliveryChatId:
|
|
327
|
-
guardianPrincipalId:
|
|
362
|
+
assistantId: "self",
|
|
363
|
+
channel: "telegram",
|
|
364
|
+
guardianExternalUserId: "telegram-user-default",
|
|
365
|
+
guardianDeliveryChatId: "chat-123",
|
|
366
|
+
guardianPrincipalId: "telegram-user-default",
|
|
328
367
|
});
|
|
329
368
|
});
|
|
330
369
|
|
|
331
370
|
test('text "approve" triggers approve_once decision', async () => {
|
|
332
|
-
const deliverSpy = spyOn(
|
|
371
|
+
const deliverSpy = spyOn(
|
|
372
|
+
gatewayClient,
|
|
373
|
+
"deliverChannelReply",
|
|
374
|
+
).mockResolvedValue(undefined);
|
|
333
375
|
|
|
334
|
-
const initReq = makeInboundRequest({ content:
|
|
376
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
335
377
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
336
378
|
|
|
337
379
|
const db = getDb();
|
|
338
|
-
const events = db.$client
|
|
380
|
+
const events = db.$client
|
|
381
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
382
|
+
.all() as Array<{ conversation_id: string }>;
|
|
339
383
|
const conversationId = events[0]?.conversation_id;
|
|
340
384
|
ensureConversation(conversationId!);
|
|
341
385
|
|
|
342
|
-
const sessionMock = registerPendingInteraction(
|
|
386
|
+
const sessionMock = registerPendingInteraction(
|
|
387
|
+
"req-txt-1",
|
|
388
|
+
conversationId!,
|
|
389
|
+
"shell",
|
|
390
|
+
);
|
|
343
391
|
|
|
344
|
-
const req = makeInboundRequest({ content:
|
|
392
|
+
const req = makeInboundRequest({ content: "approve" });
|
|
345
393
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
346
|
-
const body = await res.json() as Record<string, unknown>;
|
|
394
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
347
395
|
|
|
348
396
|
expect(body.accepted).toBe(true);
|
|
349
|
-
expect(body.approval).toBe(
|
|
350
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
397
|
+
expect(body.approval).toBe("decision_applied");
|
|
398
|
+
expect(sessionMock).toHaveBeenCalledWith("req-txt-1", "allow");
|
|
351
399
|
|
|
352
400
|
deliverSpy.mockRestore();
|
|
353
401
|
});
|
|
354
402
|
|
|
355
403
|
test('text "always" triggers approve_always decision', async () => {
|
|
356
|
-
const deliverSpy = spyOn(
|
|
404
|
+
const deliverSpy = spyOn(
|
|
405
|
+
gatewayClient,
|
|
406
|
+
"deliverChannelReply",
|
|
407
|
+
).mockResolvedValue(undefined);
|
|
357
408
|
|
|
358
|
-
const initReq = makeInboundRequest({ content:
|
|
409
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
359
410
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
360
411
|
|
|
361
412
|
const db = getDb();
|
|
362
|
-
const events = db.$client
|
|
413
|
+
const events = db.$client
|
|
414
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
415
|
+
.all() as Array<{ conversation_id: string }>;
|
|
363
416
|
const conversationId = events[0]?.conversation_id;
|
|
364
417
|
ensureConversation(conversationId!);
|
|
365
418
|
|
|
366
|
-
const sessionMock = registerPendingInteraction(
|
|
419
|
+
const sessionMock = registerPendingInteraction(
|
|
420
|
+
"req-txt-2",
|
|
421
|
+
conversationId!,
|
|
422
|
+
"shell",
|
|
423
|
+
);
|
|
367
424
|
|
|
368
|
-
const req = makeInboundRequest({ content:
|
|
425
|
+
const req = makeInboundRequest({ content: "always" });
|
|
369
426
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
370
|
-
const body = await res.json() as Record<string, unknown>;
|
|
427
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
371
428
|
|
|
372
429
|
expect(body.accepted).toBe(true);
|
|
373
|
-
expect(body.approval).toBe(
|
|
374
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
430
|
+
expect(body.approval).toBe("decision_applied");
|
|
431
|
+
expect(sessionMock).toHaveBeenCalledWith("req-txt-2", "allow");
|
|
375
432
|
|
|
376
433
|
deliverSpy.mockRestore();
|
|
377
434
|
});
|
|
@@ -381,47 +438,54 @@ describe('inbound text matching approval phrases triggers decision handling', ()
|
|
|
381
438
|
// 4. Non-decision messages during pending approval (no conversational engine)
|
|
382
439
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
383
440
|
|
|
384
|
-
describe(
|
|
441
|
+
describe("non-decision messages during pending approval (legacy fallback)", () => {
|
|
385
442
|
beforeEach(() => {
|
|
386
443
|
createBinding({
|
|
387
|
-
assistantId:
|
|
388
|
-
channel:
|
|
389
|
-
guardianExternalUserId:
|
|
390
|
-
guardianDeliveryChatId:
|
|
391
|
-
guardianPrincipalId:
|
|
444
|
+
assistantId: "self",
|
|
445
|
+
channel: "telegram",
|
|
446
|
+
guardianExternalUserId: "telegram-user-default",
|
|
447
|
+
guardianDeliveryChatId: "chat-123",
|
|
448
|
+
guardianPrincipalId: "telegram-user-default",
|
|
392
449
|
});
|
|
393
450
|
});
|
|
394
451
|
|
|
395
|
-
test(
|
|
396
|
-
const replySpy = spyOn(
|
|
452
|
+
test("sends a status reply when message is not a decision and no conversational engine", async () => {
|
|
453
|
+
const replySpy = spyOn(
|
|
454
|
+
gatewayClient,
|
|
455
|
+
"deliverChannelReply",
|
|
456
|
+
).mockResolvedValue(undefined);
|
|
397
457
|
|
|
398
|
-
const initReq = makeInboundRequest({ content:
|
|
458
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
399
459
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
400
460
|
|
|
401
461
|
const db = getDb();
|
|
402
|
-
const events = db.$client
|
|
462
|
+
const events = db.$client
|
|
463
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
464
|
+
.all() as Array<{ conversation_id: string }>;
|
|
403
465
|
const conversationId = events[0]?.conversation_id;
|
|
404
466
|
ensureConversation(conversationId!);
|
|
405
467
|
|
|
406
|
-
registerPendingInteraction(
|
|
468
|
+
registerPendingInteraction("req-nd-1", conversationId!, "shell");
|
|
407
469
|
|
|
408
470
|
// Send a message that is NOT a decision
|
|
409
|
-
const req = makeInboundRequest({ content:
|
|
471
|
+
const req = makeInboundRequest({ content: "what is the weather?" });
|
|
410
472
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
411
|
-
const body = await res.json() as Record<string, unknown>;
|
|
473
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
412
474
|
|
|
413
475
|
expect(body.accepted).toBe(true);
|
|
414
|
-
expect(body.approval).toBe(
|
|
476
|
+
expect(body.approval).toBe("assistant_turn");
|
|
415
477
|
|
|
416
478
|
// A status reply should have been delivered via deliverChannelReply
|
|
417
479
|
expect(replySpy).toHaveBeenCalled();
|
|
418
480
|
const statusCall = replySpy.mock.calls.find(
|
|
419
|
-
(call) =>
|
|
481
|
+
(call) =>
|
|
482
|
+
typeof call[1] === "object" &&
|
|
483
|
+
(call[1] as { chatId?: string }).chatId === "chat-123",
|
|
420
484
|
);
|
|
421
485
|
expect(statusCall).toBeDefined();
|
|
422
486
|
const statusPayload = statusCall![1] as { text?: string };
|
|
423
487
|
// The status text mentions a pending approval
|
|
424
|
-
expect(statusPayload.text).toContain(
|
|
488
|
+
expect(statusPayload.text).toContain("pending approval request");
|
|
425
489
|
|
|
426
490
|
replySpy.mockRestore();
|
|
427
491
|
});
|
|
@@ -431,20 +495,20 @@ describe('non-decision messages during pending approval (legacy fallback)', () =
|
|
|
431
495
|
// 5. Messages without pending approval proceed normally
|
|
432
496
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
433
497
|
|
|
434
|
-
describe(
|
|
435
|
-
test(
|
|
436
|
-
const req = makeInboundRequest({ content:
|
|
498
|
+
describe("messages without pending approval proceed normally", () => {
|
|
499
|
+
test("proceeds to normal processing when no pending approval exists", async () => {
|
|
500
|
+
const req = makeInboundRequest({ content: "hello world" });
|
|
437
501
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
438
|
-
const body = await res.json() as Record<string, unknown>;
|
|
502
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
439
503
|
|
|
440
504
|
expect(body.accepted).toBe(true);
|
|
441
505
|
expect(body.approval).toBeUndefined();
|
|
442
506
|
});
|
|
443
507
|
|
|
444
508
|
test('text "approve" is processed normally when no pending approval exists', async () => {
|
|
445
|
-
const req = makeInboundRequest({ content:
|
|
509
|
+
const req = makeInboundRequest({ content: "approve" });
|
|
446
510
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
447
|
-
const body = await res.json() as Record<string, unknown>;
|
|
511
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
448
512
|
|
|
449
513
|
expect(body.accepted).toBe(true);
|
|
450
514
|
// Should NOT be treated as an approval decision since there's no pending approval
|
|
@@ -456,90 +520,109 @@ describe('messages without pending approval proceed normally', () => {
|
|
|
456
520
|
// 6. Empty content with callbackData bypasses validation
|
|
457
521
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
458
522
|
|
|
459
|
-
describe(
|
|
523
|
+
describe("empty content with callbackData bypasses validation", () => {
|
|
460
524
|
beforeEach(() => {
|
|
461
525
|
createBinding({
|
|
462
|
-
assistantId:
|
|
463
|
-
channel:
|
|
464
|
-
guardianExternalUserId:
|
|
465
|
-
guardianDeliveryChatId:
|
|
466
|
-
guardianPrincipalId:
|
|
526
|
+
assistantId: "self",
|
|
527
|
+
channel: "telegram",
|
|
528
|
+
guardianExternalUserId: "telegram-user-default",
|
|
529
|
+
guardianDeliveryChatId: "chat-123",
|
|
530
|
+
guardianPrincipalId: "telegram-user-default",
|
|
467
531
|
});
|
|
468
532
|
});
|
|
469
533
|
|
|
470
|
-
test(
|
|
471
|
-
const req = makeInboundRequest({ content:
|
|
534
|
+
test("rejects empty content without callbackData", async () => {
|
|
535
|
+
const req = makeInboundRequest({ content: "" });
|
|
472
536
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
473
537
|
expect(res.status).toBe(400);
|
|
474
|
-
const body = await res.json() as Record<string, unknown>;
|
|
475
|
-
expect((body.error as Record<string, unknown>).message).toBe(
|
|
538
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
539
|
+
expect((body.error as Record<string, unknown>).message).toBe(
|
|
540
|
+
"content or attachmentIds is required",
|
|
541
|
+
);
|
|
476
542
|
});
|
|
477
543
|
|
|
478
|
-
test(
|
|
544
|
+
test("allows empty content when callbackData is present", async () => {
|
|
479
545
|
// Establish the conversation first
|
|
480
|
-
const initReq = makeInboundRequest({ content:
|
|
546
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
481
547
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
482
548
|
|
|
483
549
|
const db = getDb();
|
|
484
|
-
const events = db.$client
|
|
550
|
+
const events = db.$client
|
|
551
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
552
|
+
.all() as Array<{ conversation_id: string }>;
|
|
485
553
|
const conversationId = events[0]?.conversation_id;
|
|
486
554
|
ensureConversation(conversationId!);
|
|
487
555
|
|
|
488
|
-
const sessionMock = registerPendingInteraction(
|
|
556
|
+
const sessionMock = registerPendingInteraction(
|
|
557
|
+
"req-empty-1",
|
|
558
|
+
conversationId!,
|
|
559
|
+
"shell",
|
|
560
|
+
);
|
|
489
561
|
|
|
490
|
-
const deliverSpy = spyOn(
|
|
562
|
+
const deliverSpy = spyOn(
|
|
563
|
+
gatewayClient,
|
|
564
|
+
"deliverChannelReply",
|
|
565
|
+
).mockResolvedValue(undefined);
|
|
491
566
|
|
|
492
567
|
const req = makeInboundRequest({
|
|
493
|
-
content:
|
|
494
|
-
callbackData:
|
|
568
|
+
content: "",
|
|
569
|
+
callbackData: "apr:req-empty-1:approve_once",
|
|
495
570
|
});
|
|
496
571
|
|
|
497
572
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
498
573
|
expect(res.status).toBe(200);
|
|
499
|
-
const body = await res.json() as Record<string, unknown>;
|
|
574
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
500
575
|
expect(body.accepted).toBe(true);
|
|
501
|
-
expect(body.approval).toBe(
|
|
502
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
576
|
+
expect(body.approval).toBe("decision_applied");
|
|
577
|
+
expect(sessionMock).toHaveBeenCalledWith("req-empty-1", "allow");
|
|
503
578
|
|
|
504
579
|
deliverSpy.mockRestore();
|
|
505
580
|
});
|
|
506
581
|
|
|
507
|
-
test(
|
|
582
|
+
test("allows undefined content when callbackData is present", async () => {
|
|
508
583
|
// Establish the conversation first
|
|
509
|
-
const initReq = makeInboundRequest({ content:
|
|
584
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
510
585
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
511
586
|
|
|
512
587
|
const db = getDb();
|
|
513
|
-
const events = db.$client
|
|
588
|
+
const events = db.$client
|
|
589
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
590
|
+
.all() as Array<{ conversation_id: string }>;
|
|
514
591
|
const conversationId = events[0]?.conversation_id;
|
|
515
592
|
ensureConversation(conversationId!);
|
|
516
593
|
|
|
517
|
-
const _sessionMock = registerPendingInteraction(
|
|
594
|
+
const _sessionMock = registerPendingInteraction(
|
|
595
|
+
"req-empty-2",
|
|
596
|
+
conversationId!,
|
|
597
|
+
"shell",
|
|
598
|
+
);
|
|
518
599
|
|
|
519
|
-
const deliverSpy = spyOn(
|
|
600
|
+
const deliverSpy = spyOn(
|
|
601
|
+
gatewayClient,
|
|
602
|
+
"deliverChannelReply",
|
|
603
|
+
).mockResolvedValue(undefined);
|
|
520
604
|
|
|
521
605
|
// Send with no content field at all, just callbackData
|
|
522
606
|
const reqBody = {
|
|
523
|
-
sourceChannel:
|
|
524
|
-
interface:
|
|
525
|
-
conversationExternalId:
|
|
607
|
+
sourceChannel: "telegram",
|
|
608
|
+
interface: "telegram",
|
|
609
|
+
conversationExternalId: "chat-123",
|
|
526
610
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
527
|
-
callbackData:
|
|
528
|
-
replyCallbackUrl:
|
|
529
|
-
actorExternalId:
|
|
611
|
+
callbackData: "apr:req-empty-2:approve_once",
|
|
612
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
613
|
+
actorExternalId: "telegram-user-default",
|
|
530
614
|
};
|
|
531
|
-
const req = new Request(
|
|
532
|
-
method:
|
|
615
|
+
const req = new Request("http://localhost/channels/inbound", {
|
|
616
|
+
method: "POST",
|
|
533
617
|
headers: {
|
|
534
|
-
|
|
535
|
-
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
618
|
+
"Content-Type": "application/json",
|
|
536
619
|
},
|
|
537
620
|
body: JSON.stringify(reqBody),
|
|
538
621
|
});
|
|
539
622
|
|
|
540
623
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
541
624
|
expect(res.status).toBe(200);
|
|
542
|
-
const resBody = await res.json() as Record<string, unknown>;
|
|
625
|
+
const resBody = (await res.json()) as Record<string, unknown>;
|
|
543
626
|
expect(resBody.accepted).toBe(true);
|
|
544
627
|
|
|
545
628
|
deliverSpy.mockRestore();
|
|
@@ -550,98 +633,125 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
550
633
|
// 7. Callback requestId validation — stale button press
|
|
551
634
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
552
635
|
|
|
553
|
-
describe(
|
|
636
|
+
describe("callback requestId validation", () => {
|
|
554
637
|
beforeEach(() => {
|
|
555
638
|
createBinding({
|
|
556
|
-
assistantId:
|
|
557
|
-
channel:
|
|
558
|
-
guardianExternalUserId:
|
|
559
|
-
guardianDeliveryChatId:
|
|
560
|
-
guardianPrincipalId:
|
|
639
|
+
assistantId: "self",
|
|
640
|
+
channel: "telegram",
|
|
641
|
+
guardianExternalUserId: "telegram-user-default",
|
|
642
|
+
guardianDeliveryChatId: "chat-123",
|
|
643
|
+
guardianPrincipalId: "telegram-user-default",
|
|
561
644
|
});
|
|
562
645
|
});
|
|
563
646
|
|
|
564
|
-
test(
|
|
565
|
-
const deliverSpy = spyOn(
|
|
647
|
+
test("ignores stale callback when requestId does not match any pending interaction", async () => {
|
|
648
|
+
const deliverSpy = spyOn(
|
|
649
|
+
gatewayClient,
|
|
650
|
+
"deliverChannelReply",
|
|
651
|
+
).mockResolvedValue(undefined);
|
|
566
652
|
|
|
567
|
-
const initReq = makeInboundRequest({ content:
|
|
653
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
568
654
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
569
655
|
|
|
570
656
|
const db = getDb();
|
|
571
|
-
const events = db.$client
|
|
657
|
+
const events = db.$client
|
|
658
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
659
|
+
.all() as Array<{ conversation_id: string }>;
|
|
572
660
|
const conversationId = events[0]?.conversation_id;
|
|
573
661
|
ensureConversation(conversationId!);
|
|
574
662
|
|
|
575
663
|
// Register a pending interaction
|
|
576
|
-
const sessionMock = registerPendingInteraction(
|
|
664
|
+
const sessionMock = registerPendingInteraction(
|
|
665
|
+
"req-valid",
|
|
666
|
+
conversationId!,
|
|
667
|
+
"shell",
|
|
668
|
+
);
|
|
577
669
|
|
|
578
670
|
// Send callback with a DIFFERENT requestId (stale button)
|
|
579
671
|
const req = makeInboundRequest({
|
|
580
|
-
content:
|
|
581
|
-
callbackData:
|
|
672
|
+
content: "",
|
|
673
|
+
callbackData: "apr:stale-request-id:approve_once",
|
|
582
674
|
});
|
|
583
675
|
|
|
584
676
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
585
|
-
const body = await res.json() as Record<string, unknown>;
|
|
677
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
586
678
|
|
|
587
679
|
expect(body.accepted).toBe(true);
|
|
588
|
-
expect(body.approval).toBe(
|
|
680
|
+
expect(body.approval).toBe("stale_ignored");
|
|
589
681
|
// session should NOT have been called because the requestId didn't match
|
|
590
682
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
591
683
|
|
|
592
684
|
deliverSpy.mockRestore();
|
|
593
685
|
});
|
|
594
686
|
|
|
595
|
-
test(
|
|
596
|
-
const deliverSpy = spyOn(
|
|
687
|
+
test("applies callback when requestId matches pending interaction", async () => {
|
|
688
|
+
const deliverSpy = spyOn(
|
|
689
|
+
gatewayClient,
|
|
690
|
+
"deliverChannelReply",
|
|
691
|
+
).mockResolvedValue(undefined);
|
|
597
692
|
|
|
598
|
-
const initReq = makeInboundRequest({ content:
|
|
693
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
599
694
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
600
695
|
|
|
601
696
|
const db = getDb();
|
|
602
|
-
const events = db.$client
|
|
697
|
+
const events = db.$client
|
|
698
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
699
|
+
.all() as Array<{ conversation_id: string }>;
|
|
603
700
|
const conversationId = events[0]?.conversation_id;
|
|
604
701
|
ensureConversation(conversationId!);
|
|
605
702
|
|
|
606
|
-
const sessionMock = registerPendingInteraction(
|
|
703
|
+
const sessionMock = registerPendingInteraction(
|
|
704
|
+
"req-match",
|
|
705
|
+
conversationId!,
|
|
706
|
+
"shell",
|
|
707
|
+
);
|
|
607
708
|
|
|
608
709
|
// Send callback with the CORRECT requestId
|
|
609
710
|
const req = makeInboundRequest({
|
|
610
|
-
content:
|
|
611
|
-
callbackData:
|
|
711
|
+
content: "",
|
|
712
|
+
callbackData: "apr:req-match:approve_once",
|
|
612
713
|
});
|
|
613
714
|
|
|
614
715
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
615
|
-
const body = await res.json() as Record<string, unknown>;
|
|
716
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
616
717
|
|
|
617
718
|
expect(body.accepted).toBe(true);
|
|
618
|
-
expect(body.approval).toBe(
|
|
619
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
719
|
+
expect(body.approval).toBe("decision_applied");
|
|
720
|
+
expect(sessionMock).toHaveBeenCalledWith("req-match", "allow");
|
|
620
721
|
|
|
621
722
|
deliverSpy.mockRestore();
|
|
622
723
|
});
|
|
623
724
|
|
|
624
|
-
test(
|
|
625
|
-
const deliverSpy = spyOn(
|
|
725
|
+
test("plain-text decisions bypass requestId validation (no requestId in result)", async () => {
|
|
726
|
+
const deliverSpy = spyOn(
|
|
727
|
+
gatewayClient,
|
|
728
|
+
"deliverChannelReply",
|
|
729
|
+
).mockResolvedValue(undefined);
|
|
626
730
|
|
|
627
|
-
const initReq = makeInboundRequest({ content:
|
|
731
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
628
732
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
629
733
|
|
|
630
734
|
const db = getDb();
|
|
631
|
-
const events = db.$client
|
|
735
|
+
const events = db.$client
|
|
736
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
737
|
+
.all() as Array<{ conversation_id: string }>;
|
|
632
738
|
const conversationId = events[0]?.conversation_id;
|
|
633
739
|
ensureConversation(conversationId!);
|
|
634
740
|
|
|
635
|
-
const sessionMock = registerPendingInteraction(
|
|
741
|
+
const sessionMock = registerPendingInteraction(
|
|
742
|
+
"req-plaintext",
|
|
743
|
+
conversationId!,
|
|
744
|
+
"shell",
|
|
745
|
+
);
|
|
636
746
|
|
|
637
747
|
// Send plain text "yes" — no requestId in the parsed result
|
|
638
|
-
const req = makeInboundRequest({ content:
|
|
748
|
+
const req = makeInboundRequest({ content: "yes" });
|
|
639
749
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
640
|
-
const body = await res.json() as Record<string, unknown>;
|
|
750
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
641
751
|
|
|
642
752
|
expect(body.accepted).toBe(true);
|
|
643
|
-
expect(body.approval).toBe(
|
|
644
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
753
|
+
expect(body.approval).toBe("decision_applied");
|
|
754
|
+
expect(sessionMock).toHaveBeenCalledWith("req-plaintext", "allow");
|
|
645
755
|
|
|
646
756
|
deliverSpy.mockRestore();
|
|
647
757
|
});
|
|
@@ -651,43 +761,48 @@ describe('callback requestId validation', () => {
|
|
|
651
761
|
// 10. No immediate reply after approval decision
|
|
652
762
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
653
763
|
|
|
654
|
-
describe(
|
|
764
|
+
describe("no immediate reply after approval decision", () => {
|
|
655
765
|
beforeEach(() => {
|
|
656
766
|
createBinding({
|
|
657
|
-
assistantId:
|
|
658
|
-
channel:
|
|
659
|
-
guardianExternalUserId:
|
|
660
|
-
guardianDeliveryChatId:
|
|
661
|
-
guardianPrincipalId:
|
|
767
|
+
assistantId: "self",
|
|
768
|
+
channel: "telegram",
|
|
769
|
+
guardianExternalUserId: "telegram-user-default",
|
|
770
|
+
guardianDeliveryChatId: "chat-123",
|
|
771
|
+
guardianPrincipalId: "telegram-user-default",
|
|
662
772
|
});
|
|
663
773
|
});
|
|
664
774
|
|
|
665
|
-
test(
|
|
666
|
-
const deliverSpy = spyOn(
|
|
775
|
+
test("deliverChannelReply is NOT called from interception after decision is applied", async () => {
|
|
776
|
+
const deliverSpy = spyOn(
|
|
777
|
+
gatewayClient,
|
|
778
|
+
"deliverChannelReply",
|
|
779
|
+
).mockResolvedValue(undefined);
|
|
667
780
|
|
|
668
|
-
const initReq = makeInboundRequest({ content:
|
|
781
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
669
782
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
670
783
|
|
|
671
784
|
const db = getDb();
|
|
672
|
-
const events = db.$client
|
|
785
|
+
const events = db.$client
|
|
786
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
787
|
+
.all() as Array<{ conversation_id: string }>;
|
|
673
788
|
const conversationId = events[0]?.conversation_id;
|
|
674
789
|
ensureConversation(conversationId!);
|
|
675
790
|
|
|
676
|
-
registerPendingInteraction(
|
|
791
|
+
registerPendingInteraction("req-noreply-1", conversationId!, "shell");
|
|
677
792
|
|
|
678
793
|
// Clear the spy to only track calls from the decision path
|
|
679
794
|
deliverSpy.mockClear();
|
|
680
795
|
|
|
681
796
|
// Send a callback decision
|
|
682
797
|
const req = makeInboundRequest({
|
|
683
|
-
content:
|
|
684
|
-
callbackData:
|
|
798
|
+
content: "",
|
|
799
|
+
callbackData: "apr:req-noreply-1:approve_once",
|
|
685
800
|
});
|
|
686
801
|
|
|
687
802
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
688
|
-
const body = await res.json() as Record<string, unknown>;
|
|
803
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
689
804
|
|
|
690
|
-
expect(body.approval).toBe(
|
|
805
|
+
expect(body.approval).toBe("decision_applied");
|
|
691
806
|
|
|
692
807
|
// The interception handler should NOT have called deliverChannelReply.
|
|
693
808
|
// The reply should only come from the session's onEvent callback.
|
|
@@ -696,27 +811,32 @@ describe('no immediate reply after approval decision', () => {
|
|
|
696
811
|
deliverSpy.mockRestore();
|
|
697
812
|
});
|
|
698
813
|
|
|
699
|
-
test(
|
|
700
|
-
const deliverSpy = spyOn(
|
|
814
|
+
test("plain-text decision also does not trigger immediate reply", async () => {
|
|
815
|
+
const deliverSpy = spyOn(
|
|
816
|
+
gatewayClient,
|
|
817
|
+
"deliverChannelReply",
|
|
818
|
+
).mockResolvedValue(undefined);
|
|
701
819
|
|
|
702
|
-
const initReq = makeInboundRequest({ content:
|
|
820
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
703
821
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
704
822
|
|
|
705
823
|
const db = getDb();
|
|
706
|
-
const events = db.$client
|
|
824
|
+
const events = db.$client
|
|
825
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
826
|
+
.all() as Array<{ conversation_id: string }>;
|
|
707
827
|
const conversationId = events[0]?.conversation_id;
|
|
708
828
|
ensureConversation(conversationId!);
|
|
709
829
|
|
|
710
|
-
registerPendingInteraction(
|
|
830
|
+
registerPendingInteraction("req-noreply-2", conversationId!, "shell");
|
|
711
831
|
|
|
712
832
|
deliverSpy.mockClear();
|
|
713
833
|
|
|
714
834
|
// Send a plain-text approval
|
|
715
|
-
const req = makeInboundRequest({ content:
|
|
835
|
+
const req = makeInboundRequest({ content: "approve" });
|
|
716
836
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
717
|
-
const body = await res.json() as Record<string, unknown>;
|
|
837
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
718
838
|
|
|
719
|
-
expect(body.approval).toBe(
|
|
839
|
+
expect(body.approval).toBe("decision_applied");
|
|
720
840
|
expect(deliverSpy).not.toHaveBeenCalled();
|
|
721
841
|
|
|
722
842
|
deliverSpy.mockRestore();
|
|
@@ -727,42 +847,42 @@ describe('no immediate reply after approval decision', () => {
|
|
|
727
847
|
// 11. Stale callback with no pending approval returns stale_ignored
|
|
728
848
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
729
849
|
|
|
730
|
-
describe(
|
|
731
|
-
test(
|
|
850
|
+
describe("stale callback handling", () => {
|
|
851
|
+
test("callback with no pending approval returns stale_ignored", async () => {
|
|
732
852
|
// No pending interactions — send a stale callback
|
|
733
853
|
const req = makeInboundRequest({
|
|
734
|
-
content:
|
|
735
|
-
callbackData:
|
|
854
|
+
content: "",
|
|
855
|
+
callbackData: "apr:stale-req:approve_once",
|
|
736
856
|
});
|
|
737
857
|
|
|
738
858
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
739
|
-
const body = await res.json() as Record<string, unknown>;
|
|
859
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
740
860
|
|
|
741
861
|
expect(body.accepted).toBe(true);
|
|
742
|
-
expect(body.approval).toBe(
|
|
862
|
+
expect(body.approval).toBe("stale_ignored");
|
|
743
863
|
});
|
|
744
864
|
|
|
745
|
-
test(
|
|
865
|
+
test("callback with non-empty content but no pending approval returns stale_ignored", async () => {
|
|
746
866
|
// Simulate what normalize.ts does: callbackData present AND content is
|
|
747
867
|
// set to the callback data value (non-empty).
|
|
748
868
|
const req = makeInboundRequest({
|
|
749
|
-
content:
|
|
750
|
-
callbackData:
|
|
869
|
+
content: "apr:stale-req:approve_once",
|
|
870
|
+
callbackData: "apr:stale-req:approve_once",
|
|
751
871
|
});
|
|
752
872
|
|
|
753
873
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
754
|
-
const body = await res.json() as Record<string, unknown>;
|
|
874
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
755
875
|
|
|
756
876
|
expect(body.accepted).toBe(true);
|
|
757
|
-
expect(body.approval).toBe(
|
|
877
|
+
expect(body.approval).toBe("stale_ignored");
|
|
758
878
|
});
|
|
759
879
|
|
|
760
|
-
test(
|
|
880
|
+
test("non-callback message without pending approval proceeds to normal processing", async () => {
|
|
761
881
|
// Regular text message (no callbackData) should proceed normally
|
|
762
|
-
const req = makeInboundRequest({ content:
|
|
882
|
+
const req = makeInboundRequest({ content: "hello world" });
|
|
763
883
|
|
|
764
884
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
765
|
-
const body = await res.json() as Record<string, unknown>;
|
|
885
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
766
886
|
|
|
767
887
|
expect(body.accepted).toBe(true);
|
|
768
888
|
// No approval field — normal processing
|
|
@@ -774,118 +894,150 @@ describe('stale callback handling', () => {
|
|
|
774
894
|
// 15. SMS channel approval decisions
|
|
775
895
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
776
896
|
|
|
777
|
-
describe(
|
|
897
|
+
describe("SMS channel approval decisions", () => {
|
|
778
898
|
beforeEach(() => {
|
|
779
899
|
createBinding({
|
|
780
|
-
assistantId:
|
|
781
|
-
channel:
|
|
782
|
-
guardianExternalUserId:
|
|
783
|
-
guardianDeliveryChatId:
|
|
784
|
-
guardianPrincipalId:
|
|
900
|
+
assistantId: "self",
|
|
901
|
+
channel: "sms",
|
|
902
|
+
guardianExternalUserId: "sms-user-default",
|
|
903
|
+
guardianDeliveryChatId: "sms-chat-123",
|
|
904
|
+
guardianPrincipalId: "sms-user-default",
|
|
785
905
|
});
|
|
786
906
|
});
|
|
787
907
|
|
|
788
|
-
function makeSmsInboundRequest(
|
|
908
|
+
function makeSmsInboundRequest(
|
|
909
|
+
overrides: Record<string, unknown> = {},
|
|
910
|
+
): Request {
|
|
789
911
|
const body = {
|
|
790
|
-
sourceChannel:
|
|
791
|
-
interface:
|
|
792
|
-
conversationExternalId:
|
|
793
|
-
actorExternalId:
|
|
912
|
+
sourceChannel: "sms",
|
|
913
|
+
interface: "sms",
|
|
914
|
+
conversationExternalId: "sms-chat-123",
|
|
915
|
+
actorExternalId: "sms-user-default",
|
|
794
916
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
795
|
-
content:
|
|
796
|
-
replyCallbackUrl:
|
|
917
|
+
content: "hello",
|
|
918
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
797
919
|
...overrides,
|
|
798
920
|
};
|
|
799
|
-
return new Request(
|
|
800
|
-
method:
|
|
921
|
+
return new Request("http://localhost/channels/inbound", {
|
|
922
|
+
method: "POST",
|
|
801
923
|
headers: {
|
|
802
|
-
|
|
803
|
-
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
924
|
+
"Content-Type": "application/json",
|
|
804
925
|
},
|
|
805
926
|
body: JSON.stringify(body),
|
|
806
927
|
});
|
|
807
928
|
}
|
|
808
929
|
|
|
809
930
|
test('plain-text "yes" via SMS triggers approve_once decision', async () => {
|
|
810
|
-
const deliverSpy = spyOn(
|
|
931
|
+
const deliverSpy = spyOn(
|
|
932
|
+
gatewayClient,
|
|
933
|
+
"deliverChannelReply",
|
|
934
|
+
).mockResolvedValue(undefined);
|
|
811
935
|
|
|
812
936
|
// Establish the conversation via SMS
|
|
813
|
-
const initReq = makeSmsInboundRequest({ content:
|
|
937
|
+
const initReq = makeSmsInboundRequest({ content: "init" });
|
|
814
938
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
815
939
|
|
|
816
940
|
const db = getDb();
|
|
817
|
-
const events = db.$client
|
|
941
|
+
const events = db.$client
|
|
942
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
943
|
+
.all() as Array<{ conversation_id: string }>;
|
|
818
944
|
const conversationId = events[events.length - 1]?.conversation_id;
|
|
819
945
|
ensureConversation(conversationId!);
|
|
820
946
|
|
|
821
|
-
const sessionMock = registerPendingInteraction(
|
|
947
|
+
const sessionMock = registerPendingInteraction(
|
|
948
|
+
"req-sms-1",
|
|
949
|
+
conversationId!,
|
|
950
|
+
"shell",
|
|
951
|
+
);
|
|
822
952
|
|
|
823
|
-
const req = makeSmsInboundRequest({ content:
|
|
953
|
+
const req = makeSmsInboundRequest({ content: "yes" });
|
|
824
954
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
825
|
-
const body = await res.json() as Record<string, unknown>;
|
|
955
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
826
956
|
|
|
827
957
|
expect(body.accepted).toBe(true);
|
|
828
|
-
expect(body.approval).toBe(
|
|
829
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
958
|
+
expect(body.approval).toBe("decision_applied");
|
|
959
|
+
expect(sessionMock).toHaveBeenCalledWith("req-sms-1", "allow");
|
|
830
960
|
|
|
831
961
|
deliverSpy.mockRestore();
|
|
832
962
|
});
|
|
833
963
|
|
|
834
964
|
test('plain-text "no" via SMS triggers reject decision', async () => {
|
|
835
|
-
const deliverSpy = spyOn(
|
|
965
|
+
const deliverSpy = spyOn(
|
|
966
|
+
gatewayClient,
|
|
967
|
+
"deliverChannelReply",
|
|
968
|
+
).mockResolvedValue(undefined);
|
|
836
969
|
|
|
837
|
-
const initReq = makeSmsInboundRequest({ content:
|
|
970
|
+
const initReq = makeSmsInboundRequest({ content: "init" });
|
|
838
971
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
839
972
|
|
|
840
973
|
const db = getDb();
|
|
841
|
-
const events = db.$client
|
|
974
|
+
const events = db.$client
|
|
975
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
976
|
+
.all() as Array<{ conversation_id: string }>;
|
|
842
977
|
const conversationId = events[events.length - 1]?.conversation_id;
|
|
843
978
|
ensureConversation(conversationId!);
|
|
844
979
|
|
|
845
|
-
const sessionMock = registerPendingInteraction(
|
|
980
|
+
const sessionMock = registerPendingInteraction(
|
|
981
|
+
"req-sms-2",
|
|
982
|
+
conversationId!,
|
|
983
|
+
"shell",
|
|
984
|
+
);
|
|
846
985
|
|
|
847
|
-
const req = makeSmsInboundRequest({ content:
|
|
986
|
+
const req = makeSmsInboundRequest({ content: "no" });
|
|
848
987
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
849
|
-
const body = await res.json() as Record<string, unknown>;
|
|
988
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
850
989
|
|
|
851
990
|
expect(body.accepted).toBe(true);
|
|
852
|
-
expect(body.approval).toBe(
|
|
853
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
991
|
+
expect(body.approval).toBe("decision_applied");
|
|
992
|
+
expect(sessionMock).toHaveBeenCalledWith("req-sms-2", "deny");
|
|
854
993
|
|
|
855
994
|
deliverSpy.mockRestore();
|
|
856
995
|
});
|
|
857
996
|
|
|
858
|
-
test(
|
|
859
|
-
const deliverSpy = spyOn(
|
|
860
|
-
|
|
997
|
+
test("non-decision SMS message during pending approval sends status reply", async () => {
|
|
998
|
+
const deliverSpy = spyOn(
|
|
999
|
+
gatewayClient,
|
|
1000
|
+
"deliverChannelReply",
|
|
1001
|
+
).mockResolvedValue(undefined);
|
|
1002
|
+
const approvalSpy = spyOn(
|
|
1003
|
+
gatewayClient,
|
|
1004
|
+
"deliverApprovalPrompt",
|
|
1005
|
+
).mockResolvedValue(undefined);
|
|
861
1006
|
|
|
862
|
-
const initReq = makeSmsInboundRequest({ content:
|
|
1007
|
+
const initReq = makeSmsInboundRequest({ content: "init" });
|
|
863
1008
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
864
1009
|
|
|
865
1010
|
const db = getDb();
|
|
866
|
-
const events = db.$client
|
|
1011
|
+
const events = db.$client
|
|
1012
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
1013
|
+
.all() as Array<{ conversation_id: string }>;
|
|
867
1014
|
const conversationId = events[events.length - 1]?.conversation_id;
|
|
868
1015
|
ensureConversation(conversationId!);
|
|
869
1016
|
|
|
870
|
-
registerPendingInteraction(
|
|
1017
|
+
registerPendingInteraction("req-sms-3", conversationId!, "shell");
|
|
871
1018
|
|
|
872
|
-
const req = makeSmsInboundRequest({ content:
|
|
1019
|
+
const req = makeSmsInboundRequest({ content: "what is happening?" });
|
|
873
1020
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
874
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1021
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
875
1022
|
|
|
876
1023
|
expect(body.accepted).toBe(true);
|
|
877
|
-
expect(body.approval).toBe(
|
|
1024
|
+
expect(body.approval).toBe("assistant_turn");
|
|
878
1025
|
|
|
879
1026
|
// SMS non-decision: status reply delivered via plain text
|
|
880
1027
|
expect(deliverSpy).toHaveBeenCalled();
|
|
881
1028
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
882
1029
|
const statusCall = deliverSpy.mock.calls.find(
|
|
883
|
-
(call) =>
|
|
1030
|
+
(call) =>
|
|
1031
|
+
typeof call[1] === "object" &&
|
|
1032
|
+
(call[1] as { chatId?: string }).chatId === "sms-chat-123",
|
|
884
1033
|
);
|
|
885
1034
|
expect(statusCall).toBeDefined();
|
|
886
|
-
const statusPayload = statusCall![1] as {
|
|
887
|
-
|
|
888
|
-
|
|
1035
|
+
const statusPayload = statusCall![1] as {
|
|
1036
|
+
text?: string;
|
|
1037
|
+
approval?: unknown;
|
|
1038
|
+
};
|
|
1039
|
+
const deliveredText = statusPayload.text ?? "";
|
|
1040
|
+
expect(deliveredText).toContain("pending approval request");
|
|
889
1041
|
expect(statusPayload.approval).toBeUndefined();
|
|
890
1042
|
|
|
891
1043
|
deliverSpy.mockRestore();
|
|
@@ -897,97 +1049,104 @@ describe('SMS channel approval decisions', () => {
|
|
|
897
1049
|
// 16. SMS guardian verify intercept
|
|
898
1050
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
899
1051
|
|
|
900
|
-
describe(
|
|
901
|
-
test(
|
|
902
|
-
const { createVerificationChallenge } =
|
|
903
|
-
|
|
1052
|
+
describe("SMS guardian verify intercept", () => {
|
|
1053
|
+
test("verification code reply works with sourceChannel sms", async () => {
|
|
1054
|
+
const { createVerificationChallenge } =
|
|
1055
|
+
await import("../runtime/channel-guardian-service.js");
|
|
1056
|
+
const { secret } = createVerificationChallenge("self", "sms");
|
|
904
1057
|
|
|
905
|
-
const deliverSpy = spyOn(
|
|
1058
|
+
const deliverSpy = spyOn(
|
|
1059
|
+
gatewayClient,
|
|
1060
|
+
"deliverChannelReply",
|
|
1061
|
+
).mockResolvedValue(undefined);
|
|
906
1062
|
|
|
907
|
-
const req = new Request(
|
|
908
|
-
method:
|
|
1063
|
+
const req = new Request("http://localhost/channels/inbound", {
|
|
1064
|
+
method: "POST",
|
|
909
1065
|
headers: {
|
|
910
|
-
|
|
911
|
-
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1066
|
+
"Content-Type": "application/json",
|
|
912
1067
|
},
|
|
913
1068
|
body: JSON.stringify({
|
|
914
|
-
sourceChannel:
|
|
915
|
-
interface:
|
|
916
|
-
conversationExternalId:
|
|
1069
|
+
sourceChannel: "sms",
|
|
1070
|
+
interface: "sms",
|
|
1071
|
+
conversationExternalId: "sms-chat-verify",
|
|
917
1072
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
918
1073
|
content: secret,
|
|
919
|
-
actorExternalId:
|
|
920
|
-
replyCallbackUrl:
|
|
1074
|
+
actorExternalId: "sms-user-42",
|
|
1075
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
921
1076
|
}),
|
|
922
1077
|
});
|
|
923
1078
|
|
|
924
1079
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
925
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1080
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
926
1081
|
|
|
927
1082
|
expect(body.accepted).toBe(true);
|
|
928
|
-
expect(body.guardianVerification).toBe(
|
|
1083
|
+
expect(body.guardianVerification).toBe("verified");
|
|
929
1084
|
|
|
930
1085
|
expect(deliverSpy).toHaveBeenCalled();
|
|
931
1086
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
932
1087
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
933
|
-
expect(replyPayload.chatId).toBe(
|
|
934
|
-
expect(typeof replyPayload.text).toBe(
|
|
935
|
-
expect(replyPayload.text.toLowerCase()).toContain(
|
|
936
|
-
expect(replyPayload.text.toLowerCase()).toContain(
|
|
1088
|
+
expect(replyPayload.chatId).toBe("sms-chat-verify");
|
|
1089
|
+
expect(typeof replyPayload.text).toBe("string");
|
|
1090
|
+
expect(replyPayload.text.toLowerCase()).toContain("guardian");
|
|
1091
|
+
expect(replyPayload.text.toLowerCase()).toContain("verif");
|
|
937
1092
|
|
|
938
1093
|
deliverSpy.mockRestore();
|
|
939
1094
|
});
|
|
940
1095
|
|
|
941
|
-
test(
|
|
942
|
-
const { createVerificationChallenge } =
|
|
1096
|
+
test("invalid verification code returns failed via SMS", async () => {
|
|
1097
|
+
const { createVerificationChallenge } =
|
|
1098
|
+
await import("../runtime/channel-guardian-service.js");
|
|
943
1099
|
// Ensure there is a pending challenge so bare-code verification is intercepted.
|
|
944
|
-
createVerificationChallenge(
|
|
1100
|
+
createVerificationChallenge("self", "sms");
|
|
945
1101
|
|
|
946
|
-
const deliverSpy = spyOn(
|
|
1102
|
+
const deliverSpy = spyOn(
|
|
1103
|
+
gatewayClient,
|
|
1104
|
+
"deliverChannelReply",
|
|
1105
|
+
).mockResolvedValue(undefined);
|
|
947
1106
|
|
|
948
|
-
const req = new Request(
|
|
949
|
-
method:
|
|
1107
|
+
const req = new Request("http://localhost/channels/inbound", {
|
|
1108
|
+
method: "POST",
|
|
950
1109
|
headers: {
|
|
951
|
-
|
|
952
|
-
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1110
|
+
"Content-Type": "application/json",
|
|
953
1111
|
},
|
|
954
1112
|
body: JSON.stringify({
|
|
955
|
-
sourceChannel:
|
|
956
|
-
interface:
|
|
957
|
-
conversationExternalId:
|
|
1113
|
+
sourceChannel: "sms",
|
|
1114
|
+
interface: "sms",
|
|
1115
|
+
conversationExternalId: "sms-chat-verify-fail",
|
|
958
1116
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
959
|
-
content:
|
|
960
|
-
actorExternalId:
|
|
961
|
-
replyCallbackUrl:
|
|
1117
|
+
content: "000000",
|
|
1118
|
+
actorExternalId: "sms-user-43",
|
|
1119
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
962
1120
|
}),
|
|
963
1121
|
});
|
|
964
1122
|
|
|
965
1123
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
966
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1124
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
967
1125
|
|
|
968
1126
|
expect(body.accepted).toBe(true);
|
|
969
|
-
expect(body.guardianVerification).toBe(
|
|
1127
|
+
expect(body.guardianVerification).toBe("failed");
|
|
970
1128
|
|
|
971
1129
|
expect(deliverSpy).toHaveBeenCalled();
|
|
972
1130
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
973
1131
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
974
|
-
expect(typeof replyPayload.text).toBe(
|
|
975
|
-
expect(replyPayload.text.toLowerCase()).toContain(
|
|
976
|
-
expect(replyPayload.text.toLowerCase()).toContain(
|
|
1132
|
+
expect(typeof replyPayload.text).toBe("string");
|
|
1133
|
+
expect(replyPayload.text.toLowerCase()).toContain("verif");
|
|
1134
|
+
expect(replyPayload.text.toLowerCase()).toContain("invalid");
|
|
977
1135
|
|
|
978
1136
|
deliverSpy.mockRestore();
|
|
979
1137
|
});
|
|
980
1138
|
|
|
981
|
-
test(
|
|
982
|
-
const { createHash, randomBytes } = await import(
|
|
983
|
-
const { createChallenge } =
|
|
1139
|
+
test("64-char hex verification codes are intercepted when a pending challenge exists", async () => {
|
|
1140
|
+
const { createHash, randomBytes } = await import("node:crypto");
|
|
1141
|
+
const { createChallenge } =
|
|
1142
|
+
await import("../memory/channel-guardian-store.js");
|
|
984
1143
|
|
|
985
|
-
const secret = randomBytes(32).toString(
|
|
986
|
-
const challengeHash = createHash(
|
|
1144
|
+
const secret = randomBytes(32).toString("hex");
|
|
1145
|
+
const challengeHash = createHash("sha256").update(secret).digest("hex");
|
|
987
1146
|
createChallenge({
|
|
988
1147
|
id: `challenge-hex-${Date.now()}`,
|
|
989
|
-
assistantId:
|
|
990
|
-
channel:
|
|
1148
|
+
assistantId: "self",
|
|
1149
|
+
channel: "sms",
|
|
991
1150
|
challengeHash,
|
|
992
1151
|
expiresAt: Date.now() + 600_000,
|
|
993
1152
|
});
|
|
@@ -995,31 +1154,30 @@ describe('SMS guardian verify intercept', () => {
|
|
|
995
1154
|
let processMessageCalled = false;
|
|
996
1155
|
const processMessage = async () => {
|
|
997
1156
|
processMessageCalled = true;
|
|
998
|
-
return { messageId:
|
|
1157
|
+
return { messageId: "msg-hex-not-verify" };
|
|
999
1158
|
};
|
|
1000
1159
|
|
|
1001
|
-
const req = new Request(
|
|
1002
|
-
method:
|
|
1160
|
+
const req = new Request("http://localhost/channels/inbound", {
|
|
1161
|
+
method: "POST",
|
|
1003
1162
|
headers: {
|
|
1004
|
-
|
|
1005
|
-
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1163
|
+
"Content-Type": "application/json",
|
|
1006
1164
|
},
|
|
1007
1165
|
body: JSON.stringify({
|
|
1008
|
-
sourceChannel:
|
|
1009
|
-
interface:
|
|
1010
|
-
conversationExternalId:
|
|
1166
|
+
sourceChannel: "sms",
|
|
1167
|
+
interface: "sms",
|
|
1168
|
+
conversationExternalId: "sms-chat-hex-message",
|
|
1011
1169
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1012
1170
|
content: secret,
|
|
1013
|
-
actorExternalId:
|
|
1014
|
-
replyCallbackUrl:
|
|
1171
|
+
actorExternalId: "sms-user-hex",
|
|
1172
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
1015
1173
|
}),
|
|
1016
1174
|
});
|
|
1017
1175
|
|
|
1018
1176
|
const res = await handleChannelInbound(req, processMessage);
|
|
1019
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1177
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1020
1178
|
|
|
1021
1179
|
expect(body.accepted).toBe(true);
|
|
1022
|
-
expect(body.guardianVerification).toBe(
|
|
1180
|
+
expect(body.guardianVerification).toBe("verified");
|
|
1023
1181
|
expect(processMessageCalled).toBe(false);
|
|
1024
1182
|
});
|
|
1025
1183
|
});
|
|
@@ -1028,68 +1186,79 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1028
1186
|
// 21. Guardian decision scoping — callback for older request
|
|
1029
1187
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1030
1188
|
|
|
1031
|
-
describe(
|
|
1032
|
-
test(
|
|
1189
|
+
describe("guardian decision scoping — multiple pending approvals", () => {
|
|
1190
|
+
test("callback for older request resolves to the correct approval request", async () => {
|
|
1033
1191
|
createBinding({
|
|
1034
|
-
assistantId:
|
|
1035
|
-
channel:
|
|
1036
|
-
guardianExternalUserId:
|
|
1037
|
-
guardianDeliveryChatId:
|
|
1038
|
-
guardianPrincipalId:
|
|
1192
|
+
assistantId: "self",
|
|
1193
|
+
channel: "telegram",
|
|
1194
|
+
guardianExternalUserId: "guardian-scope-user",
|
|
1195
|
+
guardianDeliveryChatId: "guardian-scope-chat",
|
|
1196
|
+
guardianPrincipalId: "guardian-scope-user",
|
|
1039
1197
|
});
|
|
1040
1198
|
|
|
1041
|
-
const deliverSpy = spyOn(
|
|
1199
|
+
const deliverSpy = spyOn(
|
|
1200
|
+
gatewayClient,
|
|
1201
|
+
"deliverChannelReply",
|
|
1202
|
+
).mockResolvedValue(undefined);
|
|
1042
1203
|
|
|
1043
|
-
const olderConvId =
|
|
1044
|
-
const newerConvId =
|
|
1204
|
+
const olderConvId = "conv-scope-older";
|
|
1205
|
+
const newerConvId = "conv-scope-newer";
|
|
1045
1206
|
ensureConversation(olderConvId);
|
|
1046
1207
|
ensureConversation(newerConvId);
|
|
1047
1208
|
|
|
1048
1209
|
// Register pending interactions and create guardian approval requests
|
|
1049
|
-
const olderSession = registerPendingInteraction(
|
|
1210
|
+
const olderSession = registerPendingInteraction(
|
|
1211
|
+
"req-older",
|
|
1212
|
+
olderConvId,
|
|
1213
|
+
"shell",
|
|
1214
|
+
);
|
|
1050
1215
|
createApprovalRequest({
|
|
1051
|
-
runId:
|
|
1052
|
-
requestId:
|
|
1216
|
+
runId: "run-older",
|
|
1217
|
+
requestId: "req-older",
|
|
1053
1218
|
conversationId: olderConvId,
|
|
1054
|
-
channel:
|
|
1055
|
-
requesterExternalUserId:
|
|
1056
|
-
requesterChatId:
|
|
1057
|
-
guardianExternalUserId:
|
|
1058
|
-
guardianChatId:
|
|
1059
|
-
toolName:
|
|
1219
|
+
channel: "telegram",
|
|
1220
|
+
requesterExternalUserId: "requester-a",
|
|
1221
|
+
requesterChatId: "chat-requester-a",
|
|
1222
|
+
guardianExternalUserId: "guardian-scope-user",
|
|
1223
|
+
guardianChatId: "guardian-scope-chat",
|
|
1224
|
+
toolName: "shell",
|
|
1060
1225
|
expiresAt: Date.now() + 300_000,
|
|
1061
1226
|
});
|
|
1062
1227
|
|
|
1063
|
-
const newerSession = registerPendingInteraction(
|
|
1228
|
+
const newerSession = registerPendingInteraction(
|
|
1229
|
+
"req-newer",
|
|
1230
|
+
newerConvId,
|
|
1231
|
+
"browser",
|
|
1232
|
+
);
|
|
1064
1233
|
createApprovalRequest({
|
|
1065
|
-
runId:
|
|
1066
|
-
requestId:
|
|
1234
|
+
runId: "run-newer",
|
|
1235
|
+
requestId: "req-newer",
|
|
1067
1236
|
conversationId: newerConvId,
|
|
1068
|
-
channel:
|
|
1069
|
-
requesterExternalUserId:
|
|
1070
|
-
requesterChatId:
|
|
1071
|
-
guardianExternalUserId:
|
|
1072
|
-
guardianChatId:
|
|
1073
|
-
toolName:
|
|
1237
|
+
channel: "telegram",
|
|
1238
|
+
requesterExternalUserId: "requester-b",
|
|
1239
|
+
requesterChatId: "chat-requester-b",
|
|
1240
|
+
guardianExternalUserId: "guardian-scope-user",
|
|
1241
|
+
guardianChatId: "guardian-scope-chat",
|
|
1242
|
+
toolName: "browser",
|
|
1074
1243
|
expiresAt: Date.now() + 300_000,
|
|
1075
1244
|
});
|
|
1076
1245
|
|
|
1077
1246
|
// The guardian clicks the approval button for the OLDER request
|
|
1078
1247
|
const req = makeInboundRequest({
|
|
1079
|
-
content:
|
|
1080
|
-
conversationExternalId:
|
|
1081
|
-
callbackData:
|
|
1082
|
-
actorExternalId:
|
|
1248
|
+
content: "",
|
|
1249
|
+
conversationExternalId: "guardian-scope-chat",
|
|
1250
|
+
callbackData: "apr:req-older:approve_once",
|
|
1251
|
+
actorExternalId: "guardian-scope-user",
|
|
1083
1252
|
});
|
|
1084
1253
|
|
|
1085
1254
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
1086
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1255
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1087
1256
|
|
|
1088
1257
|
expect(body.accepted).toBe(true);
|
|
1089
|
-
expect(body.approval).toBe(
|
|
1258
|
+
expect(body.approval).toBe("guardian_decision_applied");
|
|
1090
1259
|
|
|
1091
1260
|
// The older request's session should have been called
|
|
1092
|
-
expect(olderSession).toHaveBeenCalledWith(
|
|
1261
|
+
expect(olderSession).toHaveBeenCalledWith("req-older", "allow");
|
|
1093
1262
|
|
|
1094
1263
|
// The newer request's session should NOT have been called
|
|
1095
1264
|
expect(newerSession).not.toHaveBeenCalled();
|
|
@@ -1102,71 +1271,82 @@ describe('guardian decision scoping — multiple pending approvals', () => {
|
|
|
1102
1271
|
// 22. Ambiguous plain-text decision with multiple pending requests
|
|
1103
1272
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1104
1273
|
|
|
1105
|
-
describe(
|
|
1106
|
-
test(
|
|
1274
|
+
describe("ambiguous plain-text decision with multiple pending requests", () => {
|
|
1275
|
+
test("does not apply plain-text decision to wrong request when multiple pending", async () => {
|
|
1107
1276
|
createBinding({
|
|
1108
|
-
assistantId:
|
|
1109
|
-
channel:
|
|
1110
|
-
guardianExternalUserId:
|
|
1111
|
-
guardianDeliveryChatId:
|
|
1112
|
-
guardianPrincipalId:
|
|
1277
|
+
assistantId: "self",
|
|
1278
|
+
channel: "telegram",
|
|
1279
|
+
guardianExternalUserId: "guardian-ambig-user",
|
|
1280
|
+
guardianDeliveryChatId: "guardian-ambig-chat",
|
|
1281
|
+
guardianPrincipalId: "guardian-ambig-user",
|
|
1113
1282
|
});
|
|
1114
1283
|
|
|
1115
|
-
const deliverSpy = spyOn(
|
|
1284
|
+
const deliverSpy = spyOn(
|
|
1285
|
+
gatewayClient,
|
|
1286
|
+
"deliverChannelReply",
|
|
1287
|
+
).mockResolvedValue(undefined);
|
|
1116
1288
|
|
|
1117
|
-
const convA =
|
|
1118
|
-
const convB =
|
|
1289
|
+
const convA = "conv-ambig-a";
|
|
1290
|
+
const convB = "conv-ambig-b";
|
|
1119
1291
|
ensureConversation(convA);
|
|
1120
1292
|
ensureConversation(convB);
|
|
1121
1293
|
|
|
1122
|
-
const sessionA = registerPendingInteraction(
|
|
1294
|
+
const sessionA = registerPendingInteraction("req-ambig-a", convA, "shell");
|
|
1123
1295
|
createApprovalRequest({
|
|
1124
|
-
runId:
|
|
1125
|
-
requestId:
|
|
1296
|
+
runId: "run-ambig-a",
|
|
1297
|
+
requestId: "req-ambig-a",
|
|
1126
1298
|
conversationId: convA,
|
|
1127
|
-
channel:
|
|
1128
|
-
requesterExternalUserId:
|
|
1129
|
-
requesterChatId:
|
|
1130
|
-
guardianExternalUserId:
|
|
1131
|
-
guardianChatId:
|
|
1132
|
-
toolName:
|
|
1299
|
+
channel: "telegram",
|
|
1300
|
+
requesterExternalUserId: "requester-x",
|
|
1301
|
+
requesterChatId: "chat-requester-x",
|
|
1302
|
+
guardianExternalUserId: "guardian-ambig-user",
|
|
1303
|
+
guardianChatId: "guardian-ambig-chat",
|
|
1304
|
+
toolName: "shell",
|
|
1133
1305
|
expiresAt: Date.now() + 300_000,
|
|
1134
1306
|
});
|
|
1135
1307
|
|
|
1136
|
-
const sessionB = registerPendingInteraction(
|
|
1308
|
+
const sessionB = registerPendingInteraction(
|
|
1309
|
+
"req-ambig-b",
|
|
1310
|
+
convB,
|
|
1311
|
+
"browser",
|
|
1312
|
+
);
|
|
1137
1313
|
createApprovalRequest({
|
|
1138
|
-
runId:
|
|
1139
|
-
requestId:
|
|
1314
|
+
runId: "run-ambig-b",
|
|
1315
|
+
requestId: "req-ambig-b",
|
|
1140
1316
|
conversationId: convB,
|
|
1141
|
-
channel:
|
|
1142
|
-
requesterExternalUserId:
|
|
1143
|
-
requesterChatId:
|
|
1144
|
-
guardianExternalUserId:
|
|
1145
|
-
guardianChatId:
|
|
1146
|
-
toolName:
|
|
1317
|
+
channel: "telegram",
|
|
1318
|
+
requesterExternalUserId: "requester-y",
|
|
1319
|
+
requesterChatId: "chat-requester-y",
|
|
1320
|
+
guardianExternalUserId: "guardian-ambig-user",
|
|
1321
|
+
guardianChatId: "guardian-ambig-chat",
|
|
1322
|
+
toolName: "browser",
|
|
1147
1323
|
expiresAt: Date.now() + 300_000,
|
|
1148
1324
|
});
|
|
1149
1325
|
|
|
1150
1326
|
// Conversational engine that returns keep_pending for disambiguation
|
|
1151
1327
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1152
|
-
disposition:
|
|
1153
|
-
replyText:
|
|
1328
|
+
disposition: "keep_pending" as const,
|
|
1329
|
+
replyText: "You have 2 pending requests. Which one?",
|
|
1154
1330
|
}));
|
|
1155
1331
|
|
|
1156
1332
|
// Guardian sends plain-text "yes" — ambiguous because two approvals are pending
|
|
1157
1333
|
const req = makeInboundRequest({
|
|
1158
|
-
content:
|
|
1159
|
-
conversationExternalId:
|
|
1160
|
-
actorExternalId:
|
|
1334
|
+
content: "yes",
|
|
1335
|
+
conversationExternalId: "guardian-ambig-chat",
|
|
1336
|
+
actorExternalId: "guardian-ambig-user",
|
|
1161
1337
|
});
|
|
1162
1338
|
|
|
1163
1339
|
const res = await handleChannelInbound(
|
|
1164
|
-
req,
|
|
1340
|
+
req,
|
|
1341
|
+
noopProcessMessage,
|
|
1342
|
+
"self",
|
|
1343
|
+
undefined,
|
|
1344
|
+
mockConversationGenerator,
|
|
1165
1345
|
);
|
|
1166
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1346
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1167
1347
|
|
|
1168
1348
|
expect(body.accepted).toBe(true);
|
|
1169
|
-
expect(body.approval).toBe(
|
|
1349
|
+
expect(body.approval).toBe("assistant_turn");
|
|
1170
1350
|
|
|
1171
1351
|
// Neither session should have been called — disambiguation was required
|
|
1172
1352
|
expect(sessionA).not.toHaveBeenCalled();
|
|
@@ -1174,8 +1354,11 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
1174
1354
|
|
|
1175
1355
|
// The conversational engine should have been called with both pending approvals
|
|
1176
1356
|
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
1177
|
-
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
1178
|
-
|
|
1357
|
+
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
1358
|
+
string,
|
|
1359
|
+
unknown
|
|
1360
|
+
>;
|
|
1361
|
+
expect(engineCtx.pendingApprovals as Array<unknown>).toHaveLength(2);
|
|
1179
1362
|
|
|
1180
1363
|
deliverSpy.mockRestore();
|
|
1181
1364
|
});
|
|
@@ -1185,84 +1368,96 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
1185
1368
|
// 23. Expired guardian approval auto-denies and transitions to terminal status
|
|
1186
1369
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1187
1370
|
|
|
1188
|
-
describe(
|
|
1189
|
-
test(
|
|
1190
|
-
const deliverSpy = spyOn(
|
|
1371
|
+
describe("expired guardian approval auto-denies via sweep", () => {
|
|
1372
|
+
test("sweepExpiredGuardianApprovals auto-denies and notifies both parties", async () => {
|
|
1373
|
+
const deliverSpy = spyOn(
|
|
1374
|
+
gatewayClient,
|
|
1375
|
+
"deliverChannelReply",
|
|
1376
|
+
).mockResolvedValue(undefined);
|
|
1191
1377
|
|
|
1192
|
-
const convId =
|
|
1378
|
+
const convId = "conv-expiry-sweep";
|
|
1193
1379
|
ensureConversation(convId);
|
|
1194
1380
|
|
|
1195
1381
|
// Register a pending interaction so the sweep can resolve the session
|
|
1196
|
-
const sessionMock = registerPendingInteraction(
|
|
1382
|
+
const sessionMock = registerPendingInteraction(
|
|
1383
|
+
"req-exp-1",
|
|
1384
|
+
convId,
|
|
1385
|
+
"shell",
|
|
1386
|
+
);
|
|
1197
1387
|
|
|
1198
1388
|
createApprovalRequest({
|
|
1199
|
-
runId:
|
|
1200
|
-
requestId:
|
|
1389
|
+
runId: "run-exp-1",
|
|
1390
|
+
requestId: "req-exp-1",
|
|
1201
1391
|
conversationId: convId,
|
|
1202
|
-
channel:
|
|
1203
|
-
requesterExternalUserId:
|
|
1204
|
-
requesterChatId:
|
|
1205
|
-
guardianExternalUserId:
|
|
1206
|
-
guardianChatId:
|
|
1207
|
-
toolName:
|
|
1392
|
+
channel: "telegram",
|
|
1393
|
+
requesterExternalUserId: "requester-exp",
|
|
1394
|
+
requesterChatId: "chat-requester-exp",
|
|
1395
|
+
guardianExternalUserId: "guardian-exp-user",
|
|
1396
|
+
guardianChatId: "guardian-exp-chat",
|
|
1397
|
+
toolName: "shell",
|
|
1208
1398
|
expiresAt: Date.now() - 1000, // already expired
|
|
1209
1399
|
});
|
|
1210
1400
|
|
|
1211
1401
|
// Run the sweep
|
|
1212
|
-
sweepExpiredGuardianApprovals(
|
|
1402
|
+
sweepExpiredGuardianApprovals("https://gateway.test", () => "token");
|
|
1213
1403
|
|
|
1214
1404
|
// Wait for async notifications
|
|
1215
1405
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1216
1406
|
|
|
1217
1407
|
// The session should have been denied
|
|
1218
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
1408
|
+
expect(sessionMock).toHaveBeenCalledWith("req-exp-1", "deny");
|
|
1219
1409
|
|
|
1220
1410
|
// Both requester and guardian should have been notified
|
|
1221
1411
|
const requesterNotify = deliverSpy.mock.calls.filter(
|
|
1222
|
-
(call) =>
|
|
1223
|
-
|
|
1224
|
-
(call[1] as {
|
|
1412
|
+
(call) =>
|
|
1413
|
+
typeof call[1] === "object" &&
|
|
1414
|
+
(call[1] as { chatId?: string }).chatId === "chat-requester-exp" &&
|
|
1415
|
+
(call[1] as { text?: string }).text?.includes("expired"),
|
|
1225
1416
|
);
|
|
1226
1417
|
expect(requesterNotify.length).toBeGreaterThanOrEqual(1);
|
|
1227
1418
|
|
|
1228
1419
|
const guardianNotify = deliverSpy.mock.calls.filter(
|
|
1229
|
-
(call) =>
|
|
1230
|
-
|
|
1231
|
-
(call[1] as {
|
|
1420
|
+
(call) =>
|
|
1421
|
+
typeof call[1] === "object" &&
|
|
1422
|
+
(call[1] as { chatId?: string }).chatId === "guardian-exp-chat" &&
|
|
1423
|
+
(call[1] as { text?: string }).text?.includes("expired"),
|
|
1232
1424
|
);
|
|
1233
1425
|
expect(guardianNotify.length).toBeGreaterThanOrEqual(1);
|
|
1234
1426
|
|
|
1235
1427
|
// Verify the delivery URL is constructed per-channel
|
|
1236
1428
|
const allDeliverCalls = deliverSpy.mock.calls;
|
|
1237
1429
|
for (const call of allDeliverCalls) {
|
|
1238
|
-
expect(call[0]).toBe(
|
|
1430
|
+
expect(call[0]).toBe("https://gateway.test/deliver/telegram");
|
|
1239
1431
|
}
|
|
1240
1432
|
|
|
1241
1433
|
deliverSpy.mockRestore();
|
|
1242
1434
|
});
|
|
1243
1435
|
|
|
1244
|
-
test(
|
|
1245
|
-
const deliverSpy = spyOn(
|
|
1436
|
+
test("non-expired approvals are not affected by the sweep", async () => {
|
|
1437
|
+
const deliverSpy = spyOn(
|
|
1438
|
+
gatewayClient,
|
|
1439
|
+
"deliverChannelReply",
|
|
1440
|
+
).mockResolvedValue(undefined);
|
|
1246
1441
|
|
|
1247
|
-
const convId =
|
|
1442
|
+
const convId = "conv-not-expired";
|
|
1248
1443
|
ensureConversation(convId);
|
|
1249
1444
|
|
|
1250
|
-
const sessionMock = registerPendingInteraction(
|
|
1445
|
+
const sessionMock = registerPendingInteraction("req-ne-1", convId, "shell");
|
|
1251
1446
|
|
|
1252
1447
|
createApprovalRequest({
|
|
1253
|
-
runId:
|
|
1254
|
-
requestId:
|
|
1448
|
+
runId: "run-ne-1",
|
|
1449
|
+
requestId: "req-ne-1",
|
|
1255
1450
|
conversationId: convId,
|
|
1256
|
-
channel:
|
|
1257
|
-
requesterExternalUserId:
|
|
1258
|
-
requesterChatId:
|
|
1259
|
-
guardianExternalUserId:
|
|
1260
|
-
guardianChatId:
|
|
1261
|
-
toolName:
|
|
1451
|
+
channel: "telegram",
|
|
1452
|
+
requesterExternalUserId: "requester-ne",
|
|
1453
|
+
requesterChatId: "chat-requester-ne",
|
|
1454
|
+
guardianExternalUserId: "guardian-ne-user",
|
|
1455
|
+
guardianChatId: "guardian-ne-chat",
|
|
1456
|
+
toolName: "shell",
|
|
1262
1457
|
expiresAt: Date.now() + 300_000, // still valid
|
|
1263
1458
|
});
|
|
1264
1459
|
|
|
1265
|
-
sweepExpiredGuardianApprovals(
|
|
1460
|
+
sweepExpiredGuardianApprovals("https://gateway.test", () => "token");
|
|
1266
1461
|
|
|
1267
1462
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1268
1463
|
|
|
@@ -1277,26 +1472,26 @@ describe('expired guardian approval auto-denies via sweep', () => {
|
|
|
1277
1472
|
// 24. Deliver-once idempotency guard
|
|
1278
1473
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1279
1474
|
|
|
1280
|
-
describe(
|
|
1281
|
-
test(
|
|
1282
|
-
const runId =
|
|
1475
|
+
describe("deliver-once idempotency guard", () => {
|
|
1476
|
+
test("claimRunDelivery returns true on first call, false on subsequent calls", () => {
|
|
1477
|
+
const runId = "run-idem-unit";
|
|
1283
1478
|
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
1284
1479
|
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
|
|
1285
1480
|
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
|
|
1286
1481
|
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
1287
1482
|
});
|
|
1288
1483
|
|
|
1289
|
-
test(
|
|
1290
|
-
expect(channelDeliveryStore.claimRunDelivery(
|
|
1291
|
-
expect(channelDeliveryStore.claimRunDelivery(
|
|
1292
|
-
expect(channelDeliveryStore.claimRunDelivery(
|
|
1293
|
-
expect(channelDeliveryStore.claimRunDelivery(
|
|
1294
|
-
channelDeliveryStore.resetRunDeliveryClaim(
|
|
1295
|
-
channelDeliveryStore.resetRunDeliveryClaim(
|
|
1484
|
+
test("different run IDs are independent", () => {
|
|
1485
|
+
expect(channelDeliveryStore.claimRunDelivery("run-a")).toBe(true);
|
|
1486
|
+
expect(channelDeliveryStore.claimRunDelivery("run-b")).toBe(true);
|
|
1487
|
+
expect(channelDeliveryStore.claimRunDelivery("run-a")).toBe(false);
|
|
1488
|
+
expect(channelDeliveryStore.claimRunDelivery("run-b")).toBe(false);
|
|
1489
|
+
channelDeliveryStore.resetRunDeliveryClaim("run-a");
|
|
1490
|
+
channelDeliveryStore.resetRunDeliveryClaim("run-b");
|
|
1296
1491
|
});
|
|
1297
1492
|
|
|
1298
|
-
test(
|
|
1299
|
-
const runId =
|
|
1493
|
+
test("resetRunDeliveryClaim allows re-claim", () => {
|
|
1494
|
+
const runId = "run-idem-reset";
|
|
1300
1495
|
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
1301
1496
|
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
1302
1497
|
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
@@ -1308,105 +1503,137 @@ describe('deliver-once idempotency guard', () => {
|
|
|
1308
1503
|
// 26. Assistant-scoped guardian verification via handleChannelInbound
|
|
1309
1504
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1310
1505
|
|
|
1311
|
-
describe(
|
|
1312
|
-
test(
|
|
1313
|
-
const { createVerificationChallenge } =
|
|
1314
|
-
|
|
1506
|
+
describe("assistant-scoped guardian verification via handleChannelInbound", () => {
|
|
1507
|
+
test("verification code uses the threaded assistantId (default: self)", async () => {
|
|
1508
|
+
const { createVerificationChallenge } =
|
|
1509
|
+
await import("../runtime/channel-guardian-service.js");
|
|
1510
|
+
const { secret } = createVerificationChallenge("self", "telegram");
|
|
1315
1511
|
|
|
1316
|
-
const deliverSpy = spyOn(
|
|
1512
|
+
const deliverSpy = spyOn(
|
|
1513
|
+
gatewayClient,
|
|
1514
|
+
"deliverChannelReply",
|
|
1515
|
+
).mockResolvedValue(undefined);
|
|
1317
1516
|
|
|
1318
1517
|
const req = makeInboundRequest({
|
|
1319
1518
|
content: secret,
|
|
1320
|
-
actorExternalId:
|
|
1519
|
+
actorExternalId: "user-default-asst",
|
|
1321
1520
|
});
|
|
1322
1521
|
|
|
1323
1522
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
1324
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1523
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1325
1524
|
|
|
1326
1525
|
expect(body.accepted).toBe(true);
|
|
1327
|
-
expect(body.guardianVerification).toBe(
|
|
1526
|
+
expect(body.guardianVerification).toBe("verified");
|
|
1328
1527
|
|
|
1329
1528
|
deliverSpy.mockRestore();
|
|
1330
1529
|
});
|
|
1331
1530
|
|
|
1332
|
-
test(
|
|
1333
|
-
const { createVerificationChallenge } =
|
|
1334
|
-
|
|
1531
|
+
test("verification code with explicit assistantId resolves against canonical scope", async () => {
|
|
1532
|
+
const { createVerificationChallenge } =
|
|
1533
|
+
await import("../runtime/channel-guardian-service.js");
|
|
1534
|
+
const { getGuardianBinding } =
|
|
1535
|
+
await import("../runtime/channel-guardian-service.js");
|
|
1335
1536
|
|
|
1336
1537
|
// All assistant IDs canonicalize to 'self' in the single-tenant daemon
|
|
1337
|
-
const { secret } = createVerificationChallenge(
|
|
1538
|
+
const { secret } = createVerificationChallenge("self", "telegram");
|
|
1338
1539
|
|
|
1339
|
-
const deliverSpy = spyOn(
|
|
1540
|
+
const deliverSpy = spyOn(
|
|
1541
|
+
gatewayClient,
|
|
1542
|
+
"deliverChannelReply",
|
|
1543
|
+
).mockResolvedValue(undefined);
|
|
1340
1544
|
|
|
1341
1545
|
const req = makeInboundRequest({
|
|
1342
1546
|
content: secret,
|
|
1343
|
-
actorExternalId:
|
|
1547
|
+
actorExternalId: "user-for-asst-x",
|
|
1344
1548
|
});
|
|
1345
1549
|
|
|
1346
|
-
const res = await handleChannelInbound(
|
|
1347
|
-
|
|
1550
|
+
const res = await handleChannelInbound(
|
|
1551
|
+
req,
|
|
1552
|
+
noopProcessMessage,
|
|
1553
|
+
"asst-route-X",
|
|
1554
|
+
);
|
|
1555
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1348
1556
|
|
|
1349
1557
|
expect(body.accepted).toBe(true);
|
|
1350
|
-
expect(body.guardianVerification).toBe(
|
|
1558
|
+
expect(body.guardianVerification).toBe("verified");
|
|
1351
1559
|
|
|
1352
|
-
const bindingX = getGuardianBinding(
|
|
1560
|
+
const bindingX = getGuardianBinding("self", "telegram");
|
|
1353
1561
|
expect(bindingX).not.toBeNull();
|
|
1354
|
-
expect(bindingX!.guardianExternalUserId).toBe(
|
|
1562
|
+
expect(bindingX!.guardianExternalUserId).toBe("user-for-asst-x");
|
|
1355
1563
|
|
|
1356
1564
|
deliverSpy.mockRestore();
|
|
1357
1565
|
});
|
|
1358
1566
|
|
|
1359
|
-
test(
|
|
1360
|
-
const { createVerificationChallenge } =
|
|
1567
|
+
test("all assistant IDs share canonical scope for verification", async () => {
|
|
1568
|
+
const { createVerificationChallenge } =
|
|
1569
|
+
await import("../runtime/channel-guardian-service.js");
|
|
1361
1570
|
|
|
1362
1571
|
// Both IDs canonicalize to 'self', so the challenge is found
|
|
1363
|
-
const { secret } = createVerificationChallenge(
|
|
1572
|
+
const { secret } = createVerificationChallenge("self", "telegram");
|
|
1364
1573
|
|
|
1365
|
-
const deliverSpy = spyOn(
|
|
1574
|
+
const deliverSpy = spyOn(
|
|
1575
|
+
gatewayClient,
|
|
1576
|
+
"deliverChannelReply",
|
|
1577
|
+
).mockResolvedValue(undefined);
|
|
1366
1578
|
|
|
1367
1579
|
const req = makeInboundRequest({
|
|
1368
1580
|
content: secret,
|
|
1369
|
-
actorExternalId:
|
|
1581
|
+
actorExternalId: "user-cross-test",
|
|
1370
1582
|
});
|
|
1371
1583
|
|
|
1372
|
-
const res = await handleChannelInbound(
|
|
1373
|
-
|
|
1584
|
+
const res = await handleChannelInbound(
|
|
1585
|
+
req,
|
|
1586
|
+
noopProcessMessage,
|
|
1587
|
+
"asst-B-cross",
|
|
1588
|
+
);
|
|
1589
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1374
1590
|
|
|
1375
1591
|
expect(body.accepted).toBe(true);
|
|
1376
|
-
expect(body.guardianVerification).toBe(
|
|
1592
|
+
expect(body.guardianVerification).toBe("verified");
|
|
1377
1593
|
|
|
1378
1594
|
deliverSpy.mockRestore();
|
|
1379
1595
|
});
|
|
1380
1596
|
|
|
1381
|
-
test(
|
|
1597
|
+
test("inbound with explicit assistantId does not mutate existing external bindings", async () => {
|
|
1382
1598
|
const db = getDb();
|
|
1383
1599
|
const now = Date.now();
|
|
1384
|
-
ensureConversation(
|
|
1385
|
-
db.insert(externalConversationBindings)
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1600
|
+
ensureConversation("conv-existing-binding");
|
|
1601
|
+
db.insert(externalConversationBindings)
|
|
1602
|
+
.values({
|
|
1603
|
+
conversationId: "conv-existing-binding",
|
|
1604
|
+
sourceChannel: "telegram",
|
|
1605
|
+
externalChatId: "chat-existing-999",
|
|
1606
|
+
externalUserId: "existing-user",
|
|
1607
|
+
createdAt: now,
|
|
1608
|
+
updatedAt: now,
|
|
1609
|
+
lastInboundAt: now,
|
|
1610
|
+
})
|
|
1611
|
+
.run();
|
|
1394
1612
|
|
|
1395
1613
|
const req = makeInboundRequest({
|
|
1396
|
-
content:
|
|
1397
|
-
actorExternalId:
|
|
1614
|
+
content: "hello from non-self assistant",
|
|
1615
|
+
actorExternalId: "incoming-user",
|
|
1398
1616
|
});
|
|
1399
1617
|
|
|
1400
|
-
const res = await handleChannelInbound(
|
|
1618
|
+
const res = await handleChannelInbound(
|
|
1619
|
+
req,
|
|
1620
|
+
noopProcessMessage,
|
|
1621
|
+
"asst-non-self",
|
|
1622
|
+
);
|
|
1401
1623
|
expect(res.status).toBe(200);
|
|
1402
1624
|
|
|
1403
1625
|
const binding = db
|
|
1404
1626
|
.select()
|
|
1405
1627
|
.from(externalConversationBindings)
|
|
1406
|
-
.where(
|
|
1628
|
+
.where(
|
|
1629
|
+
eq(
|
|
1630
|
+
externalConversationBindings.conversationId,
|
|
1631
|
+
"conv-existing-binding",
|
|
1632
|
+
),
|
|
1633
|
+
)
|
|
1407
1634
|
.get();
|
|
1408
1635
|
expect(binding).not.toBeNull();
|
|
1409
|
-
expect(binding!.externalUserId).toBe(
|
|
1636
|
+
expect(binding!.externalUserId).toBe("existing-user");
|
|
1410
1637
|
});
|
|
1411
1638
|
});
|
|
1412
1639
|
|
|
@@ -1418,50 +1645,66 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
1418
1645
|
// Conversational approval engine — standard path
|
|
1419
1646
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1420
1647
|
|
|
1421
|
-
describe(
|
|
1648
|
+
describe("conversational approval engine — standard path", () => {
|
|
1422
1649
|
beforeEach(() => {
|
|
1423
1650
|
createBinding({
|
|
1424
|
-
assistantId:
|
|
1425
|
-
channel:
|
|
1426
|
-
guardianExternalUserId:
|
|
1427
|
-
guardianDeliveryChatId:
|
|
1428
|
-
guardianPrincipalId:
|
|
1651
|
+
assistantId: "self",
|
|
1652
|
+
channel: "telegram",
|
|
1653
|
+
guardianExternalUserId: "telegram-user-default",
|
|
1654
|
+
guardianDeliveryChatId: "chat-123",
|
|
1655
|
+
guardianPrincipalId: "telegram-user-default",
|
|
1429
1656
|
});
|
|
1430
1657
|
});
|
|
1431
1658
|
|
|
1432
|
-
test(
|
|
1433
|
-
const deliverSpy = spyOn(
|
|
1659
|
+
test("non-decision follow-up: engine returns keep_pending, reply sent", async () => {
|
|
1660
|
+
const deliverSpy = spyOn(
|
|
1661
|
+
gatewayClient,
|
|
1662
|
+
"deliverChannelReply",
|
|
1663
|
+
).mockResolvedValue(undefined);
|
|
1434
1664
|
|
|
1435
|
-
const initReq = makeInboundRequest({ content:
|
|
1665
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
1436
1666
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
1437
1667
|
|
|
1438
1668
|
const db = getDb();
|
|
1439
|
-
const events = db.$client
|
|
1669
|
+
const events = db.$client
|
|
1670
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
1671
|
+
.all() as Array<{ conversation_id: string }>;
|
|
1440
1672
|
const conversationId = events[0]?.conversation_id;
|
|
1441
1673
|
ensureConversation(conversationId!);
|
|
1442
1674
|
|
|
1443
|
-
const sessionMock = registerPendingInteraction(
|
|
1675
|
+
const sessionMock = registerPendingInteraction(
|
|
1676
|
+
"req-conv-1",
|
|
1677
|
+
conversationId!,
|
|
1678
|
+
"shell",
|
|
1679
|
+
);
|
|
1444
1680
|
|
|
1445
1681
|
deliverSpy.mockClear();
|
|
1446
1682
|
|
|
1447
1683
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1448
|
-
disposition:
|
|
1449
|
-
replyText:
|
|
1684
|
+
disposition: "keep_pending" as const,
|
|
1685
|
+
replyText:
|
|
1686
|
+
"There is a pending shell command. Would you like to approve or deny it?",
|
|
1450
1687
|
}));
|
|
1451
1688
|
|
|
1452
|
-
const req = makeInboundRequest({ content:
|
|
1689
|
+
const req = makeInboundRequest({ content: "what does this command do?" });
|
|
1453
1690
|
const res = await handleChannelInbound(
|
|
1454
|
-
req,
|
|
1691
|
+
req,
|
|
1692
|
+
noopProcessMessage,
|
|
1693
|
+
"self",
|
|
1694
|
+
undefined,
|
|
1695
|
+
mockConversationGenerator,
|
|
1455
1696
|
);
|
|
1456
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1697
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1457
1698
|
|
|
1458
1699
|
expect(body.accepted).toBe(true);
|
|
1459
|
-
expect(body.approval).toBe(
|
|
1700
|
+
expect(body.approval).toBe("assistant_turn");
|
|
1460
1701
|
|
|
1461
1702
|
// The engine reply should have been delivered
|
|
1462
1703
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1463
1704
|
const replyCall = deliverSpy.mock.calls.find(
|
|
1464
|
-
(call) =>
|
|
1705
|
+
(call) =>
|
|
1706
|
+
typeof call[1] === "object" &&
|
|
1707
|
+
(call[1] as { text?: string }).text?.includes("pending shell command"),
|
|
1465
1708
|
);
|
|
1466
1709
|
expect(replyCall).toBeDefined();
|
|
1467
1710
|
|
|
@@ -1471,110 +1714,149 @@ describe('conversational approval engine — standard path', () => {
|
|
|
1471
1714
|
deliverSpy.mockRestore();
|
|
1472
1715
|
});
|
|
1473
1716
|
|
|
1474
|
-
test(
|
|
1475
|
-
const deliverSpy = spyOn(
|
|
1717
|
+
test("natural-language approval: engine returns approve_once, decision applied", async () => {
|
|
1718
|
+
const deliverSpy = spyOn(
|
|
1719
|
+
gatewayClient,
|
|
1720
|
+
"deliverChannelReply",
|
|
1721
|
+
).mockResolvedValue(undefined);
|
|
1476
1722
|
|
|
1477
|
-
const initReq = makeInboundRequest({ content:
|
|
1723
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
1478
1724
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
1479
1725
|
|
|
1480
1726
|
const db = getDb();
|
|
1481
|
-
const events = db.$client
|
|
1727
|
+
const events = db.$client
|
|
1728
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
1729
|
+
.all() as Array<{ conversation_id: string }>;
|
|
1482
1730
|
const conversationId = events[0]?.conversation_id;
|
|
1483
1731
|
ensureConversation(conversationId!);
|
|
1484
1732
|
|
|
1485
|
-
const sessionMock = registerPendingInteraction(
|
|
1733
|
+
const sessionMock = registerPendingInteraction(
|
|
1734
|
+
"req-conv-2",
|
|
1735
|
+
conversationId!,
|
|
1736
|
+
"shell",
|
|
1737
|
+
);
|
|
1486
1738
|
|
|
1487
1739
|
deliverSpy.mockClear();
|
|
1488
1740
|
|
|
1489
1741
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1490
|
-
disposition:
|
|
1491
|
-
replyText:
|
|
1742
|
+
disposition: "approve_once" as const,
|
|
1743
|
+
replyText: "Got it, approving the shell command.",
|
|
1492
1744
|
}));
|
|
1493
1745
|
|
|
1494
|
-
const req = makeInboundRequest({ content:
|
|
1746
|
+
const req = makeInboundRequest({ content: "yeah go ahead and run it" });
|
|
1495
1747
|
const res = await handleChannelInbound(
|
|
1496
|
-
req,
|
|
1748
|
+
req,
|
|
1749
|
+
noopProcessMessage,
|
|
1750
|
+
"self",
|
|
1751
|
+
undefined,
|
|
1752
|
+
mockConversationGenerator,
|
|
1497
1753
|
);
|
|
1498
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1754
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1499
1755
|
|
|
1500
1756
|
expect(body.accepted).toBe(true);
|
|
1501
|
-
expect(body.approval).toBe(
|
|
1757
|
+
expect(body.approval).toBe("decision_applied");
|
|
1502
1758
|
|
|
1503
1759
|
// The session should have received an allow decision
|
|
1504
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
1760
|
+
expect(sessionMock).toHaveBeenCalledWith("req-conv-2", "allow");
|
|
1505
1761
|
|
|
1506
1762
|
deliverSpy.mockRestore();
|
|
1507
1763
|
});
|
|
1508
1764
|
|
|
1509
1765
|
test('"nevermind" style message: engine returns reject, rejection applied', async () => {
|
|
1510
|
-
const deliverSpy = spyOn(
|
|
1766
|
+
const deliverSpy = spyOn(
|
|
1767
|
+
gatewayClient,
|
|
1768
|
+
"deliverChannelReply",
|
|
1769
|
+
).mockResolvedValue(undefined);
|
|
1511
1770
|
|
|
1512
|
-
const initReq = makeInboundRequest({ content:
|
|
1771
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
1513
1772
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
1514
1773
|
|
|
1515
1774
|
const db = getDb();
|
|
1516
|
-
const events = db.$client
|
|
1775
|
+
const events = db.$client
|
|
1776
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
1777
|
+
.all() as Array<{ conversation_id: string }>;
|
|
1517
1778
|
const conversationId = events[0]?.conversation_id;
|
|
1518
1779
|
ensureConversation(conversationId!);
|
|
1519
1780
|
|
|
1520
|
-
const sessionMock = registerPendingInteraction(
|
|
1781
|
+
const sessionMock = registerPendingInteraction(
|
|
1782
|
+
"req-conv-3",
|
|
1783
|
+
conversationId!,
|
|
1784
|
+
"shell",
|
|
1785
|
+
);
|
|
1521
1786
|
|
|
1522
1787
|
deliverSpy.mockClear();
|
|
1523
1788
|
|
|
1524
1789
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1525
|
-
disposition:
|
|
1526
|
-
replyText:
|
|
1790
|
+
disposition: "reject" as const,
|
|
1791
|
+
replyText: "No problem, I've cancelled the shell command.",
|
|
1527
1792
|
}));
|
|
1528
1793
|
|
|
1529
|
-
const req = makeInboundRequest({ content:
|
|
1794
|
+
const req = makeInboundRequest({ content: "nevermind, don't run that" });
|
|
1530
1795
|
const res = await handleChannelInbound(
|
|
1531
|
-
req,
|
|
1796
|
+
req,
|
|
1797
|
+
noopProcessMessage,
|
|
1798
|
+
"self",
|
|
1799
|
+
undefined,
|
|
1800
|
+
mockConversationGenerator,
|
|
1532
1801
|
);
|
|
1533
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1802
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1534
1803
|
|
|
1535
1804
|
expect(body.accepted).toBe(true);
|
|
1536
|
-
expect(body.approval).toBe(
|
|
1805
|
+
expect(body.approval).toBe("decision_applied");
|
|
1537
1806
|
|
|
1538
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
1807
|
+
expect(sessionMock).toHaveBeenCalledWith("req-conv-3", "deny");
|
|
1539
1808
|
|
|
1540
1809
|
deliverSpy.mockRestore();
|
|
1541
1810
|
});
|
|
1542
1811
|
|
|
1543
|
-
test(
|
|
1544
|
-
const deliverSpy = spyOn(
|
|
1812
|
+
test("callback button still takes priority even with conversational engine present", async () => {
|
|
1813
|
+
const deliverSpy = spyOn(
|
|
1814
|
+
gatewayClient,
|
|
1815
|
+
"deliverChannelReply",
|
|
1816
|
+
).mockResolvedValue(undefined);
|
|
1545
1817
|
|
|
1546
|
-
const initReq = makeInboundRequest({ content:
|
|
1818
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
1547
1819
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
1548
1820
|
|
|
1549
1821
|
const db = getDb();
|
|
1550
|
-
const events = db.$client
|
|
1822
|
+
const events = db.$client
|
|
1823
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
1824
|
+
.all() as Array<{ conversation_id: string }>;
|
|
1551
1825
|
const conversationId = events[0]?.conversation_id;
|
|
1552
1826
|
ensureConversation(conversationId!);
|
|
1553
1827
|
|
|
1554
|
-
const sessionMock = registerPendingInteraction(
|
|
1828
|
+
const sessionMock = registerPendingInteraction(
|
|
1829
|
+
"req-conv-4",
|
|
1830
|
+
conversationId!,
|
|
1831
|
+
"shell",
|
|
1832
|
+
);
|
|
1555
1833
|
|
|
1556
1834
|
// Mock conversational engine — should NOT be called for callback buttons
|
|
1557
1835
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1558
|
-
disposition:
|
|
1559
|
-
replyText:
|
|
1836
|
+
disposition: "keep_pending" as const,
|
|
1837
|
+
replyText: "This should not be called",
|
|
1560
1838
|
}));
|
|
1561
1839
|
|
|
1562
1840
|
const req = makeInboundRequest({
|
|
1563
|
-
content:
|
|
1564
|
-
callbackData:
|
|
1841
|
+
content: "",
|
|
1842
|
+
callbackData: "apr:req-conv-4:approve_once",
|
|
1565
1843
|
});
|
|
1566
1844
|
|
|
1567
1845
|
const res = await handleChannelInbound(
|
|
1568
|
-
req,
|
|
1846
|
+
req,
|
|
1847
|
+
noopProcessMessage,
|
|
1848
|
+
"self",
|
|
1849
|
+
undefined,
|
|
1850
|
+
mockConversationGenerator,
|
|
1569
1851
|
);
|
|
1570
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1852
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1571
1853
|
|
|
1572
1854
|
expect(body.accepted).toBe(true);
|
|
1573
|
-
expect(body.approval).toBe(
|
|
1855
|
+
expect(body.approval).toBe("decision_applied");
|
|
1574
1856
|
|
|
1575
1857
|
// The callback button should have been used directly, not the engine
|
|
1576
1858
|
expect(mockConversationGenerator).not.toHaveBeenCalled();
|
|
1577
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
1859
|
+
expect(sessionMock).toHaveBeenCalledWith("req-conv-4", "allow");
|
|
1578
1860
|
|
|
1579
1861
|
deliverSpy.mockRestore();
|
|
1580
1862
|
});
|
|
@@ -1584,246 +1866,298 @@ describe('conversational approval engine — standard path', () => {
|
|
|
1584
1866
|
// Guardian conversational approval engine tests
|
|
1585
1867
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1586
1868
|
|
|
1587
|
-
describe(
|
|
1588
|
-
test(
|
|
1869
|
+
describe("guardian conversational approval via conversation engine", () => {
|
|
1870
|
+
test("guardian follow-up clarification: engine returns keep_pending", async () => {
|
|
1589
1871
|
createBinding({
|
|
1590
|
-
assistantId:
|
|
1591
|
-
channel:
|
|
1592
|
-
guardianExternalUserId:
|
|
1593
|
-
guardianDeliveryChatId:
|
|
1594
|
-
guardianPrincipalId:
|
|
1872
|
+
assistantId: "self",
|
|
1873
|
+
channel: "telegram",
|
|
1874
|
+
guardianExternalUserId: "guardian-conv-user",
|
|
1875
|
+
guardianDeliveryChatId: "guardian-conv-chat",
|
|
1876
|
+
guardianPrincipalId: "guardian-conv-user",
|
|
1595
1877
|
});
|
|
1596
1878
|
|
|
1597
|
-
const deliverSpy = spyOn(
|
|
1879
|
+
const deliverSpy = spyOn(
|
|
1880
|
+
gatewayClient,
|
|
1881
|
+
"deliverChannelReply",
|
|
1882
|
+
).mockResolvedValue(undefined);
|
|
1598
1883
|
|
|
1599
|
-
const convId =
|
|
1884
|
+
const convId = "conv-guardian-clarify";
|
|
1600
1885
|
ensureConversation(convId);
|
|
1601
1886
|
|
|
1602
|
-
const sessionMock = registerPendingInteraction(
|
|
1887
|
+
const sessionMock = registerPendingInteraction(
|
|
1888
|
+
"req-gclarify-1",
|
|
1889
|
+
convId,
|
|
1890
|
+
"shell",
|
|
1891
|
+
);
|
|
1603
1892
|
createApprovalRequest({
|
|
1604
|
-
runId:
|
|
1605
|
-
requestId:
|
|
1893
|
+
runId: "run-gclarify-1",
|
|
1894
|
+
requestId: "req-gclarify-1",
|
|
1606
1895
|
conversationId: convId,
|
|
1607
|
-
channel:
|
|
1608
|
-
requesterExternalUserId:
|
|
1609
|
-
requesterChatId:
|
|
1610
|
-
guardianExternalUserId:
|
|
1611
|
-
guardianChatId:
|
|
1612
|
-
toolName:
|
|
1896
|
+
channel: "telegram",
|
|
1897
|
+
requesterExternalUserId: "requester-clarify",
|
|
1898
|
+
requesterChatId: "chat-requester-clarify",
|
|
1899
|
+
guardianExternalUserId: "guardian-conv-user",
|
|
1900
|
+
guardianChatId: "guardian-conv-chat",
|
|
1901
|
+
toolName: "shell",
|
|
1613
1902
|
expiresAt: Date.now() + 300_000,
|
|
1614
1903
|
});
|
|
1615
1904
|
|
|
1616
1905
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1617
|
-
disposition:
|
|
1618
|
-
replyText:
|
|
1906
|
+
disposition: "keep_pending" as const,
|
|
1907
|
+
replyText: "Could you clarify which action you want me to approve?",
|
|
1619
1908
|
}));
|
|
1620
1909
|
|
|
1621
1910
|
const req = makeInboundRequest({
|
|
1622
|
-
content:
|
|
1623
|
-
conversationExternalId:
|
|
1624
|
-
actorExternalId:
|
|
1911
|
+
content: "hmm what does this do?",
|
|
1912
|
+
conversationExternalId: "guardian-conv-chat",
|
|
1913
|
+
actorExternalId: "guardian-conv-user",
|
|
1625
1914
|
});
|
|
1626
1915
|
|
|
1627
1916
|
const res = await handleChannelInbound(
|
|
1628
|
-
req,
|
|
1917
|
+
req,
|
|
1918
|
+
noopProcessMessage,
|
|
1919
|
+
"self",
|
|
1920
|
+
undefined,
|
|
1921
|
+
mockConversationGenerator,
|
|
1629
1922
|
);
|
|
1630
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1923
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1631
1924
|
|
|
1632
1925
|
expect(body.accepted).toBe(true);
|
|
1633
|
-
expect(body.approval).toBe(
|
|
1926
|
+
expect(body.approval).toBe("assistant_turn");
|
|
1634
1927
|
|
|
1635
1928
|
// The engine should have been called with role: 'guardian'
|
|
1636
1929
|
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
1637
|
-
const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1930
|
+
const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
1931
|
+
string,
|
|
1932
|
+
unknown
|
|
1933
|
+
>;
|
|
1934
|
+
expect(callCtx.role).toBe("guardian");
|
|
1935
|
+
expect(callCtx.allowedActions).toEqual(["approve_once", "reject"]);
|
|
1936
|
+
expect(callCtx.userMessage).toBe("hmm what does this do?");
|
|
1641
1937
|
|
|
1642
1938
|
// The session should NOT have received a decision
|
|
1643
1939
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
1644
1940
|
|
|
1645
1941
|
// The approval should still be pending
|
|
1646
|
-
const pending = getAllPendingApprovalsByGuardianChat(
|
|
1942
|
+
const pending = getAllPendingApprovalsByGuardianChat(
|
|
1943
|
+
"telegram",
|
|
1944
|
+
"guardian-conv-chat",
|
|
1945
|
+
"self",
|
|
1946
|
+
);
|
|
1647
1947
|
expect(pending).toHaveLength(1);
|
|
1648
1948
|
|
|
1649
1949
|
deliverSpy.mockRestore();
|
|
1650
1950
|
});
|
|
1651
1951
|
|
|
1652
|
-
test(
|
|
1952
|
+
test("guardian natural-language approval: engine returns approve_once", async () => {
|
|
1653
1953
|
createBinding({
|
|
1654
|
-
assistantId:
|
|
1655
|
-
channel:
|
|
1656
|
-
guardianExternalUserId:
|
|
1657
|
-
guardianDeliveryChatId:
|
|
1658
|
-
guardianPrincipalId:
|
|
1954
|
+
assistantId: "self",
|
|
1955
|
+
channel: "telegram",
|
|
1956
|
+
guardianExternalUserId: "guardian-nlp-user",
|
|
1957
|
+
guardianDeliveryChatId: "guardian-nlp-chat",
|
|
1958
|
+
guardianPrincipalId: "guardian-nlp-user",
|
|
1659
1959
|
});
|
|
1660
1960
|
|
|
1661
|
-
const deliverSpy = spyOn(
|
|
1961
|
+
const deliverSpy = spyOn(
|
|
1962
|
+
gatewayClient,
|
|
1963
|
+
"deliverChannelReply",
|
|
1964
|
+
).mockResolvedValue(undefined);
|
|
1662
1965
|
|
|
1663
|
-
const convId =
|
|
1966
|
+
const convId = "conv-guardian-nlp";
|
|
1664
1967
|
ensureConversation(convId);
|
|
1665
1968
|
|
|
1666
|
-
const sessionMock = registerPendingInteraction(
|
|
1969
|
+
const sessionMock = registerPendingInteraction(
|
|
1970
|
+
"req-gnlp-1",
|
|
1971
|
+
convId,
|
|
1972
|
+
"shell",
|
|
1973
|
+
);
|
|
1667
1974
|
createApprovalRequest({
|
|
1668
|
-
runId:
|
|
1669
|
-
requestId:
|
|
1975
|
+
runId: "run-gnlp-1",
|
|
1976
|
+
requestId: "req-gnlp-1",
|
|
1670
1977
|
conversationId: convId,
|
|
1671
|
-
channel:
|
|
1672
|
-
requesterExternalUserId:
|
|
1673
|
-
requesterChatId:
|
|
1674
|
-
guardianExternalUserId:
|
|
1675
|
-
guardianChatId:
|
|
1676
|
-
toolName:
|
|
1978
|
+
channel: "telegram",
|
|
1979
|
+
requesterExternalUserId: "requester-nlp",
|
|
1980
|
+
requesterChatId: "chat-requester-nlp",
|
|
1981
|
+
guardianExternalUserId: "guardian-nlp-user",
|
|
1982
|
+
guardianChatId: "guardian-nlp-chat",
|
|
1983
|
+
toolName: "shell",
|
|
1677
1984
|
expiresAt: Date.now() + 300_000,
|
|
1678
1985
|
});
|
|
1679
1986
|
|
|
1680
1987
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1681
|
-
disposition:
|
|
1682
|
-
replyText:
|
|
1988
|
+
disposition: "approve_once" as const,
|
|
1989
|
+
replyText: "Approved! The shell command will proceed.",
|
|
1683
1990
|
}));
|
|
1684
1991
|
|
|
1685
1992
|
const req = makeInboundRequest({
|
|
1686
|
-
content:
|
|
1687
|
-
conversationExternalId:
|
|
1688
|
-
actorExternalId:
|
|
1993
|
+
content: "yes go ahead and run it",
|
|
1994
|
+
conversationExternalId: "guardian-nlp-chat",
|
|
1995
|
+
actorExternalId: "guardian-nlp-user",
|
|
1689
1996
|
});
|
|
1690
1997
|
|
|
1691
1998
|
const res = await handleChannelInbound(
|
|
1692
|
-
req,
|
|
1999
|
+
req,
|
|
2000
|
+
noopProcessMessage,
|
|
2001
|
+
"self",
|
|
2002
|
+
undefined,
|
|
2003
|
+
mockConversationGenerator,
|
|
1693
2004
|
);
|
|
1694
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2005
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1695
2006
|
|
|
1696
2007
|
expect(body.accepted).toBe(true);
|
|
1697
|
-
expect(body.approval).toBe(
|
|
2008
|
+
expect(body.approval).toBe("guardian_decision_applied");
|
|
1698
2009
|
|
|
1699
2010
|
// The session should have received an 'allow' decision
|
|
1700
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
2011
|
+
expect(sessionMock).toHaveBeenCalledWith("req-gnlp-1", "allow");
|
|
1701
2012
|
|
|
1702
2013
|
// The approval record should have been updated (no longer pending)
|
|
1703
|
-
const pending = getAllPendingApprovalsByGuardianChat(
|
|
2014
|
+
const pending = getAllPendingApprovalsByGuardianChat(
|
|
2015
|
+
"telegram",
|
|
2016
|
+
"guardian-nlp-chat",
|
|
2017
|
+
"self",
|
|
2018
|
+
);
|
|
1704
2019
|
expect(pending).toHaveLength(0);
|
|
1705
2020
|
|
|
1706
2021
|
// The engine context excluded approve_always for guardians
|
|
1707
|
-
const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
1708
|
-
|
|
1709
|
-
|
|
2022
|
+
const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
2023
|
+
string,
|
|
2024
|
+
unknown
|
|
2025
|
+
>;
|
|
2026
|
+
expect(callCtx.allowedActions).toEqual(["approve_once", "reject"]);
|
|
2027
|
+
expect(callCtx.allowedActions as string[]).not.toContain("approve_always");
|
|
1710
2028
|
|
|
1711
2029
|
deliverSpy.mockRestore();
|
|
1712
2030
|
});
|
|
1713
2031
|
|
|
1714
|
-
test(
|
|
2032
|
+
test("guardian callback button approve_always is downgraded to approve_once", async () => {
|
|
1715
2033
|
createBinding({
|
|
1716
|
-
assistantId:
|
|
1717
|
-
channel:
|
|
1718
|
-
guardianExternalUserId:
|
|
1719
|
-
guardianDeliveryChatId:
|
|
1720
|
-
guardianPrincipalId:
|
|
2034
|
+
assistantId: "self",
|
|
2035
|
+
channel: "telegram",
|
|
2036
|
+
guardianExternalUserId: "guardian-dg-user",
|
|
2037
|
+
guardianDeliveryChatId: "guardian-dg-chat",
|
|
2038
|
+
guardianPrincipalId: "guardian-dg-user",
|
|
1721
2039
|
});
|
|
1722
2040
|
|
|
1723
|
-
const deliverSpy = spyOn(
|
|
2041
|
+
const deliverSpy = spyOn(
|
|
2042
|
+
gatewayClient,
|
|
2043
|
+
"deliverChannelReply",
|
|
2044
|
+
).mockResolvedValue(undefined);
|
|
1724
2045
|
|
|
1725
|
-
const convId =
|
|
2046
|
+
const convId = "conv-guardian-downgrade";
|
|
1726
2047
|
ensureConversation(convId);
|
|
1727
2048
|
|
|
1728
|
-
const sessionMock = registerPendingInteraction(
|
|
2049
|
+
const sessionMock = registerPendingInteraction(
|
|
2050
|
+
"req-gdg-1",
|
|
2051
|
+
convId,
|
|
2052
|
+
"shell",
|
|
2053
|
+
);
|
|
1729
2054
|
createApprovalRequest({
|
|
1730
|
-
runId:
|
|
1731
|
-
requestId:
|
|
2055
|
+
runId: "run-gdg-1",
|
|
2056
|
+
requestId: "req-gdg-1",
|
|
1732
2057
|
conversationId: convId,
|
|
1733
|
-
channel:
|
|
1734
|
-
requesterExternalUserId:
|
|
1735
|
-
requesterChatId:
|
|
1736
|
-
guardianExternalUserId:
|
|
1737
|
-
guardianChatId:
|
|
1738
|
-
toolName:
|
|
2058
|
+
channel: "telegram",
|
|
2059
|
+
requesterExternalUserId: "requester-dg",
|
|
2060
|
+
requesterChatId: "chat-requester-dg",
|
|
2061
|
+
guardianExternalUserId: "guardian-dg-user",
|
|
2062
|
+
guardianChatId: "guardian-dg-chat",
|
|
2063
|
+
toolName: "shell",
|
|
1739
2064
|
expiresAt: Date.now() + 300_000,
|
|
1740
2065
|
});
|
|
1741
2066
|
|
|
1742
2067
|
// Guardian clicks approve_always via callback button
|
|
1743
2068
|
const req = makeInboundRequest({
|
|
1744
|
-
content:
|
|
1745
|
-
conversationExternalId:
|
|
1746
|
-
callbackData:
|
|
1747
|
-
actorExternalId:
|
|
2069
|
+
content: "",
|
|
2070
|
+
conversationExternalId: "guardian-dg-chat",
|
|
2071
|
+
callbackData: "apr:req-gdg-1:approve_always",
|
|
2072
|
+
actorExternalId: "guardian-dg-user",
|
|
1748
2073
|
});
|
|
1749
2074
|
|
|
1750
|
-
const res = await handleChannelInbound(
|
|
1751
|
-
|
|
1752
|
-
);
|
|
1753
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2075
|
+
const res = await handleChannelInbound(req, noopProcessMessage, "self");
|
|
2076
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1754
2077
|
|
|
1755
2078
|
expect(body.accepted).toBe(true);
|
|
1756
|
-
expect(body.approval).toBe(
|
|
2079
|
+
expect(body.approval).toBe("guardian_decision_applied");
|
|
1757
2080
|
|
|
1758
2081
|
// approve_always should have been downgraded to approve_once ('allow')
|
|
1759
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
2082
|
+
expect(sessionMock).toHaveBeenCalledWith("req-gdg-1", "allow");
|
|
1760
2083
|
|
|
1761
2084
|
deliverSpy.mockRestore();
|
|
1762
2085
|
});
|
|
1763
2086
|
|
|
1764
|
-
test(
|
|
2087
|
+
test("multi-pending guardian disambiguation: engine requests clarification", async () => {
|
|
1765
2088
|
createBinding({
|
|
1766
|
-
assistantId:
|
|
1767
|
-
channel:
|
|
1768
|
-
guardianExternalUserId:
|
|
1769
|
-
guardianDeliveryChatId:
|
|
1770
|
-
guardianPrincipalId:
|
|
2089
|
+
assistantId: "self",
|
|
2090
|
+
channel: "telegram",
|
|
2091
|
+
guardianExternalUserId: "guardian-multi-user",
|
|
2092
|
+
guardianDeliveryChatId: "guardian-multi-chat",
|
|
2093
|
+
guardianPrincipalId: "guardian-multi-user",
|
|
1771
2094
|
});
|
|
1772
2095
|
|
|
1773
|
-
const deliverSpy = spyOn(
|
|
2096
|
+
const deliverSpy = spyOn(
|
|
2097
|
+
gatewayClient,
|
|
2098
|
+
"deliverChannelReply",
|
|
2099
|
+
).mockResolvedValue(undefined);
|
|
1774
2100
|
|
|
1775
|
-
const convA =
|
|
1776
|
-
const convB =
|
|
2101
|
+
const convA = "conv-multi-a";
|
|
2102
|
+
const convB = "conv-multi-b";
|
|
1777
2103
|
ensureConversation(convA);
|
|
1778
2104
|
ensureConversation(convB);
|
|
1779
2105
|
|
|
1780
|
-
const sessionA = registerPendingInteraction(
|
|
2106
|
+
const sessionA = registerPendingInteraction("req-multi-a", convA, "shell");
|
|
1781
2107
|
createApprovalRequest({
|
|
1782
|
-
runId:
|
|
1783
|
-
requestId:
|
|
2108
|
+
runId: "run-multi-a",
|
|
2109
|
+
requestId: "req-multi-a",
|
|
1784
2110
|
conversationId: convA,
|
|
1785
|
-
channel:
|
|
1786
|
-
requesterExternalUserId:
|
|
1787
|
-
requesterChatId:
|
|
1788
|
-
guardianExternalUserId:
|
|
1789
|
-
guardianChatId:
|
|
1790
|
-
toolName:
|
|
2111
|
+
channel: "telegram",
|
|
2112
|
+
requesterExternalUserId: "requester-multi-a",
|
|
2113
|
+
requesterChatId: "chat-requester-multi-a",
|
|
2114
|
+
guardianExternalUserId: "guardian-multi-user",
|
|
2115
|
+
guardianChatId: "guardian-multi-chat",
|
|
2116
|
+
toolName: "shell",
|
|
1791
2117
|
expiresAt: Date.now() + 300_000,
|
|
1792
2118
|
});
|
|
1793
2119
|
|
|
1794
|
-
const sessionB = registerPendingInteraction(
|
|
2120
|
+
const sessionB = registerPendingInteraction(
|
|
2121
|
+
"req-multi-b",
|
|
2122
|
+
convB,
|
|
2123
|
+
"file_edit",
|
|
2124
|
+
);
|
|
1795
2125
|
createApprovalRequest({
|
|
1796
|
-
runId:
|
|
1797
|
-
requestId:
|
|
2126
|
+
runId: "run-multi-b",
|
|
2127
|
+
requestId: "req-multi-b",
|
|
1798
2128
|
conversationId: convB,
|
|
1799
|
-
channel:
|
|
1800
|
-
requesterExternalUserId:
|
|
1801
|
-
requesterChatId:
|
|
1802
|
-
guardianExternalUserId:
|
|
1803
|
-
guardianChatId:
|
|
1804
|
-
toolName:
|
|
2129
|
+
channel: "telegram",
|
|
2130
|
+
requesterExternalUserId: "requester-multi-b",
|
|
2131
|
+
requesterChatId: "chat-requester-multi-b",
|
|
2132
|
+
guardianExternalUserId: "guardian-multi-user",
|
|
2133
|
+
guardianChatId: "guardian-multi-chat",
|
|
2134
|
+
toolName: "file_edit",
|
|
1805
2135
|
expiresAt: Date.now() + 300_000,
|
|
1806
2136
|
});
|
|
1807
2137
|
|
|
1808
2138
|
// Engine returns keep_pending for disambiguation
|
|
1809
2139
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1810
|
-
disposition:
|
|
1811
|
-
replyText:
|
|
2140
|
+
disposition: "keep_pending" as const,
|
|
2141
|
+
replyText: "You have 2 pending requests: shell and file_edit. Which one?",
|
|
1812
2142
|
}));
|
|
1813
2143
|
|
|
1814
2144
|
const req = makeInboundRequest({
|
|
1815
|
-
content:
|
|
1816
|
-
conversationExternalId:
|
|
1817
|
-
actorExternalId:
|
|
2145
|
+
content: "approve it",
|
|
2146
|
+
conversationExternalId: "guardian-multi-chat",
|
|
2147
|
+
actorExternalId: "guardian-multi-user",
|
|
1818
2148
|
});
|
|
1819
2149
|
|
|
1820
2150
|
const res = await handleChannelInbound(
|
|
1821
|
-
req,
|
|
2151
|
+
req,
|
|
2152
|
+
noopProcessMessage,
|
|
2153
|
+
"self",
|
|
2154
|
+
undefined,
|
|
2155
|
+
mockConversationGenerator,
|
|
1822
2156
|
);
|
|
1823
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2157
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1824
2158
|
|
|
1825
2159
|
expect(body.accepted).toBe(true);
|
|
1826
|
-
expect(body.approval).toBe(
|
|
2160
|
+
expect(body.approval).toBe("assistant_turn");
|
|
1827
2161
|
|
|
1828
2162
|
// Neither session should have been called
|
|
1829
2163
|
expect(sessionA).not.toHaveBeenCalled();
|
|
@@ -1831,13 +2165,16 @@ describe('guardian conversational approval via conversation engine', () => {
|
|
|
1831
2165
|
|
|
1832
2166
|
// The engine should have received both pending approvals
|
|
1833
2167
|
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
1834
|
-
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
1835
|
-
|
|
1836
|
-
|
|
2168
|
+
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
2169
|
+
string,
|
|
2170
|
+
unknown
|
|
2171
|
+
>;
|
|
2172
|
+
expect(engineCtx.pendingApprovals as Array<unknown>).toHaveLength(2);
|
|
2173
|
+
expect(engineCtx.role).toBe("guardian");
|
|
1837
2174
|
|
|
1838
2175
|
// Disambiguation reply delivered to guardian
|
|
1839
|
-
const disambigCall = deliverSpy.mock.calls.find(
|
|
1840
|
-
(call
|
|
2176
|
+
const disambigCall = deliverSpy.mock.calls.find((call) =>
|
|
2177
|
+
(call[1] as { text?: string }).text?.includes("2 pending requests"),
|
|
1841
2178
|
);
|
|
1842
2179
|
expect(disambigCall).toBeTruthy();
|
|
1843
2180
|
|
|
@@ -1849,47 +2186,60 @@ describe('guardian conversational approval via conversation engine', () => {
|
|
|
1849
2186
|
// keep_pending must remain conversational (no deterministic fallback)
|
|
1850
2187
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1851
2188
|
|
|
1852
|
-
describe(
|
|
2189
|
+
describe("keep_pending remains conversational — standard path", () => {
|
|
1853
2190
|
beforeEach(() => {
|
|
1854
2191
|
createBinding({
|
|
1855
|
-
assistantId:
|
|
1856
|
-
channel:
|
|
1857
|
-
guardianExternalUserId:
|
|
1858
|
-
guardianDeliveryChatId:
|
|
1859
|
-
guardianPrincipalId:
|
|
2192
|
+
assistantId: "self",
|
|
2193
|
+
channel: "telegram",
|
|
2194
|
+
guardianExternalUserId: "telegram-user-default",
|
|
2195
|
+
guardianDeliveryChatId: "chat-123",
|
|
2196
|
+
guardianPrincipalId: "telegram-user-default",
|
|
1860
2197
|
});
|
|
1861
2198
|
});
|
|
1862
2199
|
|
|
1863
2200
|
test('explicit "approve" with keep_pending returns assistant_turn and does not auto-decide', async () => {
|
|
1864
|
-
const deliverSpy = spyOn(
|
|
2201
|
+
const deliverSpy = spyOn(
|
|
2202
|
+
gatewayClient,
|
|
2203
|
+
"deliverChannelReply",
|
|
2204
|
+
).mockResolvedValue(undefined);
|
|
1865
2205
|
|
|
1866
|
-
const initReq = makeInboundRequest({ content:
|
|
2206
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
1867
2207
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
1868
2208
|
|
|
1869
2209
|
const db = getDb();
|
|
1870
|
-
const events = db.$client
|
|
2210
|
+
const events = db.$client
|
|
2211
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2212
|
+
.all() as Array<{ conversation_id: string }>;
|
|
1871
2213
|
const conversationId = events[0]?.conversation_id;
|
|
1872
2214
|
ensureConversation(conversationId!);
|
|
1873
2215
|
|
|
1874
|
-
const sessionMock = registerPendingInteraction(
|
|
2216
|
+
const sessionMock = registerPendingInteraction(
|
|
2217
|
+
"req-kp-1",
|
|
2218
|
+
conversationId!,
|
|
2219
|
+
"shell",
|
|
2220
|
+
);
|
|
1875
2221
|
|
|
1876
2222
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1877
|
-
disposition:
|
|
1878
|
-
replyText:
|
|
2223
|
+
disposition: "keep_pending" as const,
|
|
2224
|
+
replyText: "Before deciding, can you confirm the intent?",
|
|
1879
2225
|
}));
|
|
1880
2226
|
|
|
1881
|
-
const req = makeInboundRequest({ content:
|
|
2227
|
+
const req = makeInboundRequest({ content: "approve" });
|
|
1882
2228
|
const res = await handleChannelInbound(
|
|
1883
|
-
req,
|
|
2229
|
+
req,
|
|
2230
|
+
noopProcessMessage,
|
|
2231
|
+
"self",
|
|
2232
|
+
undefined,
|
|
2233
|
+
mockConversationGenerator,
|
|
1884
2234
|
);
|
|
1885
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2235
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1886
2236
|
|
|
1887
2237
|
expect(body.accepted).toBe(true);
|
|
1888
|
-
expect(body.approval).toBe(
|
|
2238
|
+
expect(body.approval).toBe("assistant_turn");
|
|
1889
2239
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
1890
2240
|
|
|
1891
|
-
const followupReply = deliverSpy.mock.calls.find(
|
|
1892
|
-
(call
|
|
2241
|
+
const followupReply = deliverSpy.mock.calls.find((call) =>
|
|
2242
|
+
(call[1] as { text?: string }).text?.includes("confirm the intent"),
|
|
1893
2243
|
);
|
|
1894
2244
|
expect(followupReply).toBeDefined();
|
|
1895
2245
|
|
|
@@ -1897,57 +2247,70 @@ describe('keep_pending remains conversational — standard path', () => {
|
|
|
1897
2247
|
});
|
|
1898
2248
|
});
|
|
1899
2249
|
|
|
1900
|
-
describe(
|
|
2250
|
+
describe("keep_pending remains conversational — guardian path", () => {
|
|
1901
2251
|
test('guardian explicit "yes" with keep_pending returns assistant_turn without applying a decision', async () => {
|
|
1902
2252
|
createBinding({
|
|
1903
|
-
assistantId:
|
|
1904
|
-
channel:
|
|
1905
|
-
guardianExternalUserId:
|
|
1906
|
-
guardianDeliveryChatId:
|
|
1907
|
-
guardianPrincipalId:
|
|
2253
|
+
assistantId: "self",
|
|
2254
|
+
channel: "telegram",
|
|
2255
|
+
guardianExternalUserId: "guardian-user-fb",
|
|
2256
|
+
guardianDeliveryChatId: "guardian-chat-fb",
|
|
2257
|
+
guardianPrincipalId: "guardian-user-fb",
|
|
1908
2258
|
});
|
|
1909
2259
|
|
|
1910
|
-
const deliverSpy = spyOn(
|
|
2260
|
+
const deliverSpy = spyOn(
|
|
2261
|
+
gatewayClient,
|
|
2262
|
+
"deliverChannelReply",
|
|
2263
|
+
).mockResolvedValue(undefined);
|
|
1911
2264
|
|
|
1912
|
-
const convId =
|
|
2265
|
+
const convId = "conv-gfb-1";
|
|
1913
2266
|
ensureConversation(convId);
|
|
1914
2267
|
|
|
1915
|
-
const sessionMock = registerPendingInteraction(
|
|
2268
|
+
const sessionMock = registerPendingInteraction(
|
|
2269
|
+
"req-gfb-1",
|
|
2270
|
+
convId,
|
|
2271
|
+
"shell",
|
|
2272
|
+
);
|
|
1916
2273
|
createApprovalRequest({
|
|
1917
|
-
runId:
|
|
1918
|
-
requestId:
|
|
2274
|
+
runId: "run-gfb-1",
|
|
2275
|
+
requestId: "req-gfb-1",
|
|
1919
2276
|
conversationId: convId,
|
|
1920
|
-
assistantId:
|
|
1921
|
-
channel:
|
|
1922
|
-
requesterExternalUserId:
|
|
1923
|
-
requesterChatId:
|
|
1924
|
-
guardianExternalUserId:
|
|
1925
|
-
guardianChatId:
|
|
1926
|
-
toolName:
|
|
2277
|
+
assistantId: "self",
|
|
2278
|
+
channel: "telegram",
|
|
2279
|
+
requesterExternalUserId: "requester-user-fb",
|
|
2280
|
+
requesterChatId: "requester-chat-fb",
|
|
2281
|
+
guardianExternalUserId: "guardian-user-fb",
|
|
2282
|
+
guardianChatId: "guardian-chat-fb",
|
|
2283
|
+
toolName: "shell",
|
|
1927
2284
|
expiresAt: Date.now() + 300_000,
|
|
1928
2285
|
});
|
|
1929
2286
|
|
|
1930
2287
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
1931
|
-
disposition:
|
|
1932
|
-
replyText:
|
|
2288
|
+
disposition: "keep_pending" as const,
|
|
2289
|
+
replyText: "Which run are you approving?",
|
|
1933
2290
|
}));
|
|
1934
2291
|
|
|
1935
2292
|
const guardianReq = makeInboundRequest({
|
|
1936
|
-
content:
|
|
1937
|
-
conversationExternalId:
|
|
1938
|
-
actorExternalId:
|
|
2293
|
+
content: "yes",
|
|
2294
|
+
conversationExternalId: "guardian-chat-fb",
|
|
2295
|
+
actorExternalId: "guardian-user-fb",
|
|
1939
2296
|
});
|
|
1940
2297
|
const res = await handleChannelInbound(
|
|
1941
|
-
guardianReq,
|
|
2298
|
+
guardianReq,
|
|
2299
|
+
noopProcessMessage,
|
|
2300
|
+
"self",
|
|
2301
|
+
undefined,
|
|
2302
|
+
mockConversationGenerator,
|
|
1942
2303
|
);
|
|
1943
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2304
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1944
2305
|
|
|
1945
2306
|
expect(body.accepted).toBe(true);
|
|
1946
|
-
expect(body.approval).toBe(
|
|
2307
|
+
expect(body.approval).toBe("assistant_turn");
|
|
1947
2308
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
1948
2309
|
|
|
1949
|
-
const followupReply = deliverSpy.mock.calls.find(
|
|
1950
|
-
(call
|
|
2310
|
+
const followupReply = deliverSpy.mock.calls.find((call) =>
|
|
2311
|
+
(call[1] as { text?: string }).text?.includes(
|
|
2312
|
+
"Which run are you approving",
|
|
2313
|
+
),
|
|
1951
2314
|
);
|
|
1952
2315
|
expect(followupReply).toBeDefined();
|
|
1953
2316
|
|
|
@@ -1959,79 +2322,94 @@ describe('keep_pending remains conversational — guardian path', () => {
|
|
|
1959
2322
|
// Requester cancel of guardian-gated pending request
|
|
1960
2323
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1961
2324
|
|
|
1962
|
-
describe(
|
|
2325
|
+
describe("requester cancel of guardian-gated pending request", () => {
|
|
1963
2326
|
beforeEach(() => {
|
|
1964
2327
|
createBinding({
|
|
1965
|
-
assistantId:
|
|
1966
|
-
channel:
|
|
1967
|
-
guardianExternalUserId:
|
|
1968
|
-
guardianDeliveryChatId:
|
|
1969
|
-
guardianPrincipalId:
|
|
2328
|
+
assistantId: "self",
|
|
2329
|
+
channel: "telegram",
|
|
2330
|
+
guardianExternalUserId: "guardian-cancel",
|
|
2331
|
+
guardianDeliveryChatId: "guardian-cancel-chat",
|
|
2332
|
+
guardianPrincipalId: "guardian-cancel",
|
|
1970
2333
|
});
|
|
1971
2334
|
});
|
|
1972
2335
|
|
|
1973
2336
|
test('requester explicit "deny" can cancel when the conversation engine returns reject', async () => {
|
|
1974
|
-
const deliverSpy = spyOn(
|
|
2337
|
+
const deliverSpy = spyOn(
|
|
2338
|
+
gatewayClient,
|
|
2339
|
+
"deliverChannelReply",
|
|
2340
|
+
).mockResolvedValue(undefined);
|
|
1975
2341
|
|
|
1976
2342
|
// Create requester conversation
|
|
1977
2343
|
const initReq = makeInboundRequest({
|
|
1978
|
-
content:
|
|
1979
|
-
conversationExternalId:
|
|
1980
|
-
actorExternalId:
|
|
2344
|
+
content: "init",
|
|
2345
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2346
|
+
actorExternalId: "requester-cancel-user",
|
|
1981
2347
|
});
|
|
1982
2348
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
1983
2349
|
|
|
1984
2350
|
const db = getDb();
|
|
1985
|
-
const events = db.$client
|
|
2351
|
+
const events = db.$client
|
|
2352
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2353
|
+
.all() as Array<{ conversation_id: string }>;
|
|
1986
2354
|
const conversationId = events[0]?.conversation_id;
|
|
1987
2355
|
ensureConversation(conversationId!);
|
|
1988
2356
|
|
|
1989
|
-
const sessionMock = registerPendingInteraction(
|
|
2357
|
+
const sessionMock = registerPendingInteraction(
|
|
2358
|
+
"req-cancel-1",
|
|
2359
|
+
conversationId!,
|
|
2360
|
+
"shell",
|
|
2361
|
+
);
|
|
1990
2362
|
|
|
1991
2363
|
createApprovalRequest({
|
|
1992
|
-
runId:
|
|
1993
|
-
requestId:
|
|
2364
|
+
runId: "run-cancel-1",
|
|
2365
|
+
requestId: "req-cancel-1",
|
|
1994
2366
|
conversationId: conversationId!,
|
|
1995
|
-
assistantId:
|
|
1996
|
-
channel:
|
|
1997
|
-
requesterExternalUserId:
|
|
1998
|
-
requesterChatId:
|
|
1999
|
-
guardianExternalUserId:
|
|
2000
|
-
guardianChatId:
|
|
2001
|
-
toolName:
|
|
2367
|
+
assistantId: "self",
|
|
2368
|
+
channel: "telegram",
|
|
2369
|
+
requesterExternalUserId: "requester-cancel-user",
|
|
2370
|
+
requesterChatId: "requester-cancel-chat",
|
|
2371
|
+
guardianExternalUserId: "guardian-cancel",
|
|
2372
|
+
guardianChatId: "guardian-cancel-chat",
|
|
2373
|
+
toolName: "shell",
|
|
2002
2374
|
expiresAt: Date.now() + 300_000,
|
|
2003
2375
|
});
|
|
2004
2376
|
|
|
2005
2377
|
deliverSpy.mockClear();
|
|
2006
2378
|
|
|
2007
2379
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
2008
|
-
disposition:
|
|
2009
|
-
replyText:
|
|
2380
|
+
disposition: "reject" as const,
|
|
2381
|
+
replyText: "Cancelling this request now.",
|
|
2010
2382
|
}));
|
|
2011
2383
|
|
|
2012
2384
|
const req = makeInboundRequest({
|
|
2013
|
-
content:
|
|
2014
|
-
conversationExternalId:
|
|
2015
|
-
actorExternalId:
|
|
2385
|
+
content: "deny",
|
|
2386
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2387
|
+
actorExternalId: "requester-cancel-user",
|
|
2016
2388
|
});
|
|
2017
2389
|
const res = await handleChannelInbound(
|
|
2018
|
-
req,
|
|
2390
|
+
req,
|
|
2391
|
+
noopProcessMessage,
|
|
2392
|
+
"self",
|
|
2393
|
+
undefined,
|
|
2394
|
+
mockConversationGenerator,
|
|
2019
2395
|
);
|
|
2020
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2396
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2021
2397
|
|
|
2022
2398
|
expect(body.accepted).toBe(true);
|
|
2023
|
-
expect(body.approval).toBe(
|
|
2024
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
2399
|
+
expect(body.approval).toBe("decision_applied");
|
|
2400
|
+
expect(sessionMock).toHaveBeenCalledWith("req-cancel-1", "deny");
|
|
2025
2401
|
|
|
2026
2402
|
// Requester should have been notified
|
|
2027
2403
|
const requesterReply = deliverSpy.mock.calls.find(
|
|
2028
|
-
(call) =>
|
|
2404
|
+
(call) =>
|
|
2405
|
+
(call[1] as { chatId?: string }).chatId === "requester-cancel-chat",
|
|
2029
2406
|
);
|
|
2030
2407
|
expect(requesterReply).toBeDefined();
|
|
2031
2408
|
|
|
2032
2409
|
// Guardian should have been notified of the cancellation
|
|
2033
2410
|
const guardianNotice = deliverSpy.mock.calls.find(
|
|
2034
|
-
(call) =>
|
|
2411
|
+
(call) =>
|
|
2412
|
+
(call[1] as { chatId?: string }).chatId === "guardian-cancel-chat",
|
|
2035
2413
|
);
|
|
2036
2414
|
expect(guardianNotice).toBeDefined();
|
|
2037
2415
|
|
|
@@ -2039,119 +2417,148 @@ describe('requester cancel of guardian-gated pending request', () => {
|
|
|
2039
2417
|
});
|
|
2040
2418
|
|
|
2041
2419
|
test('requester "nevermind" via conversational engine cancels guardian-gated request', async () => {
|
|
2042
|
-
const deliverSpy = spyOn(
|
|
2420
|
+
const deliverSpy = spyOn(
|
|
2421
|
+
gatewayClient,
|
|
2422
|
+
"deliverChannelReply",
|
|
2423
|
+
).mockResolvedValue(undefined);
|
|
2043
2424
|
|
|
2044
2425
|
const initReq = makeInboundRequest({
|
|
2045
|
-
content:
|
|
2046
|
-
conversationExternalId:
|
|
2047
|
-
actorExternalId:
|
|
2426
|
+
content: "init",
|
|
2427
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2428
|
+
actorExternalId: "requester-cancel-user",
|
|
2048
2429
|
});
|
|
2049
2430
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2050
2431
|
|
|
2051
2432
|
const db = getDb();
|
|
2052
|
-
const events = db.$client
|
|
2433
|
+
const events = db.$client
|
|
2434
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2435
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2053
2436
|
const conversationId = events[0]?.conversation_id;
|
|
2054
2437
|
ensureConversation(conversationId!);
|
|
2055
2438
|
|
|
2056
|
-
const sessionMock = registerPendingInteraction(
|
|
2439
|
+
const sessionMock = registerPendingInteraction(
|
|
2440
|
+
"req-cancel-2",
|
|
2441
|
+
conversationId!,
|
|
2442
|
+
"shell",
|
|
2443
|
+
);
|
|
2057
2444
|
|
|
2058
2445
|
createApprovalRequest({
|
|
2059
|
-
runId:
|
|
2060
|
-
requestId:
|
|
2446
|
+
runId: "run-cancel-2",
|
|
2447
|
+
requestId: "req-cancel-2",
|
|
2061
2448
|
conversationId: conversationId!,
|
|
2062
|
-
assistantId:
|
|
2063
|
-
channel:
|
|
2064
|
-
requesterExternalUserId:
|
|
2065
|
-
requesterChatId:
|
|
2066
|
-
guardianExternalUserId:
|
|
2067
|
-
guardianChatId:
|
|
2068
|
-
toolName:
|
|
2449
|
+
assistantId: "self",
|
|
2450
|
+
channel: "telegram",
|
|
2451
|
+
requesterExternalUserId: "requester-cancel-user",
|
|
2452
|
+
requesterChatId: "requester-cancel-chat",
|
|
2453
|
+
guardianExternalUserId: "guardian-cancel",
|
|
2454
|
+
guardianChatId: "guardian-cancel-chat",
|
|
2455
|
+
toolName: "shell",
|
|
2069
2456
|
expiresAt: Date.now() + 300_000,
|
|
2070
2457
|
});
|
|
2071
2458
|
|
|
2072
2459
|
deliverSpy.mockClear();
|
|
2073
2460
|
|
|
2074
2461
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
2075
|
-
disposition:
|
|
2076
|
-
replyText:
|
|
2462
|
+
disposition: "reject" as const,
|
|
2463
|
+
replyText: "OK, I have cancelled the pending request.",
|
|
2077
2464
|
}));
|
|
2078
2465
|
|
|
2079
2466
|
const req = makeInboundRequest({
|
|
2080
|
-
content:
|
|
2081
|
-
conversationExternalId:
|
|
2082
|
-
actorExternalId:
|
|
2467
|
+
content: "actually never mind, cancel it",
|
|
2468
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2469
|
+
actorExternalId: "requester-cancel-user",
|
|
2083
2470
|
});
|
|
2084
2471
|
const res = await handleChannelInbound(
|
|
2085
|
-
req,
|
|
2472
|
+
req,
|
|
2473
|
+
noopProcessMessage,
|
|
2474
|
+
"self",
|
|
2475
|
+
undefined,
|
|
2476
|
+
mockConversationGenerator,
|
|
2086
2477
|
);
|
|
2087
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2478
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2088
2479
|
|
|
2089
2480
|
expect(body.accepted).toBe(true);
|
|
2090
|
-
expect(body.approval).toBe(
|
|
2091
|
-
expect(sessionMock).toHaveBeenCalledWith(
|
|
2481
|
+
expect(body.approval).toBe("decision_applied");
|
|
2482
|
+
expect(sessionMock).toHaveBeenCalledWith("req-cancel-2", "deny");
|
|
2092
2483
|
|
|
2093
2484
|
// Engine should have been called with reject-only allowed actions
|
|
2094
2485
|
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
2095
|
-
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
2096
|
-
|
|
2486
|
+
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<
|
|
2487
|
+
string,
|
|
2488
|
+
unknown
|
|
2489
|
+
>;
|
|
2490
|
+
expect(engineCtx.allowedActions).toEqual(["reject"]);
|
|
2097
2491
|
|
|
2098
2492
|
deliverSpy.mockRestore();
|
|
2099
2493
|
});
|
|
2100
2494
|
|
|
2101
|
-
test(
|
|
2102
|
-
const deliverSpy = spyOn(
|
|
2495
|
+
test("requester non-cancel message with keep_pending returns conversational reply", async () => {
|
|
2496
|
+
const deliverSpy = spyOn(
|
|
2497
|
+
gatewayClient,
|
|
2498
|
+
"deliverChannelReply",
|
|
2499
|
+
).mockResolvedValue(undefined);
|
|
2103
2500
|
|
|
2104
2501
|
const initReq = makeInboundRequest({
|
|
2105
|
-
content:
|
|
2106
|
-
conversationExternalId:
|
|
2107
|
-
actorExternalId:
|
|
2502
|
+
content: "init",
|
|
2503
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2504
|
+
actorExternalId: "requester-cancel-user",
|
|
2108
2505
|
});
|
|
2109
2506
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2110
2507
|
|
|
2111
2508
|
const db = getDb();
|
|
2112
|
-
const events = db.$client
|
|
2509
|
+
const events = db.$client
|
|
2510
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2511
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2113
2512
|
const conversationId = events[0]?.conversation_id;
|
|
2114
2513
|
ensureConversation(conversationId!);
|
|
2115
2514
|
|
|
2116
|
-
const sessionMock = registerPendingInteraction(
|
|
2515
|
+
const sessionMock = registerPendingInteraction(
|
|
2516
|
+
"req-cancel-3",
|
|
2517
|
+
conversationId!,
|
|
2518
|
+
"shell",
|
|
2519
|
+
);
|
|
2117
2520
|
|
|
2118
2521
|
createApprovalRequest({
|
|
2119
|
-
runId:
|
|
2120
|
-
requestId:
|
|
2522
|
+
runId: "run-cancel-3",
|
|
2523
|
+
requestId: "req-cancel-3",
|
|
2121
2524
|
conversationId: conversationId!,
|
|
2122
|
-
assistantId:
|
|
2123
|
-
channel:
|
|
2124
|
-
requesterExternalUserId:
|
|
2125
|
-
requesterChatId:
|
|
2126
|
-
guardianExternalUserId:
|
|
2127
|
-
guardianChatId:
|
|
2128
|
-
toolName:
|
|
2525
|
+
assistantId: "self",
|
|
2526
|
+
channel: "telegram",
|
|
2527
|
+
requesterExternalUserId: "requester-cancel-user",
|
|
2528
|
+
requesterChatId: "requester-cancel-chat",
|
|
2529
|
+
guardianExternalUserId: "guardian-cancel",
|
|
2530
|
+
guardianChatId: "guardian-cancel-chat",
|
|
2531
|
+
toolName: "shell",
|
|
2129
2532
|
expiresAt: Date.now() + 300_000,
|
|
2130
2533
|
});
|
|
2131
2534
|
|
|
2132
2535
|
deliverSpy.mockClear();
|
|
2133
2536
|
|
|
2134
2537
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
2135
|
-
disposition:
|
|
2136
|
-
replyText:
|
|
2538
|
+
disposition: "keep_pending" as const,
|
|
2539
|
+
replyText: "Still waiting.",
|
|
2137
2540
|
}));
|
|
2138
2541
|
|
|
2139
2542
|
const req = makeInboundRequest({
|
|
2140
|
-
content:
|
|
2141
|
-
conversationExternalId:
|
|
2142
|
-
actorExternalId:
|
|
2543
|
+
content: "what is happening?",
|
|
2544
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2545
|
+
actorExternalId: "requester-cancel-user",
|
|
2143
2546
|
});
|
|
2144
2547
|
const res = await handleChannelInbound(
|
|
2145
|
-
req,
|
|
2548
|
+
req,
|
|
2549
|
+
noopProcessMessage,
|
|
2550
|
+
"self",
|
|
2551
|
+
undefined,
|
|
2552
|
+
mockConversationGenerator,
|
|
2146
2553
|
);
|
|
2147
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2554
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2148
2555
|
|
|
2149
2556
|
expect(body.accepted).toBe(true);
|
|
2150
|
-
expect(body.approval).toBe(
|
|
2557
|
+
expect(body.approval).toBe("assistant_turn");
|
|
2151
2558
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
2152
2559
|
|
|
2153
|
-
const pendingReply = deliverSpy.mock.calls.find(
|
|
2154
|
-
(call
|
|
2560
|
+
const pendingReply = deliverSpy.mock.calls.find((call) =>
|
|
2561
|
+
(call[1] as { text?: string }).text?.includes("Still waiting."),
|
|
2155
2562
|
);
|
|
2156
2563
|
expect(pendingReply).toBeDefined();
|
|
2157
2564
|
|
|
@@ -2159,33 +2566,38 @@ describe('requester cancel of guardian-gated pending request', () => {
|
|
|
2159
2566
|
});
|
|
2160
2567
|
|
|
2161
2568
|
test('requester "approve" is blocked — self-approval not allowed even during cancel check', async () => {
|
|
2162
|
-
const deliverSpy = spyOn(
|
|
2569
|
+
const deliverSpy = spyOn(
|
|
2570
|
+
gatewayClient,
|
|
2571
|
+
"deliverChannelReply",
|
|
2572
|
+
).mockResolvedValue(undefined);
|
|
2163
2573
|
|
|
2164
2574
|
const initReq = makeInboundRequest({
|
|
2165
|
-
content:
|
|
2166
|
-
conversationExternalId:
|
|
2167
|
-
actorExternalId:
|
|
2575
|
+
content: "init",
|
|
2576
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2577
|
+
actorExternalId: "requester-cancel-user",
|
|
2168
2578
|
});
|
|
2169
2579
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2170
2580
|
|
|
2171
2581
|
const db = getDb();
|
|
2172
|
-
const events = db.$client
|
|
2582
|
+
const events = db.$client
|
|
2583
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2584
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2173
2585
|
const conversationId = events[0]?.conversation_id;
|
|
2174
2586
|
ensureConversation(conversationId!);
|
|
2175
2587
|
|
|
2176
|
-
registerPendingInteraction(
|
|
2588
|
+
registerPendingInteraction("req-cancel-4", conversationId!, "shell");
|
|
2177
2589
|
|
|
2178
2590
|
createApprovalRequest({
|
|
2179
|
-
runId:
|
|
2180
|
-
requestId:
|
|
2591
|
+
runId: "run-cancel-4",
|
|
2592
|
+
requestId: "req-cancel-4",
|
|
2181
2593
|
conversationId: conversationId!,
|
|
2182
|
-
assistantId:
|
|
2183
|
-
channel:
|
|
2184
|
-
requesterExternalUserId:
|
|
2185
|
-
requesterChatId:
|
|
2186
|
-
guardianExternalUserId:
|
|
2187
|
-
guardianChatId:
|
|
2188
|
-
toolName:
|
|
2594
|
+
assistantId: "self",
|
|
2595
|
+
channel: "telegram",
|
|
2596
|
+
requesterExternalUserId: "requester-cancel-user",
|
|
2597
|
+
requesterChatId: "requester-cancel-chat",
|
|
2598
|
+
guardianExternalUserId: "guardian-cancel",
|
|
2599
|
+
guardianChatId: "guardian-cancel-chat",
|
|
2600
|
+
toolName: "shell",
|
|
2189
2601
|
expiresAt: Date.now() + 300_000,
|
|
2190
2602
|
});
|
|
2191
2603
|
|
|
@@ -2193,16 +2605,16 @@ describe('requester cancel of guardian-gated pending request', () => {
|
|
|
2193
2605
|
|
|
2194
2606
|
// Requester tries to self-approve while guardian approval is pending.
|
|
2195
2607
|
const req = makeInboundRequest({
|
|
2196
|
-
content:
|
|
2197
|
-
conversationExternalId:
|
|
2198
|
-
actorExternalId:
|
|
2608
|
+
content: "approve",
|
|
2609
|
+
conversationExternalId: "requester-cancel-chat",
|
|
2610
|
+
actorExternalId: "requester-cancel-user",
|
|
2199
2611
|
});
|
|
2200
2612
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
2201
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2613
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2202
2614
|
|
|
2203
2615
|
expect(body.accepted).toBe(true);
|
|
2204
2616
|
// Should get the guardian-pending notice, NOT decision_applied
|
|
2205
|
-
expect(body.approval).toBe(
|
|
2617
|
+
expect(body.approval).toBe("assistant_turn");
|
|
2206
2618
|
|
|
2207
2619
|
deliverSpy.mockRestore();
|
|
2208
2620
|
});
|
|
@@ -2212,60 +2624,69 @@ describe('requester cancel of guardian-gated pending request', () => {
|
|
|
2212
2624
|
// Engine decision race condition — standard path
|
|
2213
2625
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2214
2626
|
|
|
2215
|
-
describe(
|
|
2627
|
+
describe("engine decision race condition — standard path", () => {
|
|
2216
2628
|
beforeEach(() => {
|
|
2217
2629
|
createBinding({
|
|
2218
|
-
assistantId:
|
|
2219
|
-
channel:
|
|
2220
|
-
guardianExternalUserId:
|
|
2221
|
-
guardianDeliveryChatId:
|
|
2222
|
-
guardianPrincipalId:
|
|
2630
|
+
assistantId: "self",
|
|
2631
|
+
channel: "telegram",
|
|
2632
|
+
guardianExternalUserId: "telegram-user-default",
|
|
2633
|
+
guardianDeliveryChatId: "chat-123",
|
|
2634
|
+
guardianPrincipalId: "telegram-user-default",
|
|
2223
2635
|
});
|
|
2224
2636
|
});
|
|
2225
2637
|
|
|
2226
|
-
test(
|
|
2227
|
-
const deliverSpy = spyOn(
|
|
2638
|
+
test("returns stale_ignored when engine approves but interaction was already resolved", async () => {
|
|
2639
|
+
const deliverSpy = spyOn(
|
|
2640
|
+
gatewayClient,
|
|
2641
|
+
"deliverChannelReply",
|
|
2642
|
+
).mockResolvedValue(undefined);
|
|
2228
2643
|
|
|
2229
|
-
const initReq = makeInboundRequest({ content:
|
|
2644
|
+
const initReq = makeInboundRequest({ content: "init" });
|
|
2230
2645
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2231
2646
|
|
|
2232
2647
|
const db = getDb();
|
|
2233
|
-
const events = db.$client
|
|
2648
|
+
const events = db.$client
|
|
2649
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2650
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2234
2651
|
const conversationId = events[0]?.conversation_id;
|
|
2235
2652
|
ensureConversation(conversationId!);
|
|
2236
2653
|
|
|
2237
|
-
registerPendingInteraction(
|
|
2654
|
+
registerPendingInteraction("req-race-1", conversationId!, "shell");
|
|
2238
2655
|
|
|
2239
2656
|
deliverSpy.mockClear();
|
|
2240
2657
|
|
|
2241
2658
|
// Engine returns approve_once, but resolves the pending interaction
|
|
2242
2659
|
// before handleChannelDecision is called (simulating race condition)
|
|
2243
2660
|
const mockConversationGenerator = mock(async (_ctx: unknown) => {
|
|
2244
|
-
pendingInteractions.resolve(
|
|
2661
|
+
pendingInteractions.resolve("req-race-1");
|
|
2245
2662
|
return {
|
|
2246
|
-
disposition:
|
|
2247
|
-
replyText:
|
|
2663
|
+
disposition: "approve_once" as const,
|
|
2664
|
+
replyText: "Approved! Running the command now.",
|
|
2248
2665
|
};
|
|
2249
2666
|
});
|
|
2250
2667
|
|
|
2251
|
-
const req = makeInboundRequest({ content:
|
|
2668
|
+
const req = makeInboundRequest({ content: "go ahead" });
|
|
2252
2669
|
const res = await handleChannelInbound(
|
|
2253
|
-
req,
|
|
2670
|
+
req,
|
|
2671
|
+
noopProcessMessage,
|
|
2672
|
+
"self",
|
|
2673
|
+
undefined,
|
|
2674
|
+
mockConversationGenerator,
|
|
2254
2675
|
);
|
|
2255
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2676
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2256
2677
|
|
|
2257
2678
|
expect(body.accepted).toBe(true);
|
|
2258
|
-
expect(body.approval).toBe(
|
|
2679
|
+
expect(body.approval).toBe("stale_ignored");
|
|
2259
2680
|
|
|
2260
2681
|
// The engine's optimistic "Approved!" reply should NOT have been delivered
|
|
2261
|
-
const approvedReply = deliverSpy.mock.calls.find(
|
|
2262
|
-
(call
|
|
2682
|
+
const approvedReply = deliverSpy.mock.calls.find((call) =>
|
|
2683
|
+
(call[1] as { text?: string }).text?.includes("Approved!"),
|
|
2263
2684
|
);
|
|
2264
2685
|
expect(approvedReply).toBeUndefined();
|
|
2265
2686
|
|
|
2266
2687
|
// A stale notice should have been delivered instead
|
|
2267
|
-
const staleReply = deliverSpy.mock.calls.find(
|
|
2268
|
-
(call
|
|
2688
|
+
const staleReply = deliverSpy.mock.calls.find((call) =>
|
|
2689
|
+
(call[1] as { text?: string }).text?.includes("already been resolved"),
|
|
2269
2690
|
);
|
|
2270
2691
|
expect(staleReply).toBeDefined();
|
|
2271
2692
|
|
|
@@ -2273,33 +2694,36 @@ describe('engine decision race condition — standard path', () => {
|
|
|
2273
2694
|
});
|
|
2274
2695
|
});
|
|
2275
2696
|
|
|
2276
|
-
describe(
|
|
2277
|
-
test(
|
|
2697
|
+
describe("engine decision race condition — guardian path", () => {
|
|
2698
|
+
test("returns stale_ignored when guardian engine approves but interaction was already resolved", async () => {
|
|
2278
2699
|
createBinding({
|
|
2279
|
-
assistantId:
|
|
2280
|
-
channel:
|
|
2281
|
-
guardianExternalUserId:
|
|
2282
|
-
guardianDeliveryChatId:
|
|
2283
|
-
guardianPrincipalId:
|
|
2700
|
+
assistantId: "self",
|
|
2701
|
+
channel: "telegram",
|
|
2702
|
+
guardianExternalUserId: "guardian-race-user",
|
|
2703
|
+
guardianDeliveryChatId: "guardian-race-chat",
|
|
2704
|
+
guardianPrincipalId: "guardian-race-user",
|
|
2284
2705
|
});
|
|
2285
2706
|
|
|
2286
|
-
const deliverSpy = spyOn(
|
|
2707
|
+
const deliverSpy = spyOn(
|
|
2708
|
+
gatewayClient,
|
|
2709
|
+
"deliverChannelReply",
|
|
2710
|
+
).mockResolvedValue(undefined);
|
|
2287
2711
|
|
|
2288
|
-
const convId =
|
|
2712
|
+
const convId = "conv-guardian-race";
|
|
2289
2713
|
ensureConversation(convId);
|
|
2290
2714
|
|
|
2291
|
-
registerPendingInteraction(
|
|
2715
|
+
registerPendingInteraction("req-grc-1", convId, "shell");
|
|
2292
2716
|
createApprovalRequest({
|
|
2293
|
-
runId:
|
|
2294
|
-
requestId:
|
|
2717
|
+
runId: "run-grc-1",
|
|
2718
|
+
requestId: "req-grc-1",
|
|
2295
2719
|
conversationId: convId,
|
|
2296
|
-
assistantId:
|
|
2297
|
-
channel:
|
|
2298
|
-
requesterExternalUserId:
|
|
2299
|
-
requesterChatId:
|
|
2300
|
-
guardianExternalUserId:
|
|
2301
|
-
guardianChatId:
|
|
2302
|
-
toolName:
|
|
2720
|
+
assistantId: "self",
|
|
2721
|
+
channel: "telegram",
|
|
2722
|
+
requesterExternalUserId: "requester-race-user",
|
|
2723
|
+
requesterChatId: "requester-race-chat",
|
|
2724
|
+
guardianExternalUserId: "guardian-race-user",
|
|
2725
|
+
guardianChatId: "guardian-race-chat",
|
|
2726
|
+
toolName: "shell",
|
|
2303
2727
|
expiresAt: Date.now() + 300_000,
|
|
2304
2728
|
});
|
|
2305
2729
|
|
|
@@ -2308,35 +2732,39 @@ describe('engine decision race condition — guardian path', () => {
|
|
|
2308
2732
|
// Guardian engine returns approve_once, but resolves the pending interaction
|
|
2309
2733
|
// to simulate a concurrent resolution (expiry sweep or requester cancel)
|
|
2310
2734
|
const mockConversationGenerator = mock(async (_ctx: unknown) => {
|
|
2311
|
-
pendingInteractions.resolve(
|
|
2735
|
+
pendingInteractions.resolve("req-grc-1");
|
|
2312
2736
|
return {
|
|
2313
|
-
disposition:
|
|
2314
|
-
replyText:
|
|
2737
|
+
disposition: "approve_once" as const,
|
|
2738
|
+
replyText: "Approved the request.",
|
|
2315
2739
|
};
|
|
2316
2740
|
});
|
|
2317
2741
|
|
|
2318
2742
|
const guardianReq = makeInboundRequest({
|
|
2319
|
-
content:
|
|
2320
|
-
conversationExternalId:
|
|
2321
|
-
actorExternalId:
|
|
2743
|
+
content: "approve it",
|
|
2744
|
+
conversationExternalId: "guardian-race-chat",
|
|
2745
|
+
actorExternalId: "guardian-race-user",
|
|
2322
2746
|
});
|
|
2323
2747
|
const res = await handleChannelInbound(
|
|
2324
|
-
guardianReq,
|
|
2748
|
+
guardianReq,
|
|
2749
|
+
noopProcessMessage,
|
|
2750
|
+
"self",
|
|
2751
|
+
undefined,
|
|
2752
|
+
mockConversationGenerator,
|
|
2325
2753
|
);
|
|
2326
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2754
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2327
2755
|
|
|
2328
2756
|
expect(body.accepted).toBe(true);
|
|
2329
|
-
expect(body.approval).toBe(
|
|
2757
|
+
expect(body.approval).toBe("stale_ignored");
|
|
2330
2758
|
|
|
2331
2759
|
// The engine's "Approved the request." should NOT be delivered
|
|
2332
|
-
const optimisticReply = deliverSpy.mock.calls.find(
|
|
2333
|
-
(call
|
|
2760
|
+
const optimisticReply = deliverSpy.mock.calls.find((call) =>
|
|
2761
|
+
(call[1] as { text?: string }).text?.includes("Approved the request"),
|
|
2334
2762
|
);
|
|
2335
2763
|
expect(optimisticReply).toBeUndefined();
|
|
2336
2764
|
|
|
2337
2765
|
// A stale notice should have been delivered instead
|
|
2338
|
-
const staleReply = deliverSpy.mock.calls.find(
|
|
2339
|
-
(call
|
|
2766
|
+
const staleReply = deliverSpy.mock.calls.find((call) =>
|
|
2767
|
+
(call[1] as { text?: string }).text?.includes("already been resolved"),
|
|
2340
2768
|
);
|
|
2341
2769
|
expect(staleReply).toBeDefined();
|
|
2342
2770
|
|
|
@@ -2348,88 +2776,114 @@ describe('engine decision race condition — guardian path', () => {
|
|
|
2348
2776
|
// Non-decision status reply for different channels
|
|
2349
2777
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2350
2778
|
|
|
2351
|
-
describe(
|
|
2779
|
+
describe("non-decision status reply for different channels", () => {
|
|
2352
2780
|
beforeEach(() => {
|
|
2353
2781
|
createBinding({
|
|
2354
|
-
assistantId:
|
|
2355
|
-
channel:
|
|
2356
|
-
guardianExternalUserId:
|
|
2357
|
-
guardianDeliveryChatId:
|
|
2358
|
-
guardianPrincipalId:
|
|
2782
|
+
assistantId: "self",
|
|
2783
|
+
channel: "telegram",
|
|
2784
|
+
guardianExternalUserId: "telegram-user-default",
|
|
2785
|
+
guardianDeliveryChatId: "chat-123",
|
|
2786
|
+
guardianPrincipalId: "telegram-user-default",
|
|
2359
2787
|
});
|
|
2360
2788
|
createBinding({
|
|
2361
|
-
assistantId:
|
|
2362
|
-
channel:
|
|
2363
|
-
guardianExternalUserId:
|
|
2364
|
-
guardianDeliveryChatId:
|
|
2365
|
-
guardianPrincipalId:
|
|
2789
|
+
assistantId: "self",
|
|
2790
|
+
channel: "sms",
|
|
2791
|
+
guardianExternalUserId: "telegram-user-default",
|
|
2792
|
+
guardianDeliveryChatId: "chat-123",
|
|
2793
|
+
guardianPrincipalId: "telegram-user-default",
|
|
2366
2794
|
});
|
|
2367
2795
|
});
|
|
2368
2796
|
|
|
2369
|
-
test(
|
|
2370
|
-
const deliverSpy = spyOn(
|
|
2797
|
+
test("non-decision message on non-rich channel (sms) sends status reply", async () => {
|
|
2798
|
+
const deliverSpy = spyOn(
|
|
2799
|
+
gatewayClient,
|
|
2800
|
+
"deliverChannelReply",
|
|
2801
|
+
).mockResolvedValue(undefined);
|
|
2371
2802
|
|
|
2372
2803
|
// Establish the conversation using sms (non-rich channel)
|
|
2373
|
-
const initReq = makeInboundRequest({
|
|
2804
|
+
const initReq = makeInboundRequest({
|
|
2805
|
+
content: "init",
|
|
2806
|
+
sourceChannel: "sms",
|
|
2807
|
+
});
|
|
2374
2808
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2375
2809
|
|
|
2376
2810
|
const db = getDb();
|
|
2377
|
-
const events = db.$client
|
|
2811
|
+
const events = db.$client
|
|
2812
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2813
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2378
2814
|
const conversationId = events[0]?.conversation_id;
|
|
2379
2815
|
ensureConversation(conversationId!);
|
|
2380
2816
|
|
|
2381
|
-
registerPendingInteraction(
|
|
2817
|
+
registerPendingInteraction("req-status-sms", conversationId!, "shell");
|
|
2382
2818
|
|
|
2383
2819
|
// Send a non-decision message
|
|
2384
|
-
const req = makeInboundRequest({
|
|
2820
|
+
const req = makeInboundRequest({
|
|
2821
|
+
content: "what is happening?",
|
|
2822
|
+
sourceChannel: "sms",
|
|
2823
|
+
});
|
|
2385
2824
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
2386
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2825
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2387
2826
|
|
|
2388
2827
|
expect(body.accepted).toBe(true);
|
|
2389
|
-
expect(body.approval).toBe(
|
|
2828
|
+
expect(body.approval).toBe("assistant_turn");
|
|
2390
2829
|
|
|
2391
2830
|
// Status reply delivered via deliverChannelReply
|
|
2392
2831
|
expect(deliverSpy).toHaveBeenCalled();
|
|
2393
2832
|
const statusCall = deliverSpy.mock.calls.find(
|
|
2394
|
-
(call) =>
|
|
2833
|
+
(call) =>
|
|
2834
|
+
typeof call[1] === "object" &&
|
|
2835
|
+
(call[1] as { chatId?: string }).chatId === "chat-123",
|
|
2395
2836
|
);
|
|
2396
2837
|
expect(statusCall).toBeDefined();
|
|
2397
2838
|
const statusPayload = statusCall![1] as { text?: string };
|
|
2398
|
-
expect(statusPayload.text).toContain(
|
|
2839
|
+
expect(statusPayload.text).toContain("pending approval request");
|
|
2399
2840
|
|
|
2400
2841
|
deliverSpy.mockRestore();
|
|
2401
2842
|
});
|
|
2402
2843
|
|
|
2403
|
-
test(
|
|
2404
|
-
const replySpy = spyOn(
|
|
2844
|
+
test("non-decision message on telegram sends status reply", async () => {
|
|
2845
|
+
const replySpy = spyOn(
|
|
2846
|
+
gatewayClient,
|
|
2847
|
+
"deliverChannelReply",
|
|
2848
|
+
).mockResolvedValue(undefined);
|
|
2405
2849
|
|
|
2406
2850
|
// Establish the conversation using telegram (rich channel)
|
|
2407
|
-
const initReq = makeInboundRequest({
|
|
2851
|
+
const initReq = makeInboundRequest({
|
|
2852
|
+
content: "init",
|
|
2853
|
+
sourceChannel: "telegram",
|
|
2854
|
+
});
|
|
2408
2855
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2409
2856
|
|
|
2410
2857
|
const db = getDb();
|
|
2411
|
-
const events = db.$client
|
|
2858
|
+
const events = db.$client
|
|
2859
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
2860
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2412
2861
|
const conversationId = events[0]?.conversation_id;
|
|
2413
2862
|
ensureConversation(conversationId!);
|
|
2414
2863
|
|
|
2415
|
-
registerPendingInteraction(
|
|
2864
|
+
registerPendingInteraction("req-status-tg", conversationId!, "shell");
|
|
2416
2865
|
|
|
2417
2866
|
// Send a non-decision message
|
|
2418
|
-
const req = makeInboundRequest({
|
|
2867
|
+
const req = makeInboundRequest({
|
|
2868
|
+
content: "what is happening?",
|
|
2869
|
+
sourceChannel: "telegram",
|
|
2870
|
+
});
|
|
2419
2871
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
2420
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2872
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2421
2873
|
|
|
2422
2874
|
expect(body.accepted).toBe(true);
|
|
2423
|
-
expect(body.approval).toBe(
|
|
2875
|
+
expect(body.approval).toBe("assistant_turn");
|
|
2424
2876
|
|
|
2425
2877
|
// Status reply delivered via deliverChannelReply
|
|
2426
2878
|
expect(replySpy).toHaveBeenCalled();
|
|
2427
2879
|
const statusCall = replySpy.mock.calls.find(
|
|
2428
|
-
(call) =>
|
|
2880
|
+
(call) =>
|
|
2881
|
+
typeof call[1] === "object" &&
|
|
2882
|
+
(call[1] as { chatId?: string }).chatId === "chat-123",
|
|
2429
2883
|
);
|
|
2430
2884
|
expect(statusCall).toBeDefined();
|
|
2431
2885
|
const statusPayload = statusCall![1] as { text?: string };
|
|
2432
|
-
expect(statusPayload.text).toContain(
|
|
2886
|
+
expect(statusPayload.text).toContain("pending approval request");
|
|
2433
2887
|
|
|
2434
2888
|
replySpy.mockRestore();
|
|
2435
2889
|
});
|
|
@@ -2439,46 +2893,54 @@ describe('non-decision status reply for different channels', () => {
|
|
|
2439
2893
|
// Background prompt delivery for channel-triggered tool approvals
|
|
2440
2894
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2441
2895
|
|
|
2442
|
-
describe(
|
|
2443
|
-
test(
|
|
2896
|
+
describe("background channel processing approval prompts", () => {
|
|
2897
|
+
test("marks guardian channel turns interactive and delivers approval prompt when confirmation is pending", async () => {
|
|
2444
2898
|
// Set up a guardian binding so the sender is recognized as a guardian
|
|
2445
2899
|
createBinding({
|
|
2446
|
-
assistantId:
|
|
2447
|
-
channel:
|
|
2448
|
-
guardianExternalUserId:
|
|
2449
|
-
guardianDeliveryChatId:
|
|
2450
|
-
guardianPrincipalId:
|
|
2900
|
+
assistantId: "self",
|
|
2901
|
+
channel: "telegram",
|
|
2902
|
+
guardianExternalUserId: "telegram-user-default",
|
|
2903
|
+
guardianDeliveryChatId: "chat-123",
|
|
2904
|
+
guardianPrincipalId: "telegram-user-default",
|
|
2451
2905
|
});
|
|
2452
2906
|
|
|
2453
|
-
const deliverPromptSpy = spyOn(
|
|
2907
|
+
const deliverPromptSpy = spyOn(
|
|
2908
|
+
gatewayClient,
|
|
2909
|
+
"deliverApprovalPrompt",
|
|
2910
|
+
).mockResolvedValue(undefined);
|
|
2454
2911
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
2455
2912
|
|
|
2456
|
-
const processMessage = mock(
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2913
|
+
const processMessage = mock(
|
|
2914
|
+
async (
|
|
2915
|
+
conversationId: string,
|
|
2916
|
+
_content: string,
|
|
2917
|
+
_attachmentIds?: string[],
|
|
2918
|
+
options?: Record<string, unknown>,
|
|
2919
|
+
) => {
|
|
2920
|
+
processCalls.push({ options });
|
|
2921
|
+
|
|
2922
|
+
registerPendingInteraction("req-bg-1", conversationId, "host_bash", {
|
|
2923
|
+
input: { command: "ls -la" },
|
|
2924
|
+
riskLevel: "medium",
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
2928
|
+
return { messageId: "msg-bg-1" };
|
|
2929
|
+
},
|
|
2930
|
+
);
|
|
2472
2931
|
|
|
2473
2932
|
const req = makeInboundRequest({
|
|
2474
|
-
content:
|
|
2475
|
-
sourceChannel:
|
|
2476
|
-
replyCallbackUrl:
|
|
2477
|
-
externalMessageId:
|
|
2933
|
+
content: "run ls",
|
|
2934
|
+
sourceChannel: "telegram",
|
|
2935
|
+
replyCallbackUrl: "https://gateway.test/deliver/telegram",
|
|
2936
|
+
externalMessageId: "msg-bg-1",
|
|
2478
2937
|
});
|
|
2479
2938
|
|
|
2480
|
-
const res = await handleChannelInbound(
|
|
2481
|
-
|
|
2939
|
+
const res = await handleChannelInbound(
|
|
2940
|
+
req,
|
|
2941
|
+
processMessage as unknown as typeof noopProcessMessage,
|
|
2942
|
+
);
|
|
2943
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2482
2944
|
expect(body.accepted).toBe(true);
|
|
2483
2945
|
|
|
2484
2946
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
@@ -2487,52 +2949,67 @@ describe('background channel processing approval prompts', () => {
|
|
|
2487
2949
|
expect(processCalls[0].options?.isInteractive).toBe(true);
|
|
2488
2950
|
|
|
2489
2951
|
expect(deliverPromptSpy).toHaveBeenCalled();
|
|
2490
|
-
const approvalMeta = deliverPromptSpy.mock.calls[0]?.[3] as
|
|
2491
|
-
|
|
2952
|
+
const approvalMeta = deliverPromptSpy.mock.calls[0]?.[3] as
|
|
2953
|
+
| { requestId?: string }
|
|
2954
|
+
| undefined;
|
|
2955
|
+
expect(approvalMeta?.requestId).toBe("req-bg-1");
|
|
2492
2956
|
|
|
2493
2957
|
deliverPromptSpy.mockRestore();
|
|
2494
2958
|
});
|
|
2495
2959
|
|
|
2496
|
-
test(
|
|
2960
|
+
test("guardian prompt delivery still works when binding ID formatting differs from sender ID", async () => {
|
|
2497
2961
|
// Guardian binding includes extra whitespace; trust resolution canonicalizes
|
|
2498
2962
|
// identity and prompt delivery should still treat this sender as the guardian.
|
|
2499
2963
|
createBinding({
|
|
2500
|
-
assistantId:
|
|
2501
|
-
channel:
|
|
2502
|
-
guardianExternalUserId:
|
|
2503
|
-
guardianDeliveryChatId:
|
|
2504
|
-
guardianPrincipalId:
|
|
2964
|
+
assistantId: "self",
|
|
2965
|
+
channel: "telegram",
|
|
2966
|
+
guardianExternalUserId: " telegram-user-default ",
|
|
2967
|
+
guardianDeliveryChatId: "chat-123",
|
|
2968
|
+
guardianPrincipalId: " telegram-user-default ",
|
|
2505
2969
|
});
|
|
2506
2970
|
|
|
2507
|
-
const deliverPromptSpy = spyOn(
|
|
2971
|
+
const deliverPromptSpy = spyOn(
|
|
2972
|
+
gatewayClient,
|
|
2973
|
+
"deliverApprovalPrompt",
|
|
2974
|
+
).mockResolvedValue(undefined);
|
|
2508
2975
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
2509
2976
|
|
|
2510
|
-
const processMessage = mock(
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2977
|
+
const processMessage = mock(
|
|
2978
|
+
async (
|
|
2979
|
+
conversationId: string,
|
|
2980
|
+
_content: string,
|
|
2981
|
+
_attachmentIds?: string[],
|
|
2982
|
+
options?: Record<string, unknown>,
|
|
2983
|
+
) => {
|
|
2984
|
+
processCalls.push({ options });
|
|
2985
|
+
|
|
2986
|
+
registerPendingInteraction(
|
|
2987
|
+
"req-bg-format-1",
|
|
2988
|
+
conversationId,
|
|
2989
|
+
"host_bash",
|
|
2990
|
+
{
|
|
2991
|
+
input: { command: "ls -la" },
|
|
2992
|
+
riskLevel: "medium",
|
|
2993
|
+
},
|
|
2994
|
+
);
|
|
2995
|
+
|
|
2996
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
2997
|
+
return { messageId: "msg-bg-format-1" };
|
|
2998
|
+
},
|
|
2999
|
+
);
|
|
2526
3000
|
|
|
2527
3001
|
const req = makeInboundRequest({
|
|
2528
|
-
content:
|
|
2529
|
-
sourceChannel:
|
|
2530
|
-
replyCallbackUrl:
|
|
2531
|
-
externalMessageId:
|
|
3002
|
+
content: "run ls",
|
|
3003
|
+
sourceChannel: "telegram",
|
|
3004
|
+
replyCallbackUrl: "https://gateway.test/deliver/telegram",
|
|
3005
|
+
externalMessageId: "msg-bg-format-1",
|
|
2532
3006
|
});
|
|
2533
3007
|
|
|
2534
|
-
const res = await handleChannelInbound(
|
|
2535
|
-
|
|
3008
|
+
const res = await handleChannelInbound(
|
|
3009
|
+
req,
|
|
3010
|
+
processMessage as unknown as typeof noopProcessMessage,
|
|
3011
|
+
);
|
|
3012
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2536
3013
|
expect(body.accepted).toBe(true);
|
|
2537
3014
|
|
|
2538
3015
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
@@ -2544,40 +3021,45 @@ describe('background channel processing approval prompts', () => {
|
|
|
2544
3021
|
deliverPromptSpy.mockRestore();
|
|
2545
3022
|
});
|
|
2546
3023
|
|
|
2547
|
-
test(
|
|
3024
|
+
test("trusted-contact channel turns with resolvable guardian route are interactive", async () => {
|
|
2548
3025
|
// Set up a guardian binding for a DIFFERENT user so the sender is a
|
|
2549
3026
|
// trusted contact (not the guardian). The guardian route is resolvable
|
|
2550
3027
|
// because the binding exists — approval notifications can be delivered.
|
|
2551
3028
|
createBinding({
|
|
2552
|
-
assistantId:
|
|
2553
|
-
channel:
|
|
2554
|
-
guardianExternalUserId:
|
|
2555
|
-
guardianDeliveryChatId:
|
|
2556
|
-
guardianPrincipalId:
|
|
3029
|
+
assistantId: "self",
|
|
3030
|
+
channel: "telegram",
|
|
3031
|
+
guardianExternalUserId: "guardian-user-other",
|
|
3032
|
+
guardianDeliveryChatId: "guardian-chat-other",
|
|
3033
|
+
guardianPrincipalId: "guardian-user-other",
|
|
2557
3034
|
});
|
|
2558
3035
|
|
|
2559
3036
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
2560
3037
|
|
|
2561
|
-
const processMessage = mock(
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
3038
|
+
const processMessage = mock(
|
|
3039
|
+
async (
|
|
3040
|
+
_conversationId: string,
|
|
3041
|
+
_content: string,
|
|
3042
|
+
_attachmentIds?: string[],
|
|
3043
|
+
options?: Record<string, unknown>,
|
|
3044
|
+
) => {
|
|
3045
|
+
processCalls.push({ options });
|
|
3046
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
3047
|
+
return { messageId: "msg-ng-1" };
|
|
3048
|
+
},
|
|
3049
|
+
);
|
|
2571
3050
|
|
|
2572
3051
|
const req = makeInboundRequest({
|
|
2573
|
-
content:
|
|
2574
|
-
sourceChannel:
|
|
2575
|
-
replyCallbackUrl:
|
|
2576
|
-
externalMessageId:
|
|
3052
|
+
content: "run something",
|
|
3053
|
+
sourceChannel: "telegram",
|
|
3054
|
+
replyCallbackUrl: "https://gateway.test/deliver/telegram",
|
|
3055
|
+
externalMessageId: "msg-ng-1",
|
|
2577
3056
|
});
|
|
2578
3057
|
|
|
2579
|
-
const res = await handleChannelInbound(
|
|
2580
|
-
|
|
3058
|
+
const res = await handleChannelInbound(
|
|
3059
|
+
req,
|
|
3060
|
+
processMessage as unknown as typeof noopProcessMessage,
|
|
3061
|
+
);
|
|
3062
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2581
3063
|
expect(body.accepted).toBe(true);
|
|
2582
3064
|
|
|
2583
3065
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
@@ -2588,39 +3070,52 @@ describe('background channel processing approval prompts', () => {
|
|
|
2588
3070
|
expect(processCalls[0].options?.isInteractive).toBe(true);
|
|
2589
3071
|
});
|
|
2590
3072
|
|
|
2591
|
-
test(
|
|
3073
|
+
test("unverified channel turns never broadcast approval prompts", async () => {
|
|
2592
3074
|
// No guardian binding is created, so the sender resolves to unverified_channel.
|
|
2593
|
-
const deliverPromptSpy = spyOn(
|
|
3075
|
+
const deliverPromptSpy = spyOn(
|
|
3076
|
+
gatewayClient,
|
|
3077
|
+
"deliverApprovalPrompt",
|
|
3078
|
+
).mockResolvedValue(undefined);
|
|
2594
3079
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
2595
3080
|
|
|
2596
|
-
const processMessage = mock(
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
3081
|
+
const processMessage = mock(
|
|
3082
|
+
async (
|
|
3083
|
+
conversationId: string,
|
|
3084
|
+
_content: string,
|
|
3085
|
+
_attachmentIds?: string[],
|
|
3086
|
+
options?: Record<string, unknown>,
|
|
3087
|
+
) => {
|
|
3088
|
+
processCalls.push({ options });
|
|
3089
|
+
|
|
3090
|
+
// Simulate a pending confirmation becoming visible while background
|
|
3091
|
+
// processing is running. Unverified actors must still not receive it.
|
|
3092
|
+
registerPendingInteraction(
|
|
3093
|
+
"req-bg-unverified-1",
|
|
3094
|
+
conversationId,
|
|
3095
|
+
"host_bash",
|
|
3096
|
+
{
|
|
3097
|
+
input: { command: "ls -la" },
|
|
3098
|
+
riskLevel: "medium",
|
|
3099
|
+
},
|
|
3100
|
+
);
|
|
3101
|
+
|
|
3102
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
3103
|
+
return { messageId: "msg-bg-unverified-1" };
|
|
3104
|
+
},
|
|
3105
|
+
);
|
|
2614
3106
|
|
|
2615
3107
|
const req = makeInboundRequest({
|
|
2616
|
-
content:
|
|
2617
|
-
sourceChannel:
|
|
2618
|
-
replyCallbackUrl:
|
|
2619
|
-
externalMessageId:
|
|
3108
|
+
content: "run ls",
|
|
3109
|
+
sourceChannel: "telegram",
|
|
3110
|
+
replyCallbackUrl: "https://gateway.test/deliver/telegram",
|
|
3111
|
+
externalMessageId: "msg-bg-unverified-1",
|
|
2620
3112
|
});
|
|
2621
3113
|
|
|
2622
|
-
const res = await handleChannelInbound(
|
|
2623
|
-
|
|
3114
|
+
const res = await handleChannelInbound(
|
|
3115
|
+
req,
|
|
3116
|
+
processMessage as unknown as typeof noopProcessMessage,
|
|
3117
|
+
);
|
|
3118
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2624
3119
|
expect(body.accepted).toBe(true);
|
|
2625
3120
|
|
|
2626
3121
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
@@ -2637,7 +3132,7 @@ describe('background channel processing approval prompts', () => {
|
|
|
2637
3132
|
// NL approval routing via destination-scoped canonical requests
|
|
2638
3133
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2639
3134
|
|
|
2640
|
-
describe(
|
|
3135
|
+
describe("NL approval routing via destination-scoped canonical requests", () => {
|
|
2641
3136
|
beforeEach(() => {
|
|
2642
3137
|
resetTables();
|
|
2643
3138
|
noopProcessMessage.mockClear();
|
|
@@ -2645,16 +3140,16 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2645
3140
|
|
|
2646
3141
|
test('guardian plain-text "yes" fails closed for tool_approval with no guardianExternalUserId', async () => {
|
|
2647
3142
|
// Simulate a voice-originated tool approval without guardianExternalUserId
|
|
2648
|
-
const guardianChatId =
|
|
2649
|
-
const guardianUserId =
|
|
3143
|
+
const guardianChatId = "guardian-chat-nl-1";
|
|
3144
|
+
const guardianUserId = "guardian-user-nl-1";
|
|
2650
3145
|
|
|
2651
3146
|
// Ensure the conversation exists so the resolver finds it
|
|
2652
|
-
ensureConversation(
|
|
3147
|
+
ensureConversation("conv-voice-nl-1");
|
|
2653
3148
|
|
|
2654
3149
|
// Create guardian binding for Telegram
|
|
2655
3150
|
createBinding({
|
|
2656
|
-
assistantId:
|
|
2657
|
-
channel:
|
|
3151
|
+
assistantId: "self",
|
|
3152
|
+
channel: "telegram",
|
|
2658
3153
|
guardianExternalUserId: guardianUserId,
|
|
2659
3154
|
guardianDeliveryChatId: guardianChatId,
|
|
2660
3155
|
|
|
@@ -2664,55 +3159,55 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2664
3159
|
// Create canonical tool_approval request WITHOUT guardianExternalUserId
|
|
2665
3160
|
// but WITH a conversationId (required by the tool_approval resolver)
|
|
2666
3161
|
const canonicalReq = createCanonicalGuardianRequest({
|
|
2667
|
-
kind:
|
|
2668
|
-
sourceType:
|
|
2669
|
-
sourceChannel:
|
|
2670
|
-
conversationId:
|
|
2671
|
-
toolName:
|
|
2672
|
-
guardianPrincipalId:
|
|
3162
|
+
kind: "tool_approval",
|
|
3163
|
+
sourceType: "voice",
|
|
3164
|
+
sourceChannel: "twilio",
|
|
3165
|
+
conversationId: "conv-voice-nl-1",
|
|
3166
|
+
toolName: "shell",
|
|
3167
|
+
guardianPrincipalId: "test-principal-id",
|
|
2673
3168
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
2674
3169
|
// guardianExternalUserId intentionally omitted
|
|
2675
3170
|
});
|
|
2676
3171
|
|
|
2677
3172
|
// Register pending interaction so resolver can find it
|
|
2678
|
-
registerPendingInteraction(canonicalReq.id,
|
|
3173
|
+
registerPendingInteraction(canonicalReq.id, "conv-voice-nl-1", "shell");
|
|
2679
3174
|
|
|
2680
3175
|
// Create canonical delivery row targeting guardian chat
|
|
2681
3176
|
createCanonicalGuardianDelivery({
|
|
2682
3177
|
requestId: canonicalReq.id,
|
|
2683
|
-
destinationChannel:
|
|
3178
|
+
destinationChannel: "telegram",
|
|
2684
3179
|
destinationChatId: guardianChatId,
|
|
2685
3180
|
});
|
|
2686
3181
|
|
|
2687
3182
|
// Send inbound guardian text reply "yes" from that chat
|
|
2688
3183
|
const req = makeInboundRequest({
|
|
2689
|
-
sourceChannel:
|
|
3184
|
+
sourceChannel: "telegram",
|
|
2690
3185
|
conversationExternalId: guardianChatId,
|
|
2691
3186
|
actorExternalId: guardianUserId,
|
|
2692
|
-
content:
|
|
3187
|
+
content: "yes",
|
|
2693
3188
|
externalMessageId: `msg-nl-approve-${Date.now()}`,
|
|
2694
3189
|
});
|
|
2695
3190
|
const res = await handleChannelInbound(req, noopProcessMessage as any);
|
|
2696
|
-
const body = await res.json() as Record<string, unknown>;
|
|
3191
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2697
3192
|
|
|
2698
3193
|
expect(body.accepted).toBe(true);
|
|
2699
|
-
expect(body.canonicalRouter).toBe(
|
|
3194
|
+
expect(body.canonicalRouter).toBe("canonical_decision_stale");
|
|
2700
3195
|
|
|
2701
3196
|
// Verify the request remains pending (identity-bound fail-closed).
|
|
2702
3197
|
const resolved = getCanonicalGuardianRequest(canonicalReq.id);
|
|
2703
3198
|
expect(resolved).not.toBeNull();
|
|
2704
|
-
expect(resolved!.status).toBe(
|
|
3199
|
+
expect(resolved!.status).toBe("pending");
|
|
2705
3200
|
});
|
|
2706
3201
|
|
|
2707
|
-
test(
|
|
2708
|
-
const guardianChatId =
|
|
2709
|
-
const guardianUserId =
|
|
2710
|
-
const differentChatId =
|
|
3202
|
+
test("inbound from different chat ID does not auto-match delivery-scoped canonical request", async () => {
|
|
3203
|
+
const guardianChatId = "guardian-chat-nl-2";
|
|
3204
|
+
const guardianUserId = "guardian-user-nl-2";
|
|
3205
|
+
const differentChatId = "different-chat-999";
|
|
2711
3206
|
|
|
2712
3207
|
// Create guardian binding for the guardian user on the different chat
|
|
2713
3208
|
createBinding({
|
|
2714
|
-
assistantId:
|
|
2715
|
-
channel:
|
|
3209
|
+
assistantId: "self",
|
|
3210
|
+
channel: "telegram",
|
|
2716
3211
|
guardianExternalUserId: guardianUserId,
|
|
2717
3212
|
guardianDeliveryChatId: differentChatId,
|
|
2718
3213
|
|
|
@@ -2721,31 +3216,31 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2721
3216
|
|
|
2722
3217
|
// Create canonical pending_question WITHOUT guardianExternalUserId
|
|
2723
3218
|
const canonicalReq = createCanonicalGuardianRequest({
|
|
2724
|
-
kind:
|
|
2725
|
-
sourceType:
|
|
2726
|
-
sourceChannel:
|
|
2727
|
-
toolName:
|
|
2728
|
-
guardianPrincipalId:
|
|
3219
|
+
kind: "tool_approval",
|
|
3220
|
+
sourceType: "voice",
|
|
3221
|
+
sourceChannel: "twilio",
|
|
3222
|
+
toolName: "shell",
|
|
3223
|
+
guardianPrincipalId: "test-principal-id",
|
|
2729
3224
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
2730
3225
|
});
|
|
2731
3226
|
|
|
2732
3227
|
// Delivery targets the original guardian chat, NOT the different chat
|
|
2733
3228
|
createCanonicalGuardianDelivery({
|
|
2734
3229
|
requestId: canonicalReq.id,
|
|
2735
|
-
destinationChannel:
|
|
3230
|
+
destinationChannel: "telegram",
|
|
2736
3231
|
destinationChatId: guardianChatId,
|
|
2737
3232
|
});
|
|
2738
3233
|
|
|
2739
3234
|
// Send from differentChatId — delivery-scoped lookup should not match
|
|
2740
3235
|
const req = makeInboundRequest({
|
|
2741
|
-
sourceChannel:
|
|
3236
|
+
sourceChannel: "telegram",
|
|
2742
3237
|
conversationExternalId: differentChatId,
|
|
2743
3238
|
actorExternalId: guardianUserId,
|
|
2744
|
-
content:
|
|
3239
|
+
content: "approve",
|
|
2745
3240
|
externalMessageId: `msg-nl-mismatch-${Date.now()}`,
|
|
2746
3241
|
});
|
|
2747
3242
|
const res = await handleChannelInbound(req, noopProcessMessage as any);
|
|
2748
|
-
const body = await res.json() as Record<string, unknown>;
|
|
3243
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2749
3244
|
|
|
2750
3245
|
expect(body.accepted).toBe(true);
|
|
2751
3246
|
// Should NOT have been consumed by canonical router since there are no
|
|
@@ -2756,7 +3251,7 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2756
3251
|
// Request should remain pending
|
|
2757
3252
|
const unchanged = getCanonicalGuardianRequest(canonicalReq.id);
|
|
2758
3253
|
expect(unchanged).not.toBeNull();
|
|
2759
|
-
expect(unchanged!.status).toBe(
|
|
3254
|
+
expect(unchanged!.status).toBe("pending");
|
|
2760
3255
|
});
|
|
2761
3256
|
});
|
|
2762
3257
|
|
|
@@ -2764,31 +3259,36 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2764
3259
|
// Trusted-contact self-approval guard (pre-row)
|
|
2765
3260
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2766
3261
|
|
|
2767
|
-
describe(
|
|
3262
|
+
describe("trusted-contact self-approval blocked before guardian approval row exists", () => {
|
|
2768
3263
|
beforeEach(() => {
|
|
2769
3264
|
// Create a guardian binding so the requester resolves as trusted_contact
|
|
2770
3265
|
createBinding({
|
|
2771
|
-
assistantId:
|
|
2772
|
-
channel:
|
|
2773
|
-
guardianExternalUserId:
|
|
2774
|
-
guardianDeliveryChatId:
|
|
2775
|
-
guardianPrincipalId:
|
|
3266
|
+
assistantId: "self",
|
|
3267
|
+
channel: "telegram",
|
|
3268
|
+
guardianExternalUserId: "guardian-tc-selfapproval",
|
|
3269
|
+
guardianDeliveryChatId: "guardian-tc-selfapproval-chat",
|
|
3270
|
+
guardianPrincipalId: "guardian-tc-selfapproval",
|
|
2776
3271
|
});
|
|
2777
3272
|
});
|
|
2778
3273
|
|
|
2779
|
-
test(
|
|
2780
|
-
const deliverSpy = spyOn(
|
|
3274
|
+
test("trusted contact cannot self-approve via conversational engine when no guardian approval row exists", async () => {
|
|
3275
|
+
const deliverSpy = spyOn(
|
|
3276
|
+
gatewayClient,
|
|
3277
|
+
"deliverChannelReply",
|
|
3278
|
+
).mockResolvedValue(undefined);
|
|
2781
3279
|
|
|
2782
3280
|
// Create the requester conversation (different user than guardian)
|
|
2783
3281
|
const initReq = makeInboundRequest({
|
|
2784
|
-
content:
|
|
2785
|
-
conversationExternalId:
|
|
2786
|
-
actorExternalId:
|
|
3282
|
+
content: "init",
|
|
3283
|
+
conversationExternalId: "tc-selfapproval-chat",
|
|
3284
|
+
actorExternalId: "tc-selfapproval-user",
|
|
2787
3285
|
});
|
|
2788
3286
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2789
3287
|
|
|
2790
3288
|
const db = getDb();
|
|
2791
|
-
const events = db.$client
|
|
3289
|
+
const events = db.$client
|
|
3290
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
3291
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2792
3292
|
const conversationId = events[0]?.conversation_id;
|
|
2793
3293
|
ensureConversation(conversationId!);
|
|
2794
3294
|
|
|
@@ -2796,79 +3296,96 @@ describe('trusted-contact self-approval blocked before guardian approval row exi
|
|
|
2796
3296
|
// row in channelGuardianApprovalRequests. This simulates the window
|
|
2797
3297
|
// between the pending confirmation being created (isInteractive=true)
|
|
2798
3298
|
// and the guardian approval prompt being delivered.
|
|
2799
|
-
const sessionMock = registerPendingInteraction(
|
|
3299
|
+
const sessionMock = registerPendingInteraction(
|
|
3300
|
+
"req-tc-selfapproval-1",
|
|
3301
|
+
conversationId!,
|
|
3302
|
+
"shell",
|
|
3303
|
+
);
|
|
2800
3304
|
|
|
2801
3305
|
deliverSpy.mockClear();
|
|
2802
3306
|
|
|
2803
3307
|
// The conversational engine would normally classify "yes" as approve_once,
|
|
2804
3308
|
// but the guard should intercept before the engine runs.
|
|
2805
3309
|
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
2806
|
-
disposition:
|
|
2807
|
-
replyText:
|
|
3310
|
+
disposition: "approve_once" as const,
|
|
3311
|
+
replyText: "Approved!",
|
|
2808
3312
|
}));
|
|
2809
3313
|
|
|
2810
3314
|
// Trusted contact sends "yes" to try to self-approve
|
|
2811
3315
|
const req = makeInboundRequest({
|
|
2812
|
-
content:
|
|
2813
|
-
conversationExternalId:
|
|
2814
|
-
actorExternalId:
|
|
3316
|
+
content: "yes",
|
|
3317
|
+
conversationExternalId: "tc-selfapproval-chat",
|
|
3318
|
+
actorExternalId: "tc-selfapproval-user",
|
|
2815
3319
|
});
|
|
2816
3320
|
const res = await handleChannelInbound(
|
|
2817
|
-
req,
|
|
3321
|
+
req,
|
|
3322
|
+
noopProcessMessage,
|
|
3323
|
+
"self",
|
|
3324
|
+
undefined,
|
|
3325
|
+
mockConversationGenerator,
|
|
2818
3326
|
);
|
|
2819
|
-
const body = await res.json() as Record<string, unknown>;
|
|
3327
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2820
3328
|
|
|
2821
3329
|
expect(body.accepted).toBe(true);
|
|
2822
3330
|
// Should be blocked with assistant_turn (pending guardian notice),
|
|
2823
3331
|
// NOT decision_applied
|
|
2824
|
-
expect(body.approval).toBe(
|
|
3332
|
+
expect(body.approval).toBe("assistant_turn");
|
|
2825
3333
|
// The session should NOT have been resolved
|
|
2826
3334
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
2827
3335
|
|
|
2828
3336
|
// The pending interaction should still be registered (not consumed)
|
|
2829
|
-
const stillPending = pendingInteractions.get(
|
|
3337
|
+
const stillPending = pendingInteractions.get("req-tc-selfapproval-1");
|
|
2830
3338
|
expect(stillPending).toBeDefined();
|
|
2831
3339
|
|
|
2832
3340
|
deliverSpy.mockRestore();
|
|
2833
3341
|
});
|
|
2834
3342
|
|
|
2835
|
-
test(
|
|
2836
|
-
const deliverSpy = spyOn(
|
|
3343
|
+
test("trusted contact cannot self-approve via legacy parser when no guardian approval row exists", async () => {
|
|
3344
|
+
const deliverSpy = spyOn(
|
|
3345
|
+
gatewayClient,
|
|
3346
|
+
"deliverChannelReply",
|
|
3347
|
+
).mockResolvedValue(undefined);
|
|
2837
3348
|
|
|
2838
3349
|
const initReq = makeInboundRequest({
|
|
2839
|
-
content:
|
|
2840
|
-
conversationExternalId:
|
|
2841
|
-
actorExternalId:
|
|
3350
|
+
content: "init",
|
|
3351
|
+
conversationExternalId: "tc-selfapproval-chat",
|
|
3352
|
+
actorExternalId: "tc-selfapproval-user",
|
|
2842
3353
|
});
|
|
2843
3354
|
await handleChannelInbound(initReq, noopProcessMessage);
|
|
2844
3355
|
|
|
2845
3356
|
const db = getDb();
|
|
2846
|
-
const events = db.$client
|
|
3357
|
+
const events = db.$client
|
|
3358
|
+
.prepare("SELECT conversation_id FROM channel_inbound_events")
|
|
3359
|
+
.all() as Array<{ conversation_id: string }>;
|
|
2847
3360
|
const conversationId = events[0]?.conversation_id;
|
|
2848
3361
|
ensureConversation(conversationId!);
|
|
2849
3362
|
|
|
2850
3363
|
// Register pending interaction without guardian approval row
|
|
2851
|
-
const sessionMock = registerPendingInteraction(
|
|
3364
|
+
const sessionMock = registerPendingInteraction(
|
|
3365
|
+
"req-tc-selfapproval-2",
|
|
3366
|
+
conversationId!,
|
|
3367
|
+
"shell",
|
|
3368
|
+
);
|
|
2852
3369
|
|
|
2853
3370
|
deliverSpy.mockClear();
|
|
2854
3371
|
|
|
2855
3372
|
// No conversational engine — falls through to legacy parser path.
|
|
2856
3373
|
// "approve" would normally be parsed as an approval decision.
|
|
2857
3374
|
const req = makeInboundRequest({
|
|
2858
|
-
content:
|
|
2859
|
-
conversationExternalId:
|
|
2860
|
-
actorExternalId:
|
|
3375
|
+
content: "approve",
|
|
3376
|
+
conversationExternalId: "tc-selfapproval-chat",
|
|
3377
|
+
actorExternalId: "tc-selfapproval-user",
|
|
2861
3378
|
});
|
|
2862
3379
|
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
2863
|
-
const body = await res.json() as Record<string, unknown>;
|
|
3380
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2864
3381
|
|
|
2865
3382
|
expect(body.accepted).toBe(true);
|
|
2866
3383
|
// Should be blocked, not decision_applied
|
|
2867
|
-
expect(body.approval).toBe(
|
|
3384
|
+
expect(body.approval).toBe("assistant_turn");
|
|
2868
3385
|
expect(sessionMock).not.toHaveBeenCalled();
|
|
2869
3386
|
|
|
2870
3387
|
// Pending interaction should still exist
|
|
2871
|
-
const stillPending = pendingInteractions.get(
|
|
3388
|
+
const stillPending = pendingInteractions.get("req-tc-selfapproval-2");
|
|
2872
3389
|
expect(stillPending).toBeDefined();
|
|
2873
3390
|
|
|
2874
3391
|
deliverSpy.mockRestore();
|