@vellumai/assistant 0.3.0
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/.dockerignore +27 -0
- package/.env.example +22 -0
- package/Dockerfile +99 -0
- package/Dockerfile.sandbox +5 -0
- package/README.md +248 -0
- package/bun.lock +1723 -0
- package/bunfig.toml +2 -0
- package/docs/skills.md +158 -0
- package/drizzle/0000_dizzy_maggott.sql +301 -0
- package/drizzle/meta/0000_snapshot.json +1999 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/eslint.config.mjs +17 -0
- package/hook-templates/debug-prompt-logger/hook.json +7 -0
- package/hook-templates/debug-prompt-logger/run.sh +68 -0
- package/knip.json +9 -0
- package/package.json +70 -0
- package/scripts/capture-x-graphql.ts +545 -0
- package/scripts/ipc/check-contract-inventory.ts +104 -0
- package/scripts/ipc/check-swift-decoder-drift.ts +166 -0
- package/scripts/ipc/generate-swift.ts +492 -0
- package/scripts/test-filesystem-tools.sh +48 -0
- package/scripts/test.sh +127 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2485 -0
- package/src/__tests__/account-registry.test.ts +245 -0
- package/src/__tests__/active-skill-tools.test.ts +378 -0
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/agent-loop-thinking.test.ts +81 -0
- package/src/__tests__/agent-loop.test.ts +1135 -0
- package/src/__tests__/anthropic-provider.test.ts +778 -0
- package/src/__tests__/app-builder-tool-scripts.test.ts +290 -0
- package/src/__tests__/app-bundler.test.ts +292 -0
- package/src/__tests__/app-executors.test.ts +613 -0
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/app-open-proxy.test.ts +62 -0
- package/src/__tests__/asset-materialize-tool.test.ts +452 -0
- package/src/__tests__/asset-search-tool.test.ts +477 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +401 -0
- package/src/__tests__/assistant-attachments.test.ts +437 -0
- package/src/__tests__/assistant-event-hub.test.ts +226 -0
- package/src/__tests__/assistant-event.test.ts +123 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/attachments-store.test.ts +476 -0
- package/src/__tests__/attachments.test.ts +134 -0
- package/src/__tests__/audit-log-rotation.test.ts +154 -0
- package/src/__tests__/browser-fill-credential.test.ts +309 -0
- package/src/__tests__/browser-manager.test.ts +203 -0
- package/src/__tests__/browser-runtime-check.test.ts +55 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +68 -0
- package/src/__tests__/browser-skill-endstate.test.ts +195 -0
- package/src/__tests__/bundle-scanner.test.ts +313 -0
- package/src/__tests__/call-bridge.test.ts +517 -0
- package/src/__tests__/call-constants.test.ts +40 -0
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +625 -0
- package/src/__tests__/call-recovery.test.ts +518 -0
- package/src/__tests__/call-routes-http.test.ts +699 -0
- package/src/__tests__/call-state-machine.test.ts +143 -0
- package/src/__tests__/call-state.test.ts +174 -0
- package/src/__tests__/call-store.test.ts +691 -0
- package/src/__tests__/channel-approval-routes.test.ts +2356 -0
- package/src/__tests__/channel-approval.test.ts +299 -0
- package/src/__tests__/channel-approvals.test.ts +521 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/channel-guardian.test.ts +1005 -0
- package/src/__tests__/checker.test.ts +3519 -0
- package/src/__tests__/clarification-resolver.test.ts +159 -0
- package/src/__tests__/classifier.test.ts +67 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +127 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +88 -0
- package/src/__tests__/cli-discover.test.ts +85 -0
- package/src/__tests__/cli.test.ts +26 -0
- package/src/__tests__/clipboard.test.ts +80 -0
- package/src/__tests__/commit-guarantee.test.ts +335 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
- package/src/__tests__/compaction.benchmark.test.ts +176 -0
- package/src/__tests__/computer-use-session-compaction.test.ts +132 -0
- package/src/__tests__/computer-use-session-lifecycle.test.ts +293 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +117 -0
- package/src/__tests__/computer-use-skill-baseline.test.ts +74 -0
- package/src/__tests__/computer-use-skill-endstate.test.ts +89 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +217 -0
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +107 -0
- package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +54 -0
- package/src/__tests__/computer-use-tools.test.ts +250 -0
- package/src/__tests__/config-schema.test.ts +1462 -0
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +332 -0
- package/src/__tests__/connection-policy.test.ts +102 -0
- package/src/__tests__/contacts-tools.test.ts +331 -0
- package/src/__tests__/context-memory-e2e.test.ts +434 -0
- package/src/__tests__/context-token-estimator.test.ts +135 -0
- package/src/__tests__/context-window-manager.test.ts +376 -0
- package/src/__tests__/contradiction-checker.test.ts +314 -0
- package/src/__tests__/conversation-store.test.ts +612 -0
- package/src/__tests__/credential-broker-browser-fill.test.ts +517 -0
- package/src/__tests__/credential-broker-server-use.test.ts +554 -0
- package/src/__tests__/credential-broker.test.ts +167 -0
- package/src/__tests__/credential-host-pattern-match.test.ts +104 -0
- package/src/__tests__/credential-metadata-store.test.ts +779 -0
- package/src/__tests__/credential-policy-validate.test.ts +121 -0
- package/src/__tests__/credential-resolve.test.ts +328 -0
- package/src/__tests__/credential-security-e2e.test.ts +352 -0
- package/src/__tests__/credential-security-invariants.test.ts +583 -0
- package/src/__tests__/credential-selection.test.ts +354 -0
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/credential-vault.test.ts +852 -0
- package/src/__tests__/daemon-assistant-events.test.ts +164 -0
- package/src/__tests__/daemon-server-session-init.test.ts +522 -0
- package/src/__tests__/date-context.test.ts +373 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
- package/src/__tests__/delete-managed-skill-tool.test.ts +97 -0
- package/src/__tests__/diff.test.ts +121 -0
- package/src/__tests__/domain-normalize.test.ts +112 -0
- package/src/__tests__/domain-policy.test.ts +124 -0
- package/src/__tests__/doordash-client.test.ts +186 -0
- package/src/__tests__/doordash-session.test.ts +152 -0
- package/src/__tests__/dynamic-page-surface.test.ts +91 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +132 -0
- package/src/__tests__/edit-engine.test.ts +180 -0
- package/src/__tests__/elevenlabs-client.test.ts +271 -0
- package/src/__tests__/email-cli.test.ts +283 -0
- package/src/__tests__/encrypted-store.test.ts +332 -0
- package/src/__tests__/entity-extractor.test.ts +190 -0
- package/src/__tests__/ephemeral-permissions.test.ts +362 -0
- package/src/__tests__/evaluate-typescript-tool.test.ts +286 -0
- package/src/__tests__/event-bus.test.ts +222 -0
- package/src/__tests__/file-edit-tool.test.ts +122 -0
- package/src/__tests__/file-ops-service.test.ts +330 -0
- package/src/__tests__/file-read-tool.test.ts +75 -0
- package/src/__tests__/file-write-tool.test.ts +113 -0
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/fixtures/credential-security-fixtures.ts +181 -0
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +126 -0
- package/src/__tests__/fixtures/mock-signup-server.ts +387 -0
- package/src/__tests__/fixtures/proxy-fixtures.ts +147 -0
- package/src/__tests__/followup-tools.test.ts +303 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +71 -0
- package/src/__tests__/fuzzy-match-property.test.ts +216 -0
- package/src/__tests__/fuzzy-match.test.ts +138 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +631 -0
- package/src/__tests__/gemini-image-service.test.ts +261 -0
- package/src/__tests__/gemini-provider.test.ts +651 -0
- package/src/__tests__/get-weather.test.ts +318 -0
- package/src/__tests__/gmail-integration.test.ts +73 -0
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +352 -0
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +191 -0
- package/src/__tests__/handlers-slack-config.test.ts +200 -0
- package/src/__tests__/handlers-task-submit-slash.test.ts +38 -0
- package/src/__tests__/handlers-telegram-config.test.ts +968 -0
- package/src/__tests__/handlers-twilio-config.test.ts +659 -0
- package/src/__tests__/handlers-twitter-config.test.ts +858 -0
- package/src/__tests__/headless-browser-interactions.test.ts +536 -0
- package/src/__tests__/headless-browser-navigate.test.ts +211 -0
- package/src/__tests__/headless-browser-read-tools.test.ts +261 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +185 -0
- package/src/__tests__/history-repair-observability.test.ts +56 -0
- package/src/__tests__/history-repair.test.ts +510 -0
- package/src/__tests__/home-base-bootstrap.test.ts +82 -0
- package/src/__tests__/hooks-blocking.test.ts +128 -0
- package/src/__tests__/hooks-cli.test.ts +144 -0
- package/src/__tests__/hooks-config.test.ts +93 -0
- package/src/__tests__/hooks-discovery.test.ts +199 -0
- package/src/__tests__/hooks-integration.test.ts +189 -0
- package/src/__tests__/hooks-manager.test.ts +187 -0
- package/src/__tests__/hooks-runner.test.ts +182 -0
- package/src/__tests__/hooks-settings.test.ts +154 -0
- package/src/__tests__/hooks-templates.test.ts +137 -0
- package/src/__tests__/hooks-ts-runner.test.ts +125 -0
- package/src/__tests__/hooks-watch.test.ts +100 -0
- package/src/__tests__/host-file-edit-tool.test.ts +228 -0
- package/src/__tests__/host-file-read-tool.test.ts +123 -0
- package/src/__tests__/host-file-write-tool.test.ts +136 -0
- package/src/__tests__/host-shell-tool.test.ts +562 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/intent-routing.test.ts +259 -0
- package/src/__tests__/ipc-blob-store.test.ts +315 -0
- package/src/__tests__/ipc-contract-inventory.test.ts +54 -0
- package/src/__tests__/ipc-contract.test.ts +74 -0
- package/src/__tests__/ipc-protocol.test.ts +113 -0
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
- package/src/__tests__/ipc-snapshot.test.ts +1769 -0
- package/src/__tests__/ipc-validate.test.ts +407 -0
- package/src/__tests__/key-migration.test.ts +206 -0
- package/src/__tests__/keychain.test.ts +258 -0
- package/src/__tests__/llm-usage-store.test.ts +221 -0
- package/src/__tests__/managed-skill-lifecycle.test.ts +257 -0
- package/src/__tests__/managed-store.test.ts +608 -0
- package/src/__tests__/media-generate-image.test.ts +238 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +676 -0
- package/src/__tests__/media-visibility-policy.test.ts +141 -0
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +235 -0
- package/src/__tests__/memory-lifecycle-e2e.test.ts +481 -0
- package/src/__tests__/memory-query-builder.test.ts +59 -0
- package/src/__tests__/memory-recall-quality.test.ts +846 -0
- package/src/__tests__/memory-regressions.experimental.test.ts +538 -0
- package/src/__tests__/memory-regressions.test.ts +4435 -0
- package/src/__tests__/memory-retrieval-budget.test.ts +49 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
- package/src/__tests__/migration-cli-flows.test.ts +169 -0
- package/src/__tests__/migration-ordering.test.ts +249 -0
- package/src/__tests__/mock-signup-server.test.ts +528 -0
- package/src/__tests__/oauth-callback-registry.test.ts +92 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +285 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +176 -0
- package/src/__tests__/onboarding-template-contract.test.ts +58 -0
- package/src/__tests__/openai-provider.test.ts +753 -0
- package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
- package/src/__tests__/parser.test.ts +472 -0
- package/src/__tests__/path-classifier.test.ts +73 -0
- package/src/__tests__/path-policy.test.ts +435 -0
- package/src/__tests__/platform-move-helper.test.ts +99 -0
- package/src/__tests__/platform-socket-path.test.ts +52 -0
- package/src/__tests__/platform-workspace-migration.test.ts +1000 -0
- package/src/__tests__/platform.test.ts +131 -0
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +340 -0
- package/src/__tests__/prebuilt-home-base-seed.test.ts +75 -0
- package/src/__tests__/pricing.test.ts +256 -0
- package/src/__tests__/profile-compiler.test.ts +374 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
- package/src/__tests__/provider-registry-ollama.test.ts +16 -0
- package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
- package/src/__tests__/proxy-approval-callback.test.ts +601 -0
- package/src/__tests__/public-ingress-urls.test.ts +256 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/ratelimit.test.ts +297 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +175 -0
- package/src/__tests__/recurrence-engine.test.ts +78 -0
- package/src/__tests__/recurrence-types.test.ts +79 -0
- package/src/__tests__/registry.test.ts +494 -0
- package/src/__tests__/relay-server.test.ts +688 -0
- package/src/__tests__/reminder-store.test.ts +223 -0
- package/src/__tests__/reminder.test.ts +229 -0
- package/src/__tests__/request-file-tool.test.ts +158 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +227 -0
- package/src/__tests__/run-orchestrator.test.ts +425 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +189 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/runtime-runs-http.test.ts +438 -0
- package/src/__tests__/runtime-runs.test.ts +260 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +408 -0
- package/src/__tests__/sandbox-host-parity.test.ts +950 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +253 -0
- package/src/__tests__/schedule-store.test.ts +484 -0
- package/src/__tests__/schedule-tools.test.ts +783 -0
- package/src/__tests__/scheduler-recurrence.test.ts +430 -0
- package/src/__tests__/script-proxy-certs.test.ts +90 -0
- package/src/__tests__/script-proxy-connect-tunnel.test.ts +177 -0
- package/src/__tests__/script-proxy-decision-trace.test.ts +156 -0
- package/src/__tests__/script-proxy-http-forwarder.test.ts +281 -0
- package/src/__tests__/script-proxy-injection-runtime.test.ts +401 -0
- package/src/__tests__/script-proxy-mitm-handler.test.ts +407 -0
- package/src/__tests__/script-proxy-policy-runtime.test.ts +287 -0
- package/src/__tests__/script-proxy-policy.test.ts +310 -0
- package/src/__tests__/script-proxy-rewrite-specificity.test.ts +135 -0
- package/src/__tests__/script-proxy-router.test.ts +180 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +382 -0
- package/src/__tests__/script-proxy-session-runtime.test.ts +113 -0
- package/src/__tests__/secret-allowlist.test.ts +230 -0
- package/src/__tests__/secret-ingress-handler.test.ts +110 -0
- package/src/__tests__/secret-onetime-send.test.ts +130 -0
- package/src/__tests__/secret-prompt-log-hygiene.test.ts +106 -0
- package/src/__tests__/secret-response-routing.test.ts +93 -0
- package/src/__tests__/secret-scanner-executor.test.ts +348 -0
- package/src/__tests__/secret-scanner.test.ts +900 -0
- package/src/__tests__/secure-keys.test.ts +323 -0
- package/src/__tests__/server-history-render.test.ts +431 -0
- package/src/__tests__/session-abort-tool-results.test.ts +240 -0
- package/src/__tests__/session-conflict-gate.test.ts +1136 -0
- package/src/__tests__/session-error.test.ts +369 -0
- package/src/__tests__/session-evictor.test.ts +188 -0
- package/src/__tests__/session-init.benchmark.test.ts +465 -0
- package/src/__tests__/session-load-history-repair.test.ts +222 -0
- package/src/__tests__/session-pre-run-repair.test.ts +213 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-profile-injection.test.ts +444 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +306 -0
- package/src/__tests__/session-queue.test.ts +1535 -0
- package/src/__tests__/session-runtime-assembly.test.ts +476 -0
- package/src/__tests__/session-runtime-workspace.test.ts +183 -0
- package/src/__tests__/session-skill-tools.test.ts +2431 -0
- package/src/__tests__/session-slash-known.test.ts +368 -0
- package/src/__tests__/session-slash-queue.test.ts +288 -0
- package/src/__tests__/session-slash-unknown.test.ts +271 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +473 -0
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +140 -0
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +140 -0
- package/src/__tests__/session-undo.test.ts +75 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +246 -0
- package/src/__tests__/session-workspace-injection.test.ts +327 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +240 -0
- package/src/__tests__/shared-filesystem-errors.test.ts +78 -0
- package/src/__tests__/shell-credential-ref.test.ts +187 -0
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/shell-parser-fuzz.test.ts +544 -0
- package/src/__tests__/shell-parser-property.test.ts +433 -0
- package/src/__tests__/shell-tool-proxy-mode.test.ts +272 -0
- package/src/__tests__/signup-e2e.test.ts +353 -0
- package/src/__tests__/size-guard.test.ts +117 -0
- package/src/__tests__/skill-include-graph.test.ts +303 -0
- package/src/__tests__/skill-load-tool.test.ts +409 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +338 -0
- package/src/__tests__/skill-script-runner-host.test.ts +489 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +349 -0
- package/src/__tests__/skill-script-runner.test.ts +159 -0
- package/src/__tests__/skill-tool-factory.test.ts +252 -0
- package/src/__tests__/skill-tool-manifest.test.ts +658 -0
- package/src/__tests__/skill-version-hash.test.ts +182 -0
- package/src/__tests__/skills.test.ts +680 -0
- package/src/__tests__/slash-commands-catalog.test.ts +86 -0
- package/src/__tests__/slash-commands-parser.test.ts +119 -0
- package/src/__tests__/slash-commands-resolver.test.ts +193 -0
- package/src/__tests__/slash-commands-rewrite.test.ts +39 -0
- package/src/__tests__/speaker-identification.test.ts +52 -0
- package/src/__tests__/starter-bundle.test.ts +136 -0
- package/src/__tests__/starter-task-flow.test.ts +143 -0
- package/src/__tests__/subagent-manager-notify.test.ts +404 -0
- package/src/__tests__/subagent-tools.test.ts +801 -0
- package/src/__tests__/subagent-types.test.ts +78 -0
- package/src/__tests__/swarm-orchestrator.test.ts +428 -0
- package/src/__tests__/swarm-plan-validator.test.ts +330 -0
- package/src/__tests__/swarm-recursion.test.ts +165 -0
- package/src/__tests__/swarm-router-planner.test.ts +208 -0
- package/src/__tests__/swarm-session-integration.test.ts +274 -0
- package/src/__tests__/swarm-tool.test.ts +145 -0
- package/src/__tests__/swarm-worker-backend.test.ts +129 -0
- package/src/__tests__/swarm-worker-runner.test.ts +272 -0
- package/src/__tests__/system-prompt.test.ts +439 -0
- package/src/__tests__/task-compiler.test.ts +284 -0
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +216 -0
- package/src/__tests__/task-scheduler.test.ts +217 -0
- package/src/__tests__/task-tools.test.ts +595 -0
- package/src/__tests__/terminal-sandbox-docker.test.ts +1064 -0
- package/src/__tests__/terminal-sandbox.integration.test.ts +178 -0
- package/src/__tests__/terminal-sandbox.test.ts +202 -0
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/test-support/browser-skill-harness.ts +90 -0
- package/src/__tests__/test-support/computer-use-skill-harness.ts +45 -0
- package/src/__tests__/tool-audit-listener.test.ts +113 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +253 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +516 -0
- package/src/__tests__/tool-executor-redaction.test.ts +289 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +1989 -0
- package/src/__tests__/tool-metrics-listener.test.ts +225 -0
- package/src/__tests__/tool-notification-listener.test.ts +49 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/tool-policy.test.ts +54 -0
- package/src/__tests__/tool-profiling-listener.test.ts +268 -0
- package/src/__tests__/tool-result-truncation.test.ts +217 -0
- package/src/__tests__/tool-trace-listener.test.ts +226 -0
- package/src/__tests__/top-level-renderer.test.ts +121 -0
- package/src/__tests__/top-level-scanner.test.ts +141 -0
- package/src/__tests__/trace-emitter.test.ts +173 -0
- package/src/__tests__/trust-store.test.ts +1605 -0
- package/src/__tests__/turn-commit.test.ts +554 -0
- package/src/__tests__/twilio-provider.test.ts +329 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/__tests__/twilio-routes.test.ts +577 -0
- package/src/__tests__/twitter-auth-handler.test.ts +667 -0
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/url-safety.test.ts +418 -0
- package/src/__tests__/view-image-tool.test.ts +217 -0
- package/src/__tests__/weather-skill-regression.test.ts +225 -0
- package/src/__tests__/web-fetch.test.ts +869 -0
- package/src/__tests__/web-search.test.ts +584 -0
- package/src/__tests__/workspace-git-service.test.ts +1153 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +486 -0
- package/src/__tests__/workspace-lifecycle.test.ts +292 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/agent/attachments.ts +35 -0
- package/src/agent/loop.ts +500 -0
- package/src/agent/message-types.ts +17 -0
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/autonomy/autonomy-resolver.ts +60 -0
- package/src/autonomy/autonomy-store.ts +122 -0
- package/src/autonomy/disposition-mapper.ts +31 -0
- package/src/autonomy/index.ts +11 -0
- package/src/autonomy/types.ts +39 -0
- package/src/bundler/app-bundler.ts +295 -0
- package/src/bundler/bundle-scanner.ts +535 -0
- package/src/bundler/bundle-signer.ts +124 -0
- package/src/bundler/manifest.ts +21 -0
- package/src/bundler/signature-verifier.ts +184 -0
- package/src/calls/call-bridge.ts +168 -0
- package/src/calls/call-constants.ts +48 -0
- package/src/calls/call-domain.ts +430 -0
- package/src/calls/call-orchestrator.ts +498 -0
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-state.ts +87 -0
- package/src/calls/call-store.ts +422 -0
- package/src/calls/elevenlabs-client.ts +97 -0
- package/src/calls/elevenlabs-config.ts +31 -0
- package/src/calls/relay-server.ts +390 -0
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-config.ts +45 -0
- package/src/calls/twilio-provider.ts +263 -0
- package/src/calls/twilio-rest.ts +156 -0
- package/src/calls/twilio-routes.ts +311 -0
- package/src/calls/types.ts +39 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/calls/voice-quality.ts +114 -0
- package/src/cli/autonomy.ts +188 -0
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/contacts.ts +149 -0
- package/src/cli/core-commands.ts +784 -0
- package/src/cli/doordash.ts +1055 -0
- package/src/cli/email-guardrails.ts +200 -0
- package/src/cli/email.ts +405 -0
- package/src/cli/ipc-client.ts +82 -0
- package/src/cli/main-screen.tsx +53 -0
- package/src/cli/map.ts +270 -0
- package/src/cli/twitter.ts +754 -0
- package/src/cli.ts +918 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
- package/src/commands/cc-command-registry.ts +209 -0
- package/src/config/bundled-skills/.gitkeep +0 -0
- package/src/config/bundled-skills/agentmail/SKILL.md +128 -0
- package/src/config/bundled-skills/agentmail/icon.svg +21 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +1404 -0
- package/src/config/bundled-skills/app-builder/TOOLS.json +279 -0
- package/src/config/bundled-skills/app-builder/icon.svg +9 -0
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +15 -0
- package/src/config/bundled-skills/app-builder/tools/app-delete.ts +10 -0
- package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +11 -0
- package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +10 -0
- package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +18 -0
- package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +11 -0
- package/src/config/bundled-skills/app-builder/tools/app-list.ts +10 -0
- package/src/config/bundled-skills/app-builder/tools/app-query.ts +10 -0
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +20 -0
- package/src/config/bundled-skills/browser/SKILL.md +28 -0
- package/src/config/bundled-skills/browser/TOOLS.json +234 -0
- package/src/config/bundled-skills/browser/tools/browser-click.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-close.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-extract.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-navigate.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-press-key.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-type.ts +9 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +9 -0
- package/src/config/bundled-skills/claude-code/SKILL.md +50 -0
- package/src/config/bundled-skills/claude-code/TOOLS.json +40 -0
- package/src/config/bundled-skills/claude-code/tools/claude-code.ts +9 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +17 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +326 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-click.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-done.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-double-click.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-drag.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-key.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-open-app.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-respond.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-right-click.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-run-applescript.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-scroll.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-type-text.ts +9 -0
- package/src/config/bundled-skills/computer-use/tools/computer-use-wait.ts +9 -0
- package/src/config/bundled-skills/contacts/SKILL.md +39 -0
- package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +57 -0
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +60 -0
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +66 -0
- package/src/config/bundled-skills/document/SKILL.md +26 -0
- package/src/config/bundled-skills/document/TOOLS.json +53 -0
- package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
- package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
- package/src/config/bundled-skills/doordash/SKILL.md +163 -0
- package/src/config/bundled-skills/followups/SKILL.md +32 -0
- package/src/config/bundled-skills/followups/TOOLS.json +100 -0
- package/src/config/bundled-skills/followups/icon.svg +24 -0
- package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
- package/src/config/bundled-skills/google-calendar/SKILL.md +51 -0
- package/src/config/bundled-skills/google-calendar/TOOLS.json +108 -0
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +165 -0
- package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +21 -0
- package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +42 -0
- package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +13 -0
- package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +30 -0
- package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +41 -0
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +18 -0
- package/src/config/bundled-skills/google-calendar/types.ts +97 -0
- package/src/config/bundled-skills/image-studio/SKILL.md +32 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +42 -0
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +115 -0
- package/src/config/bundled-skills/macos-automation/SKILL.md +66 -0
- package/src/config/bundled-skills/messaging/SKILL.md +153 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +357 -0
- package/src/config/bundled-skills/messaging/tools/gmail-archive.ts +23 -0
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +23 -0
- package/src/config/bundled-skills/messaging/tools/gmail-batch-label.ts +25 -0
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +26 -0
- package/src/config/bundled-skills/messaging/tools/gmail-label.ts +25 -0
- package/src/config/bundled-skills/messaging/tools/gmail-trash.ts +23 -0
- package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +84 -0
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-activity.ts +18 -0
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +125 -0
- package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +16 -0
- package/src/config/bundled-skills/messaging/tools/messaging-draft.ts +49 -0
- package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +21 -0
- package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +25 -0
- package/src/config/bundled-skills/messaging/tools/messaging-read.ts +28 -0
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +32 -0
- package/src/config/bundled-skills/messaging/tools/messaging-search.ts +22 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +31 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/slack-add-reaction.ts +25 -0
- package/src/config/bundled-skills/messaging/tools/slack-leave-channel.ts +23 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +533 -0
- package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
- package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +98 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +54 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +76 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +113 -0
- package/src/config/bundled-skills/public-ingress/SKILL.md +200 -0
- package/src/config/bundled-skills/reminder/SKILL.md +20 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
- package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
- package/src/config/bundled-skills/schedule/SKILL.md +74 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
- package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
- package/src/config/bundled-skills/self-upgrade/SKILL.md +68 -0
- package/src/config/bundled-skills/start-the-day/SKILL.md +70 -0
- package/src/config/bundled-skills/start-the-day/icon.svg +13 -0
- package/src/config/bundled-skills/subagent/SKILL.md +25 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
- package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
- package/src/config/bundled-skills/tasks/SKILL.md +28 -0
- package/src/config/bundled-skills/tasks/TOOLS.json +281 -0
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/bundled-skills/twitter/SKILL.md +220 -0
- package/src/config/bundled-skills/watcher/SKILL.md +27 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
- package/src/config/bundled-skills/weather/SKILL.md +37 -0
- package/src/config/bundled-skills/weather/TOOLS.json +32 -0
- package/src/config/bundled-skills/weather/icon.svg +24 -0
- package/src/config/bundled-skills/weather/tools/get-weather.ts +9 -0
- package/src/config/computer-use-prompt.ts +97 -0
- package/src/config/defaults.ts +263 -0
- package/src/config/loader.ts +339 -0
- package/src/config/schema.ts +1436 -0
- package/src/config/skill-state.ts +95 -0
- package/src/config/skills.ts +972 -0
- package/src/config/system-prompt.ts +675 -0
- package/src/config/templates/BOOTSTRAP.md +70 -0
- package/src/config/templates/IDENTITY.md +25 -0
- package/src/config/templates/LOOKS.md +25 -0
- package/src/config/templates/SOUL.md +37 -0
- package/src/config/templates/USER.md +19 -0
- package/src/config/types.ts +42 -0
- package/src/config/vellum-skills/chatgpt-import/SKILL.md +24 -0
- package/src/config/vellum-skills/chatgpt-import/TOOLS.json +23 -0
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +284 -0
- package/src/config/vellum-skills/deploy-fullstack-vercel/SKILL.md +179 -0
- package/src/config/vellum-skills/document-writer/SKILL.md +195 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +199 -0
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +153 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +143 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +213 -0
- package/src/contacts/contact-store.ts +410 -0
- package/src/contacts/index.ts +11 -0
- package/src/contacts/types.ts +28 -0
- package/src/context/token-estimator.ts +108 -0
- package/src/context/tool-result-truncation.ts +128 -0
- package/src/context/window-manager.ts +531 -0
- package/src/daemon/assistant-attachments.ts +691 -0
- package/src/daemon/classifier.ts +110 -0
- package/src/daemon/computer-use-session.ts +903 -0
- package/src/daemon/connection-policy.ts +41 -0
- package/src/daemon/date-context.ts +136 -0
- package/src/daemon/handlers/apps.ts +530 -0
- package/src/daemon/handlers/browser.ts +54 -0
- package/src/daemon/handlers/computer-use.ts +187 -0
- package/src/daemon/handlers/config.ts +1517 -0
- package/src/daemon/handlers/diagnostics.ts +338 -0
- package/src/daemon/handlers/documents.ts +173 -0
- package/src/daemon/handlers/home-base.ts +78 -0
- package/src/daemon/handlers/identity.ts +127 -0
- package/src/daemon/handlers/index.ts +129 -0
- package/src/daemon/handlers/misc.ts +331 -0
- package/src/daemon/handlers/open-bundle-handler.ts +80 -0
- package/src/daemon/handlers/publish.ts +187 -0
- package/src/daemon/handlers/sessions.ts +555 -0
- package/src/daemon/handlers/shared.ts +570 -0
- package/src/daemon/handlers/signing.ts +37 -0
- package/src/daemon/handlers/skills.ts +486 -0
- package/src/daemon/handlers/subagents.ts +210 -0
- package/src/daemon/handlers/twitter-auth.ts +198 -0
- package/src/daemon/handlers/work-items.ts +632 -0
- package/src/daemon/handlers/workspace-files.ts +75 -0
- package/src/daemon/handlers.ts +17 -0
- package/src/daemon/history-repair.ts +214 -0
- package/src/daemon/ipc-blob-store.ts +231 -0
- package/src/daemon/ipc-contract-inventory.json +495 -0
- package/src/daemon/ipc-contract-inventory.ts +126 -0
- package/src/daemon/ipc-contract.ts +2551 -0
- package/src/daemon/ipc-protocol.ts +75 -0
- package/src/daemon/ipc-validate.ts +188 -0
- package/src/daemon/lifecycle.ts +582 -0
- package/src/daemon/main.ts +21 -0
- package/src/daemon/media-visibility-policy.ts +57 -0
- package/src/daemon/ride-shotgun-handler.ts +309 -0
- package/src/daemon/server.ts +1215 -0
- package/src/daemon/session-agent-loop.ts +922 -0
- package/src/daemon/session-attachments.ts +196 -0
- package/src/daemon/session-conflict-gate.ts +184 -0
- package/src/daemon/session-dynamic-profile.ts +63 -0
- package/src/daemon/session-error.ts +290 -0
- package/src/daemon/session-evictor.ts +196 -0
- package/src/daemon/session-history.ts +437 -0
- package/src/daemon/session-lifecycle.ts +147 -0
- package/src/daemon/session-media-retry.ts +147 -0
- package/src/daemon/session-memory.ts +212 -0
- package/src/daemon/session-messaging.ts +145 -0
- package/src/daemon/session-notifiers.ts +193 -0
- package/src/daemon/session-process.ts +323 -0
- package/src/daemon/session-queue-manager.ts +82 -0
- package/src/daemon/session-runtime-assembly.ts +447 -0
- package/src/daemon/session-skill-tools.ts +356 -0
- package/src/daemon/session-slash.ts +305 -0
- package/src/daemon/session-surfaces.ts +702 -0
- package/src/daemon/session-tool-setup.ts +523 -0
- package/src/daemon/session-usage.ts +72 -0
- package/src/daemon/session-workspace.ts +19 -0
- package/src/daemon/session.ts +400 -0
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/trace-emitter.ts +82 -0
- package/src/daemon/video-thumbnail.ts +62 -0
- package/src/daemon/watch-handler.ts +274 -0
- package/src/doordash/client.ts +999 -0
- package/src/doordash/queries.ts +1311 -0
- package/src/doordash/query-extractor.ts +93 -0
- package/src/doordash/session.ts +82 -0
- package/src/email/provider.ts +117 -0
- package/src/email/providers/agentmail.ts +317 -0
- package/src/email/providers/index.ts +58 -0
- package/src/email/service.ts +303 -0
- package/src/email/types.ts +126 -0
- package/src/events/bus.ts +157 -0
- package/src/events/domain-events.ts +83 -0
- package/src/events/index.ts +18 -0
- package/src/events/tool-audit-listener.ts +80 -0
- package/src/events/tool-domain-event-publisher.ts +111 -0
- package/src/events/tool-metrics-listener.ts +159 -0
- package/src/events/tool-notification-listener.ts +17 -0
- package/src/events/tool-profiling-listener.ts +158 -0
- package/src/events/tool-trace-listener.ts +75 -0
- package/src/export/formatter.ts +98 -0
- package/src/followups/followup-store.ts +168 -0
- package/src/followups/index.ts +10 -0
- package/src/followups/types.ts +29 -0
- package/src/gallery/default-gallery.ts +795 -0
- package/src/gallery/gallery-manifest.ts +24 -0
- package/src/home-base/app-link-store.ts +82 -0
- package/src/home-base/bootstrap.ts +68 -0
- package/src/home-base/prebuilt/index.html +662 -0
- package/src/home-base/prebuilt/seed-metadata.json +21 -0
- package/src/home-base/prebuilt/seed.ts +112 -0
- package/src/home-base/prebuilt-home-base-updater.ts +30 -0
- package/src/hooks/cli.ts +163 -0
- package/src/hooks/config.ts +88 -0
- package/src/hooks/discovery.ts +110 -0
- package/src/hooks/manager.ts +124 -0
- package/src/hooks/runner.ts +123 -0
- package/src/hooks/templates.ts +52 -0
- package/src/hooks/types.ts +72 -0
- package/src/inbound/public-ingress-urls.ts +123 -0
- package/src/index.ts +81 -0
- package/src/instrument.ts +60 -0
- package/src/logfire.ts +99 -0
- package/src/media/gemini-image-service.ts +136 -0
- package/src/memory/account-store.ts +108 -0
- package/src/memory/admin.ts +211 -0
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +577 -0
- package/src/memory/attachments-store.ts +397 -0
- package/src/memory/channel-delivery-store.ts +353 -0
- package/src/memory/channel-guardian-store.ts +669 -0
- package/src/memory/checkpoints.ts +52 -0
- package/src/memory/clarification-resolver.ts +298 -0
- package/src/memory/conflict-intent.ts +157 -0
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +350 -0
- package/src/memory/contradiction-checker.ts +358 -0
- package/src/memory/conversation-key-store.ts +122 -0
- package/src/memory/conversation-store.ts +470 -0
- package/src/memory/db.ts +1991 -0
- package/src/memory/embedding-backend.ts +229 -0
- package/src/memory/embedding-gemini.ts +52 -0
- package/src/memory/embedding-local.ts +65 -0
- package/src/memory/embedding-ollama.ts +55 -0
- package/src/memory/embedding-openai.ts +25 -0
- package/src/memory/entity-extractor.ts +474 -0
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/fingerprint.ts +20 -0
- package/src/memory/indexer.ts +156 -0
- package/src/memory/items-extractor.ts +461 -0
- package/src/memory/job-handlers/backfill.ts +139 -0
- package/src/memory/job-handlers/cleanup.ts +58 -0
- package/src/memory/job-handlers/conflict.ts +141 -0
- package/src/memory/job-handlers/embedding.ts +61 -0
- package/src/memory/job-handlers/extraction.ts +123 -0
- package/src/memory/job-handlers/index-maintenance.ts +54 -0
- package/src/memory/job-handlers/summarization.ts +286 -0
- package/src/memory/job-utils.ts +170 -0
- package/src/memory/jobs-store.ts +401 -0
- package/src/memory/jobs-worker.ts +313 -0
- package/src/memory/llm-request-log-store.ts +45 -0
- package/src/memory/llm-usage-store.ts +60 -0
- package/src/memory/message-content.ts +54 -0
- package/src/memory/profile-compiler.ts +160 -0
- package/src/memory/published-pages-store.ts +137 -0
- package/src/memory/qdrant-client.ts +366 -0
- package/src/memory/qdrant-manager.ts +242 -0
- package/src/memory/query-builder.ts +45 -0
- package/src/memory/retrieval-budget.ts +30 -0
- package/src/memory/retriever.ts +653 -0
- package/src/memory/runs-store.ts +305 -0
- package/src/memory/schema.ts +677 -0
- package/src/memory/search/entity.ts +298 -0
- package/src/memory/search/formatting.ts +207 -0
- package/src/memory/search/lexical.ts +227 -0
- package/src/memory/search/ranking.ts +401 -0
- package/src/memory/search/semantic.ts +121 -0
- package/src/memory/search/types.ts +137 -0
- package/src/memory/segmenter.ts +68 -0
- package/src/memory/shared-app-links-store.ts +138 -0
- package/src/memory/tool-usage-store.ts +62 -0
- package/src/messaging/activity-analyzer.ts +76 -0
- package/src/messaging/draft-store.ts +88 -0
- package/src/messaging/index.ts +3 -0
- package/src/messaging/provider-types.ts +80 -0
- package/src/messaging/provider.ts +52 -0
- package/src/messaging/providers/gmail/adapter.ts +193 -0
- package/src/messaging/providers/gmail/client.ts +204 -0
- package/src/messaging/providers/gmail/types.ts +90 -0
- package/src/messaging/providers/slack/adapter.ts +202 -0
- package/src/messaging/providers/slack/client.ts +198 -0
- package/src/messaging/providers/slack/types.ts +119 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +35 -0
- package/src/messaging/style-analyzer.ts +159 -0
- package/src/messaging/thread-summarizer.ts +306 -0
- package/src/messaging/triage-engine.ts +323 -0
- package/src/messaging/types.ts +55 -0
- package/src/permissions/checker.ts +640 -0
- package/src/permissions/defaults.ts +254 -0
- package/src/permissions/prompter.ts +98 -0
- package/src/permissions/secret-prompter.ts +114 -0
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +607 -0
- package/src/permissions/types.ts +43 -0
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/playbooks/index.ts +2 -0
- package/src/playbooks/playbook-compiler.ts +90 -0
- package/src/playbooks/types.ts +55 -0
- package/src/providers/anthropic/client.ts +751 -0
- package/src/providers/failover.ts +129 -0
- package/src/providers/fireworks/client.ts +20 -0
- package/src/providers/gemini/client.ts +285 -0
- package/src/providers/ollama/client.ts +30 -0
- package/src/providers/openai/client.ts +337 -0
- package/src/providers/openrouter/client.ts +20 -0
- package/src/providers/ratelimit.ts +93 -0
- package/src/providers/registry.ts +146 -0
- package/src/providers/retry.ts +81 -0
- package/src/providers/stream-timeout.ts +38 -0
- package/src/providers/types.ts +109 -0
- package/src/runtime/assistant-event-hub.ts +157 -0
- package/src/runtime/assistant-event.ts +82 -0
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +73 -0
- package/src/runtime/channel-approvals.ts +206 -0
- package/src/runtime/channel-guardian-service.ts +212 -0
- package/src/runtime/gateway-client.ts +58 -0
- package/src/runtime/http-server.ts +1076 -0
- package/src/runtime/http-types.ts +66 -0
- package/src/runtime/routes/app-routes.ts +174 -0
- package/src/runtime/routes/attachment-routes.ts +133 -0
- package/src/runtime/routes/call-routes.ts +190 -0
- package/src/runtime/routes/channel-routes.ts +1404 -0
- package/src/runtime/routes/conversation-routes.ts +352 -0
- package/src/runtime/routes/events-routes.ts +148 -0
- package/src/runtime/routes/run-routes.ts +257 -0
- package/src/runtime/routes/secret-routes.ts +76 -0
- package/src/runtime/run-orchestrator.ts +330 -0
- package/src/schedule/recurrence-engine.ts +162 -0
- package/src/schedule/recurrence-types.ts +67 -0
- package/src/schedule/schedule-store.ts +506 -0
- package/src/schedule/scheduler.ts +171 -0
- package/src/security/encrypted-store.ts +238 -0
- package/src/security/keychain.ts +252 -0
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +274 -0
- package/src/security/redaction.ts +89 -0
- package/src/security/secret-allowlist.ts +164 -0
- package/src/security/secret-ingress.ts +57 -0
- package/src/security/secret-scanner.ts +550 -0
- package/src/security/secure-keys.ts +180 -0
- package/src/security/token-manager.ts +141 -0
- package/src/services/published-app-updater.ts +69 -0
- package/src/services/vercel-deploy.ts +73 -0
- package/src/skills/active-skill-tools.ts +81 -0
- package/src/skills/clawhub.ts +414 -0
- package/src/skills/include-graph.ts +146 -0
- package/src/skills/managed-store.ts +233 -0
- package/src/skills/path-classifier.ts +128 -0
- package/src/skills/slash-commands.ts +174 -0
- package/src/skills/tool-manifest.ts +165 -0
- package/src/skills/version-hash.ts +110 -0
- package/src/slack/slack-webhook.ts +61 -0
- package/src/subagent/index.ts +19 -0
- package/src/subagent/manager.ts +511 -0
- package/src/subagent/types.ts +69 -0
- package/src/swarm/backend-claude-code.ts +145 -0
- package/src/swarm/index.ts +44 -0
- package/src/swarm/limits.ts +37 -0
- package/src/swarm/orchestrator.ts +279 -0
- package/src/swarm/plan-validator.ts +151 -0
- package/src/swarm/router-planner.ts +100 -0
- package/src/swarm/router-prompts.ts +36 -0
- package/src/swarm/synthesizer.ts +62 -0
- package/src/swarm/types.ts +62 -0
- package/src/swarm/worker-backend.ts +121 -0
- package/src/swarm/worker-prompts.ts +79 -0
- package/src/swarm/worker-runner.ts +164 -0
- package/src/tasks/SPEC.md +139 -0
- package/src/tasks/candidate-store.ts +86 -0
- package/src/tasks/ephemeral-permissions.ts +48 -0
- package/src/tasks/task-compiler.ts +199 -0
- package/src/tasks/task-runner.ts +90 -0
- package/src/tasks/task-scheduler.ts +21 -0
- package/src/tasks/task-store.ts +127 -0
- package/src/tasks/tool-sanitizer.ts +36 -0
- package/src/tools/apps/definitions.ts +59 -0
- package/src/tools/apps/executors.ts +313 -0
- package/src/tools/apps/open-proxy.ts +43 -0
- package/src/tools/apps/registry.ts +16 -0
- package/src/tools/assets/materialize.ts +218 -0
- package/src/tools/assets/search.ts +361 -0
- package/src/tools/browser/__tests__/auth-cache.test.ts +219 -0
- package/src/tools/browser/__tests__/auth-detector.test.ts +362 -0
- package/src/tools/browser/__tests__/jit-auth.test.ts +189 -0
- package/src/tools/browser/api-map.ts +293 -0
- package/src/tools/browser/auth-cache.ts +149 -0
- package/src/tools/browser/auth-detector.ts +347 -0
- package/src/tools/browser/auto-navigate.ts +270 -0
- package/src/tools/browser/browser-execution.ts +980 -0
- package/src/tools/browser/browser-handoff.ts +79 -0
- package/src/tools/browser/browser-manager.ts +715 -0
- package/src/tools/browser/browser-screencast.ts +217 -0
- package/src/tools/browser/headless-browser.ts +450 -0
- package/src/tools/browser/jit-auth.ts +51 -0
- package/src/tools/browser/network-recorder.ts +349 -0
- package/src/tools/browser/network-recording-types.ts +49 -0
- package/src/tools/browser/recording-store.ts +49 -0
- package/src/tools/browser/runtime-check.ts +43 -0
- package/src/tools/browser/x-auto-navigate.ts +207 -0
- package/src/tools/calls/call-end.ts +67 -0
- package/src/tools/calls/call-start.ts +81 -0
- package/src/tools/calls/call-status.ts +81 -0
- package/src/tools/claude-code/claude-code.ts +428 -0
- package/src/tools/computer-use/definitions.ts +443 -0
- package/src/tools/computer-use/registry.ts +22 -0
- package/src/tools/computer-use/request-computer-control.ts +53 -0
- package/src/tools/computer-use/skill-proxy-bridge.ts +28 -0
- package/src/tools/credentials/account-registry.ts +127 -0
- package/src/tools/credentials/broker-types.ts +107 -0
- package/src/tools/credentials/broker.ts +372 -0
- package/src/tools/credentials/domain-policy.ts +51 -0
- package/src/tools/credentials/host-pattern-match.ts +60 -0
- package/src/tools/credentials/metadata-store.ts +335 -0
- package/src/tools/credentials/policy-types.ts +52 -0
- package/src/tools/credentials/policy-validate.ts +80 -0
- package/src/tools/credentials/resolve.ts +122 -0
- package/src/tools/credentials/selection.ts +159 -0
- package/src/tools/credentials/tool-policy.ts +25 -0
- package/src/tools/credentials/vault.ts +657 -0
- package/src/tools/document/document-tool.ts +92 -0
- package/src/tools/document/editor-template.ts +237 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +815 -0
- package/src/tools/filesystem/edit.ts +127 -0
- package/src/tools/filesystem/fuzzy-match.ts +202 -0
- package/src/tools/filesystem/read.ts +71 -0
- package/src/tools/filesystem/view-image.ts +199 -0
- package/src/tools/filesystem/write.ts +79 -0
- package/src/tools/followups/followup_create.ts +76 -0
- package/src/tools/followups/followup_list.ts +60 -0
- package/src/tools/followups/followup_resolve.ts +56 -0
- package/src/tools/host-filesystem/edit.ts +125 -0
- package/src/tools/host-filesystem/read.ts +80 -0
- package/src/tools/host-filesystem/write.ts +76 -0
- package/src/tools/host-terminal/cli-discover.ts +180 -0
- package/src/tools/host-terminal/host-shell.ts +191 -0
- package/src/tools/memory/definitions.ts +69 -0
- package/src/tools/memory/handlers.ts +246 -0
- package/src/tools/memory/register.ts +66 -0
- package/src/tools/network/__tests__/web-search.test.ts +427 -0
- package/src/tools/network/domain-normalize.ts +85 -0
- package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
- package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
- package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
- package/src/tools/network/script-proxy/certs.ts +237 -0
- package/src/tools/network/script-proxy/connect-tunnel.ts +82 -0
- package/src/tools/network/script-proxy/http-forwarder.ts +151 -0
- package/src/tools/network/script-proxy/index.ts +28 -0
- package/src/tools/network/script-proxy/logging.ts +196 -0
- package/src/tools/network/script-proxy/mitm-handler.ts +269 -0
- package/src/tools/network/script-proxy/policy.ts +152 -0
- package/src/tools/network/script-proxy/router.ts +60 -0
- package/src/tools/network/script-proxy/server.ts +136 -0
- package/src/tools/network/script-proxy/session-manager.ts +534 -0
- package/src/tools/network/script-proxy/types.ts +125 -0
- package/src/tools/network/url-safety.ts +227 -0
- package/src/tools/network/web-fetch.ts +713 -0
- package/src/tools/network/web-search.ts +296 -0
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/registry.ts +295 -0
- package/src/tools/reminder/reminder-store.ts +148 -0
- package/src/tools/reminder/reminder.ts +80 -0
- package/src/tools/schedule/create.ts +81 -0
- package/src/tools/schedule/delete.ts +28 -0
- package/src/tools/schedule/list.ts +69 -0
- package/src/tools/schedule/update.ts +97 -0
- package/src/tools/shared/filesystem/edit-engine.ts +56 -0
- package/src/tools/shared/filesystem/errors.ts +85 -0
- package/src/tools/shared/filesystem/file-ops-service.ts +215 -0
- package/src/tools/shared/filesystem/format-diff.ts +35 -0
- package/src/tools/shared/filesystem/path-policy.ts +125 -0
- package/src/tools/shared/filesystem/size-guard.ts +41 -0
- package/src/tools/shared/filesystem/types.ts +80 -0
- package/src/tools/shared/shell-output.ts +52 -0
- package/src/tools/skills/delete-managed.ts +60 -0
- package/src/tools/skills/load.ts +139 -0
- package/src/tools/skills/sandbox-runner.ts +279 -0
- package/src/tools/skills/scaffold-managed.ts +150 -0
- package/src/tools/skills/script-contract.ts +6 -0
- package/src/tools/skills/skill-script-runner.ts +86 -0
- package/src/tools/skills/skill-tool-factory.ts +64 -0
- package/src/tools/skills/vellum-catalog.ts +217 -0
- package/src/tools/subagent/abort.ts +33 -0
- package/src/tools/subagent/message.ts +39 -0
- package/src/tools/subagent/read.ts +67 -0
- package/src/tools/subagent/spawn.ts +46 -0
- package/src/tools/subagent/status.ts +45 -0
- package/src/tools/swarm/delegate.ts +183 -0
- package/src/tools/system/request-permission.ts +98 -0
- package/src/tools/system/version.ts +43 -0
- package/src/tools/tasks/index.ts +27 -0
- package/src/tools/tasks/task-delete.ts +82 -0
- package/src/tools/tasks/task-list.ts +44 -0
- package/src/tools/tasks/task-run.ts +97 -0
- package/src/tools/tasks/task-save.ts +47 -0
- package/src/tools/tasks/work-item-enqueue.ts +234 -0
- package/src/tools/tasks/work-item-list.ts +55 -0
- package/src/tools/tasks/work-item-remove.ts +60 -0
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/tools/tasks/work-item-update.ts +114 -0
- package/src/tools/terminal/backends/docker.ts +372 -0
- package/src/tools/terminal/backends/native.ts +190 -0
- package/src/tools/terminal/backends/types.ts +26 -0
- package/src/tools/terminal/evaluate-typescript.ts +275 -0
- package/src/tools/terminal/parser.ts +413 -0
- package/src/tools/terminal/safe-env.ts +37 -0
- package/src/tools/terminal/sandbox-diagnostics.ts +149 -0
- package/src/tools/terminal/sandbox.ts +44 -0
- package/src/tools/terminal/shell.ts +257 -0
- package/src/tools/tool-manifest.ts +198 -0
- package/src/tools/types.ts +176 -0
- package/src/tools/ui-surface/definitions.ts +244 -0
- package/src/tools/ui-surface/registry.ts +14 -0
- package/src/tools/watch/screen-watch.ts +130 -0
- package/src/tools/watch/watch-state.ts +119 -0
- package/src/tools/watcher/create.ts +64 -0
- package/src/tools/watcher/delete.ts +27 -0
- package/src/tools/watcher/digest.ts +50 -0
- package/src/tools/watcher/list.ts +60 -0
- package/src/tools/watcher/update.ts +56 -0
- package/src/tools/weather/service.ts +551 -0
- package/src/twitter/client.ts +690 -0
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/twitter/session.ts +91 -0
- package/src/usage/actors.ts +24 -0
- package/src/usage/types.ts +37 -0
- package/src/util/clipboard.ts +33 -0
- package/src/util/content-id.ts +16 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/diff.ts +181 -0
- package/src/util/errors.ts +129 -0
- package/src/util/logger.ts +243 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +632 -0
- package/src/util/pricing.ts +150 -0
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/spinner.ts +51 -0
- package/src/util/time.ts +16 -0
- package/src/util/truncate.ts +6 -0
- package/src/util/xml.ts +4 -0
- package/src/version.ts +3 -0
- package/src/watcher/constants.ts +11 -0
- package/src/watcher/engine.ts +199 -0
- package/src/watcher/provider-registry.ts +15 -0
- package/src/watcher/provider-types.ts +48 -0
- package/src/watcher/providers/gmail.ts +198 -0
- package/src/watcher/providers/google-calendar.ts +228 -0
- package/src/watcher/providers/slack.ts +129 -0
- package/src/watcher/watcher-store.ts +419 -0
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/work-items/work-item-store.ts +325 -0
- package/src/workspace/commit-message-enrichment-service.ts +284 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +857 -0
- package/src/workspace/heartbeat-service.ts +345 -0
- package/src/workspace/provider-commit-message-generator.ts +285 -0
- package/src/workspace/top-level-renderer.ts +19 -0
- package/src/workspace/top-level-scanner.ts +41 -0
- package/src/workspace/turn-commit.ts +175 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,2356 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, afterEach, mock, spyOn } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test isolation: in-memory SQLite via temp directory
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const testDir = mkdtempSync(join(tmpdir(), 'channel-approval-routes-test-'));
|
|
11
|
+
|
|
12
|
+
mock.module('../util/platform.js', () => ({
|
|
13
|
+
getRootDir: () => testDir,
|
|
14
|
+
getDataDir: () => testDir,
|
|
15
|
+
isMacOS: () => process.platform === 'darwin',
|
|
16
|
+
isLinux: () => process.platform === 'linux',
|
|
17
|
+
isWindows: () => process.platform === 'win32',
|
|
18
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
19
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
20
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
21
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
22
|
+
ensureDataDir: () => {},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
mock.module('../util/logger.js', () => ({
|
|
26
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
27
|
+
get: () => () => {},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Mock security check to always pass
|
|
32
|
+
mock.module('../security/secret-ingress.js', () => ({
|
|
33
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Mock render to return the raw content as text
|
|
37
|
+
mock.module('../daemon/handlers.js', () => ({
|
|
38
|
+
renderHistoryContent: (content: unknown) => ({
|
|
39
|
+
text: typeof content === 'string' ? content : JSON.stringify(content),
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
44
|
+
import { conversations } from '../memory/schema.js';
|
|
45
|
+
import {
|
|
46
|
+
createRun,
|
|
47
|
+
setRunConfirmation,
|
|
48
|
+
} from '../memory/runs-store.js';
|
|
49
|
+
import type { PendingConfirmation } from '../memory/runs-store.js';
|
|
50
|
+
import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
|
|
51
|
+
import * as conversationStore from '../memory/conversation-store.js';
|
|
52
|
+
import {
|
|
53
|
+
createBinding,
|
|
54
|
+
createApprovalRequest,
|
|
55
|
+
getPendingApprovalForRun,
|
|
56
|
+
getUnresolvedApprovalForRun,
|
|
57
|
+
getExpiredPendingApprovals,
|
|
58
|
+
updateApprovalDecision,
|
|
59
|
+
} from '../memory/channel-guardian-store.js';
|
|
60
|
+
import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
|
|
61
|
+
import {
|
|
62
|
+
handleChannelInbound,
|
|
63
|
+
isChannelApprovalsEnabled,
|
|
64
|
+
sweepExpiredGuardianApprovals,
|
|
65
|
+
} from '../runtime/routes/channel-routes.js';
|
|
66
|
+
import * as gatewayClient from '../runtime/gateway-client.js';
|
|
67
|
+
|
|
68
|
+
initializeDb();
|
|
69
|
+
|
|
70
|
+
afterAll(() => {
|
|
71
|
+
resetDb();
|
|
72
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function ensureConversation(conversationId: string): void {
|
|
80
|
+
const db = getDb();
|
|
81
|
+
try {
|
|
82
|
+
db.insert(conversations).values({
|
|
83
|
+
id: conversationId,
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
updatedAt: Date.now(),
|
|
86
|
+
}).run();
|
|
87
|
+
} catch {
|
|
88
|
+
// already exists
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resetTables(): void {
|
|
93
|
+
const db = getDb();
|
|
94
|
+
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
95
|
+
db.run('DELETE FROM channel_guardian_verification_challenges');
|
|
96
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
97
|
+
db.run('DELETE FROM message_runs');
|
|
98
|
+
db.run('DELETE FROM channel_inbound_events');
|
|
99
|
+
db.run('DELETE FROM messages');
|
|
100
|
+
db.run('DELETE FROM conversations');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sampleConfirmation: PendingConfirmation = {
|
|
104
|
+
toolName: 'shell',
|
|
105
|
+
toolUseId: 'req-abc-123',
|
|
106
|
+
input: { command: 'rm -rf /tmp/test' },
|
|
107
|
+
riskLevel: 'high',
|
|
108
|
+
allowlistOptions: [{ label: 'rm -rf /tmp/test', pattern: 'rm -rf /tmp/test' }],
|
|
109
|
+
scopeOptions: [{ label: 'everywhere', scope: 'everywhere' }],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function makeMockOrchestrator(
|
|
113
|
+
submitResult: 'applied' | 'run_not_found' | 'no_pending_decision' = 'applied',
|
|
114
|
+
): RunOrchestrator {
|
|
115
|
+
return {
|
|
116
|
+
submitDecision: mock(() => submitResult),
|
|
117
|
+
getRun: mock(() => null),
|
|
118
|
+
startRun: mock(async () => ({
|
|
119
|
+
id: 'run-1',
|
|
120
|
+
conversationId: 'conv-1',
|
|
121
|
+
messageId: null,
|
|
122
|
+
status: 'running' as const,
|
|
123
|
+
pendingConfirmation: null,
|
|
124
|
+
pendingSecret: null,
|
|
125
|
+
inputTokens: 0,
|
|
126
|
+
outputTokens: 0,
|
|
127
|
+
estimatedCost: 0,
|
|
128
|
+
error: null,
|
|
129
|
+
createdAt: Date.now(),
|
|
130
|
+
updatedAt: Date.now(),
|
|
131
|
+
})),
|
|
132
|
+
} as unknown as RunOrchestrator;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
136
|
+
const body = {
|
|
137
|
+
sourceChannel: 'telegram',
|
|
138
|
+
externalChatId: 'chat-123',
|
|
139
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
140
|
+
content: 'hello',
|
|
141
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
142
|
+
...overrides,
|
|
143
|
+
};
|
|
144
|
+
return new Request('http://localhost/channels/inbound', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify(body),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Set up / tear down feature flag for each test
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
let originalEnv: string | undefined;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
resetTables();
|
|
161
|
+
originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
|
|
162
|
+
noopProcessMessage.mockClear();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
if (originalEnv === undefined) {
|
|
167
|
+
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
168
|
+
} else {
|
|
169
|
+
process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
174
|
+
// 1. Feature flag gating
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
176
|
+
|
|
177
|
+
describe('isChannelApprovalsEnabled', () => {
|
|
178
|
+
test('returns false when env var is not set', () => {
|
|
179
|
+
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
180
|
+
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('returns false when env var is "false"', () => {
|
|
184
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'false';
|
|
185
|
+
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('returns true when env var is "true"', () => {
|
|
189
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
190
|
+
expect(isChannelApprovalsEnabled()).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('feature flag disabled → normal flow', () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('proceeds normally even when pending approvals exist', async () => {
|
|
200
|
+
ensureConversation('conv-1');
|
|
201
|
+
const run = createRun('conv-1');
|
|
202
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
203
|
+
|
|
204
|
+
const orchestrator = makeMockOrchestrator();
|
|
205
|
+
const req = makeInboundRequest({
|
|
206
|
+
content: 'approve',
|
|
207
|
+
callbackData: 'apr:run-1:approve_once',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
|
|
211
|
+
const body = await res.json() as Record<string, unknown>;
|
|
212
|
+
|
|
213
|
+
// Should proceed normally — no approval interception
|
|
214
|
+
expect(body.accepted).toBe(true);
|
|
215
|
+
expect(body.approval).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
220
|
+
// 2. Callback data triggers decision handling
|
|
221
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
+
|
|
223
|
+
describe('inbound callback metadata triggers decision handling', () => {
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
|
|
229
|
+
// We need the conversation to exist AND have a pending run.
|
|
230
|
+
// The channel-delivery-store will create a conversation for us via recordInbound,
|
|
231
|
+
// but we also need the run to be linked to the same conversationId.
|
|
232
|
+
// Let's set up the conversation first, then create a run.
|
|
233
|
+
ensureConversation('conv-1');
|
|
234
|
+
|
|
235
|
+
// Create and record an earlier inbound event for this chat to establish
|
|
236
|
+
// the conversation mapping (so recordInbound returns the same conversationId).
|
|
237
|
+
// Actually, recordInbound auto-creates a conversationId based on source+chat.
|
|
238
|
+
// We need to find out what conversationId will be generated for telegram:chat-123.
|
|
239
|
+
|
|
240
|
+
// Let's use a spy to check if handleChannelDecision-equivalent behavior fires.
|
|
241
|
+
const orchestrator = makeMockOrchestrator();
|
|
242
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
243
|
+
|
|
244
|
+
// First, send a normal message to establish the conversation.
|
|
245
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
246
|
+
const initRes = await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
247
|
+
const _initBody = await initRes.json() as { conversationId?: string; eventId?: string; accepted?: boolean };
|
|
248
|
+
|
|
249
|
+
// Now we need to find the actual conversationId that was created.
|
|
250
|
+
// Check the channel_inbound_events table.
|
|
251
|
+
const db = getDb();
|
|
252
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
253
|
+
const conversationId = events[0]?.conversation_id;
|
|
254
|
+
expect(conversationId).toBeTruthy();
|
|
255
|
+
|
|
256
|
+
// Ensure conversation row exists for FK constraints
|
|
257
|
+
ensureConversation(conversationId!);
|
|
258
|
+
|
|
259
|
+
// Create a pending run for this conversation
|
|
260
|
+
const run = createRun(conversationId!);
|
|
261
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
262
|
+
|
|
263
|
+
// Now send a callback data message
|
|
264
|
+
const req = makeInboundRequest({
|
|
265
|
+
content: '',
|
|
266
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
270
|
+
const body = await res.json() as Record<string, unknown>;
|
|
271
|
+
|
|
272
|
+
expect(body.accepted).toBe(true);
|
|
273
|
+
expect(body.approval).toBe('decision_applied');
|
|
274
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
275
|
+
|
|
276
|
+
deliverSpy.mockRestore();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('callback data "apr:<runId>:reject" applies a rejection', async () => {
|
|
280
|
+
const orchestrator = makeMockOrchestrator();
|
|
281
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
282
|
+
|
|
283
|
+
// Establish the conversation
|
|
284
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
285
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
286
|
+
|
|
287
|
+
const db = getDb();
|
|
288
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
289
|
+
const conversationId = events[0]?.conversation_id;
|
|
290
|
+
ensureConversation(conversationId!);
|
|
291
|
+
|
|
292
|
+
const run = createRun(conversationId!);
|
|
293
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
294
|
+
|
|
295
|
+
const req = makeInboundRequest({
|
|
296
|
+
content: '',
|
|
297
|
+
callbackData: `apr:${run.id}:reject`,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
301
|
+
const body = await res.json() as Record<string, unknown>;
|
|
302
|
+
|
|
303
|
+
expect(body.accepted).toBe(true);
|
|
304
|
+
expect(body.approval).toBe('decision_applied');
|
|
305
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
306
|
+
|
|
307
|
+
deliverSpy.mockRestore();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
312
|
+
// 3. Plain text triggers decision handling
|
|
313
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
314
|
+
|
|
315
|
+
describe('inbound text matching approval phrases triggers decision handling', () => {
|
|
316
|
+
beforeEach(() => {
|
|
317
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('text "approve" triggers approve_once decision', async () => {
|
|
321
|
+
const orchestrator = makeMockOrchestrator();
|
|
322
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
323
|
+
|
|
324
|
+
// Establish the conversation
|
|
325
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
326
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
327
|
+
|
|
328
|
+
const db = getDb();
|
|
329
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
330
|
+
const conversationId = events[0]?.conversation_id;
|
|
331
|
+
ensureConversation(conversationId!);
|
|
332
|
+
|
|
333
|
+
const run = createRun(conversationId!);
|
|
334
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
335
|
+
|
|
336
|
+
const req = makeInboundRequest({ content: 'approve' });
|
|
337
|
+
|
|
338
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
339
|
+
const body = await res.json() as Record<string, unknown>;
|
|
340
|
+
|
|
341
|
+
expect(body.accepted).toBe(true);
|
|
342
|
+
expect(body.approval).toBe('decision_applied');
|
|
343
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
344
|
+
|
|
345
|
+
deliverSpy.mockRestore();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('text "always" triggers approve_always decision', async () => {
|
|
349
|
+
const orchestrator = makeMockOrchestrator();
|
|
350
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
351
|
+
|
|
352
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
353
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
354
|
+
|
|
355
|
+
const db = getDb();
|
|
356
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
357
|
+
const conversationId = events[0]?.conversation_id;
|
|
358
|
+
ensureConversation(conversationId!);
|
|
359
|
+
|
|
360
|
+
const run = createRun(conversationId!);
|
|
361
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
362
|
+
|
|
363
|
+
const req = makeInboundRequest({ content: 'always' });
|
|
364
|
+
|
|
365
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
366
|
+
const body = await res.json() as Record<string, unknown>;
|
|
367
|
+
|
|
368
|
+
expect(body.accepted).toBe(true);
|
|
369
|
+
expect(body.approval).toBe('decision_applied');
|
|
370
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
371
|
+
|
|
372
|
+
deliverSpy.mockRestore();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
377
|
+
// 4. Non-decision messages during pending approval trigger reminder
|
|
378
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
379
|
+
|
|
380
|
+
describe('non-decision messages during pending approval trigger reminder', () => {
|
|
381
|
+
beforeEach(() => {
|
|
382
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('sends a reminder prompt when message is not a decision', async () => {
|
|
386
|
+
const orchestrator = makeMockOrchestrator();
|
|
387
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
388
|
+
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
389
|
+
|
|
390
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
391
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
392
|
+
|
|
393
|
+
const db = getDb();
|
|
394
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
395
|
+
const conversationId = events[0]?.conversation_id;
|
|
396
|
+
ensureConversation(conversationId!);
|
|
397
|
+
|
|
398
|
+
const run = createRun(conversationId!);
|
|
399
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
400
|
+
|
|
401
|
+
// Send a message that is NOT a decision
|
|
402
|
+
const req = makeInboundRequest({ content: 'what is the weather?' });
|
|
403
|
+
|
|
404
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
405
|
+
const body = await res.json() as Record<string, unknown>;
|
|
406
|
+
|
|
407
|
+
expect(body.accepted).toBe(true);
|
|
408
|
+
expect(body.approval).toBe('reminder_sent');
|
|
409
|
+
|
|
410
|
+
// The approval prompt delivery should have been called
|
|
411
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
412
|
+
const callArgs = deliverSpy.mock.calls[0];
|
|
413
|
+
// The text should contain the reminder prefix
|
|
414
|
+
expect(callArgs[2]).toContain("I'm still waiting");
|
|
415
|
+
// The approval UI metadata should be present
|
|
416
|
+
expect(callArgs[3]).toBeDefined();
|
|
417
|
+
expect(callArgs[3]!.runId).toBe(run.id);
|
|
418
|
+
|
|
419
|
+
deliverSpy.mockRestore();
|
|
420
|
+
replySpy.mockRestore();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
425
|
+
// 5. Messages without pending approval proceed normally
|
|
426
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
427
|
+
|
|
428
|
+
describe('messages without pending approval proceed normally', () => {
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('proceeds to normal processing when no pending approval exists', async () => {
|
|
434
|
+
const orchestrator = makeMockOrchestrator();
|
|
435
|
+
|
|
436
|
+
const req = makeInboundRequest({ content: 'hello world' });
|
|
437
|
+
|
|
438
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
439
|
+
const body = await res.json() as Record<string, unknown>;
|
|
440
|
+
|
|
441
|
+
expect(body.accepted).toBe(true);
|
|
442
|
+
expect(body.approval).toBeUndefined();
|
|
443
|
+
// Normal flow should have been triggered
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('text "approve" is processed normally when no pending approval exists', async () => {
|
|
447
|
+
const orchestrator = makeMockOrchestrator();
|
|
448
|
+
|
|
449
|
+
const req = makeInboundRequest({ content: 'approve' });
|
|
450
|
+
|
|
451
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
452
|
+
const body = await res.json() as Record<string, unknown>;
|
|
453
|
+
|
|
454
|
+
expect(body.accepted).toBe(true);
|
|
455
|
+
// Should NOT be treated as an approval decision since there's no pending approval
|
|
456
|
+
expect(body.approval).toBeUndefined();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
461
|
+
// 6. Empty content with callbackData bypasses validation
|
|
462
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
463
|
+
|
|
464
|
+
describe('empty content with callbackData bypasses validation', () => {
|
|
465
|
+
test('rejects empty content without callbackData', async () => {
|
|
466
|
+
const req = makeInboundRequest({ content: '' });
|
|
467
|
+
const res = await handleChannelInbound(req, noopProcessMessage);
|
|
468
|
+
expect(res.status).toBe(400);
|
|
469
|
+
const body = await res.json() as Record<string, unknown>;
|
|
470
|
+
expect(body.error).toBe('content or attachmentIds is required');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('allows empty content when callbackData is present', async () => {
|
|
474
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
475
|
+
const orchestrator = makeMockOrchestrator();
|
|
476
|
+
|
|
477
|
+
// Establish the conversation first
|
|
478
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
479
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
480
|
+
|
|
481
|
+
const db = getDb();
|
|
482
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
483
|
+
const conversationId = events[0]?.conversation_id;
|
|
484
|
+
ensureConversation(conversationId!);
|
|
485
|
+
|
|
486
|
+
const run = createRun(conversationId!);
|
|
487
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
488
|
+
|
|
489
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
490
|
+
|
|
491
|
+
const req = makeInboundRequest({
|
|
492
|
+
content: '',
|
|
493
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
497
|
+
// Should NOT return 400 — callbackData allows empty content through
|
|
498
|
+
expect(res.status).toBe(200);
|
|
499
|
+
const body = await res.json() as Record<string, unknown>;
|
|
500
|
+
expect(body.accepted).toBe(true);
|
|
501
|
+
expect(body.approval).toBe('decision_applied');
|
|
502
|
+
|
|
503
|
+
deliverSpy.mockRestore();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('allows undefined content when callbackData is present', async () => {
|
|
507
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
508
|
+
const orchestrator = makeMockOrchestrator();
|
|
509
|
+
|
|
510
|
+
// Establish the conversation first
|
|
511
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
512
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
513
|
+
|
|
514
|
+
const db = getDb();
|
|
515
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
516
|
+
const conversationId = events[0]?.conversation_id;
|
|
517
|
+
ensureConversation(conversationId!);
|
|
518
|
+
|
|
519
|
+
const run = createRun(conversationId!);
|
|
520
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
521
|
+
|
|
522
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
523
|
+
|
|
524
|
+
// Send with no content field at all, just callbackData
|
|
525
|
+
const body = {
|
|
526
|
+
sourceChannel: 'telegram',
|
|
527
|
+
externalChatId: 'chat-123',
|
|
528
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
529
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
530
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
531
|
+
};
|
|
532
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: { 'Content-Type': 'application/json' },
|
|
535
|
+
body: JSON.stringify(body),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
539
|
+
expect(res.status).toBe(200);
|
|
540
|
+
const resBody = await res.json() as Record<string, unknown>;
|
|
541
|
+
expect(resBody.accepted).toBe(true);
|
|
542
|
+
|
|
543
|
+
deliverSpy.mockRestore();
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
548
|
+
// 7. Callback run ID validation — stale button press
|
|
549
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
550
|
+
|
|
551
|
+
describe('callback run ID validation', () => {
|
|
552
|
+
beforeEach(() => {
|
|
553
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('ignores stale callback when run ID does not match pending run', async () => {
|
|
557
|
+
const orchestrator = makeMockOrchestrator();
|
|
558
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
559
|
+
|
|
560
|
+
// Establish the conversation
|
|
561
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
562
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
563
|
+
|
|
564
|
+
const db = getDb();
|
|
565
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
566
|
+
const conversationId = events[0]?.conversation_id;
|
|
567
|
+
ensureConversation(conversationId!);
|
|
568
|
+
|
|
569
|
+
// Create a pending run
|
|
570
|
+
const run = createRun(conversationId!);
|
|
571
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
572
|
+
|
|
573
|
+
// Send callback with a DIFFERENT run ID (stale button)
|
|
574
|
+
const req = makeInboundRequest({
|
|
575
|
+
content: '',
|
|
576
|
+
callbackData: `apr:stale-run-id:approve_once`,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
580
|
+
const body = await res.json() as Record<string, unknown>;
|
|
581
|
+
|
|
582
|
+
expect(body.accepted).toBe(true);
|
|
583
|
+
expect(body.approval).toBe('stale_ignored');
|
|
584
|
+
// submitDecision should NOT have been called because the run ID didn't match
|
|
585
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
586
|
+
|
|
587
|
+
deliverSpy.mockRestore();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('applies callback when run ID matches pending run', async () => {
|
|
591
|
+
const orchestrator = makeMockOrchestrator();
|
|
592
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
593
|
+
|
|
594
|
+
// Establish the conversation
|
|
595
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
596
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
597
|
+
|
|
598
|
+
const db = getDb();
|
|
599
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
600
|
+
const conversationId = events[0]?.conversation_id;
|
|
601
|
+
ensureConversation(conversationId!);
|
|
602
|
+
|
|
603
|
+
// Create a pending run
|
|
604
|
+
const run = createRun(conversationId!);
|
|
605
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
606
|
+
|
|
607
|
+
// Send callback with the CORRECT run ID
|
|
608
|
+
const req = makeInboundRequest({
|
|
609
|
+
content: '',
|
|
610
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
614
|
+
const body = await res.json() as Record<string, unknown>;
|
|
615
|
+
|
|
616
|
+
expect(body.accepted).toBe(true);
|
|
617
|
+
expect(body.approval).toBe('decision_applied');
|
|
618
|
+
// submitDecision SHOULD have been called with the correct run ID
|
|
619
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
620
|
+
|
|
621
|
+
deliverSpy.mockRestore();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('plain-text decisions bypass run ID validation (no runId in result)', async () => {
|
|
625
|
+
const orchestrator = makeMockOrchestrator();
|
|
626
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
627
|
+
|
|
628
|
+
// Establish the conversation
|
|
629
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
630
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
631
|
+
|
|
632
|
+
const db = getDb();
|
|
633
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
634
|
+
const conversationId = events[0]?.conversation_id;
|
|
635
|
+
ensureConversation(conversationId!);
|
|
636
|
+
|
|
637
|
+
// Create a pending run
|
|
638
|
+
const run = createRun(conversationId!);
|
|
639
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
640
|
+
|
|
641
|
+
// Send plain text "yes" — no runId in the parsed result, so validation is skipped
|
|
642
|
+
const req = makeInboundRequest({ content: 'yes' });
|
|
643
|
+
|
|
644
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
645
|
+
const body = await res.json() as Record<string, unknown>;
|
|
646
|
+
|
|
647
|
+
expect(body.accepted).toBe(true);
|
|
648
|
+
expect(body.approval).toBe('decision_applied');
|
|
649
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
650
|
+
|
|
651
|
+
deliverSpy.mockRestore();
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
656
|
+
// 8. linkMessage in approval-aware processing path
|
|
657
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
658
|
+
|
|
659
|
+
describe('linkMessage in approval-aware processing path', () => {
|
|
660
|
+
beforeEach(() => {
|
|
661
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('linkMessage is called when run has a messageId and reaches terminal state', async () => {
|
|
665
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
666
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
667
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
668
|
+
|
|
669
|
+
const mockRun = {
|
|
670
|
+
id: 'run-link-test',
|
|
671
|
+
conversationId: 'conv-1',
|
|
672
|
+
messageId: 'user-msg-42',
|
|
673
|
+
status: 'running' as const,
|
|
674
|
+
pendingConfirmation: null,
|
|
675
|
+
pendingSecret: null,
|
|
676
|
+
inputTokens: 0,
|
|
677
|
+
outputTokens: 0,
|
|
678
|
+
estimatedCost: 0,
|
|
679
|
+
error: null,
|
|
680
|
+
createdAt: Date.now(),
|
|
681
|
+
updatedAt: Date.now(),
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// getRun returns completed status immediately so the poll loop exits
|
|
685
|
+
const orchestrator = {
|
|
686
|
+
submitDecision: mock(() => 'applied' as const),
|
|
687
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
688
|
+
startRun: mock(async () => mockRun),
|
|
689
|
+
} as unknown as RunOrchestrator;
|
|
690
|
+
|
|
691
|
+
const req = makeInboundRequest({ content: 'hello world' });
|
|
692
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
693
|
+
|
|
694
|
+
// Wait for the background async to complete (must exceed RUN_POLL_INTERVAL_MS of 500ms)
|
|
695
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
696
|
+
|
|
697
|
+
// Verify linkMessage was called with the run's messageId
|
|
698
|
+
const linkCalls = linkSpy.mock.calls.filter(
|
|
699
|
+
(call) => call[1] === 'user-msg-42',
|
|
700
|
+
);
|
|
701
|
+
expect(linkCalls.length).toBeGreaterThanOrEqual(1);
|
|
702
|
+
|
|
703
|
+
// Verify markProcessed was also called
|
|
704
|
+
expect(markSpy).toHaveBeenCalled();
|
|
705
|
+
|
|
706
|
+
linkSpy.mockRestore();
|
|
707
|
+
markSpy.mockRestore();
|
|
708
|
+
deliverSpy.mockRestore();
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
713
|
+
// 9. Terminal state check before markProcessed
|
|
714
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
715
|
+
|
|
716
|
+
describe('terminal state check before markProcessed', () => {
|
|
717
|
+
beforeEach(() => {
|
|
718
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test('records processing failure when run disappears (non-approval non-terminal state)', async () => {
|
|
722
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
723
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
724
|
+
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
725
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
726
|
+
|
|
727
|
+
const mockRun = {
|
|
728
|
+
id: 'run-nonterminal',
|
|
729
|
+
conversationId: 'conv-1',
|
|
730
|
+
messageId: 'user-msg-99',
|
|
731
|
+
status: 'running' as const,
|
|
732
|
+
pendingConfirmation: null,
|
|
733
|
+
pendingSecret: null,
|
|
734
|
+
inputTokens: 0,
|
|
735
|
+
outputTokens: 0,
|
|
736
|
+
estimatedCost: 0,
|
|
737
|
+
error: null,
|
|
738
|
+
createdAt: Date.now(),
|
|
739
|
+
updatedAt: Date.now(),
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// getRun returns null — run disappeared, poll loop breaks. Since the run
|
|
743
|
+
// is not in needs_confirmation, it falls through to recordProcessingFailure
|
|
744
|
+
// so the retry/dead-letter machinery can handle it.
|
|
745
|
+
const orchNull = {
|
|
746
|
+
submitDecision: mock(() => 'applied' as const),
|
|
747
|
+
getRun: mock(() => null),
|
|
748
|
+
startRun: mock(async () => mockRun),
|
|
749
|
+
} as unknown as RunOrchestrator;
|
|
750
|
+
|
|
751
|
+
const req = makeInboundRequest({ content: 'hello world' });
|
|
752
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchNull);
|
|
753
|
+
|
|
754
|
+
// Wait for the background async to complete
|
|
755
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
756
|
+
|
|
757
|
+
// recordProcessingFailure SHOULD have been called because the run is
|
|
758
|
+
// not in needs_confirmation (it disappeared — status is null).
|
|
759
|
+
expect(failureSpy).toHaveBeenCalled();
|
|
760
|
+
|
|
761
|
+
// markProcessed should NOT have been called
|
|
762
|
+
expect(markSpy).not.toHaveBeenCalled();
|
|
763
|
+
|
|
764
|
+
linkSpy.mockRestore();
|
|
765
|
+
markSpy.mockRestore();
|
|
766
|
+
failureSpy.mockRestore();
|
|
767
|
+
deliverSpy.mockRestore();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test('markProcessed is called when run reaches completed state', async () => {
|
|
771
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
772
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
773
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
774
|
+
|
|
775
|
+
const mockRun = {
|
|
776
|
+
id: 'run-completes',
|
|
777
|
+
conversationId: 'conv-1',
|
|
778
|
+
messageId: 'user-msg-100',
|
|
779
|
+
status: 'running' as const,
|
|
780
|
+
pendingConfirmation: null,
|
|
781
|
+
pendingSecret: null,
|
|
782
|
+
inputTokens: 0,
|
|
783
|
+
outputTokens: 0,
|
|
784
|
+
estimatedCost: 0,
|
|
785
|
+
error: null,
|
|
786
|
+
createdAt: Date.now(),
|
|
787
|
+
updatedAt: Date.now(),
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const orchestrator = {
|
|
791
|
+
submitDecision: mock(() => 'applied' as const),
|
|
792
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
793
|
+
startRun: mock(async () => mockRun),
|
|
794
|
+
} as unknown as RunOrchestrator;
|
|
795
|
+
|
|
796
|
+
const req = makeInboundRequest({ content: 'hello world' });
|
|
797
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
798
|
+
|
|
799
|
+
// Wait for the background async to complete
|
|
800
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
801
|
+
|
|
802
|
+
// markProcessed should have been called because the run reached completed
|
|
803
|
+
expect(markSpy).toHaveBeenCalled();
|
|
804
|
+
|
|
805
|
+
linkSpy.mockRestore();
|
|
806
|
+
markSpy.mockRestore();
|
|
807
|
+
deliverSpy.mockRestore();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test('markProcessed is called when run reaches failed state', async () => {
|
|
811
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
812
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
813
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
814
|
+
|
|
815
|
+
const mockRun = {
|
|
816
|
+
id: 'run-fails',
|
|
817
|
+
conversationId: 'conv-1',
|
|
818
|
+
messageId: 'user-msg-101',
|
|
819
|
+
status: 'running' as const,
|
|
820
|
+
pendingConfirmation: null,
|
|
821
|
+
pendingSecret: null,
|
|
822
|
+
inputTokens: 0,
|
|
823
|
+
outputTokens: 0,
|
|
824
|
+
estimatedCost: 0,
|
|
825
|
+
error: null,
|
|
826
|
+
createdAt: Date.now(),
|
|
827
|
+
updatedAt: Date.now(),
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const orchestrator = {
|
|
831
|
+
submitDecision: mock(() => 'applied' as const),
|
|
832
|
+
getRun: mock(() => ({ ...mockRun, status: 'failed' as const })),
|
|
833
|
+
startRun: mock(async () => mockRun),
|
|
834
|
+
} as unknown as RunOrchestrator;
|
|
835
|
+
|
|
836
|
+
const req = makeInboundRequest({ content: 'hello world' });
|
|
837
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
838
|
+
|
|
839
|
+
// Wait for the background async to complete
|
|
840
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
841
|
+
|
|
842
|
+
// markProcessed should have been called because the run reached failed
|
|
843
|
+
expect(markSpy).toHaveBeenCalled();
|
|
844
|
+
|
|
845
|
+
linkSpy.mockRestore();
|
|
846
|
+
markSpy.mockRestore();
|
|
847
|
+
deliverSpy.mockRestore();
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
852
|
+
// 10. No immediate reply after approval decision (WS-A)
|
|
853
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
854
|
+
|
|
855
|
+
describe('no immediate reply after approval decision', () => {
|
|
856
|
+
beforeEach(() => {
|
|
857
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('deliverChannelReply is NOT called from interception after decision is applied', async () => {
|
|
861
|
+
const orchestrator = makeMockOrchestrator();
|
|
862
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
863
|
+
|
|
864
|
+
// Establish the conversation
|
|
865
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
866
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
867
|
+
|
|
868
|
+
const db = getDb();
|
|
869
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
870
|
+
const conversationId = events[0]?.conversation_id;
|
|
871
|
+
ensureConversation(conversationId!);
|
|
872
|
+
|
|
873
|
+
// Create a pending run
|
|
874
|
+
const run = createRun(conversationId!);
|
|
875
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
876
|
+
|
|
877
|
+
// Clear the spy to only track calls from the decision path
|
|
878
|
+
deliverSpy.mockClear();
|
|
879
|
+
|
|
880
|
+
// Send a callback decision
|
|
881
|
+
const req = makeInboundRequest({
|
|
882
|
+
content: '',
|
|
883
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
887
|
+
const body = await res.json() as Record<string, unknown>;
|
|
888
|
+
|
|
889
|
+
expect(body.approval).toBe('decision_applied');
|
|
890
|
+
|
|
891
|
+
// The interception handler should NOT have called deliverChannelReply.
|
|
892
|
+
// The reply should only come from the terminal run completion path.
|
|
893
|
+
expect(deliverSpy).not.toHaveBeenCalled();
|
|
894
|
+
|
|
895
|
+
deliverSpy.mockRestore();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test('plain-text decision also does not trigger immediate reply', async () => {
|
|
899
|
+
const orchestrator = makeMockOrchestrator();
|
|
900
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
901
|
+
|
|
902
|
+
// Establish the conversation
|
|
903
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
904
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
905
|
+
|
|
906
|
+
const db = getDb();
|
|
907
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
908
|
+
const conversationId = events[0]?.conversation_id;
|
|
909
|
+
ensureConversation(conversationId!);
|
|
910
|
+
|
|
911
|
+
const run = createRun(conversationId!);
|
|
912
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
913
|
+
|
|
914
|
+
deliverSpy.mockClear();
|
|
915
|
+
|
|
916
|
+
// Send a plain-text approval
|
|
917
|
+
const req = makeInboundRequest({ content: 'approve' });
|
|
918
|
+
|
|
919
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
920
|
+
const body = await res.json() as Record<string, unknown>;
|
|
921
|
+
|
|
922
|
+
expect(body.approval).toBe('decision_applied');
|
|
923
|
+
expect(deliverSpy).not.toHaveBeenCalled();
|
|
924
|
+
|
|
925
|
+
deliverSpy.mockRestore();
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
930
|
+
// 11. Stale callback with no pending approval returns stale_ignored (WS-B)
|
|
931
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
932
|
+
|
|
933
|
+
describe('stale callback handling', () => {
|
|
934
|
+
beforeEach(() => {
|
|
935
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test('callback with no pending approval returns stale_ignored and does not start a run', async () => {
|
|
939
|
+
const orchestrator = makeMockOrchestrator();
|
|
940
|
+
|
|
941
|
+
// No pending run/approval — send a stale callback
|
|
942
|
+
const req = makeInboundRequest({
|
|
943
|
+
content: '',
|
|
944
|
+
callbackData: 'apr:stale-run:approve_once',
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
948
|
+
const body = await res.json() as Record<string, unknown>;
|
|
949
|
+
|
|
950
|
+
expect(body.accepted).toBe(true);
|
|
951
|
+
expect(body.approval).toBe('stale_ignored');
|
|
952
|
+
|
|
953
|
+
// startRun should NOT have been called — the stale callback must not
|
|
954
|
+
// enter processChannelMessageWithApprovals or processChannelMessageInBackground
|
|
955
|
+
expect(orchestrator.startRun).not.toHaveBeenCalled();
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test('callback with non-empty content but no pending approval returns stale_ignored', async () => {
|
|
959
|
+
const orchestrator = makeMockOrchestrator();
|
|
960
|
+
|
|
961
|
+
// Simulate what normalize.ts does: callbackData present AND content is
|
|
962
|
+
// set to the callback data value (non-empty). Without the fix, this
|
|
963
|
+
// would fall through to normal processing because the old guard only
|
|
964
|
+
// checked for empty content.
|
|
965
|
+
const req = makeInboundRequest({
|
|
966
|
+
content: 'apr:stale-run:approve_once',
|
|
967
|
+
callbackData: 'apr:stale-run:approve_once',
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
971
|
+
const body = await res.json() as Record<string, unknown>;
|
|
972
|
+
|
|
973
|
+
expect(body.accepted).toBe(true);
|
|
974
|
+
expect(body.approval).toBe('stale_ignored');
|
|
975
|
+
expect(orchestrator.startRun).not.toHaveBeenCalled();
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test('non-callback message without pending approval proceeds to normal processing', async () => {
|
|
979
|
+
const orchestrator = makeMockOrchestrator();
|
|
980
|
+
|
|
981
|
+
// Regular text message (no callbackData) should proceed normally
|
|
982
|
+
const req = makeInboundRequest({ content: 'hello world' });
|
|
983
|
+
|
|
984
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
985
|
+
const body = await res.json() as Record<string, unknown>;
|
|
986
|
+
|
|
987
|
+
expect(body.accepted).toBe(true);
|
|
988
|
+
// No approval field — normal processing
|
|
989
|
+
expect(body.approval).toBeUndefined();
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
994
|
+
// 12. Timeout handling: needs_confirmation marks processed, other states fail
|
|
995
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
996
|
+
|
|
997
|
+
describe('poll timeout handling by run state', () => {
|
|
998
|
+
beforeEach(() => {
|
|
999
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test('records processing failure when run disappears (getRun returns null) before terminal state', async () => {
|
|
1003
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1004
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1005
|
+
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
1006
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1007
|
+
|
|
1008
|
+
const mockRun = {
|
|
1009
|
+
id: 'run-timeout-1',
|
|
1010
|
+
conversationId: 'conv-1',
|
|
1011
|
+
messageId: 'user-msg-200',
|
|
1012
|
+
status: 'running' as const,
|
|
1013
|
+
pendingConfirmation: null,
|
|
1014
|
+
pendingSecret: null,
|
|
1015
|
+
inputTokens: 0,
|
|
1016
|
+
outputTokens: 0,
|
|
1017
|
+
estimatedCost: 0,
|
|
1018
|
+
error: null,
|
|
1019
|
+
createdAt: Date.now(),
|
|
1020
|
+
updatedAt: Date.now(),
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
// getRun returns null — run disappeared, poll loop breaks. Since the run
|
|
1024
|
+
// is not in needs_confirmation, it records a processing failure.
|
|
1025
|
+
const orchestrator = {
|
|
1026
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1027
|
+
getRun: mock(() => null),
|
|
1028
|
+
startRun: mock(async () => mockRun),
|
|
1029
|
+
} as unknown as RunOrchestrator;
|
|
1030
|
+
|
|
1031
|
+
const req = makeInboundRequest({ content: 'hello timeout' });
|
|
1032
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1033
|
+
|
|
1034
|
+
// Wait for the background async to complete
|
|
1035
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1036
|
+
|
|
1037
|
+
// recordProcessingFailure SHOULD have been called — the run disappeared
|
|
1038
|
+
// and is not in needs_confirmation, so the retry machinery should handle it.
|
|
1039
|
+
expect(failureSpy).toHaveBeenCalled();
|
|
1040
|
+
|
|
1041
|
+
// markProcessed should NOT have been called
|
|
1042
|
+
expect(markSpy).not.toHaveBeenCalled();
|
|
1043
|
+
|
|
1044
|
+
linkSpy.mockRestore();
|
|
1045
|
+
markSpy.mockRestore();
|
|
1046
|
+
failureSpy.mockRestore();
|
|
1047
|
+
deliverSpy.mockRestore();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
|
|
1051
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1052
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1053
|
+
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
1054
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1055
|
+
|
|
1056
|
+
const mockRun = {
|
|
1057
|
+
id: 'run-needs-confirm',
|
|
1058
|
+
conversationId: 'conv-1',
|
|
1059
|
+
messageId: 'user-msg-202',
|
|
1060
|
+
status: 'running' as const,
|
|
1061
|
+
pendingConfirmation: null,
|
|
1062
|
+
pendingSecret: null,
|
|
1063
|
+
inputTokens: 0,
|
|
1064
|
+
outputTokens: 0,
|
|
1065
|
+
estimatedCost: 0,
|
|
1066
|
+
error: null,
|
|
1067
|
+
createdAt: Date.now(),
|
|
1068
|
+
updatedAt: Date.now(),
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// getRun returns needs_confirmation — run is waiting for approval decision.
|
|
1072
|
+
// The event should be marked as processed because the post-decision delivery
|
|
1073
|
+
// in handleApprovalInterception will deliver the reply when the user decides.
|
|
1074
|
+
const orchestrator = {
|
|
1075
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1076
|
+
getRun: mock(() => ({ ...mockRun, status: 'needs_confirmation' as const })),
|
|
1077
|
+
startRun: mock(async () => mockRun),
|
|
1078
|
+
} as unknown as RunOrchestrator;
|
|
1079
|
+
|
|
1080
|
+
const req = makeInboundRequest({ content: 'hello needs_confirm' });
|
|
1081
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1082
|
+
|
|
1083
|
+
// Wait for the background async to complete
|
|
1084
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1085
|
+
|
|
1086
|
+
// markProcessed SHOULD have been called — the run is waiting for approval,
|
|
1087
|
+
// and the post-decision delivery path will handle the final reply.
|
|
1088
|
+
expect(markSpy).toHaveBeenCalled();
|
|
1089
|
+
|
|
1090
|
+
// recordProcessingFailure should NOT have been called
|
|
1091
|
+
expect(failureSpy).not.toHaveBeenCalled();
|
|
1092
|
+
|
|
1093
|
+
linkSpy.mockRestore();
|
|
1094
|
+
markSpy.mockRestore();
|
|
1095
|
+
failureSpy.mockRestore();
|
|
1096
|
+
deliverSpy.mockRestore();
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
|
|
1100
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1101
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1102
|
+
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
1103
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1104
|
+
|
|
1105
|
+
const mockRun = {
|
|
1106
|
+
id: 'run-terminal-ok',
|
|
1107
|
+
conversationId: 'conv-1',
|
|
1108
|
+
messageId: 'user-msg-201',
|
|
1109
|
+
status: 'running' as const,
|
|
1110
|
+
pendingConfirmation: null,
|
|
1111
|
+
pendingSecret: null,
|
|
1112
|
+
inputTokens: 0,
|
|
1113
|
+
outputTokens: 0,
|
|
1114
|
+
estimatedCost: 0,
|
|
1115
|
+
error: null,
|
|
1116
|
+
createdAt: Date.now(),
|
|
1117
|
+
updatedAt: Date.now(),
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
// getRun returns completed — run is terminal
|
|
1121
|
+
const orchestrator = {
|
|
1122
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1123
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
1124
|
+
startRun: mock(async () => mockRun),
|
|
1125
|
+
} as unknown as RunOrchestrator;
|
|
1126
|
+
|
|
1127
|
+
const req = makeInboundRequest({ content: 'hello terminal' });
|
|
1128
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1129
|
+
|
|
1130
|
+
// Wait for the background async to complete
|
|
1131
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1132
|
+
|
|
1133
|
+
// recordProcessingFailure should NOT have been called
|
|
1134
|
+
expect(failureSpy).not.toHaveBeenCalled();
|
|
1135
|
+
|
|
1136
|
+
// markProcessed SHOULD have been called
|
|
1137
|
+
expect(markSpy).toHaveBeenCalled();
|
|
1138
|
+
|
|
1139
|
+
linkSpy.mockRestore();
|
|
1140
|
+
markSpy.mockRestore();
|
|
1141
|
+
failureSpy.mockRestore();
|
|
1142
|
+
deliverSpy.mockRestore();
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1147
|
+
// 12b. Post-decision delivery after poll timeout
|
|
1148
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1149
|
+
|
|
1150
|
+
describe('post-decision delivery after poll timeout', () => {
|
|
1151
|
+
beforeEach(() => {
|
|
1152
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test('delivers reply via callback after a late approval decision', async () => {
|
|
1156
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1157
|
+
|
|
1158
|
+
// Establish the conversation
|
|
1159
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
1160
|
+
const orchestrator = makeMockOrchestrator();
|
|
1161
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1162
|
+
|
|
1163
|
+
const db = getDb();
|
|
1164
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1165
|
+
const conversationId = events[0]?.conversation_id;
|
|
1166
|
+
ensureConversation(conversationId!);
|
|
1167
|
+
|
|
1168
|
+
// Create a pending run
|
|
1169
|
+
const run = createRun(conversationId!);
|
|
1170
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1171
|
+
|
|
1172
|
+
// Add a mock assistant message so that deliverReplyViaCallback can find
|
|
1173
|
+
// the final reply to deliver.
|
|
1174
|
+
conversationStore.addMessage(conversationId!, 'assistant', 'Here is your result.');
|
|
1175
|
+
|
|
1176
|
+
// Now create a second orchestrator that simulates the run completing after
|
|
1177
|
+
// the decision is applied (getRun returns completed after first call).
|
|
1178
|
+
let getRunCallCount = 0;
|
|
1179
|
+
const lateOrchestrator = {
|
|
1180
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1181
|
+
getRun: mock(() => {
|
|
1182
|
+
getRunCallCount++;
|
|
1183
|
+
// First call returns needs_confirmation (decision just applied, resuming),
|
|
1184
|
+
// subsequent calls return completed (run finished).
|
|
1185
|
+
if (getRunCallCount <= 1) {
|
|
1186
|
+
return {
|
|
1187
|
+
id: run.id,
|
|
1188
|
+
conversationId: conversationId!,
|
|
1189
|
+
messageId: 'user-msg-late',
|
|
1190
|
+
status: 'needs_confirmation' as const,
|
|
1191
|
+
pendingConfirmation: null,
|
|
1192
|
+
pendingSecret: null,
|
|
1193
|
+
inputTokens: 0,
|
|
1194
|
+
outputTokens: 0,
|
|
1195
|
+
estimatedCost: 0,
|
|
1196
|
+
error: null,
|
|
1197
|
+
createdAt: Date.now(),
|
|
1198
|
+
updatedAt: Date.now(),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
return {
|
|
1202
|
+
id: run.id,
|
|
1203
|
+
conversationId: conversationId!,
|
|
1204
|
+
messageId: 'user-msg-late',
|
|
1205
|
+
status: 'completed' as const,
|
|
1206
|
+
pendingConfirmation: null,
|
|
1207
|
+
pendingSecret: null,
|
|
1208
|
+
inputTokens: 0,
|
|
1209
|
+
outputTokens: 0,
|
|
1210
|
+
estimatedCost: 0,
|
|
1211
|
+
error: null,
|
|
1212
|
+
createdAt: Date.now(),
|
|
1213
|
+
updatedAt: Date.now(),
|
|
1214
|
+
};
|
|
1215
|
+
}),
|
|
1216
|
+
startRun: mock(async () => ({
|
|
1217
|
+
id: run.id,
|
|
1218
|
+
conversationId: conversationId!,
|
|
1219
|
+
messageId: 'user-msg-late',
|
|
1220
|
+
status: 'running' as const,
|
|
1221
|
+
pendingConfirmation: null,
|
|
1222
|
+
pendingSecret: null,
|
|
1223
|
+
inputTokens: 0,
|
|
1224
|
+
outputTokens: 0,
|
|
1225
|
+
estimatedCost: 0,
|
|
1226
|
+
error: null,
|
|
1227
|
+
createdAt: Date.now(),
|
|
1228
|
+
updatedAt: Date.now(),
|
|
1229
|
+
})),
|
|
1230
|
+
} as unknown as RunOrchestrator;
|
|
1231
|
+
|
|
1232
|
+
// Clear spy to only track calls from the decision + post-decision path
|
|
1233
|
+
deliverSpy.mockClear();
|
|
1234
|
+
|
|
1235
|
+
// Send an approval decision — this simulates a late approval after the
|
|
1236
|
+
// original poll has already timed out.
|
|
1237
|
+
const req = makeInboundRequest({
|
|
1238
|
+
content: '',
|
|
1239
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', lateOrchestrator);
|
|
1243
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1244
|
+
|
|
1245
|
+
expect(body.accepted).toBe(true);
|
|
1246
|
+
expect(body.approval).toBe('decision_applied');
|
|
1247
|
+
|
|
1248
|
+
// Wait for the async post-decision delivery poll to complete.
|
|
1249
|
+
// It polls every 500ms; the run becomes terminal on the second getRun call.
|
|
1250
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1251
|
+
|
|
1252
|
+
// deliverChannelReply should have been called by the post-decision
|
|
1253
|
+
// delivery path (deliverReplyViaCallback uses deliverChannelReply).
|
|
1254
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
1255
|
+
|
|
1256
|
+
deliverSpy.mockRestore();
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1261
|
+
// 13. sourceChannel is passed to orchestrator.startRun (WS-D)
|
|
1262
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1263
|
+
|
|
1264
|
+
describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
1265
|
+
beforeEach(() => {
|
|
1266
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test('startRun is called with sourceChannel from inbound event', async () => {
|
|
1270
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1271
|
+
|
|
1272
|
+
const mockRun = {
|
|
1273
|
+
id: 'run-channel-test',
|
|
1274
|
+
conversationId: 'conv-1',
|
|
1275
|
+
messageId: 'user-msg-300',
|
|
1276
|
+
status: 'completed' as const,
|
|
1277
|
+
pendingConfirmation: null,
|
|
1278
|
+
pendingSecret: null,
|
|
1279
|
+
inputTokens: 0,
|
|
1280
|
+
outputTokens: 0,
|
|
1281
|
+
estimatedCost: 0,
|
|
1282
|
+
error: null,
|
|
1283
|
+
createdAt: Date.now(),
|
|
1284
|
+
updatedAt: Date.now(),
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
const orchestrator = {
|
|
1288
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1289
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
1290
|
+
startRun: mock(async () => mockRun),
|
|
1291
|
+
} as unknown as RunOrchestrator;
|
|
1292
|
+
|
|
1293
|
+
const req = makeInboundRequest({
|
|
1294
|
+
content: 'test channel pass-through',
|
|
1295
|
+
sourceChannel: 'telegram',
|
|
1296
|
+
});
|
|
1297
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1298
|
+
|
|
1299
|
+
// Wait for the background async to fire
|
|
1300
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1301
|
+
|
|
1302
|
+
// Verify startRun was called with the sourceChannel option
|
|
1303
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
1304
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
1305
|
+
// 4th argument is the options object
|
|
1306
|
+
const options = startRunArgs[3] as { sourceChannel?: string } | undefined;
|
|
1307
|
+
expect(options).toBeDefined();
|
|
1308
|
+
expect(options!.sourceChannel).toBe('telegram');
|
|
1309
|
+
|
|
1310
|
+
deliverSpy.mockRestore();
|
|
1311
|
+
});
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1315
|
+
// 14. Plain-text fallback surfacing for non-rich channels (WS-E)
|
|
1316
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1317
|
+
|
|
1318
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1319
|
+
// 15. SMS channel approval decisions
|
|
1320
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1321
|
+
|
|
1322
|
+
describe('SMS channel approval decisions', () => {
|
|
1323
|
+
beforeEach(() => {
|
|
1324
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
function makeSmsInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
1328
|
+
const body = {
|
|
1329
|
+
sourceChannel: 'sms',
|
|
1330
|
+
externalChatId: 'sms-chat-123',
|
|
1331
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1332
|
+
content: 'hello',
|
|
1333
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
1334
|
+
...overrides,
|
|
1335
|
+
};
|
|
1336
|
+
return new Request('http://localhost/channels/inbound', {
|
|
1337
|
+
method: 'POST',
|
|
1338
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1339
|
+
body: JSON.stringify(body),
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
test('plain-text "yes" via SMS triggers approve_once decision', async () => {
|
|
1344
|
+
const orchestrator = makeMockOrchestrator();
|
|
1345
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1346
|
+
|
|
1347
|
+
// Establish the conversation via SMS
|
|
1348
|
+
const initReq = makeSmsInboundRequest({ content: 'init' });
|
|
1349
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1350
|
+
|
|
1351
|
+
const db = getDb();
|
|
1352
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1353
|
+
const conversationId = events[events.length - 1]?.conversation_id;
|
|
1354
|
+
ensureConversation(conversationId!);
|
|
1355
|
+
|
|
1356
|
+
const run = createRun(conversationId!);
|
|
1357
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1358
|
+
|
|
1359
|
+
const req = makeSmsInboundRequest({ content: 'yes' });
|
|
1360
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1361
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1362
|
+
|
|
1363
|
+
expect(body.accepted).toBe(true);
|
|
1364
|
+
expect(body.approval).toBe('decision_applied');
|
|
1365
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
1366
|
+
|
|
1367
|
+
deliverSpy.mockRestore();
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test('plain-text "no" via SMS triggers reject decision', async () => {
|
|
1371
|
+
const orchestrator = makeMockOrchestrator();
|
|
1372
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1373
|
+
|
|
1374
|
+
const initReq = makeSmsInboundRequest({ content: 'init' });
|
|
1375
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1376
|
+
|
|
1377
|
+
const db = getDb();
|
|
1378
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1379
|
+
const conversationId = events[events.length - 1]?.conversation_id;
|
|
1380
|
+
ensureConversation(conversationId!);
|
|
1381
|
+
|
|
1382
|
+
const run = createRun(conversationId!);
|
|
1383
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1384
|
+
|
|
1385
|
+
const req = makeSmsInboundRequest({ content: 'no' });
|
|
1386
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1387
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1388
|
+
|
|
1389
|
+
expect(body.accepted).toBe(true);
|
|
1390
|
+
expect(body.approval).toBe('decision_applied');
|
|
1391
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
1392
|
+
|
|
1393
|
+
deliverSpy.mockRestore();
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test('non-decision SMS message during pending approval triggers reminder with plain-text fallback', async () => {
|
|
1397
|
+
const orchestrator = makeMockOrchestrator();
|
|
1398
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1399
|
+
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1400
|
+
|
|
1401
|
+
const initReq = makeSmsInboundRequest({ content: 'init' });
|
|
1402
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1403
|
+
|
|
1404
|
+
const db = getDb();
|
|
1405
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1406
|
+
const conversationId = events[events.length - 1]?.conversation_id;
|
|
1407
|
+
ensureConversation(conversationId!);
|
|
1408
|
+
|
|
1409
|
+
const run = createRun(conversationId!);
|
|
1410
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1411
|
+
|
|
1412
|
+
const req = makeSmsInboundRequest({ content: 'what is happening?' });
|
|
1413
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1414
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1415
|
+
|
|
1416
|
+
expect(body.accepted).toBe(true);
|
|
1417
|
+
expect(body.approval).toBe('reminder_sent');
|
|
1418
|
+
|
|
1419
|
+
// SMS is a non-rich channel so the delivered text should include plain-text fallback
|
|
1420
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
1421
|
+
const callArgs = deliverSpy.mock.calls[0];
|
|
1422
|
+
const deliveredText = callArgs[2] as string;
|
|
1423
|
+
expect(deliveredText).toContain("I'm still waiting");
|
|
1424
|
+
expect(deliveredText).toContain('Reply "yes"');
|
|
1425
|
+
|
|
1426
|
+
deliverSpy.mockRestore();
|
|
1427
|
+
replySpy.mockRestore();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test('sourceChannel "sms" is passed to orchestrator.startRun', async () => {
|
|
1431
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1432
|
+
|
|
1433
|
+
const mockRun = {
|
|
1434
|
+
id: 'run-sms-channel-test',
|
|
1435
|
+
conversationId: 'conv-1',
|
|
1436
|
+
messageId: 'user-msg-sms',
|
|
1437
|
+
status: 'completed' as const,
|
|
1438
|
+
pendingConfirmation: null,
|
|
1439
|
+
pendingSecret: null,
|
|
1440
|
+
inputTokens: 0,
|
|
1441
|
+
outputTokens: 0,
|
|
1442
|
+
estimatedCost: 0,
|
|
1443
|
+
error: null,
|
|
1444
|
+
createdAt: Date.now(),
|
|
1445
|
+
updatedAt: Date.now(),
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
const orchestrator = {
|
|
1449
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1450
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
1451
|
+
startRun: mock(async () => mockRun),
|
|
1452
|
+
} as unknown as RunOrchestrator;
|
|
1453
|
+
|
|
1454
|
+
const req = makeSmsInboundRequest({ content: 'test sms channel pass-through' });
|
|
1455
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1456
|
+
|
|
1457
|
+
// Wait for the background async to fire
|
|
1458
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1459
|
+
|
|
1460
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
1461
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
1462
|
+
const options = startRunArgs[3] as { sourceChannel?: string } | undefined;
|
|
1463
|
+
expect(options).toBeDefined();
|
|
1464
|
+
expect(options!.sourceChannel).toBe('sms');
|
|
1465
|
+
|
|
1466
|
+
deliverSpy.mockRestore();
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1471
|
+
// 16. SMS guardian verify intercept
|
|
1472
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1473
|
+
|
|
1474
|
+
describe('SMS guardian verify intercept', () => {
|
|
1475
|
+
test('/guardian_verify command works with sourceChannel sms', async () => {
|
|
1476
|
+
// Set up a guardian verification challenge for SMS
|
|
1477
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
1478
|
+
const { secret } = createVerificationChallenge('self', 'sms');
|
|
1479
|
+
|
|
1480
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1481
|
+
|
|
1482
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
1483
|
+
method: 'POST',
|
|
1484
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1485
|
+
body: JSON.stringify({
|
|
1486
|
+
sourceChannel: 'sms',
|
|
1487
|
+
externalChatId: 'sms-chat-verify',
|
|
1488
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1489
|
+
content: `/guardian_verify ${secret}`,
|
|
1490
|
+
senderExternalUserId: 'sms-user-42',
|
|
1491
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
1492
|
+
}),
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token');
|
|
1496
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1497
|
+
|
|
1498
|
+
expect(body.accepted).toBe(true);
|
|
1499
|
+
expect(body.guardianVerification).toBe('verified');
|
|
1500
|
+
|
|
1501
|
+
// Verify the reply was delivered
|
|
1502
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
1503
|
+
const replyArgs = deliverSpy.mock.calls[0];
|
|
1504
|
+
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1505
|
+
expect(replyPayload.chatId).toBe('sms-chat-verify');
|
|
1506
|
+
expect(replyPayload.text).toContain('Guardian verified successfully');
|
|
1507
|
+
|
|
1508
|
+
deliverSpy.mockRestore();
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
test('/guardian_verify with invalid token returns failed via SMS', async () => {
|
|
1512
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1513
|
+
|
|
1514
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
1515
|
+
method: 'POST',
|
|
1516
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1517
|
+
body: JSON.stringify({
|
|
1518
|
+
sourceChannel: 'sms',
|
|
1519
|
+
externalChatId: 'sms-chat-verify-fail',
|
|
1520
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1521
|
+
content: '/guardian_verify invalid-token-here',
|
|
1522
|
+
senderExternalUserId: 'sms-user-43',
|
|
1523
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
1524
|
+
}),
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token');
|
|
1528
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1529
|
+
|
|
1530
|
+
expect(body.accepted).toBe(true);
|
|
1531
|
+
expect(body.guardianVerification).toBe('failed');
|
|
1532
|
+
|
|
1533
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
1534
|
+
const replyArgs = deliverSpy.mock.calls[0];
|
|
1535
|
+
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1536
|
+
expect(replyPayload.text).toContain('Verification failed');
|
|
1537
|
+
|
|
1538
|
+
deliverSpy.mockRestore();
|
|
1539
|
+
});
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1543
|
+
// 17. SMS non-guardian actor gating
|
|
1544
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1545
|
+
|
|
1546
|
+
describe('SMS non-guardian actor gating', () => {
|
|
1547
|
+
beforeEach(() => {
|
|
1548
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
|
|
1552
|
+
// Create a guardian binding for the sms channel
|
|
1553
|
+
const { createBinding } = await import('../memory/channel-guardian-store.js');
|
|
1554
|
+
createBinding({
|
|
1555
|
+
assistantId: 'self',
|
|
1556
|
+
channel: 'sms',
|
|
1557
|
+
guardianExternalUserId: 'sms-guardian-user',
|
|
1558
|
+
guardianDeliveryChatId: 'sms-guardian-chat',
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1562
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1563
|
+
|
|
1564
|
+
const mockRun = {
|
|
1565
|
+
id: 'run-sms-nongrd',
|
|
1566
|
+
conversationId: 'conv-1',
|
|
1567
|
+
messageId: 'user-msg-sms-nongrd',
|
|
1568
|
+
status: 'running' as const,
|
|
1569
|
+
pendingConfirmation: null,
|
|
1570
|
+
pendingSecret: null,
|
|
1571
|
+
inputTokens: 0,
|
|
1572
|
+
outputTokens: 0,
|
|
1573
|
+
estimatedCost: 0,
|
|
1574
|
+
error: null,
|
|
1575
|
+
createdAt: Date.now(),
|
|
1576
|
+
updatedAt: Date.now(),
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
const orchestrator = {
|
|
1580
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1581
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
1582
|
+
startRun: mock(async () => mockRun),
|
|
1583
|
+
} as unknown as RunOrchestrator;
|
|
1584
|
+
|
|
1585
|
+
// Send message from a NON-guardian sms user
|
|
1586
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
1587
|
+
method: 'POST',
|
|
1588
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1589
|
+
body: JSON.stringify({
|
|
1590
|
+
sourceChannel: 'sms',
|
|
1591
|
+
externalChatId: 'sms-other-chat',
|
|
1592
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1593
|
+
content: 'do something',
|
|
1594
|
+
senderExternalUserId: 'sms-other-user',
|
|
1595
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
1596
|
+
}),
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1600
|
+
|
|
1601
|
+
// Wait for the background async to fire
|
|
1602
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1603
|
+
|
|
1604
|
+
// startRun should have been called with forceStrictSideEffects for non-guardian
|
|
1605
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
1606
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
1607
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean; sourceChannel?: string } | undefined;
|
|
1608
|
+
expect(options).toBeDefined();
|
|
1609
|
+
expect(options!.forceStrictSideEffects).toBe(true);
|
|
1610
|
+
expect(options!.sourceChannel).toBe('sms');
|
|
1611
|
+
|
|
1612
|
+
deliverSpy.mockRestore();
|
|
1613
|
+
approvalSpy.mockRestore();
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
1618
|
+
beforeEach(() => {
|
|
1619
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
|
|
1623
|
+
const orchestrator = makeMockOrchestrator();
|
|
1624
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1625
|
+
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1626
|
+
|
|
1627
|
+
// Establish the conversation using http-api (non-rich channel)
|
|
1628
|
+
const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'http-api' });
|
|
1629
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1630
|
+
|
|
1631
|
+
const db = getDb();
|
|
1632
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1633
|
+
const conversationId = events[0]?.conversation_id;
|
|
1634
|
+
ensureConversation(conversationId!);
|
|
1635
|
+
|
|
1636
|
+
const run = createRun(conversationId!);
|
|
1637
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1638
|
+
|
|
1639
|
+
// Send a non-decision message to trigger a reminder
|
|
1640
|
+
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'http-api' });
|
|
1641
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1642
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1643
|
+
|
|
1644
|
+
expect(body.accepted).toBe(true);
|
|
1645
|
+
expect(body.approval).toBe('reminder_sent');
|
|
1646
|
+
|
|
1647
|
+
// The delivered text should include the plainTextFallback instructions
|
|
1648
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
1649
|
+
const callArgs = deliverSpy.mock.calls[0];
|
|
1650
|
+
const deliveredText = callArgs[2] as string;
|
|
1651
|
+
// For non-rich channels, the text should contain both the reminder prefix
|
|
1652
|
+
// AND the plainTextFallback instructions (e.g. "Reply yes to approve")
|
|
1653
|
+
expect(deliveredText).toContain("I'm still waiting");
|
|
1654
|
+
expect(deliveredText).toContain('Reply "yes"');
|
|
1655
|
+
|
|
1656
|
+
deliverSpy.mockRestore();
|
|
1657
|
+
replySpy.mockRestore();
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
test('reminder prompt does NOT include plainTextFallback for telegram (rich channel)', async () => {
|
|
1661
|
+
const orchestrator = makeMockOrchestrator();
|
|
1662
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1663
|
+
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1664
|
+
|
|
1665
|
+
// Establish the conversation using telegram (rich channel)
|
|
1666
|
+
const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'telegram' });
|
|
1667
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1668
|
+
|
|
1669
|
+
const db = getDb();
|
|
1670
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1671
|
+
const conversationId = events[0]?.conversation_id;
|
|
1672
|
+
ensureConversation(conversationId!);
|
|
1673
|
+
|
|
1674
|
+
const run = createRun(conversationId!);
|
|
1675
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1676
|
+
|
|
1677
|
+
// Send a non-decision message to trigger a reminder
|
|
1678
|
+
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'telegram' });
|
|
1679
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1680
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1681
|
+
|
|
1682
|
+
expect(body.accepted).toBe(true);
|
|
1683
|
+
expect(body.approval).toBe('reminder_sent');
|
|
1684
|
+
|
|
1685
|
+
// For rich channels (telegram), the delivered text should be just the
|
|
1686
|
+
// promptText (with reminder prefix) — NOT the plainTextFallback.
|
|
1687
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
1688
|
+
const callArgs = deliverSpy.mock.calls[0];
|
|
1689
|
+
const deliveredText = callArgs[2] as string;
|
|
1690
|
+
expect(deliveredText).toContain("I'm still waiting");
|
|
1691
|
+
// The raw promptText does not contain "Reply" instructions — those are
|
|
1692
|
+
// only in the plainTextFallback.
|
|
1693
|
+
expect(deliveredText).not.toContain('Reply "yes"');
|
|
1694
|
+
|
|
1695
|
+
deliverSpy.mockRestore();
|
|
1696
|
+
replySpy.mockRestore();
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
// ---------------------------------------------------------------------------
|
|
1701
|
+
// Helper: orchestrator that creates real DB runs with pending confirmations
|
|
1702
|
+
// ---------------------------------------------------------------------------
|
|
1703
|
+
|
|
1704
|
+
function makeSensitiveOrchestrator(opts: {
|
|
1705
|
+
runId: string;
|
|
1706
|
+
terminalStatus: 'completed' | 'failed';
|
|
1707
|
+
}): RunOrchestrator & { realRunId: () => string | undefined } {
|
|
1708
|
+
let realRunId: string | undefined;
|
|
1709
|
+
let pollCount = 0;
|
|
1710
|
+
return {
|
|
1711
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1712
|
+
getRun: mock(() => {
|
|
1713
|
+
pollCount++;
|
|
1714
|
+
if (pollCount === 1 && realRunId) {
|
|
1715
|
+
return {
|
|
1716
|
+
id: realRunId,
|
|
1717
|
+
conversationId: 'conv-1',
|
|
1718
|
+
messageId: null,
|
|
1719
|
+
status: 'needs_confirmation' as const,
|
|
1720
|
+
pendingConfirmation: sampleConfirmation,
|
|
1721
|
+
pendingSecret: null,
|
|
1722
|
+
inputTokens: 0,
|
|
1723
|
+
outputTokens: 0,
|
|
1724
|
+
estimatedCost: 0,
|
|
1725
|
+
error: null,
|
|
1726
|
+
createdAt: Date.now(),
|
|
1727
|
+
updatedAt: Date.now(),
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
return {
|
|
1731
|
+
id: realRunId ?? opts.runId,
|
|
1732
|
+
conversationId: 'conv-1',
|
|
1733
|
+
messageId: null,
|
|
1734
|
+
status: opts.terminalStatus,
|
|
1735
|
+
pendingConfirmation: null,
|
|
1736
|
+
pendingSecret: null,
|
|
1737
|
+
inputTokens: 0,
|
|
1738
|
+
outputTokens: 0,
|
|
1739
|
+
estimatedCost: 0,
|
|
1740
|
+
error: null,
|
|
1741
|
+
createdAt: Date.now(),
|
|
1742
|
+
updatedAt: Date.now(),
|
|
1743
|
+
};
|
|
1744
|
+
}),
|
|
1745
|
+
startRun: mock(async (conversationId: string) => {
|
|
1746
|
+
ensureConversation(conversationId);
|
|
1747
|
+
const run = createRun(conversationId);
|
|
1748
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1749
|
+
realRunId = run.id;
|
|
1750
|
+
return {
|
|
1751
|
+
id: run.id,
|
|
1752
|
+
conversationId,
|
|
1753
|
+
messageId: null,
|
|
1754
|
+
status: 'running' as const,
|
|
1755
|
+
pendingConfirmation: null,
|
|
1756
|
+
pendingSecret: null,
|
|
1757
|
+
inputTokens: 0,
|
|
1758
|
+
outputTokens: 0,
|
|
1759
|
+
estimatedCost: 0,
|
|
1760
|
+
error: null,
|
|
1761
|
+
createdAt: Date.now(),
|
|
1762
|
+
updatedAt: Date.now(),
|
|
1763
|
+
};
|
|
1764
|
+
}),
|
|
1765
|
+
realRunId: () => realRunId,
|
|
1766
|
+
} as unknown as RunOrchestrator & { realRunId: () => string | undefined };
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1770
|
+
// 18. Fail-closed guardian gate (WS-1)
|
|
1771
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1772
|
+
|
|
1773
|
+
describe('fail-closed guardian gate — unverified channel', () => {
|
|
1774
|
+
beforeEach(() => {
|
|
1775
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
test('no binding + sensitive action → auto-deny and setup notice', async () => {
|
|
1779
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1780
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1781
|
+
|
|
1782
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-unv-1', terminalStatus: 'failed' });
|
|
1783
|
+
|
|
1784
|
+
// Non-guardian sender, no binding exists → unverified_channel
|
|
1785
|
+
const req = makeInboundRequest({
|
|
1786
|
+
content: 'do something dangerous',
|
|
1787
|
+
senderExternalUserId: 'user-no-binding',
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1791
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
1792
|
+
|
|
1793
|
+
// The run should have been denied (submitDecision called with deny)
|
|
1794
|
+
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
1795
|
+
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
1796
|
+
expect(decisionArgs[1]).toBe('deny');
|
|
1797
|
+
|
|
1798
|
+
// The requester should have been notified about missing guardian setup
|
|
1799
|
+
const replyCalls = deliverSpy.mock.calls.filter(
|
|
1800
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
|
|
1801
|
+
);
|
|
1802
|
+
expect(replyCalls.length).toBeGreaterThanOrEqual(1);
|
|
1803
|
+
|
|
1804
|
+
// No approval prompt should have been sent to a guardian (none exists)
|
|
1805
|
+
expect(approvalSpy).not.toHaveBeenCalled();
|
|
1806
|
+
|
|
1807
|
+
deliverSpy.mockRestore();
|
|
1808
|
+
approvalSpy.mockRestore();
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
test('no binding + non-sensitive action → completes normally', async () => {
|
|
1812
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1813
|
+
|
|
1814
|
+
// Orchestrator that completes without hitting needs_confirmation
|
|
1815
|
+
const mockRun = {
|
|
1816
|
+
id: 'run-unv-safe',
|
|
1817
|
+
conversationId: 'conv-1',
|
|
1818
|
+
messageId: null,
|
|
1819
|
+
status: 'running' as const,
|
|
1820
|
+
pendingConfirmation: null,
|
|
1821
|
+
pendingSecret: null,
|
|
1822
|
+
inputTokens: 0,
|
|
1823
|
+
outputTokens: 0,
|
|
1824
|
+
estimatedCost: 0,
|
|
1825
|
+
error: null,
|
|
1826
|
+
createdAt: Date.now(),
|
|
1827
|
+
updatedAt: Date.now(),
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
const orchestrator = {
|
|
1831
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1832
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
1833
|
+
startRun: mock(async () => mockRun),
|
|
1834
|
+
} as unknown as RunOrchestrator;
|
|
1835
|
+
|
|
1836
|
+
const req = makeInboundRequest({
|
|
1837
|
+
content: 'what time is it',
|
|
1838
|
+
senderExternalUserId: 'user-no-binding',
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1842
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1843
|
+
|
|
1844
|
+
expect(body.accepted).toBe(true);
|
|
1845
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1846
|
+
|
|
1847
|
+
// submitDecision should NOT have been called — no confirmation needed
|
|
1848
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
1849
|
+
|
|
1850
|
+
deliverSpy.mockRestore();
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
test('unverified channel cannot self-approve via interception', async () => {
|
|
1854
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1855
|
+
|
|
1856
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-unv-self', terminalStatus: 'failed' });
|
|
1857
|
+
|
|
1858
|
+
// First, send a message to establish the conversation and trigger
|
|
1859
|
+
// the sensitive action (which will be auto-denied in the poll loop).
|
|
1860
|
+
const initReq = makeInboundRequest({
|
|
1861
|
+
content: 'do something',
|
|
1862
|
+
senderExternalUserId: 'user-no-binding',
|
|
1863
|
+
});
|
|
1864
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1865
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
1866
|
+
|
|
1867
|
+
// Now find the conversation
|
|
1868
|
+
const db = getDb();
|
|
1869
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
1870
|
+
const conversationId = events[0]?.conversation_id;
|
|
1871
|
+
ensureConversation(conversationId!);
|
|
1872
|
+
|
|
1873
|
+
// Create another pending run in this conversation
|
|
1874
|
+
const run2 = createRun(conversationId!);
|
|
1875
|
+
setRunConfirmation(run2.id, sampleConfirmation);
|
|
1876
|
+
|
|
1877
|
+
deliverSpy.mockClear();
|
|
1878
|
+
|
|
1879
|
+
// Try to self-approve
|
|
1880
|
+
const approveReq = makeInboundRequest({
|
|
1881
|
+
content: 'approve',
|
|
1882
|
+
senderExternalUserId: 'user-no-binding',
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
const res = await handleChannelInbound(approveReq, noopProcessMessage, 'token', orchestrator);
|
|
1886
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1887
|
+
|
|
1888
|
+
expect(body.accepted).toBe(true);
|
|
1889
|
+
expect(body.approval).toBe('decision_applied');
|
|
1890
|
+
|
|
1891
|
+
// The denial notice should have been sent
|
|
1892
|
+
const denialCalls = deliverSpy.mock.calls.filter(
|
|
1893
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
|
|
1894
|
+
);
|
|
1895
|
+
expect(denialCalls.length).toBeGreaterThanOrEqual(1);
|
|
1896
|
+
|
|
1897
|
+
deliverSpy.mockRestore();
|
|
1898
|
+
});
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1902
|
+
// 19. Guardian-with-binding path regression
|
|
1903
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1904
|
+
|
|
1905
|
+
describe('guardian-with-binding path regression', () => {
|
|
1906
|
+
beforeEach(() => {
|
|
1907
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
test('non-guardian with binding routes approval to guardian chat', async () => {
|
|
1911
|
+
createBinding({
|
|
1912
|
+
assistantId: 'self',
|
|
1913
|
+
channel: 'telegram',
|
|
1914
|
+
guardianExternalUserId: 'guardian-user-1',
|
|
1915
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1919
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1920
|
+
|
|
1921
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-binding-1', terminalStatus: 'completed' });
|
|
1922
|
+
|
|
1923
|
+
const req = makeInboundRequest({
|
|
1924
|
+
content: 'do something dangerous',
|
|
1925
|
+
senderExternalUserId: 'non-guardian-user',
|
|
1926
|
+
senderUsername: 'nongrd',
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1930
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
1931
|
+
|
|
1932
|
+
// Approval prompt should have been sent to the guardian's chat
|
|
1933
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
1934
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
1935
|
+
expect(approvalArgs[1]).toBe('guardian-chat-1');
|
|
1936
|
+
|
|
1937
|
+
// Requester should have been notified the request was sent to the guardian
|
|
1938
|
+
const notifyCalls = deliverSpy.mock.calls.filter(
|
|
1939
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
|
|
1940
|
+
);
|
|
1941
|
+
expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
|
|
1942
|
+
|
|
1943
|
+
deliverSpy.mockRestore();
|
|
1944
|
+
approvalSpy.mockRestore();
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
test('guardian sender gets standard self-approval flow', async () => {
|
|
1948
|
+
createBinding({
|
|
1949
|
+
assistantId: 'self',
|
|
1950
|
+
channel: 'telegram',
|
|
1951
|
+
guardianExternalUserId: 'guardian-user-2',
|
|
1952
|
+
guardianDeliveryChatId: 'guardian-chat-2',
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1956
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1957
|
+
|
|
1958
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-binding-2', terminalStatus: 'completed' });
|
|
1959
|
+
|
|
1960
|
+
// Message from the guardian user — should get standard approval prompt
|
|
1961
|
+
const req = makeInboundRequest({
|
|
1962
|
+
content: 'do something dangerous',
|
|
1963
|
+
senderExternalUserId: 'guardian-user-2',
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1967
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
1968
|
+
|
|
1969
|
+
// Approval prompt should have been sent to the requester's own chat
|
|
1970
|
+
// (standard self-approval flow, not routed to guardian)
|
|
1971
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
1972
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
1973
|
+
// The chat ID should be the sender's own chat, not guardian-chat-2
|
|
1974
|
+
expect(approvalArgs[1]).toBe('chat-123');
|
|
1975
|
+
|
|
1976
|
+
deliverSpy.mockRestore();
|
|
1977
|
+
approvalSpy.mockRestore();
|
|
1978
|
+
});
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1982
|
+
// 20. Guardian delivery failure denial (WS-2)
|
|
1983
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1984
|
+
|
|
1985
|
+
describe('guardian delivery failure → denial', () => {
|
|
1986
|
+
beforeEach(() => {
|
|
1987
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
test('delivery failure denies run and notifies requester', async () => {
|
|
1991
|
+
createBinding({
|
|
1992
|
+
assistantId: 'self',
|
|
1993
|
+
channel: 'telegram',
|
|
1994
|
+
guardianExternalUserId: 'guardian-user-df',
|
|
1995
|
+
guardianDeliveryChatId: 'guardian-chat-df',
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1999
|
+
// Make the guardian approval prompt delivery fail
|
|
2000
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
|
|
2001
|
+
new Error('Network error: guardian unreachable'),
|
|
2002
|
+
);
|
|
2003
|
+
|
|
2004
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-df-1', terminalStatus: 'failed' });
|
|
2005
|
+
|
|
2006
|
+
const req = makeInboundRequest({
|
|
2007
|
+
content: 'do something dangerous',
|
|
2008
|
+
senderExternalUserId: 'non-guardian-df-user',
|
|
2009
|
+
senderUsername: 'nongrd_df',
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2013
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2014
|
+
|
|
2015
|
+
// The run should have been denied
|
|
2016
|
+
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2017
|
+
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
2018
|
+
expect(decisionArgs[1]).toBe('deny');
|
|
2019
|
+
|
|
2020
|
+
// Requester should have been notified that delivery failed
|
|
2021
|
+
const failureCalls = deliverSpy.mock.calls.filter(
|
|
2022
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('could not be sent to the guardian for approval'),
|
|
2023
|
+
);
|
|
2024
|
+
expect(failureCalls.length).toBeGreaterThanOrEqual(1);
|
|
2025
|
+
|
|
2026
|
+
// The "has been sent to the guardian for approval" success notice should
|
|
2027
|
+
// NOT have been delivered (since delivery failed).
|
|
2028
|
+
const successCalls = deliverSpy.mock.calls.filter(
|
|
2029
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
|
|
2030
|
+
);
|
|
2031
|
+
expect(successCalls.length).toBe(0);
|
|
2032
|
+
|
|
2033
|
+
deliverSpy.mockRestore();
|
|
2034
|
+
approvalSpy.mockRestore();
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
test('no pending/unresolved approvals remain after delivery failure', async () => {
|
|
2038
|
+
createBinding({
|
|
2039
|
+
assistantId: 'self',
|
|
2040
|
+
channel: 'telegram',
|
|
2041
|
+
guardianExternalUserId: 'guardian-user-df2',
|
|
2042
|
+
guardianDeliveryChatId: 'guardian-chat-df2',
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2046
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
|
|
2047
|
+
new Error('Network error: guardian unreachable'),
|
|
2048
|
+
);
|
|
2049
|
+
|
|
2050
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-df-2', terminalStatus: 'failed' });
|
|
2051
|
+
|
|
2052
|
+
const req = makeInboundRequest({
|
|
2053
|
+
content: 'do something dangerous',
|
|
2054
|
+
senderExternalUserId: 'non-guardian-df2-user',
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2058
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2059
|
+
|
|
2060
|
+
// Verify the run ID was created
|
|
2061
|
+
const runId = orchestrator.realRunId();
|
|
2062
|
+
expect(runId).toBeTruthy();
|
|
2063
|
+
|
|
2064
|
+
// After delivery failure, there should be NO pending approval for the run
|
|
2065
|
+
const pendingApproval = getPendingApprovalForRun(runId!);
|
|
2066
|
+
expect(pendingApproval).toBeNull();
|
|
2067
|
+
|
|
2068
|
+
// There should also be NO unresolved approval (it was set to 'denied')
|
|
2069
|
+
const unresolvedApproval = getUnresolvedApprovalForRun(runId!);
|
|
2070
|
+
expect(unresolvedApproval).toBeNull();
|
|
2071
|
+
|
|
2072
|
+
deliverSpy.mockRestore();
|
|
2073
|
+
approvalSpy.mockRestore();
|
|
2074
|
+
});
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2078
|
+
// 21. Guardian decision scoping — callback for older run resolves correctly
|
|
2079
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2080
|
+
|
|
2081
|
+
describe('guardian decision scoping — multiple pending approvals', () => {
|
|
2082
|
+
beforeEach(() => {
|
|
2083
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
test('callback for older run resolves to the correct approval request', async () => {
|
|
2087
|
+
// Set up a guardian binding so the guardian actor role is recognized
|
|
2088
|
+
createBinding({
|
|
2089
|
+
assistantId: 'self',
|
|
2090
|
+
channel: 'telegram',
|
|
2091
|
+
guardianExternalUserId: 'guardian-scope-user',
|
|
2092
|
+
guardianDeliveryChatId: 'guardian-scope-chat',
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2096
|
+
|
|
2097
|
+
// Create two approval requests for different runs, both targeting the
|
|
2098
|
+
// same guardian chat. The older run (run-older) was created first.
|
|
2099
|
+
const olderConvId = 'conv-scope-older';
|
|
2100
|
+
const newerConvId = 'conv-scope-newer';
|
|
2101
|
+
ensureConversation(olderConvId);
|
|
2102
|
+
ensureConversation(newerConvId);
|
|
2103
|
+
|
|
2104
|
+
const olderRun = createRun(olderConvId);
|
|
2105
|
+
setRunConfirmation(olderRun.id, sampleConfirmation);
|
|
2106
|
+
createApprovalRequest({
|
|
2107
|
+
runId: olderRun.id,
|
|
2108
|
+
conversationId: olderConvId,
|
|
2109
|
+
channel: 'telegram',
|
|
2110
|
+
requesterExternalUserId: 'requester-a',
|
|
2111
|
+
requesterChatId: 'chat-requester-a',
|
|
2112
|
+
guardianExternalUserId: 'guardian-scope-user',
|
|
2113
|
+
guardianChatId: 'guardian-scope-chat',
|
|
2114
|
+
toolName: 'shell',
|
|
2115
|
+
expiresAt: Date.now() + 300_000,
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
const newerRun = createRun(newerConvId);
|
|
2119
|
+
setRunConfirmation(newerRun.id, sampleConfirmation);
|
|
2120
|
+
createApprovalRequest({
|
|
2121
|
+
runId: newerRun.id,
|
|
2122
|
+
conversationId: newerConvId,
|
|
2123
|
+
channel: 'telegram',
|
|
2124
|
+
requesterExternalUserId: 'requester-b',
|
|
2125
|
+
requesterChatId: 'chat-requester-b',
|
|
2126
|
+
guardianExternalUserId: 'guardian-scope-user',
|
|
2127
|
+
guardianChatId: 'guardian-scope-chat',
|
|
2128
|
+
toolName: 'browser',
|
|
2129
|
+
expiresAt: Date.now() + 300_000,
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
const orchestrator = makeMockOrchestrator();
|
|
2133
|
+
|
|
2134
|
+
// The guardian clicks the approval button for the OLDER run
|
|
2135
|
+
const req = makeInboundRequest({
|
|
2136
|
+
content: '',
|
|
2137
|
+
externalChatId: 'guardian-scope-chat',
|
|
2138
|
+
callbackData: `apr:${olderRun.id}:approve_once`,
|
|
2139
|
+
senderExternalUserId: 'guardian-scope-user',
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2143
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2144
|
+
|
|
2145
|
+
expect(body.accepted).toBe(true);
|
|
2146
|
+
expect(body.approval).toBe('guardian_decision_applied');
|
|
2147
|
+
|
|
2148
|
+
// The older run's approval should have been resolved
|
|
2149
|
+
const olderApproval = getPendingApprovalForRun(olderRun.id);
|
|
2150
|
+
expect(olderApproval).toBeNull();
|
|
2151
|
+
|
|
2152
|
+
// The newer run's approval should still be pending (untouched)
|
|
2153
|
+
const newerApproval = getPendingApprovalForRun(newerRun.id);
|
|
2154
|
+
expect(newerApproval).not.toBeNull();
|
|
2155
|
+
expect(newerApproval!.status).toBe('pending');
|
|
2156
|
+
|
|
2157
|
+
// Verify the decision was applied to the correct (older) run's conversation
|
|
2158
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(olderRun.id, 'allow');
|
|
2159
|
+
|
|
2160
|
+
deliverSpy.mockRestore();
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2165
|
+
// 22. Ambiguous plain-text decision with multiple pending requests
|
|
2166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2167
|
+
|
|
2168
|
+
describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
2169
|
+
beforeEach(() => {
|
|
2170
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
test('does not apply plain-text decision to wrong run when multiple pending', async () => {
|
|
2174
|
+
createBinding({
|
|
2175
|
+
assistantId: 'self',
|
|
2176
|
+
channel: 'telegram',
|
|
2177
|
+
guardianExternalUserId: 'guardian-ambig-user',
|
|
2178
|
+
guardianDeliveryChatId: 'guardian-ambig-chat',
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2182
|
+
|
|
2183
|
+
// Create two pending approval requests targeting the same guardian chat
|
|
2184
|
+
const convA = 'conv-ambig-a';
|
|
2185
|
+
const convB = 'conv-ambig-b';
|
|
2186
|
+
ensureConversation(convA);
|
|
2187
|
+
ensureConversation(convB);
|
|
2188
|
+
|
|
2189
|
+
const runA = createRun(convA);
|
|
2190
|
+
setRunConfirmation(runA.id, sampleConfirmation);
|
|
2191
|
+
createApprovalRequest({
|
|
2192
|
+
runId: runA.id,
|
|
2193
|
+
conversationId: convA,
|
|
2194
|
+
channel: 'telegram',
|
|
2195
|
+
requesterExternalUserId: 'requester-x',
|
|
2196
|
+
requesterChatId: 'chat-requester-x',
|
|
2197
|
+
guardianExternalUserId: 'guardian-ambig-user',
|
|
2198
|
+
guardianChatId: 'guardian-ambig-chat',
|
|
2199
|
+
toolName: 'shell',
|
|
2200
|
+
expiresAt: Date.now() + 300_000,
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
const runB = createRun(convB);
|
|
2204
|
+
setRunConfirmation(runB.id, sampleConfirmation);
|
|
2205
|
+
createApprovalRequest({
|
|
2206
|
+
runId: runB.id,
|
|
2207
|
+
conversationId: convB,
|
|
2208
|
+
channel: 'telegram',
|
|
2209
|
+
requesterExternalUserId: 'requester-y',
|
|
2210
|
+
requesterChatId: 'chat-requester-y',
|
|
2211
|
+
guardianExternalUserId: 'guardian-ambig-user',
|
|
2212
|
+
guardianChatId: 'guardian-ambig-chat',
|
|
2213
|
+
toolName: 'browser',
|
|
2214
|
+
expiresAt: Date.now() + 300_000,
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
const orchestrator = makeMockOrchestrator();
|
|
2218
|
+
|
|
2219
|
+
// Guardian sends plain-text "yes" — ambiguous because two approvals are pending
|
|
2220
|
+
const req = makeInboundRequest({
|
|
2221
|
+
content: 'yes',
|
|
2222
|
+
externalChatId: 'guardian-ambig-chat',
|
|
2223
|
+
senderExternalUserId: 'guardian-ambig-user',
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2227
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2228
|
+
|
|
2229
|
+
expect(body.accepted).toBe(true);
|
|
2230
|
+
expect(body.approval).toBe('guardian_decision_applied');
|
|
2231
|
+
|
|
2232
|
+
// Neither approval should have been resolved — disambiguation was required
|
|
2233
|
+
const approvalA = getPendingApprovalForRun(runA.id);
|
|
2234
|
+
const approvalB = getPendingApprovalForRun(runB.id);
|
|
2235
|
+
expect(approvalA).not.toBeNull();
|
|
2236
|
+
expect(approvalB).not.toBeNull();
|
|
2237
|
+
|
|
2238
|
+
// submitDecision should NOT have been called — no decision was applied
|
|
2239
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2240
|
+
|
|
2241
|
+
// A disambiguation message should have been sent to the guardian
|
|
2242
|
+
const disambigCalls = deliverSpy.mock.calls.filter(
|
|
2243
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending approval requests'),
|
|
2244
|
+
);
|
|
2245
|
+
expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
|
|
2246
|
+
|
|
2247
|
+
deliverSpy.mockRestore();
|
|
2248
|
+
});
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2252
|
+
// 23. Expired guardian approval auto-denies and transitions to terminal status
|
|
2253
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2254
|
+
|
|
2255
|
+
describe('expired guardian approval auto-denies via sweep', () => {
|
|
2256
|
+
beforeEach(() => {
|
|
2257
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
|
|
2261
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2262
|
+
|
|
2263
|
+
// Create a guardian approval that is already expired
|
|
2264
|
+
const convId = 'conv-expiry-sweep';
|
|
2265
|
+
ensureConversation(convId);
|
|
2266
|
+
|
|
2267
|
+
const run = createRun(convId);
|
|
2268
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2269
|
+
|
|
2270
|
+
createApprovalRequest({
|
|
2271
|
+
runId: run.id,
|
|
2272
|
+
conversationId: convId,
|
|
2273
|
+
channel: 'telegram',
|
|
2274
|
+
requesterExternalUserId: 'requester-exp',
|
|
2275
|
+
requesterChatId: 'chat-requester-exp',
|
|
2276
|
+
guardianExternalUserId: 'guardian-exp-user',
|
|
2277
|
+
guardianChatId: 'guardian-exp-chat',
|
|
2278
|
+
toolName: 'shell',
|
|
2279
|
+
expiresAt: Date.now() - 1000, // already expired
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
const orchestrator = makeMockOrchestrator();
|
|
2283
|
+
|
|
2284
|
+
// Run the sweep
|
|
2285
|
+
sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test/deliver', 'token');
|
|
2286
|
+
|
|
2287
|
+
// Wait for async notifications
|
|
2288
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2289
|
+
|
|
2290
|
+
// The run should have been denied
|
|
2291
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
2292
|
+
|
|
2293
|
+
// The approval should no longer be pending
|
|
2294
|
+
const pendingApproval = getPendingApprovalForRun(run.id);
|
|
2295
|
+
expect(pendingApproval).toBeNull();
|
|
2296
|
+
|
|
2297
|
+
// There should be no unresolved approval — it was set to 'expired'
|
|
2298
|
+
const unresolvedApproval = getUnresolvedApprovalForRun(run.id);
|
|
2299
|
+
expect(unresolvedApproval).toBeNull();
|
|
2300
|
+
|
|
2301
|
+
// Both requester and guardian should have been notified
|
|
2302
|
+
const requesterNotify = deliverSpy.mock.calls.filter(
|
|
2303
|
+
(call) => typeof call[1] === 'object' &&
|
|
2304
|
+
(call[1] as { chatId?: string }).chatId === 'chat-requester-exp' &&
|
|
2305
|
+
(call[1] as { text?: string }).text?.includes('expired'),
|
|
2306
|
+
);
|
|
2307
|
+
expect(requesterNotify.length).toBeGreaterThanOrEqual(1);
|
|
2308
|
+
|
|
2309
|
+
const guardianNotify = deliverSpy.mock.calls.filter(
|
|
2310
|
+
(call) => typeof call[1] === 'object' &&
|
|
2311
|
+
(call[1] as { chatId?: string }).chatId === 'guardian-exp-chat' &&
|
|
2312
|
+
(call[1] as { text?: string }).text?.includes('expired'),
|
|
2313
|
+
);
|
|
2314
|
+
expect(guardianNotify.length).toBeGreaterThanOrEqual(1);
|
|
2315
|
+
|
|
2316
|
+
deliverSpy.mockRestore();
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
test('non-expired approvals are not affected by the sweep', async () => {
|
|
2320
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2321
|
+
|
|
2322
|
+
const convId = 'conv-not-expired';
|
|
2323
|
+
ensureConversation(convId);
|
|
2324
|
+
|
|
2325
|
+
const run = createRun(convId);
|
|
2326
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2327
|
+
|
|
2328
|
+
createApprovalRequest({
|
|
2329
|
+
runId: run.id,
|
|
2330
|
+
conversationId: convId,
|
|
2331
|
+
channel: 'telegram',
|
|
2332
|
+
requesterExternalUserId: 'requester-ne',
|
|
2333
|
+
requesterChatId: 'chat-requester-ne',
|
|
2334
|
+
guardianExternalUserId: 'guardian-ne-user',
|
|
2335
|
+
guardianChatId: 'guardian-ne-chat',
|
|
2336
|
+
toolName: 'shell',
|
|
2337
|
+
expiresAt: Date.now() + 300_000, // still valid
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
const orchestrator = makeMockOrchestrator();
|
|
2341
|
+
|
|
2342
|
+
sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test/deliver', 'token');
|
|
2343
|
+
|
|
2344
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2345
|
+
|
|
2346
|
+
// The approval should still be pending
|
|
2347
|
+
const pendingApproval = getPendingApprovalForRun(run.id);
|
|
2348
|
+
expect(pendingApproval).not.toBeNull();
|
|
2349
|
+
expect(pendingApproval!.status).toBe('pending');
|
|
2350
|
+
|
|
2351
|
+
// submitDecision should NOT have been called
|
|
2352
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2353
|
+
|
|
2354
|
+
deliverSpy.mockRestore();
|
|
2355
|
+
});
|
|
2356
|
+
});
|