@vellumai/assistant 0.3.5 → 0.3.7
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 +27 -3
- package/src/config/env-registry.ts +169 -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 +157 -1138
- 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 +254 -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 +74 -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 +321 -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 +62 -0
- package/src/daemon/ipc-contract-inventory.ts +55 -29
- package/src/daemon/ipc-contract.ts +227 -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 +98 -4
- package/src/daemon/session-runtime-assembly.ts +149 -15
- package/src/daemon/session-surfaces.ts +26 -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 +12 -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 +163 -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 +126 -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/assistant-event-hub.ts +3 -1
- 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 +289 -745
- 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 +144 -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 +13 -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 +80 -18
- 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 +25 -18
- 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
|
@@ -2,7 +2,6 @@ import { describe, test, expect, beforeEach, afterAll, mock, type Mock } from 'b
|
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { EventEmitter } from 'node:events';
|
|
6
5
|
|
|
7
6
|
const testDir = mkdtempSync(join(tmpdir(), 'call-orchestrator-test-'));
|
|
8
7
|
|
|
@@ -18,6 +17,7 @@ mock.module('../util/platform.js', () => ({
|
|
|
18
17
|
getDbPath: () => join(testDir, 'test.db'),
|
|
19
18
|
getLogPath: () => join(testDir, 'test.log'),
|
|
20
19
|
ensureDataDir: () => {},
|
|
20
|
+
readHttpToken: () => null,
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
23
|
mock.module('../util/logger.js', () => ({
|
|
@@ -42,6 +42,8 @@ let mockDisclosure: { enabled: boolean; text: string } = { enabled: false, text:
|
|
|
42
42
|
|
|
43
43
|
mock.module('../config/loader.js', () => ({
|
|
44
44
|
getConfig: () => ({
|
|
45
|
+
provider: 'anthropic',
|
|
46
|
+
providerOrder: ['anthropic'],
|
|
45
47
|
apiKeys: { anthropic: 'test-key' },
|
|
46
48
|
calls: {
|
|
47
49
|
enabled: true,
|
|
@@ -54,54 +56,80 @@ mock.module('../config/loader.js', () => ({
|
|
|
54
56
|
safety: { denyCategories: [] },
|
|
55
57
|
model: mockCallModel,
|
|
56
58
|
},
|
|
59
|
+
memory: { enabled: false },
|
|
57
60
|
}),
|
|
58
61
|
}));
|
|
59
62
|
|
|
60
|
-
// ── Helpers for building mock
|
|
63
|
+
// ── Helpers for building mock provider responses ────────────────────
|
|
61
64
|
|
|
62
65
|
/**
|
|
63
|
-
* Creates a mock
|
|
64
|
-
* for each token and resolves
|
|
66
|
+
* Creates a mock provider sendMessage implementation that emits text_delta
|
|
67
|
+
* events for each token and resolves with the full response.
|
|
65
68
|
*/
|
|
66
|
-
function
|
|
67
|
-
const emitter = new EventEmitter();
|
|
69
|
+
function createMockProviderResponse(tokens: string[]) {
|
|
68
70
|
const fullText = tokens.join('');
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
71
|
+
return async (
|
|
72
|
+
_messages: unknown[],
|
|
73
|
+
_tools: unknown[],
|
|
74
|
+
_systemPrompt: string,
|
|
75
|
+
options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal },
|
|
76
|
+
) => {
|
|
77
|
+
// Emit text_delta events for each token
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: 'text', text: fullText }],
|
|
83
|
+
model: 'claude-sonnet-4-20250514',
|
|
84
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
85
|
+
stopReason: 'end_turn',
|
|
86
|
+
};
|
|
85
87
|
};
|
|
86
|
-
|
|
87
|
-
return stream;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// ──
|
|
90
|
+
// ── Provider registry mock ──────────────────────────────────────────
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
let mockSendMessage: Mock<any>;
|
|
93
94
|
|
|
94
|
-
mock.module('
|
|
95
|
-
|
|
95
|
+
mock.module('../providers/registry.js', () => {
|
|
96
|
+
mockSendMessage = mock(createMockProviderResponse(['Hello', ' there']));
|
|
96
97
|
return {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
listProviders: () => ['anthropic'],
|
|
99
|
+
getFailoverProvider: () => ({
|
|
100
|
+
name: 'anthropic',
|
|
101
|
+
sendMessage: (...args: unknown[]) => mockSendMessage(...args),
|
|
102
|
+
}),
|
|
103
|
+
getDefaultModel: (providerName: string) => {
|
|
104
|
+
const defaults: Record<string, string> = {
|
|
105
|
+
anthropic: 'claude-opus-4-6',
|
|
106
|
+
openai: 'gpt-5.2',
|
|
107
|
+
gemini: 'gemini-3-flash',
|
|
108
|
+
ollama: 'llama3.2',
|
|
109
|
+
fireworks: 'accounts/fireworks/models/kimi-k2p5',
|
|
110
|
+
openrouter: 'x-ai/grok-4',
|
|
100
111
|
};
|
|
112
|
+
return defaults[providerName] ?? defaults.anthropic;
|
|
101
113
|
},
|
|
102
114
|
};
|
|
103
115
|
});
|
|
104
116
|
|
|
117
|
+
mock.module('../providers/provider-send-message.js', () => ({
|
|
118
|
+
resolveConfiguredProvider: () => ({
|
|
119
|
+
provider: {
|
|
120
|
+
name: 'anthropic',
|
|
121
|
+
sendMessage: (...args: unknown[]) => mockSendMessage(...args),
|
|
122
|
+
},
|
|
123
|
+
configuredProviderName: 'anthropic',
|
|
124
|
+
selectedProviderName: 'anthropic',
|
|
125
|
+
usedFallbackPrimary: false,
|
|
126
|
+
}),
|
|
127
|
+
getConfiguredProvider: () => ({
|
|
128
|
+
name: 'anthropic',
|
|
129
|
+
sendMessage: (...args: unknown[]) => mockSendMessage(...args),
|
|
130
|
+
}),
|
|
131
|
+
}));
|
|
132
|
+
|
|
105
133
|
// ── Import source modules after all mocks are registered ────────────
|
|
106
134
|
|
|
107
135
|
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
@@ -177,9 +205,13 @@ function ensureConversation(id: string): void {
|
|
|
177
205
|
|
|
178
206
|
function resetTables() {
|
|
179
207
|
const db = getDb();
|
|
208
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
209
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
180
210
|
db.run('DELETE FROM call_pending_questions');
|
|
181
211
|
db.run('DELETE FROM call_events');
|
|
182
212
|
db.run('DELETE FROM call_sessions');
|
|
213
|
+
db.run('DELETE FROM tool_invocations');
|
|
214
|
+
db.run('DELETE FROM messages');
|
|
183
215
|
db.run('DELETE FROM conversations');
|
|
184
216
|
ensuredConvIds = new Set();
|
|
185
217
|
}
|
|
@@ -208,14 +240,14 @@ describe('call-orchestrator', () => {
|
|
|
208
240
|
mockCallModel = undefined;
|
|
209
241
|
mockUserReference = 'my human';
|
|
210
242
|
mockDisclosure = { enabled: false, text: '' };
|
|
211
|
-
// Reset the
|
|
212
|
-
|
|
243
|
+
// Reset the provider mock to default behaviour
|
|
244
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hello', ' there']));
|
|
213
245
|
});
|
|
214
246
|
|
|
215
247
|
// ── handleCallerUtterance ─────────────────────────────────────────
|
|
216
248
|
|
|
217
249
|
test('handleCallerUtterance: streams tokens via sendTextToken', async () => {
|
|
218
|
-
|
|
250
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hi', ', how', ' are you?']));
|
|
219
251
|
const { relay, orchestrator } = setupOrchestrator();
|
|
220
252
|
|
|
221
253
|
await orchestrator.handleCallerUtterance('Hello');
|
|
@@ -231,7 +263,7 @@ describe('call-orchestrator', () => {
|
|
|
231
263
|
});
|
|
232
264
|
|
|
233
265
|
test('handleCallerUtterance: sends last=true at end of turn', async () => {
|
|
234
|
-
|
|
266
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Simple response.']));
|
|
235
267
|
const { relay, orchestrator } = setupOrchestrator();
|
|
236
268
|
|
|
237
269
|
await orchestrator.handleCallerUtterance('Test');
|
|
@@ -244,12 +276,18 @@ describe('call-orchestrator', () => {
|
|
|
244
276
|
});
|
|
245
277
|
|
|
246
278
|
test('handleCallerUtterance: includes speaker context in model message', async () => {
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
const userMessage =
|
|
250
|
-
|
|
251
|
-
expect(
|
|
252
|
-
|
|
279
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
|
|
280
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
281
|
+
const userMessage = msgs.find((m) => m.role === 'user');
|
|
282
|
+
const userText = userMessage?.content?.[0]?.text ?? '';
|
|
283
|
+
expect(userText).toContain('[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]');
|
|
284
|
+
expect(userText).toContain('Can you summarize this meeting?');
|
|
285
|
+
return {
|
|
286
|
+
content: [{ type: 'text', text: 'Sure, here is a summary.' }],
|
|
287
|
+
model: 'claude-sonnet-4-20250514',
|
|
288
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
289
|
+
stopReason: 'end_turn',
|
|
290
|
+
};
|
|
253
291
|
});
|
|
254
292
|
|
|
255
293
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -264,12 +302,89 @@ describe('call-orchestrator', () => {
|
|
|
264
302
|
orchestrator.destroy();
|
|
265
303
|
});
|
|
266
304
|
|
|
267
|
-
|
|
305
|
+
test('startInitialGreeting: generates model-driven opening and strips control marker from speech', async () => {
|
|
306
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
|
|
307
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
308
|
+
const firstUser = msgs.find((m) => m.role === 'user');
|
|
309
|
+
expect(firstUser?.content?.[0]?.text).toContain('[CALL_OPENING]');
|
|
310
|
+
const tokens = ['Hi, I am calling about your appointment request. Is now a good time to talk?'];
|
|
311
|
+
const opts = _rest[2] as { onEvent?: (event: { type: string; text?: string }) => void } | undefined;
|
|
312
|
+
for (const token of tokens) {
|
|
313
|
+
opts?.onEvent?.({ type: 'text_delta', text: token });
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
317
|
+
model: 'claude-sonnet-4-20250514',
|
|
318
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
319
|
+
stopReason: 'end_turn',
|
|
320
|
+
};
|
|
321
|
+
});
|
|
268
322
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
);
|
|
323
|
+
const { relay, orchestrator } = setupOrchestrator('Confirm appointment');
|
|
324
|
+
|
|
325
|
+
const callCountBefore = mockSendMessage.mock.calls.length;
|
|
326
|
+
await orchestrator.startInitialGreeting();
|
|
327
|
+
await orchestrator.startInitialGreeting();
|
|
328
|
+
|
|
329
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
330
|
+
expect(allText).toContain('appointment request');
|
|
331
|
+
expect(allText).toContain('good time to talk');
|
|
332
|
+
expect(allText).not.toContain('[CALL_OPENING]');
|
|
333
|
+
expect(mockSendMessage.mock.calls.length - callCountBefore).toBe(1);
|
|
334
|
+
|
|
335
|
+
orchestrator.destroy();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('startInitialGreeting: tags only the first caller response with CALL_OPENING_ACK', async () => {
|
|
339
|
+
let callCount = 0;
|
|
340
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
341
|
+
callCount++;
|
|
342
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
343
|
+
const userMessages = msgs.filter((m) => m.role === 'user');
|
|
344
|
+
const lastUser = userMessages[userMessages.length - 1]?.content?.[0]?.text ?? '';
|
|
345
|
+
|
|
346
|
+
let tokens: string[];
|
|
347
|
+
if (callCount === 1) {
|
|
348
|
+
expect(lastUser).toContain('[CALL_OPENING]');
|
|
349
|
+
tokens = ['Hey Noa, it\'s Credence calling about your joke request. Is now okay for a quick one?'];
|
|
350
|
+
} else if (callCount === 2) {
|
|
351
|
+
expect(lastUser).toContain('[CALL_OPENING_ACK]');
|
|
352
|
+
expect(lastUser).toContain('Yeah. Sure. What\'s up?');
|
|
353
|
+
tokens = ['Great, here\'s one right away. Why did the scarecrow win an award?'];
|
|
354
|
+
} else {
|
|
355
|
+
expect(lastUser).not.toContain('[CALL_OPENING_ACK]');
|
|
356
|
+
expect(lastUser).toContain('Tell me the punchline');
|
|
357
|
+
tokens = ['Because he was outstanding in his field.'];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const token of tokens) {
|
|
361
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
365
|
+
model: 'claude-sonnet-4-20250514',
|
|
366
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
367
|
+
stopReason: 'end_turn',
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const { orchestrator } = setupOrchestrator('Tell a joke immediately');
|
|
372
|
+
|
|
373
|
+
await orchestrator.startInitialGreeting();
|
|
374
|
+
await orchestrator.handleCallerUtterance('Yeah. Sure. What\'s up?');
|
|
375
|
+
await orchestrator.handleCallerUtterance('Tell me the punchline');
|
|
376
|
+
|
|
377
|
+
expect(callCount).toBe(3);
|
|
378
|
+
|
|
379
|
+
orchestrator.destroy();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ── ASK_GUARDIAN pattern ──────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
test('ASK_GUARDIAN pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
|
|
385
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
386
|
+
['Let me check on that. ', '[ASK_GUARDIAN: What date works best?]'],
|
|
387
|
+
));
|
|
273
388
|
const { session, relay, orchestrator } = setupOrchestrator('Book appointment');
|
|
274
389
|
|
|
275
390
|
await orchestrator.handleCallerUtterance('I need to schedule something');
|
|
@@ -284,22 +399,21 @@ describe('call-orchestrator', () => {
|
|
|
284
399
|
const updatedSession = getCallSession(session.id);
|
|
285
400
|
expect(updatedSession!.status).toBe('waiting_on_user');
|
|
286
401
|
|
|
287
|
-
// The
|
|
402
|
+
// The ASK_GUARDIAN marker text should NOT appear in the relay tokens
|
|
288
403
|
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
289
|
-
expect(allText).not.toContain('[
|
|
404
|
+
expect(allText).not.toContain('[ASK_GUARDIAN:');
|
|
290
405
|
|
|
291
406
|
orchestrator.destroy();
|
|
292
407
|
});
|
|
293
408
|
|
|
294
|
-
test('strips
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
);
|
|
409
|
+
test('strips internal context markers from spoken output', async () => {
|
|
410
|
+
mockSendMessage.mockImplementation(createMockProviderResponse([
|
|
411
|
+
'Thanks for waiting. ',
|
|
412
|
+
'[USER_ANSWERED: The guardian said 3 PM works.] ',
|
|
413
|
+
'[USER_INSTRUCTION: Keep this short.] ',
|
|
414
|
+
'[CALL_OPENING_ACK] ',
|
|
415
|
+
'I can confirm 3 PM works.',
|
|
416
|
+
]));
|
|
303
417
|
const { relay, orchestrator } = setupOrchestrator();
|
|
304
418
|
|
|
305
419
|
await orchestrator.handleCallerUtterance('Any update?');
|
|
@@ -309,8 +423,10 @@ describe('call-orchestrator', () => {
|
|
|
309
423
|
expect(allText).toContain('I can confirm 3 PM works.');
|
|
310
424
|
expect(allText).not.toContain('[USER_ANSWERED:');
|
|
311
425
|
expect(allText).not.toContain('[USER_INSTRUCTION:');
|
|
426
|
+
expect(allText).not.toContain('[CALL_OPENING_ACK]');
|
|
312
427
|
expect(allText).not.toContain('USER_ANSWERED');
|
|
313
428
|
expect(allText).not.toContain('USER_INSTRUCTION');
|
|
429
|
+
expect(allText).not.toContain('CALL_OPENING_ACK');
|
|
314
430
|
|
|
315
431
|
orchestrator.destroy();
|
|
316
432
|
});
|
|
@@ -318,9 +434,9 @@ describe('call-orchestrator', () => {
|
|
|
318
434
|
// ── END_CALL pattern ──────────────────────────────────────────────
|
|
319
435
|
|
|
320
436
|
test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
);
|
|
437
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
438
|
+
['Thank you for calling, goodbye! ', '[END_CALL]'],
|
|
439
|
+
));
|
|
324
440
|
const { session, relay, orchestrator } = setupOrchestrator();
|
|
325
441
|
|
|
326
442
|
await orchestrator.handleCallerUtterance('That is all, thanks');
|
|
@@ -343,21 +459,31 @@ describe('call-orchestrator', () => {
|
|
|
343
459
|
// ── handleUserAnswer ──────────────────────────────────────────────
|
|
344
460
|
|
|
345
461
|
test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
|
|
346
|
-
// First utterance triggers
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
);
|
|
462
|
+
// First utterance triggers ASK_GUARDIAN
|
|
463
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
464
|
+
['Hold on. [ASK_GUARDIAN: Preferred time?]'],
|
|
465
|
+
));
|
|
350
466
|
const { relay, orchestrator } = setupOrchestrator();
|
|
351
467
|
|
|
352
468
|
await orchestrator.handleCallerUtterance('I need an appointment');
|
|
353
469
|
|
|
354
470
|
// Now provide the answer — reset mock for second LLM call
|
|
355
|
-
|
|
471
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
|
|
356
472
|
// Verify the messages include the USER_ANSWERED marker
|
|
357
|
-
const
|
|
358
|
-
const lastUserMsg =
|
|
359
|
-
expect(lastUserMsg?.content).toContain('[USER_ANSWERED: 3pm tomorrow]');
|
|
360
|
-
|
|
473
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
474
|
+
const lastUserMsg = msgs.filter((m) => m.role === 'user').pop();
|
|
475
|
+
expect(lastUserMsg?.content?.[0]?.text).toContain('[USER_ANSWERED: 3pm tomorrow]');
|
|
476
|
+
const tokens = ['Great, I have scheduled for 3pm tomorrow.'];
|
|
477
|
+
const opts = _rest[2] as { onEvent?: (event: { type: string; text?: string }) => void } | undefined;
|
|
478
|
+
for (const token of tokens) {
|
|
479
|
+
opts?.onEvent?.({ type: 'text_delta', text: token });
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
483
|
+
model: 'claude-sonnet-4-20250514',
|
|
484
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
485
|
+
stopReason: 'end_turn',
|
|
486
|
+
};
|
|
361
487
|
});
|
|
362
488
|
|
|
363
489
|
const accepted = await orchestrator.handleUserAnswer('3pm tomorrow');
|
|
@@ -378,9 +504,9 @@ describe('call-orchestrator', () => {
|
|
|
378
504
|
|
|
379
505
|
test('mid-call question flow: unavailable time → ask user → user confirms → resumed call', async () => {
|
|
380
506
|
// Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
);
|
|
507
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
508
|
+
['I\'m sorry, 7:30 is not available. ', '[ASK_GUARDIAN: Is 8:00 okay instead?]'],
|
|
509
|
+
));
|
|
384
510
|
|
|
385
511
|
const { session, relay, orchestrator } = setupOrchestrator('Schedule a haircut');
|
|
386
512
|
|
|
@@ -397,9 +523,9 @@ describe('call-orchestrator', () => {
|
|
|
397
523
|
expect(midSession!.status).toBe('waiting_on_user');
|
|
398
524
|
|
|
399
525
|
// Step 2: User answers "Yes, 8:00 works"
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
);
|
|
526
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
527
|
+
['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]'],
|
|
528
|
+
));
|
|
403
529
|
|
|
404
530
|
const accepted = await orchestrator.handleUserAnswer('Yes, 8:00 works for me');
|
|
405
531
|
expect(accepted).toBe(true);
|
|
@@ -421,16 +547,9 @@ describe('call-orchestrator', () => {
|
|
|
421
547
|
// ── Provider / LLM failure paths ───────────────────────────────
|
|
422
548
|
|
|
423
549
|
test('LLM error: sends error message to caller and returns to idle', async () => {
|
|
424
|
-
// Make
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
on: (event: string, handler: (...args: unknown[]) => void) => {
|
|
429
|
-
emitter.on(event, handler);
|
|
430
|
-
return { on: () => ({ on: () => ({}) }) };
|
|
431
|
-
},
|
|
432
|
-
finalMessage: () => Promise.reject(new Error('API rate limit exceeded')),
|
|
433
|
-
};
|
|
550
|
+
// Make sendMessage reject with an error
|
|
551
|
+
mockSendMessage.mockImplementation(async () => {
|
|
552
|
+
throw new Error('API rate limit exceeded');
|
|
434
553
|
});
|
|
435
554
|
|
|
436
555
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -450,19 +569,10 @@ describe('call-orchestrator', () => {
|
|
|
450
569
|
});
|
|
451
570
|
|
|
452
571
|
test('LLM APIUserAbortError: treats as expected abort without technical-issue fallback', async () => {
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
emitter.on(event, handler);
|
|
458
|
-
return { on: () => ({ on: () => ({}) }) };
|
|
459
|
-
},
|
|
460
|
-
finalMessage: () => {
|
|
461
|
-
const err = new Error('user abort');
|
|
462
|
-
err.name = 'APIUserAbortError';
|
|
463
|
-
return Promise.reject(err);
|
|
464
|
-
},
|
|
465
|
-
};
|
|
572
|
+
mockSendMessage.mockImplementation(async () => {
|
|
573
|
+
const err = new Error('user abort');
|
|
574
|
+
err.name = 'APIUserAbortError';
|
|
575
|
+
throw err;
|
|
466
576
|
});
|
|
467
577
|
|
|
468
578
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -477,22 +587,23 @@ describe('call-orchestrator', () => {
|
|
|
477
587
|
|
|
478
588
|
test('stale superseded turn errors do not emit technical-issue fallback', async () => {
|
|
479
589
|
let callCount = 0;
|
|
480
|
-
|
|
590
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
481
591
|
callCount++;
|
|
482
592
|
if (callCount === 1) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
emitter.on(event, handler);
|
|
487
|
-
return { on: () => ({ on: () => ({}) }) };
|
|
488
|
-
},
|
|
489
|
-
finalMessage: () =>
|
|
490
|
-
new Promise((_, reject) => {
|
|
491
|
-
setTimeout(() => reject(new Error('stale stream failure')), 20);
|
|
492
|
-
}),
|
|
493
|
-
};
|
|
593
|
+
return new Promise((_, reject) => {
|
|
594
|
+
setTimeout(() => reject(new Error('stale stream failure')), 20);
|
|
595
|
+
});
|
|
494
596
|
}
|
|
495
|
-
|
|
597
|
+
const tokens = ['Second turn response.'];
|
|
598
|
+
for (const token of tokens) {
|
|
599
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
603
|
+
model: 'claude-sonnet-4-20250514',
|
|
604
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
605
|
+
stopReason: 'end_turn',
|
|
606
|
+
};
|
|
496
607
|
});
|
|
497
608
|
|
|
498
609
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -511,39 +622,102 @@ describe('call-orchestrator', () => {
|
|
|
511
622
|
orchestrator.destroy();
|
|
512
623
|
});
|
|
513
624
|
|
|
514
|
-
test('
|
|
625
|
+
test('barge-in cleanup never sends empty user turns to provider', async () => {
|
|
515
626
|
let callCount = 0;
|
|
516
|
-
|
|
627
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
|
|
517
628
|
callCount++;
|
|
629
|
+
|
|
630
|
+
// Initial outbound opener
|
|
518
631
|
if (callCount === 1) {
|
|
519
|
-
const
|
|
520
|
-
const
|
|
632
|
+
const tokens = ['Hey Noa, this is Credence calling.'];
|
|
633
|
+
for (const token of tokens) {
|
|
634
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
635
|
+
}
|
|
521
636
|
return {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
finalMessage: () =>
|
|
527
|
-
new Promise((_, reject) => {
|
|
528
|
-
options?.signal?.addEventListener('abort', () => {
|
|
529
|
-
const err = new Error('aborted');
|
|
530
|
-
err.name = 'AbortError';
|
|
531
|
-
reject(err);
|
|
532
|
-
}, { once: true });
|
|
533
|
-
}),
|
|
637
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
638
|
+
model: 'claude-sonnet-4-20250514',
|
|
639
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
640
|
+
stopReason: 'end_turn',
|
|
534
641
|
};
|
|
535
642
|
}
|
|
536
643
|
|
|
537
|
-
|
|
538
|
-
|
|
644
|
+
// First caller turn enters an in-flight LLM run that gets interrupted
|
|
645
|
+
if (callCount === 2) {
|
|
646
|
+
return new Promise((_, reject) => {
|
|
647
|
+
options?.signal?.addEventListener('abort', () => {
|
|
648
|
+
const err = new Error('aborted');
|
|
649
|
+
err.name = 'AbortError';
|
|
650
|
+
reject(err);
|
|
651
|
+
}, { once: true });
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Second caller turn should never include an empty user message.
|
|
656
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
657
|
+
const userMessages = msgs.filter((m) => m.role === 'user');
|
|
658
|
+
expect(userMessages.length).toBeGreaterThan(0);
|
|
659
|
+
expect(userMessages.every((m) => m.content?.[0]?.text?.trim().length > 0)).toBe(true);
|
|
660
|
+
const tokens = ['Got it, thanks for clarifying.'];
|
|
661
|
+
for (const token of tokens) {
|
|
662
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
666
|
+
model: 'claude-sonnet-4-20250514',
|
|
667
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
668
|
+
stopReason: 'end_turn',
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const { relay, orchestrator } = setupOrchestrator('Quick check-in');
|
|
673
|
+
await orchestrator.startInitialGreeting();
|
|
674
|
+
|
|
675
|
+
const firstTurnPromise = orchestrator.handleCallerUtterance('Hello?');
|
|
676
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
677
|
+
const secondTurnPromise = orchestrator.handleCallerUtterance('What have you been up to lately?');
|
|
678
|
+
|
|
679
|
+
await Promise.all([firstTurnPromise, secondTurnPromise]);
|
|
680
|
+
|
|
681
|
+
const allTokens = relay.sentTokens.map((t) => t.token).join('');
|
|
682
|
+
expect(allTokens).toContain('Got it, thanks for clarifying.');
|
|
683
|
+
expect(allTokens).not.toContain('technical issue');
|
|
684
|
+
|
|
685
|
+
orchestrator.destroy();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('rapid caller barge-in coalesces contiguous user turns for role alternation', async () => {
|
|
689
|
+
let callCount = 0;
|
|
690
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
|
|
691
|
+
callCount++;
|
|
692
|
+
if (callCount === 1) {
|
|
693
|
+
return new Promise((_, reject) => {
|
|
694
|
+
options?.signal?.addEventListener('abort', () => {
|
|
695
|
+
const err = new Error('aborted');
|
|
696
|
+
err.name = 'AbortError';
|
|
697
|
+
reject(err);
|
|
698
|
+
}, { once: true });
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
703
|
+
const roles = msgs.map((m) => m.role);
|
|
539
704
|
for (let i = 1; i < roles.length; i++) {
|
|
540
705
|
expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
|
|
541
706
|
}
|
|
542
|
-
const userMessages =
|
|
707
|
+
const userMessages = msgs.filter((m) => m.role === 'user');
|
|
543
708
|
const lastUser = userMessages[userMessages.length - 1];
|
|
544
|
-
expect(lastUser?.content).toContain('First caller utterance');
|
|
545
|
-
expect(lastUser?.content).toContain('Second caller utterance');
|
|
546
|
-
|
|
709
|
+
expect(lastUser?.content?.[0]?.text).toContain('First caller utterance');
|
|
710
|
+
expect(lastUser?.content?.[0]?.text).toContain('Second caller utterance');
|
|
711
|
+
const tokens = ['Merged turn handled.'];
|
|
712
|
+
for (const token of tokens) {
|
|
713
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
717
|
+
model: 'claude-sonnet-4-20250514',
|
|
718
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
719
|
+
stopReason: 'end_turn',
|
|
720
|
+
};
|
|
547
721
|
});
|
|
548
722
|
|
|
549
723
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -561,37 +735,37 @@ describe('call-orchestrator', () => {
|
|
|
561
735
|
|
|
562
736
|
test('interrupt then next caller prompt still preserves role alternation', async () => {
|
|
563
737
|
let callCount = 0;
|
|
564
|
-
|
|
738
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
|
|
565
739
|
callCount++;
|
|
566
740
|
if (callCount === 1) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
finalMessage: () =>
|
|
575
|
-
new Promise((_, reject) => {
|
|
576
|
-
options?.signal?.addEventListener('abort', () => {
|
|
577
|
-
const err = new Error('aborted');
|
|
578
|
-
err.name = 'AbortError';
|
|
579
|
-
reject(err);
|
|
580
|
-
}, { once: true });
|
|
581
|
-
}),
|
|
582
|
-
};
|
|
741
|
+
return new Promise((_, reject) => {
|
|
742
|
+
options?.signal?.addEventListener('abort', () => {
|
|
743
|
+
const err = new Error('aborted');
|
|
744
|
+
err.name = 'AbortError';
|
|
745
|
+
reject(err);
|
|
746
|
+
}, { once: true });
|
|
747
|
+
});
|
|
583
748
|
}
|
|
584
749
|
|
|
585
|
-
const
|
|
586
|
-
const roles =
|
|
750
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
751
|
+
const roles = msgs.map((m) => m.role);
|
|
587
752
|
for (let i = 1; i < roles.length; i++) {
|
|
588
753
|
expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
|
|
589
754
|
}
|
|
590
|
-
const userMessages =
|
|
755
|
+
const userMessages = msgs.filter((m) => m.role === 'user');
|
|
591
756
|
const lastUser = userMessages[userMessages.length - 1];
|
|
592
|
-
expect(lastUser?.content).toContain('First caller utterance');
|
|
593
|
-
expect(lastUser?.content).toContain('Second caller utterance');
|
|
594
|
-
|
|
757
|
+
expect(lastUser?.content?.[0]?.text).toContain('First caller utterance');
|
|
758
|
+
expect(lastUser?.content?.[0]?.text).toContain('Second caller utterance');
|
|
759
|
+
const tokens = ['Post-interrupt response.'];
|
|
760
|
+
for (const token of tokens) {
|
|
761
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
765
|
+
model: 'claude-sonnet-4-20250514',
|
|
766
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
767
|
+
stopReason: 'end_turn',
|
|
768
|
+
};
|
|
595
769
|
});
|
|
596
770
|
|
|
597
771
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -631,24 +805,18 @@ describe('call-orchestrator', () => {
|
|
|
631
805
|
});
|
|
632
806
|
|
|
633
807
|
test('handleInterrupt: increments llmRunVersion to suppress stale turn side effects', async () => {
|
|
634
|
-
// Use a
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
808
|
+
// Use a sendMessage that resolves immediately but whose continuation
|
|
809
|
+
// (the code after `await provider.sendMessage()`) will run asynchronously.
|
|
810
|
+
// This simulates the race where the promise microtask is queued right
|
|
811
|
+
// as handleInterrupt fires.
|
|
812
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
813
|
+
// Emit some tokens synchronously
|
|
814
|
+
options?.onEvent?.({ type: 'text_delta', text: 'Stale response that should be suppressed.' });
|
|
640
815
|
return {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
finalMessage: () => {
|
|
646
|
-
// Emit some tokens synchronously
|
|
647
|
-
emitter.emit('text', 'Stale response that should be suppressed.');
|
|
648
|
-
return Promise.resolve({
|
|
649
|
-
content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
|
|
650
|
-
});
|
|
651
|
-
},
|
|
816
|
+
content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
|
|
817
|
+
model: 'claude-sonnet-4-20250514',
|
|
818
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
819
|
+
stopReason: 'end_turn',
|
|
652
820
|
};
|
|
653
821
|
});
|
|
654
822
|
|
|
@@ -657,7 +825,7 @@ describe('call-orchestrator', () => {
|
|
|
657
825
|
// Start an LLM turn (don't await — we want to interrupt mid-flight)
|
|
658
826
|
const turnPromise = orchestrator.handleCallerUtterance('Hello');
|
|
659
827
|
|
|
660
|
-
// Interrupt immediately. Because
|
|
828
|
+
// Interrupt immediately. Because sendMessage resolves as a microtask,
|
|
661
829
|
// its continuation hasn't run yet. handleInterrupt increments
|
|
662
830
|
// llmRunVersion so the continuation's isCurrentRun check will fail.
|
|
663
831
|
orchestrator.handleInterrupt();
|
|
@@ -680,23 +848,14 @@ describe('call-orchestrator', () => {
|
|
|
680
848
|
});
|
|
681
849
|
|
|
682
850
|
test('handleInterrupt: sends turn terminator when interrupting active speech', async () => {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
finalMessage: () =>
|
|
692
|
-
new Promise((_, reject) => {
|
|
693
|
-
options?.signal?.addEventListener('abort', () => {
|
|
694
|
-
const err = new Error('aborted');
|
|
695
|
-
err.name = 'AbortError';
|
|
696
|
-
reject(err);
|
|
697
|
-
}, { once: true });
|
|
698
|
-
}),
|
|
699
|
-
};
|
|
851
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
|
|
852
|
+
return new Promise((_, reject) => {
|
|
853
|
+
options?.signal?.addEventListener('abort', () => {
|
|
854
|
+
const err = new Error('aborted');
|
|
855
|
+
err.name = 'AbortError';
|
|
856
|
+
reject(err);
|
|
857
|
+
}, { once: true });
|
|
858
|
+
});
|
|
700
859
|
});
|
|
701
860
|
|
|
702
861
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -735,12 +894,22 @@ describe('call-orchestrator', () => {
|
|
|
735
894
|
|
|
736
895
|
// ── Model override from config ──────────────────────────────────────
|
|
737
896
|
|
|
738
|
-
test('
|
|
897
|
+
test('does not override model when calls.model is not set (preserves cross-provider failover)', async () => {
|
|
739
898
|
mockCallModel = undefined;
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
899
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
900
|
+
// When calls.model is unset, no model override should be passed so each
|
|
901
|
+
// provider in the failover chain uses its own default model.
|
|
902
|
+
expect(options?.config?.model).toBeUndefined();
|
|
903
|
+
const tokens = ['Default model response.'];
|
|
904
|
+
for (const token of tokens) {
|
|
905
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
909
|
+
model: 'claude-opus-4-6',
|
|
910
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
911
|
+
stopReason: 'end_turn',
|
|
912
|
+
};
|
|
744
913
|
});
|
|
745
914
|
|
|
746
915
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -750,10 +919,18 @@ describe('call-orchestrator', () => {
|
|
|
750
919
|
|
|
751
920
|
test('uses calls.model override from config when set', async () => {
|
|
752
921
|
mockCallModel = 'claude-haiku-4-5-20251001';
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
922
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
923
|
+
expect(options?.config?.model).toBe('claude-haiku-4-5-20251001');
|
|
924
|
+
const tokens = ['Override model response.'];
|
|
925
|
+
for (const token of tokens) {
|
|
926
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
927
|
+
}
|
|
928
|
+
return {
|
|
929
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
930
|
+
model: 'claude-haiku-4-5-20251001',
|
|
931
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
932
|
+
stopReason: 'end_turn',
|
|
933
|
+
};
|
|
757
934
|
});
|
|
758
935
|
|
|
759
936
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -761,12 +938,21 @@ describe('call-orchestrator', () => {
|
|
|
761
938
|
orchestrator.destroy();
|
|
762
939
|
});
|
|
763
940
|
|
|
764
|
-
test('treats empty string calls.model as unset and
|
|
941
|
+
test('treats empty string calls.model as unset and omits model override', async () => {
|
|
765
942
|
mockCallModel = '';
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
expect(
|
|
769
|
-
|
|
943
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
944
|
+
// Empty string is treated as unset — no model override
|
|
945
|
+
expect(options?.config?.model).toBeUndefined();
|
|
946
|
+
const tokens = ['Fallback model response.'];
|
|
947
|
+
for (const token of tokens) {
|
|
948
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
952
|
+
model: 'claude-opus-4-6',
|
|
953
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
954
|
+
stopReason: 'end_turn',
|
|
955
|
+
};
|
|
770
956
|
});
|
|
771
957
|
|
|
772
958
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -774,12 +960,21 @@ describe('call-orchestrator', () => {
|
|
|
774
960
|
orchestrator.destroy();
|
|
775
961
|
});
|
|
776
962
|
|
|
777
|
-
test('treats whitespace-only calls.model as unset and
|
|
963
|
+
test('treats whitespace-only calls.model as unset and omits model override', async () => {
|
|
778
964
|
mockCallModel = ' ';
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
expect(
|
|
782
|
-
|
|
965
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
966
|
+
// Whitespace-only is treated as unset — no model override
|
|
967
|
+
expect(options?.config?.model).toBeUndefined();
|
|
968
|
+
const tokens = ['Fallback model response.'];
|
|
969
|
+
for (const token of tokens) {
|
|
970
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
974
|
+
model: 'claude-opus-4-6',
|
|
975
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
976
|
+
stopReason: 'end_turn',
|
|
977
|
+
};
|
|
783
978
|
});
|
|
784
979
|
|
|
785
980
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -790,14 +985,23 @@ describe('call-orchestrator', () => {
|
|
|
790
985
|
// ── handleUserInstruction ─────────────────────────────────────────
|
|
791
986
|
|
|
792
987
|
test('handleUserInstruction: injects instruction marker into conversation history and triggers LLM when idle', async () => {
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
const instructionMsg =
|
|
796
|
-
m.role === 'user' && m.content
|
|
988
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
989
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
990
|
+
const instructionMsg = msgs.find((m) =>
|
|
991
|
+
m.role === 'user' && m.content?.[0]?.text?.includes('[USER_INSTRUCTION:'),
|
|
797
992
|
);
|
|
798
993
|
expect(instructionMsg).toBeDefined();
|
|
799
|
-
expect(instructionMsg!.content).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
|
|
800
|
-
|
|
994
|
+
expect(instructionMsg!.content[0].text).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
|
|
995
|
+
const tokens = ['Sure, do you have any weekend plans?'];
|
|
996
|
+
for (const token of tokens) {
|
|
997
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1001
|
+
model: 'claude-sonnet-4-20250514',
|
|
1002
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1003
|
+
stopReason: 'end_turn',
|
|
1004
|
+
};
|
|
801
1005
|
});
|
|
802
1006
|
|
|
803
1007
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -813,30 +1017,38 @@ describe('call-orchestrator', () => {
|
|
|
813
1017
|
|
|
814
1018
|
test('handleUserInstruction: does not break existing answer flow', async () => {
|
|
815
1019
|
// Step 1: Caller says something, LLM responds normally
|
|
816
|
-
|
|
1020
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hello! How can I help you today?']));
|
|
817
1021
|
const { session: _session, relay, orchestrator } = setupOrchestrator('Book appointment');
|
|
818
1022
|
|
|
819
1023
|
await orchestrator.handleCallerUtterance('Hi there');
|
|
820
1024
|
|
|
821
1025
|
// Step 2: Inject an instruction while idle
|
|
822
|
-
|
|
823
|
-
const
|
|
1026
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1027
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
824
1028
|
// Verify the history contains both the original exchange and the instruction
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
|
|
1029
|
+
expect(msgs.length).toBeGreaterThanOrEqual(3); // user utterance + assistant response + instruction
|
|
1030
|
+
const instructionMsg = msgs.find((m) =>
|
|
1031
|
+
m.role === 'user' && m.content?.[0]?.text?.includes('[USER_INSTRUCTION:'),
|
|
829
1032
|
);
|
|
830
1033
|
expect(instructionMsg).toBeDefined();
|
|
831
|
-
|
|
1034
|
+
const tokens = ['Of course, let me mention the weekend special.'];
|
|
1035
|
+
for (const token of tokens) {
|
|
1036
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1040
|
+
model: 'claude-sonnet-4-20250514',
|
|
1041
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1042
|
+
stopReason: 'end_turn',
|
|
1043
|
+
};
|
|
832
1044
|
});
|
|
833
1045
|
|
|
834
1046
|
await orchestrator.handleUserInstruction('Mention the weekend special');
|
|
835
1047
|
|
|
836
1048
|
// Step 3: Caller speaks again — the flow should continue normally
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
);
|
|
1049
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
1050
|
+
['Great choice! The weekend special is 20% off.'],
|
|
1051
|
+
));
|
|
840
1052
|
|
|
841
1053
|
await orchestrator.handleCallerUtterance('Tell me more about that');
|
|
842
1054
|
|
|
@@ -852,7 +1064,7 @@ describe('call-orchestrator', () => {
|
|
|
852
1064
|
});
|
|
853
1065
|
|
|
854
1066
|
test('handleUserInstruction: emits user_instruction_relayed event', async () => {
|
|
855
|
-
|
|
1067
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Understood, adjusting approach.']));
|
|
856
1068
|
|
|
857
1069
|
const { session, orchestrator } = setupOrchestrator();
|
|
858
1070
|
|
|
@@ -869,20 +1081,25 @@ describe('call-orchestrator', () => {
|
|
|
869
1081
|
});
|
|
870
1082
|
|
|
871
1083
|
test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
|
|
872
|
-
// First, trigger
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
);
|
|
1084
|
+
// First, trigger ASK_GUARDIAN so orchestrator enters waiting_on_user
|
|
1085
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
1086
|
+
['Hold on. [ASK_GUARDIAN: What time?]'],
|
|
1087
|
+
));
|
|
876
1088
|
|
|
877
1089
|
const { session, orchestrator } = setupOrchestrator();
|
|
878
1090
|
await orchestrator.handleCallerUtterance('I need an appointment');
|
|
879
1091
|
expect(orchestrator.getState()).toBe('waiting_on_user');
|
|
880
1092
|
|
|
881
|
-
// Track how many times the
|
|
1093
|
+
// Track how many times the provider mock is called
|
|
882
1094
|
let streamCallCount = 0;
|
|
883
|
-
|
|
1095
|
+
mockSendMessage.mockImplementation(async () => {
|
|
884
1096
|
streamCallCount++;
|
|
885
|
-
return
|
|
1097
|
+
return {
|
|
1098
|
+
content: [{ type: 'text', text: 'Response after instruction.' }],
|
|
1099
|
+
model: 'claude-sonnet-4-20250514',
|
|
1100
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1101
|
+
stopReason: 'end_turn',
|
|
1102
|
+
};
|
|
886
1103
|
});
|
|
887
1104
|
|
|
888
1105
|
// Inject instruction while in waiting_on_user state
|
|
@@ -902,10 +1119,18 @@ describe('call-orchestrator', () => {
|
|
|
902
1119
|
// ── System prompt: identity phrasing ────────────────────────────────
|
|
903
1120
|
|
|
904
1121
|
test('system prompt contains resolved user reference (default)', async () => {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1122
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1123
|
+
expect(systemPrompt as string).toContain('on behalf of my human');
|
|
1124
|
+
const tokens = ['Hello.'];
|
|
1125
|
+
for (const token of tokens) {
|
|
1126
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1130
|
+
model: 'claude-sonnet-4-20250514',
|
|
1131
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1132
|
+
stopReason: 'end_turn',
|
|
1133
|
+
};
|
|
909
1134
|
});
|
|
910
1135
|
|
|
911
1136
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -915,10 +1140,18 @@ describe('call-orchestrator', () => {
|
|
|
915
1140
|
|
|
916
1141
|
test('system prompt contains resolved user reference when set to a name', async () => {
|
|
917
1142
|
mockUserReference = 'John';
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1143
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1144
|
+
expect(systemPrompt as string).toContain('on behalf of John');
|
|
1145
|
+
const tokens = ['Hello John\'s contact.'];
|
|
1146
|
+
for (const token of tokens) {
|
|
1147
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1151
|
+
model: 'claude-sonnet-4-20250514',
|
|
1152
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1153
|
+
stopReason: 'end_turn',
|
|
1154
|
+
};
|
|
922
1155
|
});
|
|
923
1156
|
|
|
924
1157
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -928,11 +1161,19 @@ describe('call-orchestrator', () => {
|
|
|
928
1161
|
|
|
929
1162
|
test('system prompt does not hardcode "your user" in the opening line', async () => {
|
|
930
1163
|
mockUserReference = 'Alice';
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
expect(
|
|
934
|
-
|
|
935
|
-
|
|
1164
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1165
|
+
expect(systemPrompt as string).not.toContain('on behalf of your user');
|
|
1166
|
+
expect(systemPrompt as string).toContain('on behalf of Alice');
|
|
1167
|
+
const tokens = ['Hi there.'];
|
|
1168
|
+
for (const token of tokens) {
|
|
1169
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1173
|
+
model: 'claude-sonnet-4-20250514',
|
|
1174
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1175
|
+
stopReason: 'end_turn',
|
|
1176
|
+
};
|
|
936
1177
|
});
|
|
937
1178
|
|
|
938
1179
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -941,11 +1182,40 @@ describe('call-orchestrator', () => {
|
|
|
941
1182
|
});
|
|
942
1183
|
|
|
943
1184
|
test('system prompt includes assistant identity bias rule', async () => {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
expect(
|
|
947
|
-
|
|
948
|
-
|
|
1185
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1186
|
+
expect(systemPrompt as string).toContain('refer to yourself as an assistant');
|
|
1187
|
+
expect(systemPrompt as string).toContain('Avoid the phrase "AI assistant" unless directly asked');
|
|
1188
|
+
const tokens = ['Sure thing.'];
|
|
1189
|
+
for (const token of tokens) {
|
|
1190
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1194
|
+
model: 'claude-sonnet-4-20250514',
|
|
1195
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1196
|
+
stopReason: 'end_turn',
|
|
1197
|
+
};
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
const { orchestrator } = setupOrchestrator();
|
|
1201
|
+
await orchestrator.handleCallerUtterance('Hi');
|
|
1202
|
+
orchestrator.destroy();
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test('system prompt includes opening-ack guidance to avoid duplicate introductions', async () => {
|
|
1206
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1207
|
+
expect(systemPrompt as string).toContain('[CALL_OPENING_ACK]');
|
|
1208
|
+
expect(systemPrompt as string).toContain('without re-introducing yourself');
|
|
1209
|
+
const tokens = ['Understood.'];
|
|
1210
|
+
for (const token of tokens) {
|
|
1211
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1212
|
+
}
|
|
1213
|
+
return {
|
|
1214
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1215
|
+
model: 'claude-sonnet-4-20250514',
|
|
1216
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1217
|
+
stopReason: 'end_turn',
|
|
1218
|
+
};
|
|
949
1219
|
});
|
|
950
1220
|
|
|
951
1221
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -954,15 +1224,23 @@ describe('call-orchestrator', () => {
|
|
|
954
1224
|
});
|
|
955
1225
|
|
|
956
1226
|
test('assistant identity rule appears before disclosure rule in prompt', async () => {
|
|
957
|
-
|
|
958
|
-
const
|
|
959
|
-
const prompt = firstArg.system;
|
|
1227
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1228
|
+
const prompt = systemPrompt as string;
|
|
960
1229
|
const identityIdx = prompt.indexOf('refer to yourself as an assistant');
|
|
961
1230
|
const disclosureIdx = prompt.indexOf('Be concise');
|
|
962
1231
|
expect(identityIdx).toBeGreaterThan(-1);
|
|
963
1232
|
expect(disclosureIdx).toBeGreaterThan(-1);
|
|
964
1233
|
expect(identityIdx).toBeLessThan(disclosureIdx);
|
|
965
|
-
|
|
1234
|
+
const tokens = ['OK.'];
|
|
1235
|
+
for (const token of tokens) {
|
|
1236
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1240
|
+
model: 'claude-sonnet-4-20250514',
|
|
1241
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1242
|
+
stopReason: 'end_turn',
|
|
1243
|
+
};
|
|
966
1244
|
});
|
|
967
1245
|
|
|
968
1246
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -975,11 +1253,19 @@ describe('call-orchestrator', () => {
|
|
|
975
1253
|
enabled: true,
|
|
976
1254
|
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
|
|
977
1255
|
};
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
expect(
|
|
981
|
-
|
|
982
|
-
|
|
1256
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1257
|
+
expect(systemPrompt as string).toContain('introduce yourself as an assistant calling on behalf of the person you represent');
|
|
1258
|
+
expect(systemPrompt as string).toContain('Do not say "AI assistant"');
|
|
1259
|
+
const tokens = ['Hello, I am calling on behalf of my human.'];
|
|
1260
|
+
for (const token of tokens) {
|
|
1261
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1265
|
+
model: 'claude-sonnet-4-20250514',
|
|
1266
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1267
|
+
stopReason: 'end_turn',
|
|
1268
|
+
};
|
|
983
1269
|
});
|
|
984
1270
|
|
|
985
1271
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -989,11 +1275,19 @@ describe('call-orchestrator', () => {
|
|
|
989
1275
|
|
|
990
1276
|
test('system prompt falls back to "Begin the conversation naturally" when disclosure is disabled', async () => {
|
|
991
1277
|
mockDisclosure = { enabled: false, text: '' };
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
expect(
|
|
995
|
-
|
|
996
|
-
|
|
1278
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1279
|
+
expect(systemPrompt as string).toContain('Begin the conversation naturally');
|
|
1280
|
+
expect(systemPrompt as string).not.toContain('introduce yourself as an assistant calling on behalf of the person');
|
|
1281
|
+
const tokens = ['Hello there.'];
|
|
1282
|
+
for (const token of tokens) {
|
|
1283
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1284
|
+
}
|
|
1285
|
+
return {
|
|
1286
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1287
|
+
model: 'claude-sonnet-4-20250514',
|
|
1288
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1289
|
+
stopReason: 'end_turn',
|
|
1290
|
+
};
|
|
997
1291
|
});
|
|
998
1292
|
|
|
999
1293
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -1002,14 +1296,201 @@ describe('call-orchestrator', () => {
|
|
|
1002
1296
|
});
|
|
1003
1297
|
|
|
1004
1298
|
test('system prompt does not use "AI assistant" as a self-identity label', async () => {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1299
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1300
|
+
expect(systemPrompt as string).not.toMatch(/(?:you are|call yourself|introduce yourself as).*AI assistant/i);
|
|
1301
|
+
const tokens = ['Got it.'];
|
|
1302
|
+
for (const token of tokens) {
|
|
1303
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1307
|
+
model: 'claude-sonnet-4-20250514',
|
|
1308
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1309
|
+
stopReason: 'end_turn',
|
|
1310
|
+
};
|
|
1009
1311
|
});
|
|
1010
1312
|
|
|
1011
1313
|
const { orchestrator } = setupOrchestrator();
|
|
1012
1314
|
await orchestrator.handleCallerUtterance('Hello');
|
|
1013
1315
|
orchestrator.destroy();
|
|
1014
1316
|
});
|
|
1317
|
+
|
|
1318
|
+
// ── Inbound call orchestration ──────────────────────────────────────
|
|
1319
|
+
|
|
1320
|
+
test('inbound call (no task) uses receptionist-style system prompt', async () => {
|
|
1321
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1322
|
+
// Should contain inbound-specific language
|
|
1323
|
+
expect(systemPrompt as string).toContain('answering an incoming call');
|
|
1324
|
+
expect(systemPrompt as string).toContain('find out what they need');
|
|
1325
|
+
// Should NOT contain outbound-specific language
|
|
1326
|
+
expect(systemPrompt as string).not.toContain('state why you are calling');
|
|
1327
|
+
expect(systemPrompt as string).not.toContain('Task:');
|
|
1328
|
+
const tokens = ['Hello, how can I help you today?'];
|
|
1329
|
+
for (const token of tokens) {
|
|
1330
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1331
|
+
}
|
|
1332
|
+
return {
|
|
1333
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1334
|
+
model: 'claude-sonnet-4-20250514',
|
|
1335
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1336
|
+
stopReason: 'end_turn',
|
|
1337
|
+
};
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
// setupOrchestrator with no task creates an inbound-style session
|
|
1341
|
+
const { orchestrator } = setupOrchestrator(undefined);
|
|
1342
|
+
await orchestrator.handleCallerUtterance('Hi there');
|
|
1343
|
+
orchestrator.destroy();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test('outbound call (with task) uses task-driven system prompt', async () => {
|
|
1347
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1348
|
+
expect(systemPrompt as string).toContain('Task: Confirm Friday appointment');
|
|
1349
|
+
expect(systemPrompt as string).toContain('state why you are calling');
|
|
1350
|
+
expect(systemPrompt as string).not.toContain('answering an incoming call');
|
|
1351
|
+
const tokens = ['Hi, I am calling about your appointment.'];
|
|
1352
|
+
for (const token of tokens) {
|
|
1353
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1357
|
+
model: 'claude-sonnet-4-20250514',
|
|
1358
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1359
|
+
stopReason: 'end_turn',
|
|
1360
|
+
};
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
const { orchestrator } = setupOrchestrator('Confirm Friday appointment');
|
|
1364
|
+
await orchestrator.handleCallerUtterance('Hello?');
|
|
1365
|
+
orchestrator.destroy();
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
test('inbound call initial greeting sends receptionist opener', async () => {
|
|
1369
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1370
|
+
// The system prompt should use inbound framing
|
|
1371
|
+
expect(systemPrompt as string).toContain('answering an incoming call');
|
|
1372
|
+
// The opening marker should be present
|
|
1373
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
1374
|
+
const userMsgs = msgs.filter((m) => m.role === 'user');
|
|
1375
|
+
expect(userMsgs.some((m) => m.content?.[0]?.text?.includes('[CALL_OPENING]'))).toBe(true);
|
|
1376
|
+
const tokens = ['Hello, this is my human\'s assistant. How can I help you?'];
|
|
1377
|
+
for (const token of tokens) {
|
|
1378
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1379
|
+
}
|
|
1380
|
+
return {
|
|
1381
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1382
|
+
model: 'claude-sonnet-4-20250514',
|
|
1383
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1384
|
+
stopReason: 'end_turn',
|
|
1385
|
+
};
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
const { relay, orchestrator } = setupOrchestrator(undefined);
|
|
1389
|
+
await orchestrator.startInitialGreeting();
|
|
1390
|
+
|
|
1391
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
1392
|
+
expect(allText).toContain('How can I help you');
|
|
1393
|
+
|
|
1394
|
+
orchestrator.destroy();
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test('inbound call multi-turn conversation uses inbound prompt consistently', async () => {
|
|
1398
|
+
let turnNumber = 0;
|
|
1399
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1400
|
+
turnNumber++;
|
|
1401
|
+
// Every turn should use the inbound system prompt
|
|
1402
|
+
expect(systemPrompt as string).toContain('answering an incoming call');
|
|
1403
|
+
expect(systemPrompt as string).not.toContain('Task:');
|
|
1404
|
+
|
|
1405
|
+
let tokens: string[];
|
|
1406
|
+
if (turnNumber === 1) tokens = ['Hello, how can I help you?'];
|
|
1407
|
+
else if (turnNumber === 2) tokens = ['Sure, let me help with scheduling.'];
|
|
1408
|
+
else tokens = ['Your meeting is set for 3pm.'];
|
|
1409
|
+
for (const token of tokens) {
|
|
1410
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1414
|
+
model: 'claude-sonnet-4-20250514',
|
|
1415
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1416
|
+
stopReason: 'end_turn',
|
|
1417
|
+
};
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
const { orchestrator } = setupOrchestrator(undefined);
|
|
1421
|
+
|
|
1422
|
+
await orchestrator.startInitialGreeting();
|
|
1423
|
+
await orchestrator.handleCallerUtterance('I need to schedule a meeting');
|
|
1424
|
+
await orchestrator.handleCallerUtterance('How about 3pm?');
|
|
1425
|
+
|
|
1426
|
+
expect(turnNumber).toBe(3);
|
|
1427
|
+
orchestrator.destroy();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test('inbound call system prompt includes greet-the-caller guidance for CALL_OPENING', async () => {
|
|
1431
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1432
|
+
// Should tell the model to greet warmly and ask how to help
|
|
1433
|
+
expect(systemPrompt as string).toContain('greet the caller warmly');
|
|
1434
|
+
expect(systemPrompt as string).toContain('how you can help');
|
|
1435
|
+
const tokens = ['Hello!'];
|
|
1436
|
+
for (const token of tokens) {
|
|
1437
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1441
|
+
model: 'claude-sonnet-4-20250514',
|
|
1442
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1443
|
+
stopReason: 'end_turn',
|
|
1444
|
+
};
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
const { orchestrator } = setupOrchestrator(undefined);
|
|
1448
|
+
await orchestrator.handleCallerUtterance('Hi');
|
|
1449
|
+
orchestrator.destroy();
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
test('inbound call system prompt respects disclosure setting', async () => {
|
|
1453
|
+
mockDisclosure = {
|
|
1454
|
+
enabled: true,
|
|
1455
|
+
text: 'Disclose that you are an AI at the start.',
|
|
1456
|
+
};
|
|
1457
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
1458
|
+
expect(systemPrompt as string).toContain('answering an incoming call');
|
|
1459
|
+
expect(systemPrompt as string).toContain('Disclose that you are an AI at the start.');
|
|
1460
|
+
const tokens = ['Hello, I am an AI assistant.'];
|
|
1461
|
+
for (const token of tokens) {
|
|
1462
|
+
options?.onEvent?.({ type: 'text_delta', text: token });
|
|
1463
|
+
}
|
|
1464
|
+
return {
|
|
1465
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
1466
|
+
model: 'claude-sonnet-4-20250514',
|
|
1467
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1468
|
+
stopReason: 'end_turn',
|
|
1469
|
+
};
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
const { orchestrator } = setupOrchestrator(undefined);
|
|
1473
|
+
await orchestrator.handleCallerUtterance('Who is this?');
|
|
1474
|
+
orchestrator.destroy();
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test('inbound call persists assistant response to voice conversation', async () => {
|
|
1478
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['I can definitely help you with that.']));
|
|
1479
|
+
|
|
1480
|
+
const { session, orchestrator } = setupOrchestrator(undefined);
|
|
1481
|
+
await orchestrator.startInitialGreeting();
|
|
1482
|
+
|
|
1483
|
+
// Verify assistant transcript was persisted
|
|
1484
|
+
const messages = (await import('../memory/conversation-store.js')).getMessages('conv-orch-test');
|
|
1485
|
+
const assistantMsgs = messages.filter((m) => m.role === 'assistant');
|
|
1486
|
+
expect(assistantMsgs.length).toBeGreaterThan(0);
|
|
1487
|
+
const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
|
|
1488
|
+
expect(lastAssistant.content).toContain('I can definitely help you with that');
|
|
1489
|
+
|
|
1490
|
+
// Verify event was recorded
|
|
1491
|
+
const events = getCallEvents(session.id).filter((e) => e.eventType === 'assistant_spoke');
|
|
1492
|
+
expect(events.length).toBeGreaterThan(0);
|
|
1493
|
+
|
|
1494
|
+
orchestrator.destroy();
|
|
1495
|
+
});
|
|
1015
1496
|
});
|