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