@vellumai/assistant 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +2 -0
- package/README.md +88 -2
- package/eslint.config.mjs +31 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
- package/scripts/ipc/generate-swift.ts +31 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +438 -1
- package/src/__tests__/approval-conversation-turn.test.ts +214 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/browser-manager.test.ts +1 -0
- package/src/__tests__/call-conversation-messages.test.ts +130 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +799 -249
- package/src/__tests__/call-pointer-messages.test.ts +148 -0
- package/src/__tests__/call-recovery.test.ts +3 -0
- package/src/__tests__/call-routes-http.test.ts +32 -2
- package/src/__tests__/call-store.test.ts +3 -0
- package/src/__tests__/channel-approval-routes.test.ts +1277 -98
- package/src/__tests__/channel-approval.test.ts +37 -0
- package/src/__tests__/channel-approvals.test.ts +36 -50
- package/src/__tests__/channel-guardian.test.ts +630 -22
- package/src/__tests__/channel-readiness-service.test.ts +324 -0
- package/src/__tests__/checker.test.ts +14 -7
- package/src/__tests__/clarification-resolver.test.ts +44 -24
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
- package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
- package/src/__tests__/config-schema.test.ts +14 -8
- package/src/__tests__/context-window-manager.test.ts +30 -2
- package/src/__tests__/contradiction-checker.test.ts +20 -5
- package/src/__tests__/credential-security-invariants.test.ts +7 -2
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/db-migration-rollback.test.ts +752 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/fuzzy-match-property.test.ts +5 -5
- package/src/__tests__/guardian-action-store.test.ts +123 -0
- package/src/__tests__/guardian-action-sweep.test.ts +277 -0
- package/src/__tests__/guardian-dispatch.test.ts +389 -0
- package/src/__tests__/guardian-question-copy.test.ts +47 -0
- package/src/__tests__/handlers-telegram-config.test.ts +4 -2
- package/src/__tests__/handlers-twilio-config.test.ts +533 -0
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +291 -1
- package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/model-intents.test.ts +96 -0
- package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
- package/src/__tests__/provider-error-scenarios.test.ts +621 -0
- package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
- package/src/__tests__/qdrant-manager.test.ts +27 -20
- package/src/__tests__/relay-server.test.ts +779 -40
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +6 -0
- package/src/__tests__/run-orchestrator.test.ts +42 -4
- package/src/__tests__/runtime-runs-http.test.ts +17 -1
- package/src/__tests__/runtime-runs.test.ts +16 -0
- package/src/__tests__/schedule-store.test.ts +18 -4
- package/src/__tests__/scheduler-recurrence.test.ts +13 -4
- package/src/__tests__/session-abort-tool-results.test.ts +6 -0
- package/src/__tests__/session-agent-loop.test.ts +857 -0
- package/src/__tests__/session-conflict-gate.test.ts +6 -0
- package/src/__tests__/session-pre-run-repair.test.ts +6 -0
- package/src/__tests__/session-profile-injection.test.ts +6 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
- package/src/__tests__/session-queue.test.ts +6 -0
- package/src/__tests__/session-runtime-assembly.test.ts +321 -13
- package/src/__tests__/session-slash-known.test.ts +6 -0
- package/src/__tests__/session-slash-queue.test.ts +6 -0
- package/src/__tests__/session-slash-unknown.test.ts +6 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
- package/src/__tests__/session-workspace-injection.test.ts +6 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
- package/src/__tests__/skills.test.ts +2 -0
- package/src/__tests__/sms-messaging-provider.test.ts +126 -0
- package/src/__tests__/starter-task-flow.test.ts +2 -0
- package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
- package/src/__tests__/system-prompt.test.ts +2 -0
- package/src/__tests__/task-management-tools.test.ts +2 -2
- package/src/__tests__/task-runner.test.ts +14 -4
- package/src/__tests__/terminal-tools.test.ts +25 -19
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
- package/src/__tests__/tool-executor.test.ts +23 -24
- package/src/__tests__/trust-store.test.ts +3 -3
- package/src/__tests__/twilio-rest.test.ts +29 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
- package/src/__tests__/twilio-routes.test.ts +167 -11
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +2 -0
- package/src/__tests__/voice-quality.test.ts +222 -0
- package/src/__tests__/web-search.test.ts +46 -30
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/agent/loop.ts +1 -1
- package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
- package/src/amazon/client.ts +1418 -0
- package/src/amazon/request-extractor.ts +135 -0
- package/src/amazon/session.ts +109 -0
- package/src/autonomy/autonomy-store.ts +5 -5
- package/src/browser-extension-relay/client.ts +124 -0
- package/src/browser-extension-relay/protocol.ts +63 -0
- package/src/browser-extension-relay/server.ts +177 -0
- package/src/bundler/app-bundler.ts +3 -3
- package/src/bundler/bundle-signer.ts +1 -1
- package/src/bundler/signature-verifier.ts +1 -1
- package/src/calls/call-conversation-messages.ts +33 -0
- package/src/calls/call-domain.ts +114 -10
- package/src/calls/call-orchestrator.ts +268 -59
- package/src/calls/call-pointer-messages.ts +53 -0
- package/src/calls/call-recovery.ts +3 -8
- package/src/calls/call-store.ts +69 -87
- package/src/calls/elevenlabs-config.ts +3 -2
- package/src/calls/guardian-action-sweep.ts +105 -0
- package/src/calls/guardian-dispatch.ts +203 -0
- package/src/calls/guardian-question-copy.ts +133 -0
- package/src/calls/relay-server.ts +466 -8
- package/src/calls/speaker-identification.ts +1 -1
- package/src/calls/twilio-config.ts +22 -14
- package/src/calls/twilio-provider.ts +6 -4
- package/src/calls/twilio-rest.ts +308 -7
- package/src/calls/twilio-routes.ts +65 -12
- package/src/calls/types.ts +3 -1
- package/src/channels/types.ts +25 -0
- package/src/cli/amazon.ts +815 -0
- package/src/cli/config-commands.ts +2 -2
- package/src/cli/core-commands.ts +4 -3
- package/src/cli/influencer.ts +244 -0
- package/src/cli/map.ts +89 -6
- package/src/cli.ts +1 -1
- package/src/config/agent-schema.ts +171 -0
- package/src/config/bundled-skills/amazon/SKILL.md +127 -0
- package/src/config/bundled-skills/amazon/icon.svg +13 -0
- package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
- package/src/config/bundled-skills/browser/SKILL.md +1 -0
- package/src/config/bundled-skills/browser/TOOLS.json +17 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
- package/src/config/bundled-skills/doordash/SKILL.md +51 -51
- package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
- package/src/config/bundled-skills/influencer/SKILL.md +144 -0
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +176 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +230 -0
- package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
- package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
- package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
- package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
- package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +259 -0
- package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +136 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +59 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +143 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +65 -0
- package/src/config/bundled-skills/messaging/SKILL.md +33 -8
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +88 -23
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/bundled-skills/twitter/icon.svg +14 -0
- package/src/config/bundled-tool-registry.ts +310 -0
- package/src/config/calls-schema.ts +181 -0
- package/src/config/core-schema.ts +309 -0
- package/src/config/defaults.ts +28 -3
- package/src/config/env-registry.ts +162 -0
- package/src/config/env.ts +175 -0
- package/src/config/loader.ts +6 -6
- package/src/config/memory-schema.ts +528 -0
- package/src/config/sandbox-schema.ts +55 -0
- package/src/config/schema.ts +158 -1133
- package/src/config/skill-state.ts +1 -1
- package/src/config/skills-schema.ts +32 -0
- package/src/config/skills.ts +35 -24
- package/src/config/system-prompt.ts +131 -56
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/user-reference.ts +4 -9
- package/src/config/vellum-skills/catalog.json +6 -7
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +216 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/context/window-manager.ts +27 -7
- package/src/daemon/approval-generators.ts +186 -0
- package/src/daemon/approved-devices-store.ts +140 -0
- package/src/daemon/assistant-attachments.ts +1 -1
- package/src/daemon/classifier.ts +35 -32
- package/src/daemon/config-watcher.ts +1 -1
- package/src/daemon/daemon-control.ts +217 -0
- package/src/daemon/handlers/apps.ts +2 -3
- package/src/daemon/handlers/config-channels.ts +158 -0
- package/src/daemon/handlers/config-inbox.ts +540 -0
- package/src/daemon/handlers/config-ingress.ts +231 -0
- package/src/daemon/handlers/config-integrations.ts +258 -0
- package/src/daemon/handlers/config-model.ts +143 -0
- package/src/daemon/handlers/config-parental.ts +163 -0
- package/src/daemon/handlers/config-scheduling.ts +172 -0
- package/src/daemon/handlers/config-slack.ts +92 -0
- package/src/daemon/handlers/config-telegram.ts +301 -0
- package/src/daemon/handlers/config-tools.ts +177 -0
- package/src/daemon/handlers/config-trust.ts +104 -0
- package/src/daemon/handlers/config-twilio.ts +1080 -0
- package/src/daemon/handlers/config.ts +53 -1689
- package/src/daemon/handlers/diagnostics.ts +1 -1
- package/src/daemon/handlers/dictation.ts +180 -0
- package/src/daemon/handlers/documents.ts +18 -32
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +11 -0
- package/src/daemon/handlers/misc.ts +3 -5
- package/src/daemon/handlers/pairing.ts +98 -0
- package/src/daemon/handlers/sessions.ts +56 -5
- package/src/daemon/handlers/shared.ts +6 -1
- package/src/daemon/handlers/skills.ts +1 -1
- package/src/daemon/handlers/twitter-auth.ts +2 -0
- package/src/daemon/handlers/work-items.ts +17 -9
- package/src/daemon/handlers/workspace-files.ts +4 -3
- package/src/daemon/install-cli-launchers.ts +113 -0
- package/src/daemon/ipc-contract/apps.ts +356 -0
- package/src/daemon/ipc-contract/browser.ts +74 -0
- package/src/daemon/ipc-contract/computer-use.ts +151 -0
- package/src/daemon/ipc-contract/diagnostics.ts +56 -0
- package/src/daemon/ipc-contract/documents.ts +74 -0
- package/src/daemon/ipc-contract/inbox.ts +209 -0
- package/src/daemon/ipc-contract/integrations.ts +284 -0
- package/src/daemon/ipc-contract/memory.ts +48 -0
- package/src/daemon/ipc-contract/messages.ts +211 -0
- package/src/daemon/ipc-contract/pairing.ts +45 -0
- package/src/daemon/ipc-contract/parental-control.ts +95 -0
- package/src/daemon/ipc-contract/schedules.ts +97 -0
- package/src/daemon/ipc-contract/sessions.ts +315 -0
- package/src/daemon/ipc-contract/shared.ts +42 -0
- package/src/daemon/ipc-contract/skills.ts +120 -0
- package/src/daemon/ipc-contract/subagents.ts +58 -0
- package/src/daemon/ipc-contract/surfaces.ts +250 -0
- package/src/daemon/ipc-contract/trust.ts +60 -0
- package/src/daemon/ipc-contract/work-items.ts +225 -0
- package/src/daemon/ipc-contract/workspace.ts +113 -0
- package/src/daemon/ipc-contract-inventory.json +70 -0
- package/src/daemon/ipc-contract-inventory.ts +55 -29
- package/src/daemon/ipc-contract.ts +229 -2426
- package/src/daemon/ipc-protocol.ts +1 -1
- package/src/daemon/ipc-validate.ts +7 -0
- package/src/daemon/lifecycle.ts +97 -377
- package/src/daemon/pairing-store.ts +177 -0
- package/src/daemon/providers-setup.ts +43 -0
- package/src/daemon/ride-shotgun-handler.ts +68 -3
- package/src/daemon/server.ts +66 -46
- package/src/daemon/session-agent-loop-handlers.ts +421 -0
- package/src/daemon/session-agent-loop.ts +117 -275
- package/src/daemon/session-dynamic-profile.ts +1 -1
- package/src/daemon/session-history.ts +1 -1
- package/src/daemon/session-media-retry.ts +1 -1
- package/src/daemon/session-messaging.ts +37 -2
- package/src/daemon/session-notifiers.ts +5 -25
- package/src/daemon/session-process.ts +99 -59
- package/src/daemon/session-queue-manager.ts +96 -4
- package/src/daemon/session-runtime-assembly.ts +199 -10
- package/src/daemon/session-surfaces.ts +19 -4
- package/src/daemon/session-tool-setup.ts +30 -30
- package/src/daemon/session-workspace.ts +1 -1
- package/src/daemon/session.ts +35 -2
- package/src/daemon/shutdown-handlers.ts +122 -0
- package/src/daemon/trace-emitter.ts +1 -1
- package/src/daemon/watch-handler.ts +36 -33
- package/src/doordash/cart-queries.ts +787 -0
- package/src/doordash/client.ts +144 -127
- package/src/doordash/order-queries.ts +85 -0
- package/src/doordash/queries.ts +10 -1308
- package/src/doordash/search-queries.ts +203 -0
- package/src/doordash/session.ts +3 -2
- package/src/doordash/store-queries.ts +246 -0
- package/src/doordash/types.ts +367 -0
- package/src/email/providers/agentmail.ts +2 -1
- package/src/email/providers/index.ts +3 -2
- package/src/email/service.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/home-base/prebuilt/seed.ts +1 -1
- package/src/hooks/cli.ts +6 -5
- package/src/hooks/config.ts +6 -8
- package/src/hooks/discovery.ts +6 -5
- package/src/hooks/manager.ts +4 -3
- package/src/hooks/runner.ts +2 -2
- package/src/hooks/templates.ts +5 -5
- package/src/inbound/public-ingress-urls.ts +6 -4
- package/src/index.ts +4 -2
- package/src/influencer/client.ts +1104 -0
- package/src/instrument.ts +4 -3
- package/src/logfire.ts +4 -3
- package/src/memory/admin.ts +25 -35
- package/src/memory/attachments-store.ts +4 -7
- package/src/memory/channel-delivery-store.ts +30 -1
- package/src/memory/channel-guardian-store.ts +202 -2
- package/src/memory/clarification-resolver.ts +37 -33
- package/src/memory/conflict-store.ts +67 -61
- package/src/memory/contradiction-checker.ts +141 -117
- package/src/memory/conversation-store.ts +335 -51
- package/src/memory/db-connection.ts +27 -4
- package/src/memory/db-init.ts +265 -4
- package/src/memory/db.ts +14 -1
- package/src/memory/embedding-backend.ts +27 -5
- package/src/memory/embedding-ollama.ts +2 -1
- package/src/memory/entity-extractor.ts +38 -35
- package/src/memory/guardian-action-store.ts +430 -0
- package/src/memory/inbox-escalation-projection.ts +59 -0
- package/src/memory/inbox-thread-store.ts +218 -0
- package/src/memory/ingress-invite-store.ts +338 -0
- package/src/memory/ingress-member-store.ts +350 -0
- package/src/memory/items-extractor.ts +91 -97
- package/src/memory/job-handlers/index-maintenance.ts +3 -3
- package/src/memory/job-handlers/media-processing.ts +69 -0
- package/src/memory/job-handlers/summarization.ts +32 -26
- package/src/memory/job-utils.ts +3 -10
- package/src/memory/jobs-store.ts +8 -10
- package/src/memory/jobs-worker.ts +55 -36
- package/src/memory/media-store.ts +759 -0
- package/src/memory/migrations/001-job-deferrals.ts +45 -0
- package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
- package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
- package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
- package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
- package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
- package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
- package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
- package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
- package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
- package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
- package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
- package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
- package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
- package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
- package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
- package/src/memory/migrations/017-memory-items-indexes.ts +10 -0
- package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
- package/src/memory/migrations/index.ts +24 -0
- package/src/memory/migrations/registry.ts +79 -0
- package/src/memory/migrations/validate-migration-state.ts +69 -0
- package/src/memory/qdrant-manager.ts +49 -8
- package/src/memory/query-builder.ts +1 -1
- package/src/memory/raw-query.ts +119 -0
- package/src/memory/recall-cache.ts +4 -1
- package/src/memory/retriever.ts +165 -47
- package/src/memory/schema-migration.ts +25 -984
- package/src/memory/schema.ts +228 -7
- package/src/memory/search/entity.ts +205 -31
- package/src/memory/search/lexical.ts +81 -52
- package/src/memory/search/ranking.ts +27 -23
- package/src/memory/search/semantic.ts +157 -19
- package/src/memory/search/types.ts +24 -0
- package/src/memory/shared-app-links-store.ts +4 -5
- package/src/memory/validation.ts +19 -0
- package/src/messaging/draft-store.ts +5 -6
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +201 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
- package/src/messaging/providers/whatsapp/adapter.ts +136 -0
- package/src/messaging/providers/whatsapp/client.ts +67 -0
- package/src/messaging/style-analyzer.ts +5 -4
- package/src/messaging/thread-summarizer.ts +61 -69
- package/src/messaging/triage-engine.ts +62 -71
- package/src/migrations/config-merge.ts +53 -0
- package/src/migrations/data-layout.ts +68 -0
- package/src/migrations/data-merge.ts +33 -0
- package/src/migrations/hooks-merge.ts +90 -0
- package/src/migrations/index.ts +6 -0
- package/src/migrations/log.ts +23 -0
- package/src/migrations/skills-merge.ts +33 -0
- package/src/migrations/workspace-layout.ts +79 -0
- package/src/permissions/checker.ts +133 -11
- package/src/permissions/prompter.ts +14 -0
- package/src/permissions/shell-identity.ts +31 -1
- package/src/permissions/trust-store.ts +21 -1
- package/src/providers/anthropic/client.ts +4 -4
- package/src/providers/failover.ts +2 -2
- package/src/providers/model-intents.ts +70 -0
- package/src/providers/ollama/client.ts +2 -1
- package/src/providers/provider-send-message.ts +176 -0
- package/src/providers/registry.ts +71 -30
- package/src/providers/retry.ts +35 -1
- package/src/providers/types.ts +12 -1
- package/src/runtime/approval-conversation-turn.ts +97 -0
- package/src/runtime/approval-message-composer.ts +253 -0
- package/src/runtime/channel-approval-parser.ts +36 -2
- package/src/runtime/channel-approvals.ts +11 -24
- package/src/runtime/channel-guardian-service.ts +88 -21
- package/src/runtime/channel-readiness-service.ts +418 -0
- package/src/runtime/channel-readiness-types.ts +35 -0
- package/src/runtime/channel-retry-sweep.ts +184 -0
- package/src/runtime/guardian-context-resolver.ts +108 -0
- package/src/runtime/http-server.ts +275 -717
- package/src/runtime/http-types.ts +59 -3
- package/src/runtime/middleware/auth.ts +116 -0
- package/src/runtime/middleware/error-handler.ts +33 -0
- package/src/runtime/middleware/twilio-validation.ts +127 -0
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/call-routes.ts +51 -7
- package/src/runtime/routes/channel-delivery-routes.ts +170 -0
- package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
- package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
- package/src/runtime/routes/channel-route-shared.ts +144 -0
- package/src/runtime/routes/channel-routes.ts +32 -1588
- package/src/runtime/routes/conversation-routes.ts +50 -7
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/identity-routes.ts +126 -0
- package/src/runtime/routes/pairing-routes.ts +143 -0
- package/src/runtime/routes/run-routes.ts +15 -1
- package/src/runtime/run-orchestrator.ts +86 -35
- package/src/schedule/schedule-store.ts +36 -32
- package/src/schedule/scheduler.ts +3 -3
- package/src/security/encrypted-store.ts +5 -7
- package/src/security/oauth2.ts +45 -15
- package/src/security/parental-control-store.ts +183 -0
- package/src/security/secret-allowlist.ts +4 -3
- package/src/security/secret-scanner.ts +5 -5
- package/src/security/secure-keys.ts +1 -1
- package/src/security/token-manager.ts +3 -2
- package/src/services/vercel-deploy.ts +6 -2
- package/src/skills/tool-manifest.ts +3 -3
- package/src/skills/vellum-catalog-remote.ts +75 -16
- package/src/slack/slack-webhook.ts +2 -1
- package/src/swarm/orchestrator.ts +92 -1
- package/src/swarm/router-planner.ts +6 -9
- package/src/swarm/worker-prompts.ts +9 -12
- package/src/tasks/task-compiler.ts +19 -28
- package/src/tasks/task-runner.ts +1 -1
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/assets/search.ts +15 -14
- package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
- package/src/tools/browser/auto-navigate.ts +1 -0
- package/src/tools/browser/browser-execution.ts +10 -1
- package/src/tools/browser/browser-manager.ts +119 -4
- package/src/tools/browser/network-recorder.ts +5 -0
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/broker.ts +11 -2
- package/src/tools/credentials/metadata-store.ts +18 -14
- package/src/tools/credentials/post-connect-hooks.ts +61 -0
- package/src/tools/credentials/vault.ts +49 -23
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +68 -9
- package/src/tools/host-terminal/cli-discover.ts +1 -1
- package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
- package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
- package/src/tools/network/script-proxy/server.ts +1 -1
- package/src/tools/network/script-proxy/session-manager.ts +6 -5
- package/src/tools/network/web-fetch.ts +18 -2
- package/src/tools/network/web-search.ts +8 -4
- package/src/tools/reminder/reminder-store.ts +14 -15
- package/src/tools/schedule/create.ts +1 -0
- package/src/tools/schedule/list.ts +2 -1
- package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
- package/src/tools/skills/skill-script-runner.ts +24 -9
- package/src/tools/skills/skill-tool-factory.ts +1 -0
- package/src/tools/tasks/work-item-enqueue.ts +2 -2
- package/src/tools/terminal/evaluate-typescript.ts +21 -12
- package/src/tools/terminal/parser.ts +50 -0
- package/src/tools/types.ts +2 -0
- package/src/tools/watcher/delete.ts +6 -0
- package/src/tools/weather/service.ts +1 -1
- package/src/twitter/client.ts +190 -24
- package/src/twitter/router.ts +1 -1
- package/src/twitter/session.ts +4 -3
- package/src/util/clipboard.ts +1 -1
- package/src/util/errors.ts +65 -8
- package/src/util/fs.ts +40 -0
- package/src/util/json.ts +10 -0
- package/src/util/log-redact.ts +189 -0
- package/src/util/logger.ts +19 -17
- package/src/util/object.ts +3 -0
- package/src/util/platform.ts +105 -363
- package/src/util/pricing.ts +1 -1
- package/src/util/promise-guard.ts +1 -1
- package/src/util/retry.ts +19 -0
- package/src/util/row-mapper.ts +79 -0
- package/src/util/silently.ts +21 -0
- package/src/watcher/engine.ts +5 -1
- package/src/watcher/provider-types.ts +20 -0
- package/src/watcher/providers/github.ts +156 -0
- package/src/watcher/providers/gmail.ts +1 -0
- package/src/watcher/providers/google-calendar.ts +1 -0
- package/src/watcher/providers/linear.ts +460 -0
- package/src/watcher/providers/slack.ts +1 -0
- package/src/work-items/work-item-runner.ts +1 -1
- package/src/workspace/git-service.ts +1 -1
- package/src/workspace/provider-commit-message-generator.ts +51 -22
- package/src/__tests__/call-bridge.test.ts +0 -517
- package/src/__tests__/session-process-bridge.test.ts +0 -244
- package/src/calls/call-bridge.ts +0 -168
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
|
@@ -54,6 +54,7 @@ import * as conversationStore from '../memory/conversation-store.js';
|
|
|
54
54
|
import {
|
|
55
55
|
createBinding,
|
|
56
56
|
createApprovalRequest,
|
|
57
|
+
getAllPendingApprovalsByGuardianChat,
|
|
57
58
|
getPendingApprovalForRun,
|
|
58
59
|
getUnresolvedApprovalForRun,
|
|
59
60
|
} from '../memory/channel-guardian-store.js';
|
|
@@ -355,10 +356,10 @@ describe('inbound text matching approval phrases triggers decision handling', ()
|
|
|
355
356
|
});
|
|
356
357
|
|
|
357
358
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
358
|
-
// 4. Non-decision messages during pending approval
|
|
359
|
+
// 4. Non-decision messages during pending approval (no conversational engine)
|
|
359
360
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
360
361
|
|
|
361
|
-
describe('non-decision messages during pending approval
|
|
362
|
+
describe('non-decision messages during pending approval (legacy fallback)', () => {
|
|
362
363
|
beforeEach(() => {
|
|
363
364
|
createBinding({
|
|
364
365
|
assistantId: 'self',
|
|
@@ -368,9 +369,8 @@ describe('non-decision messages during pending approval trigger reminder', () =>
|
|
|
368
369
|
});
|
|
369
370
|
});
|
|
370
371
|
|
|
371
|
-
test('sends a
|
|
372
|
+
test('sends a status reply when message is not a decision and no conversational engine', async () => {
|
|
372
373
|
const orchestrator = makeMockOrchestrator();
|
|
373
|
-
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
374
374
|
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
375
375
|
|
|
376
376
|
const initReq = makeInboundRequest({ content: 'init' });
|
|
@@ -391,18 +391,19 @@ describe('non-decision messages during pending approval trigger reminder', () =>
|
|
|
391
391
|
const body = await res.json() as Record<string, unknown>;
|
|
392
392
|
|
|
393
393
|
expect(body.accepted).toBe(true);
|
|
394
|
-
expect(body.approval).toBe('
|
|
394
|
+
expect(body.approval).toBe('assistant_turn');
|
|
395
395
|
|
|
396
|
-
//
|
|
397
|
-
expect(
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
396
|
+
// A status reply should have been delivered via deliverChannelReply
|
|
397
|
+
expect(replySpy).toHaveBeenCalled();
|
|
398
|
+
const statusCall = replySpy.mock.calls.find(
|
|
399
|
+
(call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123',
|
|
400
|
+
);
|
|
401
|
+
expect(statusCall).toBeDefined();
|
|
402
|
+
const statusPayload = statusCall![1] as { text?: string };
|
|
403
|
+
// The status text is generated by composeApprovalMessageGenerative
|
|
404
|
+
// with reminder_prompt scenario — it should mention a pending approval.
|
|
405
|
+
expect(statusPayload.text).toContain('pending approval request');
|
|
404
406
|
|
|
405
|
-
deliverSpy.mockRestore();
|
|
406
407
|
replySpy.mockRestore();
|
|
407
408
|
});
|
|
408
409
|
});
|
|
@@ -1522,10 +1523,10 @@ describe('SMS channel approval decisions', () => {
|
|
|
1522
1523
|
deliverSpy.mockRestore();
|
|
1523
1524
|
});
|
|
1524
1525
|
|
|
1525
|
-
test('non-decision SMS message during pending approval
|
|
1526
|
+
test('non-decision SMS message during pending approval sends status reply', async () => {
|
|
1526
1527
|
const orchestrator = makeMockOrchestrator();
|
|
1527
|
-
const deliverSpy = spyOn(gatewayClient, '
|
|
1528
|
-
const
|
|
1528
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1529
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1529
1530
|
|
|
1530
1531
|
const initReq = makeSmsInboundRequest({ content: 'init' });
|
|
1531
1532
|
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
@@ -1543,17 +1544,23 @@ describe('SMS channel approval decisions', () => {
|
|
|
1543
1544
|
const body = await res.json() as Record<string, unknown>;
|
|
1544
1545
|
|
|
1545
1546
|
expect(body.accepted).toBe(true);
|
|
1546
|
-
expect(body.approval).toBe('
|
|
1547
|
+
expect(body.approval).toBe('assistant_turn');
|
|
1547
1548
|
|
|
1548
|
-
// SMS
|
|
1549
|
+
// SMS non-decision: status reply delivered via plain text
|
|
1549
1550
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1550
|
-
|
|
1551
|
-
const
|
|
1552
|
-
|
|
1553
|
-
|
|
1551
|
+
expect(approvalSpy).not.toHaveBeenCalled();
|
|
1552
|
+
const statusCall = deliverSpy.mock.calls.find(
|
|
1553
|
+
(call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'sms-chat-123',
|
|
1554
|
+
);
|
|
1555
|
+
expect(statusCall).toBeDefined();
|
|
1556
|
+
const statusPayload = statusCall![1] as { text?: string; approval?: unknown };
|
|
1557
|
+
const deliveredText = statusPayload.text ?? '';
|
|
1558
|
+
// Status text from composeApprovalMessageGenerative with reminder_prompt scenario
|
|
1559
|
+
expect(deliveredText).toContain('pending approval request');
|
|
1560
|
+
expect(statusPayload.approval).toBeUndefined();
|
|
1554
1561
|
|
|
1555
1562
|
deliverSpy.mockRestore();
|
|
1556
|
-
|
|
1563
|
+
approvalSpy.mockRestore();
|
|
1557
1564
|
});
|
|
1558
1565
|
|
|
1559
1566
|
test('sourceChannel "sms" is passed to orchestrator.startRun', async () => {
|
|
@@ -1635,7 +1642,9 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1635
1642
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
1636
1643
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1637
1644
|
expect(replyPayload.chatId).toBe('sms-chat-verify');
|
|
1638
|
-
expect(replyPayload.text).
|
|
1645
|
+
expect(typeof replyPayload.text).toBe('string');
|
|
1646
|
+
expect(replyPayload.text.toLowerCase()).toContain('guardian');
|
|
1647
|
+
expect(replyPayload.text.toLowerCase()).toContain('verif');
|
|
1639
1648
|
|
|
1640
1649
|
deliverSpy.mockRestore();
|
|
1641
1650
|
});
|
|
@@ -1668,7 +1677,9 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1668
1677
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1669
1678
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
1670
1679
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1671
|
-
expect(replyPayload.text).
|
|
1680
|
+
expect(typeof replyPayload.text).toBe('string');
|
|
1681
|
+
expect(replyPayload.text.toLowerCase()).toContain('verif');
|
|
1682
|
+
expect(replyPayload.text.toLowerCase()).toContain('failed');
|
|
1672
1683
|
|
|
1673
1684
|
deliverSpy.mockRestore();
|
|
1674
1685
|
});
|
|
@@ -1751,7 +1762,7 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1751
1762
|
});
|
|
1752
1763
|
});
|
|
1753
1764
|
|
|
1754
|
-
describe('
|
|
1765
|
+
describe('non-decision status reply for different channels', () => {
|
|
1755
1766
|
beforeEach(() => {
|
|
1756
1767
|
createBinding({
|
|
1757
1768
|
assistantId: 'self',
|
|
@@ -1761,19 +1772,18 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
|
1761
1772
|
});
|
|
1762
1773
|
createBinding({
|
|
1763
1774
|
assistantId: 'self',
|
|
1764
|
-
channel: '
|
|
1775
|
+
channel: 'sms',
|
|
1765
1776
|
guardianExternalUserId: 'telegram-user-default',
|
|
1766
1777
|
guardianDeliveryChatId: 'chat-123',
|
|
1767
1778
|
});
|
|
1768
1779
|
});
|
|
1769
1780
|
|
|
1770
|
-
test('
|
|
1781
|
+
test('non-decision message on non-rich channel (sms) sends status reply', async () => {
|
|
1771
1782
|
const orchestrator = makeMockOrchestrator();
|
|
1772
|
-
const deliverSpy = spyOn(gatewayClient, '
|
|
1773
|
-
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1783
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1774
1784
|
|
|
1775
|
-
// Establish the conversation using
|
|
1776
|
-
const initReq = makeInboundRequest({ content: 'init', sourceChannel: '
|
|
1785
|
+
// Establish the conversation using sms (non-rich channel)
|
|
1786
|
+
const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'sms' });
|
|
1777
1787
|
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1778
1788
|
|
|
1779
1789
|
const db = getDb();
|
|
@@ -1784,30 +1794,28 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
|
1784
1794
|
const run = createRun(conversationId!);
|
|
1785
1795
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
1786
1796
|
|
|
1787
|
-
// Send a non-decision message
|
|
1788
|
-
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: '
|
|
1797
|
+
// Send a non-decision message
|
|
1798
|
+
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'sms' });
|
|
1789
1799
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1790
1800
|
const body = await res.json() as Record<string, unknown>;
|
|
1791
1801
|
|
|
1792
1802
|
expect(body.accepted).toBe(true);
|
|
1793
|
-
expect(body.approval).toBe('
|
|
1803
|
+
expect(body.approval).toBe('assistant_turn');
|
|
1794
1804
|
|
|
1795
|
-
//
|
|
1805
|
+
// Status reply delivered via deliverChannelReply
|
|
1796
1806
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
expect(
|
|
1807
|
+
const statusCall = deliverSpy.mock.calls.find(
|
|
1808
|
+
(call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123',
|
|
1809
|
+
);
|
|
1810
|
+
expect(statusCall).toBeDefined();
|
|
1811
|
+
const statusPayload = statusCall![1] as { text?: string };
|
|
1812
|
+
expect(statusPayload.text).toContain('pending approval request');
|
|
1803
1813
|
|
|
1804
1814
|
deliverSpy.mockRestore();
|
|
1805
|
-
replySpy.mockRestore();
|
|
1806
1815
|
});
|
|
1807
1816
|
|
|
1808
|
-
test('
|
|
1817
|
+
test('non-decision message on telegram sends status reply', async () => {
|
|
1809
1818
|
const orchestrator = makeMockOrchestrator();
|
|
1810
|
-
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1811
1819
|
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1812
1820
|
|
|
1813
1821
|
// Establish the conversation using telegram (rich channel)
|
|
@@ -1822,25 +1830,23 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
|
1822
1830
|
const run = createRun(conversationId!);
|
|
1823
1831
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
1824
1832
|
|
|
1825
|
-
// Send a non-decision message
|
|
1833
|
+
// Send a non-decision message
|
|
1826
1834
|
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'telegram' });
|
|
1827
1835
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1828
1836
|
const body = await res.json() as Record<string, unknown>;
|
|
1829
1837
|
|
|
1830
1838
|
expect(body.accepted).toBe(true);
|
|
1831
|
-
expect(body.approval).toBe('
|
|
1839
|
+
expect(body.approval).toBe('assistant_turn');
|
|
1832
1840
|
|
|
1833
|
-
//
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
expect(
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
expect(deliveredText).not.toContain('Reply "yes"');
|
|
1841
|
+
// Status reply delivered via deliverChannelReply
|
|
1842
|
+
expect(replySpy).toHaveBeenCalled();
|
|
1843
|
+
const statusCall = replySpy.mock.calls.find(
|
|
1844
|
+
(call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123',
|
|
1845
|
+
);
|
|
1846
|
+
expect(statusCall).toBeDefined();
|
|
1847
|
+
const statusPayload = statusCall![1] as { text?: string };
|
|
1848
|
+
expect(statusPayload.text).toContain('pending approval request');
|
|
1842
1849
|
|
|
1843
|
-
deliverSpy.mockRestore();
|
|
1844
1850
|
replySpy.mockRestore();
|
|
1845
1851
|
});
|
|
1846
1852
|
});
|
|
@@ -1944,11 +1950,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1944
1950
|
|
|
1945
1951
|
// The deny decision should carry guardian setup context for assistant reply generation.
|
|
1946
1952
|
expect(typeof decisionArgs[2]).toBe('string');
|
|
1947
|
-
expect((decisionArgs[2] as string)).toContain('no guardian
|
|
1953
|
+
expect((decisionArgs[2] as string).toLowerCase()).toContain('no guardian');
|
|
1948
1954
|
|
|
1949
1955
|
// The runtime should not send a second deterministic denial notice.
|
|
1950
1956
|
const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
|
|
1951
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian
|
|
1957
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
|
|
1952
1958
|
);
|
|
1953
1959
|
expect(deterministicNoticeCalls.length).toBe(0);
|
|
1954
1960
|
|
|
@@ -2045,11 +2051,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
2045
2051
|
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
2046
2052
|
expect(lastDecision[1]).toBe('deny');
|
|
2047
2053
|
expect(typeof lastDecision[2]).toBe('string');
|
|
2048
|
-
expect((lastDecision[2] as string)).toContain('no guardian
|
|
2054
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('no guardian');
|
|
2049
2055
|
|
|
2050
2056
|
// Interception should not emit a separate deterministic denial notice.
|
|
2051
2057
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
2052
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian
|
|
2058
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
|
|
2053
2059
|
);
|
|
2054
2060
|
expect(denialCalls.length).toBe(0);
|
|
2055
2061
|
|
|
@@ -2092,9 +2098,9 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2092
2098
|
const approvalArgs = approvalSpy.mock.calls[0];
|
|
2093
2099
|
expect(approvalArgs[1]).toBe('guardian-chat-1');
|
|
2094
2100
|
|
|
2095
|
-
// Requester should have been notified the request was
|
|
2101
|
+
// Requester should have been notified the request was forwarded to the guardian
|
|
2096
2102
|
const notifyCalls = deliverSpy.mock.calls.filter(
|
|
2097
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2103
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('guardian'),
|
|
2098
2104
|
);
|
|
2099
2105
|
expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
|
|
2100
2106
|
|
|
@@ -2184,14 +2190,14 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2184
2190
|
});
|
|
2185
2191
|
|
|
2186
2192
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2187
|
-
// 20. Guardian delivery failure
|
|
2193
|
+
// 20. Guardian rich-delivery failure fallback (WS-2)
|
|
2188
2194
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2189
2195
|
|
|
2190
|
-
describe('guardian delivery failure →
|
|
2196
|
+
describe('guardian delivery failure → text fallback', () => {
|
|
2191
2197
|
beforeEach(() => {
|
|
2192
2198
|
});
|
|
2193
2199
|
|
|
2194
|
-
test('delivery failure
|
|
2200
|
+
test('rich delivery failure falls back to plain text and keeps request pending', async () => {
|
|
2195
2201
|
createBinding({
|
|
2196
2202
|
assistantId: 'self',
|
|
2197
2203
|
channel: 'telegram',
|
|
@@ -2216,29 +2222,30 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2216
2222
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2217
2223
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2218
2224
|
|
|
2219
|
-
//
|
|
2220
|
-
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2221
|
-
|
|
2222
|
-
expect(decisionArgs[1]).toBe('deny');
|
|
2225
|
+
// Rich button delivery failed, but plain-text fallback succeeded.
|
|
2226
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2227
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
2223
2228
|
|
|
2224
|
-
//
|
|
2225
|
-
const
|
|
2226
|
-
(call) =>
|
|
2229
|
+
// Guardian should have received a parser-compatible plain-text approval prompt.
|
|
2230
|
+
const guardianPromptCalls = deliverSpy.mock.calls.filter(
|
|
2231
|
+
(call) =>
|
|
2232
|
+
typeof call[1] === 'object' &&
|
|
2233
|
+
(call[1] as { chatId?: string; text?: string }).chatId === 'guardian-chat-df' &&
|
|
2234
|
+
((call[1] as { text?: string }).text ?? '').includes('Reply "yes"'),
|
|
2227
2235
|
);
|
|
2228
|
-
expect(
|
|
2236
|
+
expect(guardianPromptCalls.length).toBeGreaterThanOrEqual(1);
|
|
2229
2237
|
|
|
2230
|
-
//
|
|
2231
|
-
// NOT have been delivered (since delivery failed).
|
|
2238
|
+
// Requester should still get the forwarded notice once fallback delivery works.
|
|
2232
2239
|
const successCalls = deliverSpy.mock.calls.filter(
|
|
2233
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2240
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
|
|
2234
2241
|
);
|
|
2235
|
-
expect(successCalls.length).
|
|
2242
|
+
expect(successCalls.length).toBeGreaterThanOrEqual(1);
|
|
2236
2243
|
|
|
2237
2244
|
deliverSpy.mockRestore();
|
|
2238
2245
|
approvalSpy.mockRestore();
|
|
2239
2246
|
});
|
|
2240
2247
|
|
|
2241
|
-
test('
|
|
2248
|
+
test('terminal run resolution clears approvals even when rich delivery falls back to text', async () => {
|
|
2242
2249
|
createBinding({
|
|
2243
2250
|
assistantId: 'self',
|
|
2244
2251
|
channel: 'telegram',
|
|
@@ -2261,15 +2268,19 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2261
2268
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2262
2269
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2263
2270
|
|
|
2271
|
+
// Rich delivery failure alone should not apply an explicit deny decision.
|
|
2272
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2273
|
+
|
|
2264
2274
|
// Verify the run ID was created
|
|
2265
2275
|
const runId = orchestrator.realRunId();
|
|
2266
2276
|
expect(runId).toBeTruthy();
|
|
2267
2277
|
|
|
2268
|
-
//
|
|
2278
|
+
// This test orchestrator transitions the run to a terminal failed state,
|
|
2279
|
+
// which resolves the approval record via run-completion cleanup.
|
|
2269
2280
|
const pendingApproval = getPendingApprovalForRun(runId!);
|
|
2270
2281
|
expect(pendingApproval).toBeNull();
|
|
2271
2282
|
|
|
2272
|
-
//
|
|
2283
|
+
// No unresolved approval should remain after terminal resolution.
|
|
2273
2284
|
const unresolvedApproval = getUnresolvedApprovalForRun(runId!);
|
|
2274
2285
|
expect(unresolvedApproval).toBeNull();
|
|
2275
2286
|
|
|
@@ -2279,14 +2290,14 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2279
2290
|
});
|
|
2280
2291
|
|
|
2281
2292
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2282
|
-
// 20b. Standard
|
|
2293
|
+
// 20b. Standard rich prompt delivery failure → text fallback (WS-B)
|
|
2283
2294
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2284
2295
|
|
|
2285
|
-
describe('standard approval prompt delivery failure →
|
|
2296
|
+
describe('standard approval prompt delivery failure → text fallback', () => {
|
|
2286
2297
|
beforeEach(() => {
|
|
2287
2298
|
});
|
|
2288
2299
|
|
|
2289
|
-
test('standard prompt delivery failure
|
|
2300
|
+
test('standard prompt rich-delivery failure falls back to plain text without auto-deny', async () => {
|
|
2290
2301
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2291
2302
|
// Make the approval prompt delivery fail for the standard (self-approval) path
|
|
2292
2303
|
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
|
|
@@ -2313,10 +2324,16 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
|
2313
2324
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2314
2325
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2315
2326
|
|
|
2316
|
-
|
|
2317
|
-
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2318
|
-
|
|
2319
|
-
|
|
2327
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
2328
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2329
|
+
|
|
2330
|
+
const fallbackCalls = deliverSpy.mock.calls.filter(
|
|
2331
|
+
(call) =>
|
|
2332
|
+
typeof call[1] === 'object' &&
|
|
2333
|
+
(call[1] as { chatId?: string; text?: string }).chatId === 'chat-123' &&
|
|
2334
|
+
((call[1] as { text?: string }).text ?? '').includes('Reply "yes"'),
|
|
2335
|
+
);
|
|
2336
|
+
expect(fallbackCalls.length).toBeGreaterThanOrEqual(1);
|
|
2320
2337
|
|
|
2321
2338
|
deliverSpy.mockRestore();
|
|
2322
2339
|
approvalSpy.mockRestore();
|
|
@@ -2463,18 +2480,28 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2463
2480
|
|
|
2464
2481
|
const orchestrator = makeMockOrchestrator();
|
|
2465
2482
|
|
|
2466
|
-
//
|
|
2483
|
+
// Conversational engine that returns keep_pending for disambiguation
|
|
2484
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
2485
|
+
disposition: 'keep_pending' as const,
|
|
2486
|
+
replyText: 'You have 2 pending requests. Which one?',
|
|
2487
|
+
}));
|
|
2488
|
+
|
|
2489
|
+
// Guardian sends plain-text "yes" — ambiguous because two approvals are pending.
|
|
2490
|
+
// The conversational engine handles disambiguation by returning keep_pending.
|
|
2467
2491
|
const req = makeInboundRequest({
|
|
2468
2492
|
content: 'yes',
|
|
2469
2493
|
externalChatId: 'guardian-ambig-chat',
|
|
2470
2494
|
senderExternalUserId: 'guardian-ambig-user',
|
|
2471
2495
|
});
|
|
2472
2496
|
|
|
2473
|
-
const res = await handleChannelInbound(
|
|
2497
|
+
const res = await handleChannelInbound(
|
|
2498
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
2499
|
+
mockConversationGenerator,
|
|
2500
|
+
);
|
|
2474
2501
|
const body = await res.json() as Record<string, unknown>;
|
|
2475
2502
|
|
|
2476
2503
|
expect(body.accepted).toBe(true);
|
|
2477
|
-
expect(body.approval).toBe('
|
|
2504
|
+
expect(body.approval).toBe('assistant_turn');
|
|
2478
2505
|
|
|
2479
2506
|
// Neither approval should have been resolved — disambiguation was required
|
|
2480
2507
|
const approvalA = getPendingApprovalForRun(runA.id);
|
|
@@ -2485,9 +2512,14 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2485
2512
|
// submitDecision should NOT have been called — no decision was applied
|
|
2486
2513
|
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2487
2514
|
|
|
2488
|
-
//
|
|
2515
|
+
// The conversational engine should have been called with both pending approvals
|
|
2516
|
+
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
2517
|
+
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
|
|
2518
|
+
expect((engineCtx.pendingApprovals as Array<unknown>)).toHaveLength(2);
|
|
2519
|
+
|
|
2520
|
+
// A disambiguation reply should have been sent to the guardian
|
|
2489
2521
|
const disambigCalls = deliverSpy.mock.calls.filter(
|
|
2490
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending
|
|
2522
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending'),
|
|
2491
2523
|
);
|
|
2492
2524
|
expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
|
|
2493
2525
|
|
|
@@ -3178,12 +3210,12 @@ describe('guardian enforcement behavior', () => {
|
|
|
3178
3210
|
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3179
3211
|
expect(lastDecision[1]).toBe('deny');
|
|
3180
3212
|
expect(typeof lastDecision[2]).toBe('string');
|
|
3181
|
-
expect((lastDecision[2] as string)).toContain('identity
|
|
3213
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
|
|
3182
3214
|
|
|
3183
3215
|
// No separate deterministic denial notice should be emitted here.
|
|
3184
3216
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3185
3217
|
(call) => typeof call[1] === 'object'
|
|
3186
|
-
&& ((call[1] as { text?: string }).text ?? '').includes('identity
|
|
3218
|
+
&& ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
|
|
3187
3219
|
);
|
|
3188
3220
|
expect(denialCalls.length).toBe(0);
|
|
3189
3221
|
|
|
@@ -3217,11 +3249,11 @@ describe('guardian enforcement behavior', () => {
|
|
|
3217
3249
|
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3218
3250
|
expect(lastDecision[1]).toBe('deny');
|
|
3219
3251
|
expect(typeof lastDecision[2]).toBe('string');
|
|
3220
|
-
expect((lastDecision[2] as string)).toContain('identity
|
|
3252
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
|
|
3221
3253
|
|
|
3222
3254
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3223
3255
|
(call) => typeof call[1] === 'object'
|
|
3224
|
-
&& ((call[1] as { text?: string }).text ?? '').includes('identity
|
|
3256
|
+
&& ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
|
|
3225
3257
|
);
|
|
3226
3258
|
expect(denialCalls.length).toBe(0);
|
|
3227
3259
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -3534,3 +3566,1150 @@ describe('unknown actor identity — forceStrictSideEffects', () => {
|
|
|
3534
3566
|
deliverSpy.mockRestore();
|
|
3535
3567
|
});
|
|
3536
3568
|
});
|
|
3569
|
+
|
|
3570
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3571
|
+
// Conversational approval engine — standard path
|
|
3572
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3573
|
+
|
|
3574
|
+
describe('conversational approval engine — standard path', () => {
|
|
3575
|
+
beforeEach(() => {
|
|
3576
|
+
createBinding({
|
|
3577
|
+
assistantId: 'self',
|
|
3578
|
+
channel: 'telegram',
|
|
3579
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
3580
|
+
guardianDeliveryChatId: 'chat-123',
|
|
3581
|
+
});
|
|
3582
|
+
});
|
|
3583
|
+
|
|
3584
|
+
test('non-decision follow-up → engine returns keep_pending → assistant reply sent, run remains pending', async () => {
|
|
3585
|
+
const orchestrator = makeMockOrchestrator();
|
|
3586
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3587
|
+
|
|
3588
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
3589
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
3590
|
+
|
|
3591
|
+
const db = getDb();
|
|
3592
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
3593
|
+
const conversationId = events[0]?.conversation_id;
|
|
3594
|
+
ensureConversation(conversationId!);
|
|
3595
|
+
|
|
3596
|
+
const run = createRun(conversationId!);
|
|
3597
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3598
|
+
|
|
3599
|
+
deliverSpy.mockClear();
|
|
3600
|
+
|
|
3601
|
+
// Mock conversational engine that returns keep_pending
|
|
3602
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
3603
|
+
disposition: 'keep_pending' as const,
|
|
3604
|
+
replyText: 'There is a pending shell command. Would you like to approve or deny it?',
|
|
3605
|
+
}));
|
|
3606
|
+
|
|
3607
|
+
const req = makeInboundRequest({ content: 'what does this command do?' });
|
|
3608
|
+
const res = await handleChannelInbound(
|
|
3609
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3610
|
+
mockConversationGenerator,
|
|
3611
|
+
);
|
|
3612
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3613
|
+
|
|
3614
|
+
expect(body.accepted).toBe(true);
|
|
3615
|
+
expect(body.approval).toBe('assistant_turn');
|
|
3616
|
+
|
|
3617
|
+
// The engine reply should have been delivered
|
|
3618
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
3619
|
+
const replyCall = deliverSpy.mock.calls.find(
|
|
3620
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending shell command'),
|
|
3621
|
+
);
|
|
3622
|
+
expect(replyCall).toBeDefined();
|
|
3623
|
+
|
|
3624
|
+
// The orchestrator should NOT have received a decision
|
|
3625
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
3626
|
+
|
|
3627
|
+
deliverSpy.mockRestore();
|
|
3628
|
+
});
|
|
3629
|
+
|
|
3630
|
+
test('natural-language approval → engine returns approve_once → decision applied', async () => {
|
|
3631
|
+
const orchestrator = makeMockOrchestrator();
|
|
3632
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3633
|
+
|
|
3634
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
3635
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
3636
|
+
|
|
3637
|
+
const db = getDb();
|
|
3638
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
3639
|
+
const conversationId = events[0]?.conversation_id;
|
|
3640
|
+
ensureConversation(conversationId!);
|
|
3641
|
+
|
|
3642
|
+
const run = createRun(conversationId!);
|
|
3643
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3644
|
+
|
|
3645
|
+
deliverSpy.mockClear();
|
|
3646
|
+
|
|
3647
|
+
// Mock conversational engine that returns approve_once
|
|
3648
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
3649
|
+
disposition: 'approve_once' as const,
|
|
3650
|
+
replyText: 'Got it, approving the shell command.',
|
|
3651
|
+
}));
|
|
3652
|
+
|
|
3653
|
+
const req = makeInboundRequest({ content: 'yeah go ahead and run it' });
|
|
3654
|
+
const res = await handleChannelInbound(
|
|
3655
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3656
|
+
mockConversationGenerator,
|
|
3657
|
+
);
|
|
3658
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3659
|
+
|
|
3660
|
+
expect(body.accepted).toBe(true);
|
|
3661
|
+
expect(body.approval).toBe('decision_applied');
|
|
3662
|
+
|
|
3663
|
+
// The orchestrator should have received an allow decision
|
|
3664
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
3665
|
+
|
|
3666
|
+
// The engine reply should have been delivered
|
|
3667
|
+
const replyCall = deliverSpy.mock.calls.find(
|
|
3668
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('approving the shell command'),
|
|
3669
|
+
);
|
|
3670
|
+
expect(replyCall).toBeDefined();
|
|
3671
|
+
|
|
3672
|
+
deliverSpy.mockRestore();
|
|
3673
|
+
});
|
|
3674
|
+
|
|
3675
|
+
test('"nevermind" style message → engine returns reject → rejection applied', async () => {
|
|
3676
|
+
const orchestrator = makeMockOrchestrator();
|
|
3677
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3678
|
+
|
|
3679
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
3680
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
3681
|
+
|
|
3682
|
+
const db = getDb();
|
|
3683
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
3684
|
+
const conversationId = events[0]?.conversation_id;
|
|
3685
|
+
ensureConversation(conversationId!);
|
|
3686
|
+
|
|
3687
|
+
const run = createRun(conversationId!);
|
|
3688
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3689
|
+
|
|
3690
|
+
deliverSpy.mockClear();
|
|
3691
|
+
|
|
3692
|
+
// Mock conversational engine that returns reject
|
|
3693
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
3694
|
+
disposition: 'reject' as const,
|
|
3695
|
+
replyText: 'No problem, I\'ve cancelled the shell command.',
|
|
3696
|
+
}));
|
|
3697
|
+
|
|
3698
|
+
const req = makeInboundRequest({ content: 'nevermind, don\'t run that' });
|
|
3699
|
+
const res = await handleChannelInbound(
|
|
3700
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3701
|
+
mockConversationGenerator,
|
|
3702
|
+
);
|
|
3703
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3704
|
+
|
|
3705
|
+
expect(body.accepted).toBe(true);
|
|
3706
|
+
expect(body.approval).toBe('decision_applied');
|
|
3707
|
+
|
|
3708
|
+
// The orchestrator should have received a deny decision
|
|
3709
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
3710
|
+
|
|
3711
|
+
// The engine reply should have been delivered
|
|
3712
|
+
const replyCall = deliverSpy.mock.calls.find(
|
|
3713
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('cancelled the shell command'),
|
|
3714
|
+
);
|
|
3715
|
+
expect(replyCall).toBeDefined();
|
|
3716
|
+
|
|
3717
|
+
deliverSpy.mockRestore();
|
|
3718
|
+
});
|
|
3719
|
+
|
|
3720
|
+
test('callback button still takes priority even with conversational engine present', async () => {
|
|
3721
|
+
const orchestrator = makeMockOrchestrator();
|
|
3722
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3723
|
+
|
|
3724
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
3725
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
3726
|
+
|
|
3727
|
+
const db = getDb();
|
|
3728
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
3729
|
+
const conversationId = events[0]?.conversation_id;
|
|
3730
|
+
ensureConversation(conversationId!);
|
|
3731
|
+
|
|
3732
|
+
const run = createRun(conversationId!);
|
|
3733
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3734
|
+
|
|
3735
|
+
// Mock conversational engine — should NOT be called for callback buttons
|
|
3736
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
3737
|
+
disposition: 'keep_pending' as const,
|
|
3738
|
+
replyText: 'This should not be called',
|
|
3739
|
+
}));
|
|
3740
|
+
|
|
3741
|
+
const req = makeInboundRequest({
|
|
3742
|
+
content: '',
|
|
3743
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
3744
|
+
});
|
|
3745
|
+
|
|
3746
|
+
const res = await handleChannelInbound(
|
|
3747
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3748
|
+
mockConversationGenerator,
|
|
3749
|
+
);
|
|
3750
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3751
|
+
|
|
3752
|
+
expect(body.accepted).toBe(true);
|
|
3753
|
+
expect(body.approval).toBe('decision_applied');
|
|
3754
|
+
|
|
3755
|
+
// The callback button should have been used directly, not the engine
|
|
3756
|
+
expect(mockConversationGenerator).not.toHaveBeenCalled();
|
|
3757
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
3758
|
+
|
|
3759
|
+
deliverSpy.mockRestore();
|
|
3760
|
+
});
|
|
3761
|
+
});
|
|
3762
|
+
|
|
3763
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3764
|
+
// Guardian conversational approval engine tests
|
|
3765
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3766
|
+
|
|
3767
|
+
describe('guardian conversational approval via conversation engine', () => {
|
|
3768
|
+
beforeEach(() => {
|
|
3769
|
+
});
|
|
3770
|
+
|
|
3771
|
+
test('guardian follow-up clarification: engine returns keep_pending, reply sent, run remains pending', async () => {
|
|
3772
|
+
createBinding({
|
|
3773
|
+
assistantId: 'self',
|
|
3774
|
+
channel: 'telegram',
|
|
3775
|
+
guardianExternalUserId: 'guardian-conv-user',
|
|
3776
|
+
guardianDeliveryChatId: 'guardian-conv-chat',
|
|
3777
|
+
});
|
|
3778
|
+
|
|
3779
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3780
|
+
|
|
3781
|
+
const convId = 'conv-guardian-clarify';
|
|
3782
|
+
ensureConversation(convId);
|
|
3783
|
+
const run = createRun(convId);
|
|
3784
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3785
|
+
|
|
3786
|
+
createApprovalRequest({
|
|
3787
|
+
runId: run.id,
|
|
3788
|
+
conversationId: convId,
|
|
3789
|
+
channel: 'telegram',
|
|
3790
|
+
requesterExternalUserId: 'requester-clarify',
|
|
3791
|
+
requesterChatId: 'chat-requester-clarify',
|
|
3792
|
+
guardianExternalUserId: 'guardian-conv-user',
|
|
3793
|
+
guardianChatId: 'guardian-conv-chat',
|
|
3794
|
+
toolName: 'shell',
|
|
3795
|
+
expiresAt: Date.now() + 300_000,
|
|
3796
|
+
});
|
|
3797
|
+
|
|
3798
|
+
const orchestrator = makeMockOrchestrator();
|
|
3799
|
+
|
|
3800
|
+
// Engine returns keep_pending for a clarification question
|
|
3801
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
3802
|
+
disposition: 'keep_pending' as const,
|
|
3803
|
+
replyText: 'Could you clarify which action you want me to approve?',
|
|
3804
|
+
}));
|
|
3805
|
+
|
|
3806
|
+
const req = makeInboundRequest({
|
|
3807
|
+
content: 'hmm what does this do?',
|
|
3808
|
+
externalChatId: 'guardian-conv-chat',
|
|
3809
|
+
senderExternalUserId: 'guardian-conv-user',
|
|
3810
|
+
});
|
|
3811
|
+
|
|
3812
|
+
const res = await handleChannelInbound(
|
|
3813
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3814
|
+
mockConversationGenerator,
|
|
3815
|
+
);
|
|
3816
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3817
|
+
|
|
3818
|
+
expect(body.accepted).toBe(true);
|
|
3819
|
+
expect(body.approval).toBe('assistant_turn');
|
|
3820
|
+
|
|
3821
|
+
// The engine should have been called with role: 'guardian'
|
|
3822
|
+
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
3823
|
+
const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
|
|
3824
|
+
expect(callCtx.role).toBe('guardian');
|
|
3825
|
+
expect(callCtx.allowedActions).toEqual(['approve_once', 'reject']);
|
|
3826
|
+
expect(callCtx.userMessage).toBe('hmm what does this do?');
|
|
3827
|
+
|
|
3828
|
+
// Clarification reply delivered to the guardian's chat
|
|
3829
|
+
const replyCall = deliverSpy.mock.calls.find(
|
|
3830
|
+
(call) => (call[1] as { text?: string }).text === 'Could you clarify which action you want me to approve?',
|
|
3831
|
+
);
|
|
3832
|
+
expect(replyCall).toBeTruthy();
|
|
3833
|
+
|
|
3834
|
+
// The orchestrator should NOT have received a decision
|
|
3835
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
3836
|
+
|
|
3837
|
+
// The approval should still be pending
|
|
3838
|
+
const pending = getAllPendingApprovalsByGuardianChat('telegram', 'guardian-conv-chat', 'self');
|
|
3839
|
+
expect(pending).toHaveLength(1);
|
|
3840
|
+
|
|
3841
|
+
deliverSpy.mockRestore();
|
|
3842
|
+
});
|
|
3843
|
+
|
|
3844
|
+
test('guardian natural-language approval: engine returns approve_once, decision applied', async () => {
|
|
3845
|
+
createBinding({
|
|
3846
|
+
assistantId: 'self',
|
|
3847
|
+
channel: 'telegram',
|
|
3848
|
+
guardianExternalUserId: 'guardian-nlp-user',
|
|
3849
|
+
guardianDeliveryChatId: 'guardian-nlp-chat',
|
|
3850
|
+
});
|
|
3851
|
+
|
|
3852
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3853
|
+
|
|
3854
|
+
const convId = 'conv-guardian-nlp';
|
|
3855
|
+
ensureConversation(convId);
|
|
3856
|
+
const run = createRun(convId);
|
|
3857
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3858
|
+
|
|
3859
|
+
createApprovalRequest({
|
|
3860
|
+
runId: run.id,
|
|
3861
|
+
conversationId: convId,
|
|
3862
|
+
channel: 'telegram',
|
|
3863
|
+
requesterExternalUserId: 'requester-nlp',
|
|
3864
|
+
requesterChatId: 'chat-requester-nlp',
|
|
3865
|
+
guardianExternalUserId: 'guardian-nlp-user',
|
|
3866
|
+
guardianChatId: 'guardian-nlp-chat',
|
|
3867
|
+
toolName: 'shell',
|
|
3868
|
+
expiresAt: Date.now() + 300_000,
|
|
3869
|
+
});
|
|
3870
|
+
|
|
3871
|
+
const orchestrator = makeMockOrchestrator();
|
|
3872
|
+
|
|
3873
|
+
// Engine returns approve_once decision
|
|
3874
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
3875
|
+
disposition: 'approve_once' as const,
|
|
3876
|
+
replyText: 'Approved! The shell command will proceed.',
|
|
3877
|
+
}));
|
|
3878
|
+
|
|
3879
|
+
const req = makeInboundRequest({
|
|
3880
|
+
content: 'yes go ahead and run it',
|
|
3881
|
+
externalChatId: 'guardian-nlp-chat',
|
|
3882
|
+
senderExternalUserId: 'guardian-nlp-user',
|
|
3883
|
+
});
|
|
3884
|
+
|
|
3885
|
+
const res = await handleChannelInbound(
|
|
3886
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3887
|
+
mockConversationGenerator,
|
|
3888
|
+
);
|
|
3889
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3890
|
+
|
|
3891
|
+
expect(body.accepted).toBe(true);
|
|
3892
|
+
expect(body.approval).toBe('guardian_decision_applied');
|
|
3893
|
+
|
|
3894
|
+
// The orchestrator should have received an 'allow' decision
|
|
3895
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledTimes(1);
|
|
3896
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
3897
|
+
|
|
3898
|
+
// The approval record should have been updated (no longer pending)
|
|
3899
|
+
const pending = getAllPendingApprovalsByGuardianChat('telegram', 'guardian-nlp-chat', 'self');
|
|
3900
|
+
expect(pending).toHaveLength(0);
|
|
3901
|
+
|
|
3902
|
+
// The engine context excluded approve_always for guardians
|
|
3903
|
+
const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
|
|
3904
|
+
expect(callCtx.allowedActions).toEqual(['approve_once', 'reject']);
|
|
3905
|
+
expect((callCtx.allowedActions as string[])).not.toContain('approve_always');
|
|
3906
|
+
|
|
3907
|
+
deliverSpy.mockRestore();
|
|
3908
|
+
});
|
|
3909
|
+
|
|
3910
|
+
test('guardian callback button approve_always is downgraded to approve_once', async () => {
|
|
3911
|
+
createBinding({
|
|
3912
|
+
assistantId: 'self',
|
|
3913
|
+
channel: 'telegram',
|
|
3914
|
+
guardianExternalUserId: 'guardian-dg-user',
|
|
3915
|
+
guardianDeliveryChatId: 'guardian-dg-chat',
|
|
3916
|
+
});
|
|
3917
|
+
|
|
3918
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3919
|
+
|
|
3920
|
+
const convId = 'conv-guardian-downgrade';
|
|
3921
|
+
ensureConversation(convId);
|
|
3922
|
+
const run = createRun(convId);
|
|
3923
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
3924
|
+
|
|
3925
|
+
createApprovalRequest({
|
|
3926
|
+
runId: run.id,
|
|
3927
|
+
conversationId: convId,
|
|
3928
|
+
channel: 'telegram',
|
|
3929
|
+
requesterExternalUserId: 'requester-dg',
|
|
3930
|
+
requesterChatId: 'chat-requester-dg',
|
|
3931
|
+
guardianExternalUserId: 'guardian-dg-user',
|
|
3932
|
+
guardianChatId: 'guardian-dg-chat',
|
|
3933
|
+
toolName: 'shell',
|
|
3934
|
+
expiresAt: Date.now() + 300_000,
|
|
3935
|
+
});
|
|
3936
|
+
|
|
3937
|
+
const orchestrator = makeMockOrchestrator();
|
|
3938
|
+
|
|
3939
|
+
// Guardian clicks approve_always via callback button
|
|
3940
|
+
const req = makeInboundRequest({
|
|
3941
|
+
content: '',
|
|
3942
|
+
externalChatId: 'guardian-dg-chat',
|
|
3943
|
+
callbackData: `apr:${run.id}:approve_always`,
|
|
3944
|
+
senderExternalUserId: 'guardian-dg-user',
|
|
3945
|
+
});
|
|
3946
|
+
|
|
3947
|
+
const res = await handleChannelInbound(
|
|
3948
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
3949
|
+
undefined,
|
|
3950
|
+
);
|
|
3951
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3952
|
+
|
|
3953
|
+
expect(body.accepted).toBe(true);
|
|
3954
|
+
expect(body.approval).toBe('guardian_decision_applied');
|
|
3955
|
+
|
|
3956
|
+
// approve_always should have been downgraded to approve_once ('allow')
|
|
3957
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledTimes(1);
|
|
3958
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
3959
|
+
|
|
3960
|
+
deliverSpy.mockRestore();
|
|
3961
|
+
});
|
|
3962
|
+
|
|
3963
|
+
test('multi-pending guardian disambiguation: engine requests clarification', async () => {
|
|
3964
|
+
createBinding({
|
|
3965
|
+
assistantId: 'self',
|
|
3966
|
+
channel: 'telegram',
|
|
3967
|
+
guardianExternalUserId: 'guardian-multi-user',
|
|
3968
|
+
guardianDeliveryChatId: 'guardian-multi-chat',
|
|
3969
|
+
});
|
|
3970
|
+
|
|
3971
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3972
|
+
|
|
3973
|
+
const convA = 'conv-multi-a';
|
|
3974
|
+
const convB = 'conv-multi-b';
|
|
3975
|
+
ensureConversation(convA);
|
|
3976
|
+
ensureConversation(convB);
|
|
3977
|
+
|
|
3978
|
+
const runA = createRun(convA);
|
|
3979
|
+
setRunConfirmation(runA.id, { ...sampleConfirmation, toolUseId: 'req-multi-a' });
|
|
3980
|
+
|
|
3981
|
+
const runB = createRun(convB);
|
|
3982
|
+
setRunConfirmation(runB.id, { ...sampleConfirmation, toolName: 'file_edit', toolUseId: 'req-multi-b' });
|
|
3983
|
+
|
|
3984
|
+
createApprovalRequest({
|
|
3985
|
+
runId: runA.id,
|
|
3986
|
+
conversationId: convA,
|
|
3987
|
+
channel: 'telegram',
|
|
3988
|
+
requesterExternalUserId: 'requester-multi-a',
|
|
3989
|
+
requesterChatId: 'chat-requester-multi-a',
|
|
3990
|
+
guardianExternalUserId: 'guardian-multi-user',
|
|
3991
|
+
guardianChatId: 'guardian-multi-chat',
|
|
3992
|
+
toolName: 'shell',
|
|
3993
|
+
expiresAt: Date.now() + 300_000,
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
createApprovalRequest({
|
|
3997
|
+
runId: runB.id,
|
|
3998
|
+
conversationId: convB,
|
|
3999
|
+
channel: 'telegram',
|
|
4000
|
+
requesterExternalUserId: 'requester-multi-b',
|
|
4001
|
+
requesterChatId: 'chat-requester-multi-b',
|
|
4002
|
+
guardianExternalUserId: 'guardian-multi-user',
|
|
4003
|
+
guardianChatId: 'guardian-multi-chat',
|
|
4004
|
+
toolName: 'file_edit',
|
|
4005
|
+
expiresAt: Date.now() + 300_000,
|
|
4006
|
+
});
|
|
4007
|
+
|
|
4008
|
+
const orchestrator = makeMockOrchestrator();
|
|
4009
|
+
|
|
4010
|
+
// Engine returns keep_pending for disambiguation
|
|
4011
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
4012
|
+
disposition: 'keep_pending' as const,
|
|
4013
|
+
replyText: 'You have 2 pending requests: shell and file_edit. Which one?',
|
|
4014
|
+
}));
|
|
4015
|
+
|
|
4016
|
+
const req = makeInboundRequest({
|
|
4017
|
+
content: 'approve it',
|
|
4018
|
+
externalChatId: 'guardian-multi-chat',
|
|
4019
|
+
senderExternalUserId: 'guardian-multi-user',
|
|
4020
|
+
});
|
|
4021
|
+
|
|
4022
|
+
const res = await handleChannelInbound(
|
|
4023
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4024
|
+
mockConversationGenerator,
|
|
4025
|
+
);
|
|
4026
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4027
|
+
|
|
4028
|
+
expect(body.accepted).toBe(true);
|
|
4029
|
+
expect(body.approval).toBe('assistant_turn');
|
|
4030
|
+
|
|
4031
|
+
// The engine should have received both pending approvals
|
|
4032
|
+
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
4033
|
+
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
|
|
4034
|
+
expect((engineCtx.pendingApprovals as Array<unknown>)).toHaveLength(2);
|
|
4035
|
+
expect(engineCtx.role).toBe('guardian');
|
|
4036
|
+
|
|
4037
|
+
// Both approvals should remain pending
|
|
4038
|
+
const pendingA = getPendingApprovalForRun(runA.id);
|
|
4039
|
+
const pendingB = getPendingApprovalForRun(runB.id);
|
|
4040
|
+
expect(pendingA).not.toBeNull();
|
|
4041
|
+
expect(pendingB).not.toBeNull();
|
|
4042
|
+
|
|
4043
|
+
// submitDecision should NOT have been called
|
|
4044
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4045
|
+
|
|
4046
|
+
// Disambiguation reply delivered to guardian
|
|
4047
|
+
const disambigCall = deliverSpy.mock.calls.find(
|
|
4048
|
+
(call) => (call[1] as { text?: string }).text?.includes('2 pending requests'),
|
|
4049
|
+
);
|
|
4050
|
+
expect(disambigCall).toBeTruthy();
|
|
4051
|
+
|
|
4052
|
+
deliverSpy.mockRestore();
|
|
4053
|
+
});
|
|
4054
|
+
});
|
|
4055
|
+
|
|
4056
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4057
|
+
// keep_pending must remain conversational (no deterministic fallback)
|
|
4058
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4059
|
+
|
|
4060
|
+
describe('keep_pending remains conversational — standard path', () => {
|
|
4061
|
+
beforeEach(() => {
|
|
4062
|
+
createBinding({
|
|
4063
|
+
assistantId: 'self',
|
|
4064
|
+
channel: 'telegram',
|
|
4065
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
4066
|
+
guardianDeliveryChatId: 'chat-123',
|
|
4067
|
+
});
|
|
4068
|
+
});
|
|
4069
|
+
|
|
4070
|
+
test('explicit "approve" with keep_pending returns assistant_turn and does not auto-decide', async () => {
|
|
4071
|
+
const orchestrator = makeMockOrchestrator();
|
|
4072
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4073
|
+
|
|
4074
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
4075
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4076
|
+
|
|
4077
|
+
const db = getDb();
|
|
4078
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4079
|
+
const conversationId = events[0]?.conversation_id;
|
|
4080
|
+
ensureConversation(conversationId!);
|
|
4081
|
+
|
|
4082
|
+
const run = createRun(conversationId!);
|
|
4083
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4084
|
+
|
|
4085
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
4086
|
+
disposition: 'keep_pending' as const,
|
|
4087
|
+
replyText: 'Before deciding, can you confirm the intent?',
|
|
4088
|
+
}));
|
|
4089
|
+
|
|
4090
|
+
const req = makeInboundRequest({ content: 'approve' });
|
|
4091
|
+
const res = await handleChannelInbound(
|
|
4092
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4093
|
+
mockConversationGenerator,
|
|
4094
|
+
);
|
|
4095
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4096
|
+
|
|
4097
|
+
expect(body.accepted).toBe(true);
|
|
4098
|
+
expect(body.approval).toBe('assistant_turn');
|
|
4099
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4100
|
+
|
|
4101
|
+
const followupReply = deliverSpy.mock.calls.find(
|
|
4102
|
+
(call) => (call[1] as { text?: string }).text?.includes('confirm the intent'),
|
|
4103
|
+
);
|
|
4104
|
+
expect(followupReply).toBeDefined();
|
|
4105
|
+
|
|
4106
|
+
deliverSpy.mockRestore();
|
|
4107
|
+
});
|
|
4108
|
+
|
|
4109
|
+
test('keep_pending stays assistant_turn even if pending confirmation disappears mid-turn', async () => {
|
|
4110
|
+
const orchestrator = makeMockOrchestrator();
|
|
4111
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4112
|
+
|
|
4113
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
4114
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4115
|
+
|
|
4116
|
+
const db = getDb();
|
|
4117
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4118
|
+
const conversationId = events[0]?.conversation_id;
|
|
4119
|
+
ensureConversation(conversationId!);
|
|
4120
|
+
|
|
4121
|
+
const run = createRun(conversationId!);
|
|
4122
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4123
|
+
|
|
4124
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => {
|
|
4125
|
+
db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
|
|
4126
|
+
return {
|
|
4127
|
+
disposition: 'keep_pending' as const,
|
|
4128
|
+
replyText: 'Looks like that request is no longer pending.',
|
|
4129
|
+
};
|
|
4130
|
+
});
|
|
4131
|
+
|
|
4132
|
+
const req = makeInboundRequest({ content: 'deny' });
|
|
4133
|
+
const res = await handleChannelInbound(
|
|
4134
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4135
|
+
mockConversationGenerator,
|
|
4136
|
+
);
|
|
4137
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4138
|
+
|
|
4139
|
+
expect(body.accepted).toBe(true);
|
|
4140
|
+
expect(body.approval).toBe('assistant_turn');
|
|
4141
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4142
|
+
|
|
4143
|
+
const followupReply = deliverSpy.mock.calls.find(
|
|
4144
|
+
(call) => (call[1] as { text?: string }).text?.includes('no longer pending'),
|
|
4145
|
+
);
|
|
4146
|
+
expect(followupReply).toBeDefined();
|
|
4147
|
+
|
|
4148
|
+
deliverSpy.mockRestore();
|
|
4149
|
+
});
|
|
4150
|
+
});
|
|
4151
|
+
|
|
4152
|
+
describe('keep_pending remains conversational — guardian path', () => {
|
|
4153
|
+
test('guardian explicit "yes" with keep_pending returns assistant_turn without applying a decision', async () => {
|
|
4154
|
+
createBinding({
|
|
4155
|
+
assistantId: 'self',
|
|
4156
|
+
channel: 'telegram',
|
|
4157
|
+
guardianExternalUserId: 'guardian-user-fb',
|
|
4158
|
+
guardianDeliveryChatId: 'guardian-chat-fb',
|
|
4159
|
+
});
|
|
4160
|
+
|
|
4161
|
+
const orchestrator = makeMockOrchestrator();
|
|
4162
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4163
|
+
|
|
4164
|
+
const initReq = makeInboundRequest({
|
|
4165
|
+
content: 'init',
|
|
4166
|
+
externalChatId: 'requester-chat-fb',
|
|
4167
|
+
senderExternalUserId: 'requester-user-fb',
|
|
4168
|
+
});
|
|
4169
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4170
|
+
|
|
4171
|
+
const db = getDb();
|
|
4172
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4173
|
+
const conversationId = events[0]?.conversation_id;
|
|
4174
|
+
ensureConversation(conversationId!);
|
|
4175
|
+
|
|
4176
|
+
const run = createRun(conversationId!);
|
|
4177
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4178
|
+
|
|
4179
|
+
createApprovalRequest({
|
|
4180
|
+
runId: run.id,
|
|
4181
|
+
conversationId: conversationId!,
|
|
4182
|
+
assistantId: 'self',
|
|
4183
|
+
channel: 'telegram',
|
|
4184
|
+
requesterExternalUserId: 'requester-user-fb',
|
|
4185
|
+
requesterChatId: 'requester-chat-fb',
|
|
4186
|
+
guardianExternalUserId: 'guardian-user-fb',
|
|
4187
|
+
guardianChatId: 'guardian-chat-fb',
|
|
4188
|
+
toolName: 'shell',
|
|
4189
|
+
expiresAt: Date.now() + 300_000,
|
|
4190
|
+
});
|
|
4191
|
+
|
|
4192
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
4193
|
+
disposition: 'keep_pending' as const,
|
|
4194
|
+
replyText: 'Which run are you approving?',
|
|
4195
|
+
}));
|
|
4196
|
+
|
|
4197
|
+
const guardianReq = makeInboundRequest({
|
|
4198
|
+
content: 'yes',
|
|
4199
|
+
externalChatId: 'guardian-chat-fb',
|
|
4200
|
+
senderExternalUserId: 'guardian-user-fb',
|
|
4201
|
+
});
|
|
4202
|
+
const res = await handleChannelInbound(
|
|
4203
|
+
guardianReq, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4204
|
+
mockConversationGenerator,
|
|
4205
|
+
);
|
|
4206
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4207
|
+
|
|
4208
|
+
expect(body.accepted).toBe(true);
|
|
4209
|
+
expect(body.approval).toBe('assistant_turn');
|
|
4210
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4211
|
+
|
|
4212
|
+
const followupReply = deliverSpy.mock.calls.find(
|
|
4213
|
+
(call) => (call[1] as { text?: string }).text?.includes('Which run are you approving'),
|
|
4214
|
+
);
|
|
4215
|
+
expect(followupReply).toBeDefined();
|
|
4216
|
+
|
|
4217
|
+
deliverSpy.mockRestore();
|
|
4218
|
+
});
|
|
4219
|
+
});
|
|
4220
|
+
|
|
4221
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4222
|
+
// Fix: requester cancel of guardian-gated pending request (P2)
|
|
4223
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4224
|
+
|
|
4225
|
+
describe('requester cancel of guardian-gated pending request', () => {
|
|
4226
|
+
beforeEach(() => {
|
|
4227
|
+
createBinding({
|
|
4228
|
+
assistantId: 'self',
|
|
4229
|
+
channel: 'telegram',
|
|
4230
|
+
guardianExternalUserId: 'guardian-cancel',
|
|
4231
|
+
guardianDeliveryChatId: 'guardian-cancel-chat',
|
|
4232
|
+
});
|
|
4233
|
+
});
|
|
4234
|
+
|
|
4235
|
+
test('requester explicit "deny" can cancel when the conversation engine returns reject', async () => {
|
|
4236
|
+
const orchestrator = makeMockOrchestrator();
|
|
4237
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4238
|
+
|
|
4239
|
+
// Create requester conversation and run
|
|
4240
|
+
const initReq = makeInboundRequest({
|
|
4241
|
+
content: 'init',
|
|
4242
|
+
externalChatId: 'requester-cancel-chat',
|
|
4243
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4244
|
+
});
|
|
4245
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4246
|
+
|
|
4247
|
+
const db = getDb();
|
|
4248
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4249
|
+
const conversationId = events[0]?.conversation_id;
|
|
4250
|
+
ensureConversation(conversationId!);
|
|
4251
|
+
|
|
4252
|
+
const run = createRun(conversationId!);
|
|
4253
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4254
|
+
|
|
4255
|
+
// Create guardian approval request
|
|
4256
|
+
createApprovalRequest({
|
|
4257
|
+
runId: run.id,
|
|
4258
|
+
conversationId: conversationId!,
|
|
4259
|
+
assistantId: 'self',
|
|
4260
|
+
channel: 'telegram',
|
|
4261
|
+
requesterExternalUserId: 'requester-cancel-user',
|
|
4262
|
+
requesterChatId: 'requester-cancel-chat',
|
|
4263
|
+
guardianExternalUserId: 'guardian-cancel',
|
|
4264
|
+
guardianChatId: 'guardian-cancel-chat',
|
|
4265
|
+
toolName: 'shell',
|
|
4266
|
+
expiresAt: Date.now() + 300_000,
|
|
4267
|
+
});
|
|
4268
|
+
|
|
4269
|
+
deliverSpy.mockClear();
|
|
4270
|
+
|
|
4271
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
4272
|
+
disposition: 'reject' as const,
|
|
4273
|
+
replyText: 'Cancelling this request now.',
|
|
4274
|
+
}));
|
|
4275
|
+
|
|
4276
|
+
// Requester sends "deny" and the engine classifies it as reject.
|
|
4277
|
+
const req = makeInboundRequest({
|
|
4278
|
+
content: 'deny',
|
|
4279
|
+
externalChatId: 'requester-cancel-chat',
|
|
4280
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4281
|
+
});
|
|
4282
|
+
const res = await handleChannelInbound(
|
|
4283
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4284
|
+
mockConversationGenerator,
|
|
4285
|
+
);
|
|
4286
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4287
|
+
|
|
4288
|
+
expect(body.accepted).toBe(true);
|
|
4289
|
+
expect(body.approval).toBe('decision_applied');
|
|
4290
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
4291
|
+
|
|
4292
|
+
// Guardian approval should be resolved
|
|
4293
|
+
const approval = getPendingApprovalForRun(run.id);
|
|
4294
|
+
expect(approval).toBeNull();
|
|
4295
|
+
|
|
4296
|
+
// Requester should have been notified (cancel notice)
|
|
4297
|
+
const requesterReply = deliverSpy.mock.calls.find(
|
|
4298
|
+
(call) => (call[1] as { chatId?: string }).chatId === 'requester-cancel-chat',
|
|
4299
|
+
);
|
|
4300
|
+
expect(requesterReply).toBeDefined();
|
|
4301
|
+
|
|
4302
|
+
// Guardian should have been notified of the cancellation
|
|
4303
|
+
const guardianNotice = deliverSpy.mock.calls.find(
|
|
4304
|
+
(call) => (call[1] as { chatId?: string }).chatId === 'guardian-cancel-chat',
|
|
4305
|
+
);
|
|
4306
|
+
expect(guardianNotice).toBeDefined();
|
|
4307
|
+
|
|
4308
|
+
deliverSpy.mockRestore();
|
|
4309
|
+
});
|
|
4310
|
+
|
|
4311
|
+
test('requester "nevermind" via conversational engine cancels guardian-gated request', async () => {
|
|
4312
|
+
const orchestrator = makeMockOrchestrator();
|
|
4313
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4314
|
+
|
|
4315
|
+
const initReq = makeInboundRequest({
|
|
4316
|
+
content: 'init',
|
|
4317
|
+
externalChatId: 'requester-cancel-chat',
|
|
4318
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4319
|
+
});
|
|
4320
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4321
|
+
|
|
4322
|
+
const db = getDb();
|
|
4323
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4324
|
+
const conversationId = events[0]?.conversation_id;
|
|
4325
|
+
ensureConversation(conversationId!);
|
|
4326
|
+
|
|
4327
|
+
const run = createRun(conversationId!);
|
|
4328
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4329
|
+
|
|
4330
|
+
createApprovalRequest({
|
|
4331
|
+
runId: run.id,
|
|
4332
|
+
conversationId: conversationId!,
|
|
4333
|
+
assistantId: 'self',
|
|
4334
|
+
channel: 'telegram',
|
|
4335
|
+
requesterExternalUserId: 'requester-cancel-user',
|
|
4336
|
+
requesterChatId: 'requester-cancel-chat',
|
|
4337
|
+
guardianExternalUserId: 'guardian-cancel',
|
|
4338
|
+
guardianChatId: 'guardian-cancel-chat',
|
|
4339
|
+
toolName: 'shell',
|
|
4340
|
+
expiresAt: Date.now() + 300_000,
|
|
4341
|
+
});
|
|
4342
|
+
|
|
4343
|
+
deliverSpy.mockClear();
|
|
4344
|
+
|
|
4345
|
+
// Conversational engine recognises cancel intent and returns reject
|
|
4346
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
4347
|
+
disposition: 'reject' as const,
|
|
4348
|
+
replyText: 'OK, I have cancelled the pending request.',
|
|
4349
|
+
}));
|
|
4350
|
+
|
|
4351
|
+
const req = makeInboundRequest({
|
|
4352
|
+
content: 'actually never mind, cancel it',
|
|
4353
|
+
externalChatId: 'requester-cancel-chat',
|
|
4354
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4355
|
+
});
|
|
4356
|
+
const res = await handleChannelInbound(
|
|
4357
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4358
|
+
mockConversationGenerator,
|
|
4359
|
+
);
|
|
4360
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4361
|
+
|
|
4362
|
+
expect(body.accepted).toBe(true);
|
|
4363
|
+
expect(body.approval).toBe('decision_applied');
|
|
4364
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
4365
|
+
|
|
4366
|
+
// Engine should have been called with reject-only allowed actions
|
|
4367
|
+
expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
|
|
4368
|
+
const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
|
|
4369
|
+
expect(engineCtx.allowedActions).toEqual(['reject']);
|
|
4370
|
+
|
|
4371
|
+
// Engine reply should have been delivered to requester
|
|
4372
|
+
const replyCall = deliverSpy.mock.calls.find(
|
|
4373
|
+
(call) => (call[1] as { text?: string }).text?.includes('cancelled the pending request'),
|
|
4374
|
+
);
|
|
4375
|
+
expect(replyCall).toBeDefined();
|
|
4376
|
+
|
|
4377
|
+
deliverSpy.mockRestore();
|
|
4378
|
+
});
|
|
4379
|
+
|
|
4380
|
+
test('requester cancel returns stale_ignored when pending disappears before apply', async () => {
|
|
4381
|
+
const orchestrator = makeMockOrchestrator();
|
|
4382
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4383
|
+
|
|
4384
|
+
const initReq = makeInboundRequest({
|
|
4385
|
+
content: 'init',
|
|
4386
|
+
externalChatId: 'requester-cancel-race-chat',
|
|
4387
|
+
senderExternalUserId: 'requester-cancel-race-user',
|
|
4388
|
+
});
|
|
4389
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4390
|
+
|
|
4391
|
+
const db = getDb();
|
|
4392
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4393
|
+
const conversationId = events[0]?.conversation_id;
|
|
4394
|
+
ensureConversation(conversationId!);
|
|
4395
|
+
|
|
4396
|
+
const run = createRun(conversationId!);
|
|
4397
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4398
|
+
|
|
4399
|
+
createApprovalRequest({
|
|
4400
|
+
runId: run.id,
|
|
4401
|
+
conversationId: conversationId!,
|
|
4402
|
+
assistantId: 'self',
|
|
4403
|
+
channel: 'telegram',
|
|
4404
|
+
requesterExternalUserId: 'requester-cancel-race-user',
|
|
4405
|
+
requesterChatId: 'requester-cancel-race-chat',
|
|
4406
|
+
guardianExternalUserId: 'guardian-cancel',
|
|
4407
|
+
guardianChatId: 'guardian-cancel-chat',
|
|
4408
|
+
toolName: 'shell',
|
|
4409
|
+
expiresAt: Date.now() + 300_000,
|
|
4410
|
+
});
|
|
4411
|
+
|
|
4412
|
+
deliverSpy.mockClear();
|
|
4413
|
+
|
|
4414
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => {
|
|
4415
|
+
db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
|
|
4416
|
+
return {
|
|
4417
|
+
disposition: 'reject' as const,
|
|
4418
|
+
replyText: 'Cancelling that now.',
|
|
4419
|
+
};
|
|
4420
|
+
});
|
|
4421
|
+
|
|
4422
|
+
const req = makeInboundRequest({
|
|
4423
|
+
content: 'never mind cancel',
|
|
4424
|
+
externalChatId: 'requester-cancel-race-chat',
|
|
4425
|
+
senderExternalUserId: 'requester-cancel-race-user',
|
|
4426
|
+
});
|
|
4427
|
+
const res = await handleChannelInbound(
|
|
4428
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4429
|
+
mockConversationGenerator,
|
|
4430
|
+
);
|
|
4431
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4432
|
+
|
|
4433
|
+
expect(body.accepted).toBe(true);
|
|
4434
|
+
expect(body.approval).toBe('stale_ignored');
|
|
4435
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4436
|
+
|
|
4437
|
+
const staleReply = deliverSpy.mock.calls.find(
|
|
4438
|
+
(call) => (call[1] as { text?: string }).text?.includes('already been resolved'),
|
|
4439
|
+
);
|
|
4440
|
+
expect(staleReply).toBeDefined();
|
|
4441
|
+
|
|
4442
|
+
deliverSpy.mockRestore();
|
|
4443
|
+
});
|
|
4444
|
+
|
|
4445
|
+
test('requester non-cancel message with keep_pending returns conversational reply', async () => {
|
|
4446
|
+
const orchestrator = makeMockOrchestrator();
|
|
4447
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4448
|
+
|
|
4449
|
+
const initReq = makeInboundRequest({
|
|
4450
|
+
content: 'init',
|
|
4451
|
+
externalChatId: 'requester-cancel-chat',
|
|
4452
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4453
|
+
});
|
|
4454
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4455
|
+
|
|
4456
|
+
const db = getDb();
|
|
4457
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4458
|
+
const conversationId = events[0]?.conversation_id;
|
|
4459
|
+
ensureConversation(conversationId!);
|
|
4460
|
+
|
|
4461
|
+
const run = createRun(conversationId!);
|
|
4462
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4463
|
+
|
|
4464
|
+
createApprovalRequest({
|
|
4465
|
+
runId: run.id,
|
|
4466
|
+
conversationId: conversationId!,
|
|
4467
|
+
assistantId: 'self',
|
|
4468
|
+
channel: 'telegram',
|
|
4469
|
+
requesterExternalUserId: 'requester-cancel-user',
|
|
4470
|
+
requesterChatId: 'requester-cancel-chat',
|
|
4471
|
+
guardianExternalUserId: 'guardian-cancel',
|
|
4472
|
+
guardianChatId: 'guardian-cancel-chat',
|
|
4473
|
+
toolName: 'shell',
|
|
4474
|
+
expiresAt: Date.now() + 300_000,
|
|
4475
|
+
});
|
|
4476
|
+
|
|
4477
|
+
deliverSpy.mockClear();
|
|
4478
|
+
|
|
4479
|
+
// Engine returns keep_pending (not a cancel intent)
|
|
4480
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => ({
|
|
4481
|
+
disposition: 'keep_pending' as const,
|
|
4482
|
+
replyText: 'Still waiting.',
|
|
4483
|
+
}));
|
|
4484
|
+
|
|
4485
|
+
const req = makeInboundRequest({
|
|
4486
|
+
content: 'what is happening?',
|
|
4487
|
+
externalChatId: 'requester-cancel-chat',
|
|
4488
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4489
|
+
});
|
|
4490
|
+
const res = await handleChannelInbound(
|
|
4491
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4492
|
+
mockConversationGenerator,
|
|
4493
|
+
);
|
|
4494
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4495
|
+
|
|
4496
|
+
expect(body.accepted).toBe(true);
|
|
4497
|
+
expect(body.approval).toBe('assistant_turn');
|
|
4498
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4499
|
+
|
|
4500
|
+
// Should have received the conversational keep_pending reply
|
|
4501
|
+
const pendingReply = deliverSpy.mock.calls.find(
|
|
4502
|
+
(call) => (call[1] as { text?: string }).text?.includes('Still waiting.'),
|
|
4503
|
+
);
|
|
4504
|
+
expect(pendingReply).toBeDefined();
|
|
4505
|
+
|
|
4506
|
+
deliverSpy.mockRestore();
|
|
4507
|
+
});
|
|
4508
|
+
|
|
4509
|
+
test('requester "approve" is blocked — self-approval not allowed even during cancel check', async () => {
|
|
4510
|
+
const orchestrator = makeMockOrchestrator();
|
|
4511
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4512
|
+
|
|
4513
|
+
const initReq = makeInboundRequest({
|
|
4514
|
+
content: 'init',
|
|
4515
|
+
externalChatId: 'requester-cancel-chat',
|
|
4516
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4517
|
+
});
|
|
4518
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4519
|
+
|
|
4520
|
+
const db = getDb();
|
|
4521
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4522
|
+
const conversationId = events[0]?.conversation_id;
|
|
4523
|
+
ensureConversation(conversationId!);
|
|
4524
|
+
|
|
4525
|
+
const run = createRun(conversationId!);
|
|
4526
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4527
|
+
|
|
4528
|
+
createApprovalRequest({
|
|
4529
|
+
runId: run.id,
|
|
4530
|
+
conversationId: conversationId!,
|
|
4531
|
+
assistantId: 'self',
|
|
4532
|
+
channel: 'telegram',
|
|
4533
|
+
requesterExternalUserId: 'requester-cancel-user',
|
|
4534
|
+
requesterChatId: 'requester-cancel-chat',
|
|
4535
|
+
guardianExternalUserId: 'guardian-cancel',
|
|
4536
|
+
guardianChatId: 'guardian-cancel-chat',
|
|
4537
|
+
toolName: 'shell',
|
|
4538
|
+
expiresAt: Date.now() + 300_000,
|
|
4539
|
+
});
|
|
4540
|
+
|
|
4541
|
+
deliverSpy.mockClear();
|
|
4542
|
+
|
|
4543
|
+
// Requester tries to self-approve while guardian approval is pending.
|
|
4544
|
+
// Self-approval stays blocked in the requester-cancel path.
|
|
4545
|
+
const req = makeInboundRequest({
|
|
4546
|
+
content: 'approve',
|
|
4547
|
+
externalChatId: 'requester-cancel-chat',
|
|
4548
|
+
senderExternalUserId: 'requester-cancel-user',
|
|
4549
|
+
});
|
|
4550
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
4551
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4552
|
+
|
|
4553
|
+
expect(body.accepted).toBe(true);
|
|
4554
|
+
// Should get the guardian-pending notice, NOT decision_applied
|
|
4555
|
+
expect(body.approval).toBe('assistant_turn');
|
|
4556
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4557
|
+
|
|
4558
|
+
deliverSpy.mockRestore();
|
|
4559
|
+
});
|
|
4560
|
+
});
|
|
4561
|
+
|
|
4562
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4563
|
+
// Fix: stale_ignored when engine decision races with concurrent resolution
|
|
4564
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4565
|
+
|
|
4566
|
+
describe('engine decision race condition — standard path', () => {
|
|
4567
|
+
beforeEach(() => {
|
|
4568
|
+
createBinding({
|
|
4569
|
+
assistantId: 'self',
|
|
4570
|
+
channel: 'telegram',
|
|
4571
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
4572
|
+
guardianDeliveryChatId: 'chat-123',
|
|
4573
|
+
});
|
|
4574
|
+
});
|
|
4575
|
+
|
|
4576
|
+
test('returns stale_ignored when engine approves but run was already resolved', async () => {
|
|
4577
|
+
const orchestrator = makeMockOrchestrator();
|
|
4578
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4579
|
+
|
|
4580
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
4581
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4582
|
+
|
|
4583
|
+
const db = getDb();
|
|
4584
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4585
|
+
const conversationId = events[0]?.conversation_id;
|
|
4586
|
+
ensureConversation(conversationId!);
|
|
4587
|
+
|
|
4588
|
+
const run = createRun(conversationId!);
|
|
4589
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4590
|
+
|
|
4591
|
+
deliverSpy.mockClear();
|
|
4592
|
+
|
|
4593
|
+
// Engine returns approve_once, but clears the pending confirmation
|
|
4594
|
+
// before handleChannelDecision is called (simulating race condition)
|
|
4595
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => {
|
|
4596
|
+
db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
|
|
4597
|
+
return {
|
|
4598
|
+
disposition: 'approve_once' as const,
|
|
4599
|
+
replyText: 'Approved! Running the command now.',
|
|
4600
|
+
};
|
|
4601
|
+
});
|
|
4602
|
+
|
|
4603
|
+
const req = makeInboundRequest({ content: 'go ahead' });
|
|
4604
|
+
const res = await handleChannelInbound(
|
|
4605
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4606
|
+
mockConversationGenerator,
|
|
4607
|
+
);
|
|
4608
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4609
|
+
|
|
4610
|
+
expect(body.accepted).toBe(true);
|
|
4611
|
+
expect(body.approval).toBe('stale_ignored');
|
|
4612
|
+
|
|
4613
|
+
// submitDecision should NOT have been called since there was no pending
|
|
4614
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4615
|
+
|
|
4616
|
+
// The engine's optimistic "Approved!" reply should NOT have been delivered
|
|
4617
|
+
const approvedReply = deliverSpy.mock.calls.find(
|
|
4618
|
+
(call) => (call[1] as { text?: string }).text?.includes('Approved!'),
|
|
4619
|
+
);
|
|
4620
|
+
expect(approvedReply).toBeUndefined();
|
|
4621
|
+
|
|
4622
|
+
// A stale notice should have been delivered instead
|
|
4623
|
+
const staleReply = deliverSpy.mock.calls.find(
|
|
4624
|
+
(call) => (call[1] as { text?: string }).text?.includes('already been resolved'),
|
|
4625
|
+
);
|
|
4626
|
+
expect(staleReply).toBeDefined();
|
|
4627
|
+
|
|
4628
|
+
deliverSpy.mockRestore();
|
|
4629
|
+
});
|
|
4630
|
+
});
|
|
4631
|
+
|
|
4632
|
+
describe('engine decision race condition — guardian path', () => {
|
|
4633
|
+
test('returns stale_ignored when guardian engine approves but run was already resolved', async () => {
|
|
4634
|
+
createBinding({
|
|
4635
|
+
assistantId: 'self',
|
|
4636
|
+
channel: 'telegram',
|
|
4637
|
+
guardianExternalUserId: 'guardian-race-user',
|
|
4638
|
+
guardianDeliveryChatId: 'guardian-race-chat',
|
|
4639
|
+
});
|
|
4640
|
+
|
|
4641
|
+
const orchestrator = makeMockOrchestrator();
|
|
4642
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
4643
|
+
|
|
4644
|
+
const initReq = makeInboundRequest({
|
|
4645
|
+
content: 'init',
|
|
4646
|
+
externalChatId: 'requester-race-chat',
|
|
4647
|
+
senderExternalUserId: 'requester-race-user',
|
|
4648
|
+
});
|
|
4649
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
4650
|
+
|
|
4651
|
+
const db = getDb();
|
|
4652
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
4653
|
+
const conversationId = events[0]?.conversation_id;
|
|
4654
|
+
ensureConversation(conversationId!);
|
|
4655
|
+
|
|
4656
|
+
const run = createRun(conversationId!);
|
|
4657
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
4658
|
+
|
|
4659
|
+
createApprovalRequest({
|
|
4660
|
+
runId: run.id,
|
|
4661
|
+
conversationId: conversationId!,
|
|
4662
|
+
assistantId: 'self',
|
|
4663
|
+
channel: 'telegram',
|
|
4664
|
+
requesterExternalUserId: 'requester-race-user',
|
|
4665
|
+
requesterChatId: 'requester-race-chat',
|
|
4666
|
+
guardianExternalUserId: 'guardian-race-user',
|
|
4667
|
+
guardianChatId: 'guardian-race-chat',
|
|
4668
|
+
toolName: 'shell',
|
|
4669
|
+
expiresAt: Date.now() + 300_000,
|
|
4670
|
+
});
|
|
4671
|
+
|
|
4672
|
+
deliverSpy.mockClear();
|
|
4673
|
+
|
|
4674
|
+
// Guardian engine returns approve_once, but clears pending confirmation
|
|
4675
|
+
// to simulate a concurrent resolution (expiry sweep or requester cancel)
|
|
4676
|
+
const mockConversationGenerator = mock(async (_ctx: unknown) => {
|
|
4677
|
+
db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
|
|
4678
|
+
return {
|
|
4679
|
+
disposition: 'approve_once' as const,
|
|
4680
|
+
replyText: 'Approved the request.',
|
|
4681
|
+
};
|
|
4682
|
+
});
|
|
4683
|
+
|
|
4684
|
+
const guardianReq = makeInboundRequest({
|
|
4685
|
+
content: 'approve it',
|
|
4686
|
+
externalChatId: 'guardian-race-chat',
|
|
4687
|
+
senderExternalUserId: 'guardian-race-user',
|
|
4688
|
+
});
|
|
4689
|
+
const res = await handleChannelInbound(
|
|
4690
|
+
guardianReq, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
4691
|
+
mockConversationGenerator,
|
|
4692
|
+
);
|
|
4693
|
+
const body = await res.json() as Record<string, unknown>;
|
|
4694
|
+
|
|
4695
|
+
expect(body.accepted).toBe(true);
|
|
4696
|
+
expect(body.approval).toBe('stale_ignored');
|
|
4697
|
+
|
|
4698
|
+
// submitDecision should NOT have been called
|
|
4699
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
4700
|
+
|
|
4701
|
+
// The engine's "Approved the request." should NOT be delivered
|
|
4702
|
+
const optimisticReply = deliverSpy.mock.calls.find(
|
|
4703
|
+
(call) => (call[1] as { text?: string }).text?.includes('Approved the request'),
|
|
4704
|
+
);
|
|
4705
|
+
expect(optimisticReply).toBeUndefined();
|
|
4706
|
+
|
|
4707
|
+
// A stale notice should have been delivered instead
|
|
4708
|
+
const staleReply = deliverSpy.mock.calls.find(
|
|
4709
|
+
(call) => (call[1] as { text?: string }).text?.includes('already been resolved'),
|
|
4710
|
+
);
|
|
4711
|
+
expect(staleReply).toBeDefined();
|
|
4712
|
+
|
|
4713
|
+
deliverSpy.mockRestore();
|
|
4714
|
+
});
|
|
4715
|
+
});
|