@vellumai/assistant 0.3.5 → 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/README.md +51 -0
- 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 +18 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +338 -1
- package/src/__tests__/approval-conversation-turn.test.ts +214 -0
- package/src/__tests__/browser-manager.test.ts +1 -0
- package/src/__tests__/call-conversation-messages.test.ts +130 -0
- package/src/__tests__/call-orchestrator.test.ts +752 -271
- 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 +5 -0
- package/src/__tests__/call-store.test.ts +3 -0
- package/src/__tests__/channel-approval-routes.test.ts +1260 -85
- package/src/__tests__/channel-approval.test.ts +37 -0
- package/src/__tests__/channel-approvals.test.ts +4 -65
- package/src/__tests__/channel-guardian.test.ts +556 -0
- package/src/__tests__/channel-readiness-service.test.ts +74 -7
- 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 +12 -7
- 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 +6 -2
- package/src/__tests__/db-migration-rollback.test.ts +752 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -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 +126 -0
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +228 -1
- package/src/__tests__/memory-upsert-concurrency.test.ts +828 -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 +2 -0
- package/src/__tests__/run-orchestrator.test.ts +20 -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 +237 -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 +2 -1
- 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 +141 -21
- 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 +45 -29
- 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 +106 -5
- package/src/calls/call-orchestrator.ts +252 -54
- 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 +7 -5
- package/src/calls/twilio-provider.ts +6 -4
- package/src/calls/twilio-rest.ts +40 -15
- package/src/calls/twilio-routes.ts +60 -45
- 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/macos-automation/icon.svg +12 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +72 -95
- package/src/config/bundled-skills/media-processing/TOOLS.json +57 -147
- 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 +7 -9
- package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +88 -253
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +22 -153
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +2 -2
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +28 -51
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +35 -270
- package/src/config/bundled-skills/messaging/SKILL.md +12 -2
- 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/phone-calls/SKILL.md +86 -21
- 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 +26 -2
- 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 +156 -1137
- 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 +107 -56
- 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 +0 -7
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +112 -14
- 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 -2463
- package/src/daemon/handlers/diagnostics.ts +1 -1
- package/src/daemon/handlers/dictation.ts +4 -6
- package/src/daemon/handlers/documents.ts +18 -32
- package/src/daemon/handlers/index.ts +9 -0
- package/src/daemon/handlers/misc.ts +3 -5
- package/src/daemon/handlers/pairing.ts +98 -0
- package/src/daemon/handlers/sessions.ts +54 -5
- package/src/daemon/handlers/shared.ts +3 -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 +2 -2
- 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 +60 -0
- package/src/daemon/ipc-contract-inventory.ts +55 -29
- package/src/daemon/ipc-contract.ts +226 -2527
- package/src/daemon/ipc-protocol.ts +1 -1
- package/src/daemon/ipc-validate.ts +7 -0
- package/src/daemon/lifecycle.ts +97 -379
- package/src/daemon/pairing-store.ts +177 -0
- package/src/daemon/providers-setup.ts +43 -0
- package/src/daemon/ride-shotgun-handler.ts +67 -2
- package/src/daemon/server.ts +60 -44
- package/src/daemon/session-agent-loop-handlers.ts +421 -0
- package/src/daemon/session-agent-loop.ts +113 -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 +149 -15
- package/src/daemon/session-surfaces.ts +19 -4
- package/src/daemon/session-tool-setup.ts +28 -30
- package/src/daemon/session-workspace.ts +1 -1
- package/src/daemon/session.ts +24 -1
- 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 +3 -1
- 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 +200 -1
- 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 +121 -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 +11 -42
- package/src/memory/job-handlers/summarization.ts +32 -26
- package/src/memory/job-utils.ts +3 -10
- package/src/memory/jobs-store.ts +6 -9
- package/src/memory/jobs-worker.ts +51 -36
- 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 +160 -47
- package/src/memory/schema-migration.ts +25 -984
- package/src/memory/schema.ts +130 -7
- package/src/memory/search/entity.ts +10 -19
- package/src/memory/search/lexical.ts +81 -52
- package/src/memory/search/ranking.ts +21 -22
- package/src/memory/search/semantic.ts +157 -19
- 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/providers/sms/adapter.ts +3 -6
- 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 +119 -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 +115 -5
- package/src/runtime/channel-approval-parser.ts +36 -2
- package/src/runtime/channel-approvals.ts +0 -21
- package/src/runtime/channel-guardian-service.ts +48 -7
- package/src/runtime/channel-readiness-service.ts +160 -34
- package/src/runtime/channel-readiness-types.ts +10 -4
- 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 -743
- package/src/runtime/http-types.ts +56 -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 +49 -6
- 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 -1634
- 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 +52 -34
- 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/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/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/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 +7 -3
- 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/watcher/delete.ts +6 -0
- package/src/tools/weather/service.ts +1 -1
- package/src/twitter/client.ts +190 -24
- 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 +72 -365
- 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/bundled-skills/media-processing/services/capability-registry.ts +0 -137
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +0 -280
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +0 -144
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +0 -136
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +0 -95
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +0 -267
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +0 -110
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +0 -235
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +0 -142
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +0 -150
- 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 () => {
|
|
@@ -1755,7 +1762,7 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1755
1762
|
});
|
|
1756
1763
|
});
|
|
1757
1764
|
|
|
1758
|
-
describe('
|
|
1765
|
+
describe('non-decision status reply for different channels', () => {
|
|
1759
1766
|
beforeEach(() => {
|
|
1760
1767
|
createBinding({
|
|
1761
1768
|
assistantId: 'self',
|
|
@@ -1765,19 +1772,18 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
|
1765
1772
|
});
|
|
1766
1773
|
createBinding({
|
|
1767
1774
|
assistantId: 'self',
|
|
1768
|
-
channel: '
|
|
1775
|
+
channel: 'sms',
|
|
1769
1776
|
guardianExternalUserId: 'telegram-user-default',
|
|
1770
1777
|
guardianDeliveryChatId: 'chat-123',
|
|
1771
1778
|
});
|
|
1772
1779
|
});
|
|
1773
1780
|
|
|
1774
|
-
test('
|
|
1781
|
+
test('non-decision message on non-rich channel (sms) sends status reply', async () => {
|
|
1775
1782
|
const orchestrator = makeMockOrchestrator();
|
|
1776
|
-
const deliverSpy = spyOn(gatewayClient, '
|
|
1777
|
-
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1783
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1778
1784
|
|
|
1779
|
-
// Establish the conversation using
|
|
1780
|
-
const initReq = makeInboundRequest({ content: 'init', sourceChannel: '
|
|
1785
|
+
// Establish the conversation using sms (non-rich channel)
|
|
1786
|
+
const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'sms' });
|
|
1781
1787
|
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
1782
1788
|
|
|
1783
1789
|
const db = getDb();
|
|
@@ -1788,30 +1794,28 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
|
1788
1794
|
const run = createRun(conversationId!);
|
|
1789
1795
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
1790
1796
|
|
|
1791
|
-
// Send a non-decision message
|
|
1792
|
-
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: '
|
|
1797
|
+
// Send a non-decision message
|
|
1798
|
+
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'sms' });
|
|
1793
1799
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1794
1800
|
const body = await res.json() as Record<string, unknown>;
|
|
1795
1801
|
|
|
1796
1802
|
expect(body.accepted).toBe(true);
|
|
1797
|
-
expect(body.approval).toBe('
|
|
1803
|
+
expect(body.approval).toBe('assistant_turn');
|
|
1798
1804
|
|
|
1799
|
-
//
|
|
1805
|
+
// Status reply delivered via deliverChannelReply
|
|
1800
1806
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1801
|
-
const
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
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');
|
|
1807
1813
|
|
|
1808
1814
|
deliverSpy.mockRestore();
|
|
1809
|
-
replySpy.mockRestore();
|
|
1810
1815
|
});
|
|
1811
1816
|
|
|
1812
|
-
test('
|
|
1817
|
+
test('non-decision message on telegram sends status reply', async () => {
|
|
1813
1818
|
const orchestrator = makeMockOrchestrator();
|
|
1814
|
-
const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1815
1819
|
const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1816
1820
|
|
|
1817
1821
|
// Establish the conversation using telegram (rich channel)
|
|
@@ -1826,25 +1830,23 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
|
1826
1830
|
const run = createRun(conversationId!);
|
|
1827
1831
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
1828
1832
|
|
|
1829
|
-
// Send a non-decision message
|
|
1833
|
+
// Send a non-decision message
|
|
1830
1834
|
const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'telegram' });
|
|
1831
1835
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1832
1836
|
const body = await res.json() as Record<string, unknown>;
|
|
1833
1837
|
|
|
1834
1838
|
expect(body.accepted).toBe(true);
|
|
1835
|
-
expect(body.approval).toBe('
|
|
1839
|
+
expect(body.approval).toBe('assistant_turn');
|
|
1836
1840
|
|
|
1837
|
-
//
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
expect(
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
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');
|
|
1846
1849
|
|
|
1847
|
-
deliverSpy.mockRestore();
|
|
1848
1850
|
replySpy.mockRestore();
|
|
1849
1851
|
});
|
|
1850
1852
|
});
|
|
@@ -2188,14 +2190,14 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2188
2190
|
});
|
|
2189
2191
|
|
|
2190
2192
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2191
|
-
// 20. Guardian delivery failure
|
|
2193
|
+
// 20. Guardian rich-delivery failure fallback (WS-2)
|
|
2192
2194
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2193
2195
|
|
|
2194
|
-
describe('guardian delivery failure →
|
|
2196
|
+
describe('guardian delivery failure → text fallback', () => {
|
|
2195
2197
|
beforeEach(() => {
|
|
2196
2198
|
});
|
|
2197
2199
|
|
|
2198
|
-
test('delivery failure
|
|
2200
|
+
test('rich delivery failure falls back to plain text and keeps request pending', async () => {
|
|
2199
2201
|
createBinding({
|
|
2200
2202
|
assistantId: 'self',
|
|
2201
2203
|
channel: 'telegram',
|
|
@@ -2220,29 +2222,30 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2220
2222
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2221
2223
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2222
2224
|
|
|
2223
|
-
//
|
|
2224
|
-
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2225
|
-
|
|
2226
|
-
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();
|
|
2227
2228
|
|
|
2228
|
-
//
|
|
2229
|
-
const
|
|
2230
|
-
(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"'),
|
|
2231
2235
|
);
|
|
2232
|
-
expect(
|
|
2236
|
+
expect(guardianPromptCalls.length).toBeGreaterThanOrEqual(1);
|
|
2233
2237
|
|
|
2234
|
-
//
|
|
2235
|
-
// delivered (since delivery failed).
|
|
2238
|
+
// Requester should still get the forwarded notice once fallback delivery works.
|
|
2236
2239
|
const successCalls = deliverSpy.mock.calls.filter(
|
|
2237
2240
|
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
|
|
2238
2241
|
);
|
|
2239
|
-
expect(successCalls.length).
|
|
2242
|
+
expect(successCalls.length).toBeGreaterThanOrEqual(1);
|
|
2240
2243
|
|
|
2241
2244
|
deliverSpy.mockRestore();
|
|
2242
2245
|
approvalSpy.mockRestore();
|
|
2243
2246
|
});
|
|
2244
2247
|
|
|
2245
|
-
test('
|
|
2248
|
+
test('terminal run resolution clears approvals even when rich delivery falls back to text', async () => {
|
|
2246
2249
|
createBinding({
|
|
2247
2250
|
assistantId: 'self',
|
|
2248
2251
|
channel: 'telegram',
|
|
@@ -2265,15 +2268,19 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2265
2268
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2266
2269
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2267
2270
|
|
|
2271
|
+
// Rich delivery failure alone should not apply an explicit deny decision.
|
|
2272
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2273
|
+
|
|
2268
2274
|
// Verify the run ID was created
|
|
2269
2275
|
const runId = orchestrator.realRunId();
|
|
2270
2276
|
expect(runId).toBeTruthy();
|
|
2271
2277
|
|
|
2272
|
-
//
|
|
2278
|
+
// This test orchestrator transitions the run to a terminal failed state,
|
|
2279
|
+
// which resolves the approval record via run-completion cleanup.
|
|
2273
2280
|
const pendingApproval = getPendingApprovalForRun(runId!);
|
|
2274
2281
|
expect(pendingApproval).toBeNull();
|
|
2275
2282
|
|
|
2276
|
-
//
|
|
2283
|
+
// No unresolved approval should remain after terminal resolution.
|
|
2277
2284
|
const unresolvedApproval = getUnresolvedApprovalForRun(runId!);
|
|
2278
2285
|
expect(unresolvedApproval).toBeNull();
|
|
2279
2286
|
|
|
@@ -2283,14 +2290,14 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2283
2290
|
});
|
|
2284
2291
|
|
|
2285
2292
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2286
|
-
// 20b. Standard
|
|
2293
|
+
// 20b. Standard rich prompt delivery failure → text fallback (WS-B)
|
|
2287
2294
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2288
2295
|
|
|
2289
|
-
describe('standard approval prompt delivery failure →
|
|
2296
|
+
describe('standard approval prompt delivery failure → text fallback', () => {
|
|
2290
2297
|
beforeEach(() => {
|
|
2291
2298
|
});
|
|
2292
2299
|
|
|
2293
|
-
test('standard prompt delivery failure
|
|
2300
|
+
test('standard prompt rich-delivery failure falls back to plain text without auto-deny', async () => {
|
|
2294
2301
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2295
2302
|
// Make the approval prompt delivery fail for the standard (self-approval) path
|
|
2296
2303
|
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
|
|
@@ -2317,10 +2324,16 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
|
2317
2324
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2318
2325
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2319
2326
|
|
|
2320
|
-
|
|
2321
|
-
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2322
|
-
|
|
2323
|
-
|
|
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);
|
|
2324
2337
|
|
|
2325
2338
|
deliverSpy.mockRestore();
|
|
2326
2339
|
approvalSpy.mockRestore();
|
|
@@ -2467,18 +2480,28 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2467
2480
|
|
|
2468
2481
|
const orchestrator = makeMockOrchestrator();
|
|
2469
2482
|
|
|
2470
|
-
//
|
|
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.
|
|
2471
2491
|
const req = makeInboundRequest({
|
|
2472
2492
|
content: 'yes',
|
|
2473
2493
|
externalChatId: 'guardian-ambig-chat',
|
|
2474
2494
|
senderExternalUserId: 'guardian-ambig-user',
|
|
2475
2495
|
});
|
|
2476
2496
|
|
|
2477
|
-
const res = await handleChannelInbound(
|
|
2497
|
+
const res = await handleChannelInbound(
|
|
2498
|
+
req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
|
|
2499
|
+
mockConversationGenerator,
|
|
2500
|
+
);
|
|
2478
2501
|
const body = await res.json() as Record<string, unknown>;
|
|
2479
2502
|
|
|
2480
2503
|
expect(body.accepted).toBe(true);
|
|
2481
|
-
expect(body.approval).toBe('
|
|
2504
|
+
expect(body.approval).toBe('assistant_turn');
|
|
2482
2505
|
|
|
2483
2506
|
// Neither approval should have been resolved — disambiguation was required
|
|
2484
2507
|
const approvalA = getPendingApprovalForRun(runA.id);
|
|
@@ -2489,9 +2512,14 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2489
2512
|
// submitDecision should NOT have been called — no decision was applied
|
|
2490
2513
|
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
2491
2514
|
|
|
2492
|
-
//
|
|
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
|
|
2493
2521
|
const disambigCalls = deliverSpy.mock.calls.filter(
|
|
2494
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.
|
|
2522
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending'),
|
|
2495
2523
|
);
|
|
2496
2524
|
expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
|
|
2497
2525
|
|
|
@@ -3538,3 +3566,1150 @@ describe('unknown actor identity — forceStrictSideEffects', () => {
|
|
|
3538
3566
|
deliverSpy.mockRestore();
|
|
3539
3567
|
});
|
|
3540
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
|
+
});
|