@vellumai/assistant 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +2 -0
- package/README.md +88 -2
- package/eslint.config.mjs +31 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
- package/scripts/ipc/generate-swift.ts +31 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +438 -1
- package/src/__tests__/approval-conversation-turn.test.ts +214 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/browser-manager.test.ts +1 -0
- package/src/__tests__/call-conversation-messages.test.ts +130 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +799 -249
- package/src/__tests__/call-pointer-messages.test.ts +148 -0
- package/src/__tests__/call-recovery.test.ts +3 -0
- package/src/__tests__/call-routes-http.test.ts +32 -2
- package/src/__tests__/call-store.test.ts +3 -0
- package/src/__tests__/channel-approval-routes.test.ts +1277 -98
- package/src/__tests__/channel-approval.test.ts +37 -0
- package/src/__tests__/channel-approvals.test.ts +36 -50
- package/src/__tests__/channel-guardian.test.ts +630 -22
- package/src/__tests__/channel-readiness-service.test.ts +324 -0
- package/src/__tests__/checker.test.ts +14 -7
- package/src/__tests__/clarification-resolver.test.ts +44 -24
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
- package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
- package/src/__tests__/config-schema.test.ts +14 -8
- package/src/__tests__/context-window-manager.test.ts +30 -2
- package/src/__tests__/contradiction-checker.test.ts +20 -5
- package/src/__tests__/credential-security-invariants.test.ts +7 -2
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/db-migration-rollback.test.ts +752 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/fuzzy-match-property.test.ts +5 -5
- package/src/__tests__/guardian-action-store.test.ts +123 -0
- package/src/__tests__/guardian-action-sweep.test.ts +277 -0
- package/src/__tests__/guardian-dispatch.test.ts +389 -0
- package/src/__tests__/guardian-question-copy.test.ts +47 -0
- package/src/__tests__/handlers-telegram-config.test.ts +4 -2
- package/src/__tests__/handlers-twilio-config.test.ts +533 -0
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +291 -1
- package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/model-intents.test.ts +96 -0
- package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
- package/src/__tests__/provider-error-scenarios.test.ts +621 -0
- package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
- package/src/__tests__/qdrant-manager.test.ts +27 -20
- package/src/__tests__/relay-server.test.ts +779 -40
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +6 -0
- package/src/__tests__/run-orchestrator.test.ts +42 -4
- package/src/__tests__/runtime-runs-http.test.ts +17 -1
- package/src/__tests__/runtime-runs.test.ts +16 -0
- package/src/__tests__/schedule-store.test.ts +18 -4
- package/src/__tests__/scheduler-recurrence.test.ts +13 -4
- package/src/__tests__/session-abort-tool-results.test.ts +6 -0
- package/src/__tests__/session-agent-loop.test.ts +857 -0
- package/src/__tests__/session-conflict-gate.test.ts +6 -0
- package/src/__tests__/session-pre-run-repair.test.ts +6 -0
- package/src/__tests__/session-profile-injection.test.ts +6 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
- package/src/__tests__/session-queue.test.ts +6 -0
- package/src/__tests__/session-runtime-assembly.test.ts +321 -13
- package/src/__tests__/session-slash-known.test.ts +6 -0
- package/src/__tests__/session-slash-queue.test.ts +6 -0
- package/src/__tests__/session-slash-unknown.test.ts +6 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
- package/src/__tests__/session-workspace-injection.test.ts +6 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
- package/src/__tests__/skills.test.ts +2 -0
- package/src/__tests__/sms-messaging-provider.test.ts +126 -0
- package/src/__tests__/starter-task-flow.test.ts +2 -0
- package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
- package/src/__tests__/system-prompt.test.ts +2 -0
- package/src/__tests__/task-management-tools.test.ts +2 -2
- package/src/__tests__/task-runner.test.ts +14 -4
- package/src/__tests__/terminal-tools.test.ts +25 -19
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
- package/src/__tests__/tool-executor.test.ts +23 -24
- package/src/__tests__/trust-store.test.ts +3 -3
- package/src/__tests__/twilio-rest.test.ts +29 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
- package/src/__tests__/twilio-routes.test.ts +167 -11
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +2 -0
- package/src/__tests__/voice-quality.test.ts +222 -0
- package/src/__tests__/web-search.test.ts +46 -30
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/agent/loop.ts +1 -1
- package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
- package/src/amazon/client.ts +1418 -0
- package/src/amazon/request-extractor.ts +135 -0
- package/src/amazon/session.ts +109 -0
- package/src/autonomy/autonomy-store.ts +5 -5
- package/src/browser-extension-relay/client.ts +124 -0
- package/src/browser-extension-relay/protocol.ts +63 -0
- package/src/browser-extension-relay/server.ts +177 -0
- package/src/bundler/app-bundler.ts +3 -3
- package/src/bundler/bundle-signer.ts +1 -1
- package/src/bundler/signature-verifier.ts +1 -1
- package/src/calls/call-conversation-messages.ts +33 -0
- package/src/calls/call-domain.ts +114 -10
- package/src/calls/call-orchestrator.ts +268 -59
- package/src/calls/call-pointer-messages.ts +53 -0
- package/src/calls/call-recovery.ts +3 -8
- package/src/calls/call-store.ts +69 -87
- package/src/calls/elevenlabs-config.ts +3 -2
- package/src/calls/guardian-action-sweep.ts +105 -0
- package/src/calls/guardian-dispatch.ts +203 -0
- package/src/calls/guardian-question-copy.ts +133 -0
- package/src/calls/relay-server.ts +466 -8
- package/src/calls/speaker-identification.ts +1 -1
- package/src/calls/twilio-config.ts +22 -14
- package/src/calls/twilio-provider.ts +6 -4
- package/src/calls/twilio-rest.ts +308 -7
- package/src/calls/twilio-routes.ts +65 -12
- package/src/calls/types.ts +3 -1
- package/src/channels/types.ts +25 -0
- package/src/cli/amazon.ts +815 -0
- package/src/cli/config-commands.ts +2 -2
- package/src/cli/core-commands.ts +4 -3
- package/src/cli/influencer.ts +244 -0
- package/src/cli/map.ts +89 -6
- package/src/cli.ts +1 -1
- package/src/config/agent-schema.ts +171 -0
- package/src/config/bundled-skills/amazon/SKILL.md +127 -0
- package/src/config/bundled-skills/amazon/icon.svg +13 -0
- package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
- package/src/config/bundled-skills/browser/SKILL.md +1 -0
- package/src/config/bundled-skills/browser/TOOLS.json +17 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
- package/src/config/bundled-skills/doordash/SKILL.md +51 -51
- package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
- package/src/config/bundled-skills/influencer/SKILL.md +144 -0
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +176 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +230 -0
- package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
- package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
- package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
- package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
- package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +259 -0
- package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +136 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +59 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +143 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +65 -0
- package/src/config/bundled-skills/messaging/SKILL.md +33 -8
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +88 -23
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/bundled-skills/twitter/icon.svg +14 -0
- package/src/config/bundled-tool-registry.ts +310 -0
- package/src/config/calls-schema.ts +181 -0
- package/src/config/core-schema.ts +309 -0
- package/src/config/defaults.ts +28 -3
- package/src/config/env-registry.ts +162 -0
- package/src/config/env.ts +175 -0
- package/src/config/loader.ts +6 -6
- package/src/config/memory-schema.ts +528 -0
- package/src/config/sandbox-schema.ts +55 -0
- package/src/config/schema.ts +158 -1133
- package/src/config/skill-state.ts +1 -1
- package/src/config/skills-schema.ts +32 -0
- package/src/config/skills.ts +35 -24
- package/src/config/system-prompt.ts +131 -56
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/user-reference.ts +4 -9
- package/src/config/vellum-skills/catalog.json +6 -7
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +216 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/context/window-manager.ts +27 -7
- package/src/daemon/approval-generators.ts +186 -0
- package/src/daemon/approved-devices-store.ts +140 -0
- package/src/daemon/assistant-attachments.ts +1 -1
- package/src/daemon/classifier.ts +35 -32
- package/src/daemon/config-watcher.ts +1 -1
- package/src/daemon/daemon-control.ts +217 -0
- package/src/daemon/handlers/apps.ts +2 -3
- package/src/daemon/handlers/config-channels.ts +158 -0
- package/src/daemon/handlers/config-inbox.ts +540 -0
- package/src/daemon/handlers/config-ingress.ts +231 -0
- package/src/daemon/handlers/config-integrations.ts +258 -0
- package/src/daemon/handlers/config-model.ts +143 -0
- package/src/daemon/handlers/config-parental.ts +163 -0
- package/src/daemon/handlers/config-scheduling.ts +172 -0
- package/src/daemon/handlers/config-slack.ts +92 -0
- package/src/daemon/handlers/config-telegram.ts +301 -0
- package/src/daemon/handlers/config-tools.ts +177 -0
- package/src/daemon/handlers/config-trust.ts +104 -0
- package/src/daemon/handlers/config-twilio.ts +1080 -0
- package/src/daemon/handlers/config.ts +53 -1689
- package/src/daemon/handlers/diagnostics.ts +1 -1
- package/src/daemon/handlers/dictation.ts +180 -0
- package/src/daemon/handlers/documents.ts +18 -32
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +11 -0
- package/src/daemon/handlers/misc.ts +3 -5
- package/src/daemon/handlers/pairing.ts +98 -0
- package/src/daemon/handlers/sessions.ts +56 -5
- package/src/daemon/handlers/shared.ts +6 -1
- package/src/daemon/handlers/skills.ts +1 -1
- package/src/daemon/handlers/twitter-auth.ts +2 -0
- package/src/daemon/handlers/work-items.ts +17 -9
- package/src/daemon/handlers/workspace-files.ts +4 -3
- package/src/daemon/install-cli-launchers.ts +113 -0
- package/src/daemon/ipc-contract/apps.ts +356 -0
- package/src/daemon/ipc-contract/browser.ts +74 -0
- package/src/daemon/ipc-contract/computer-use.ts +151 -0
- package/src/daemon/ipc-contract/diagnostics.ts +56 -0
- package/src/daemon/ipc-contract/documents.ts +74 -0
- package/src/daemon/ipc-contract/inbox.ts +209 -0
- package/src/daemon/ipc-contract/integrations.ts +284 -0
- package/src/daemon/ipc-contract/memory.ts +48 -0
- package/src/daemon/ipc-contract/messages.ts +211 -0
- package/src/daemon/ipc-contract/pairing.ts +45 -0
- package/src/daemon/ipc-contract/parental-control.ts +95 -0
- package/src/daemon/ipc-contract/schedules.ts +97 -0
- package/src/daemon/ipc-contract/sessions.ts +315 -0
- package/src/daemon/ipc-contract/shared.ts +42 -0
- package/src/daemon/ipc-contract/skills.ts +120 -0
- package/src/daemon/ipc-contract/subagents.ts +58 -0
- package/src/daemon/ipc-contract/surfaces.ts +250 -0
- package/src/daemon/ipc-contract/trust.ts +60 -0
- package/src/daemon/ipc-contract/work-items.ts +225 -0
- package/src/daemon/ipc-contract/workspace.ts +113 -0
- package/src/daemon/ipc-contract-inventory.json +70 -0
- package/src/daemon/ipc-contract-inventory.ts +55 -29
- package/src/daemon/ipc-contract.ts +229 -2426
- package/src/daemon/ipc-protocol.ts +1 -1
- package/src/daemon/ipc-validate.ts +7 -0
- package/src/daemon/lifecycle.ts +97 -377
- package/src/daemon/pairing-store.ts +177 -0
- package/src/daemon/providers-setup.ts +43 -0
- package/src/daemon/ride-shotgun-handler.ts +68 -3
- package/src/daemon/server.ts +66 -46
- package/src/daemon/session-agent-loop-handlers.ts +421 -0
- package/src/daemon/session-agent-loop.ts +117 -275
- package/src/daemon/session-dynamic-profile.ts +1 -1
- package/src/daemon/session-history.ts +1 -1
- package/src/daemon/session-media-retry.ts +1 -1
- package/src/daemon/session-messaging.ts +37 -2
- package/src/daemon/session-notifiers.ts +5 -25
- package/src/daemon/session-process.ts +99 -59
- package/src/daemon/session-queue-manager.ts +96 -4
- package/src/daemon/session-runtime-assembly.ts +199 -10
- package/src/daemon/session-surfaces.ts +19 -4
- package/src/daemon/session-tool-setup.ts +30 -30
- package/src/daemon/session-workspace.ts +1 -1
- package/src/daemon/session.ts +35 -2
- package/src/daemon/shutdown-handlers.ts +122 -0
- package/src/daemon/trace-emitter.ts +1 -1
- package/src/daemon/watch-handler.ts +36 -33
- package/src/doordash/cart-queries.ts +787 -0
- package/src/doordash/client.ts +144 -127
- package/src/doordash/order-queries.ts +85 -0
- package/src/doordash/queries.ts +10 -1308
- package/src/doordash/search-queries.ts +203 -0
- package/src/doordash/session.ts +3 -2
- package/src/doordash/store-queries.ts +246 -0
- package/src/doordash/types.ts +367 -0
- package/src/email/providers/agentmail.ts +2 -1
- package/src/email/providers/index.ts +3 -2
- package/src/email/service.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/home-base/prebuilt/seed.ts +1 -1
- package/src/hooks/cli.ts +6 -5
- package/src/hooks/config.ts +6 -8
- package/src/hooks/discovery.ts +6 -5
- package/src/hooks/manager.ts +4 -3
- package/src/hooks/runner.ts +2 -2
- package/src/hooks/templates.ts +5 -5
- package/src/inbound/public-ingress-urls.ts +6 -4
- package/src/index.ts +4 -2
- package/src/influencer/client.ts +1104 -0
- package/src/instrument.ts +4 -3
- package/src/logfire.ts +4 -3
- package/src/memory/admin.ts +25 -35
- package/src/memory/attachments-store.ts +4 -7
- package/src/memory/channel-delivery-store.ts +30 -1
- package/src/memory/channel-guardian-store.ts +202 -2
- package/src/memory/clarification-resolver.ts +37 -33
- package/src/memory/conflict-store.ts +67 -61
- package/src/memory/contradiction-checker.ts +141 -117
- package/src/memory/conversation-store.ts +335 -51
- package/src/memory/db-connection.ts +27 -4
- package/src/memory/db-init.ts +265 -4
- package/src/memory/db.ts +14 -1
- package/src/memory/embedding-backend.ts +27 -5
- package/src/memory/embedding-ollama.ts +2 -1
- package/src/memory/entity-extractor.ts +38 -35
- package/src/memory/guardian-action-store.ts +430 -0
- package/src/memory/inbox-escalation-projection.ts +59 -0
- package/src/memory/inbox-thread-store.ts +218 -0
- package/src/memory/ingress-invite-store.ts +338 -0
- package/src/memory/ingress-member-store.ts +350 -0
- package/src/memory/items-extractor.ts +91 -97
- package/src/memory/job-handlers/index-maintenance.ts +3 -3
- package/src/memory/job-handlers/media-processing.ts +69 -0
- package/src/memory/job-handlers/summarization.ts +32 -26
- package/src/memory/job-utils.ts +3 -10
- package/src/memory/jobs-store.ts +8 -10
- package/src/memory/jobs-worker.ts +55 -36
- package/src/memory/media-store.ts +759 -0
- package/src/memory/migrations/001-job-deferrals.ts +45 -0
- package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
- package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
- package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
- package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
- package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
- package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
- package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
- package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
- package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
- package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
- package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
- package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
- package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
- package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
- package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
- package/src/memory/migrations/017-memory-items-indexes.ts +10 -0
- package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
- package/src/memory/migrations/index.ts +24 -0
- package/src/memory/migrations/registry.ts +79 -0
- package/src/memory/migrations/validate-migration-state.ts +69 -0
- package/src/memory/qdrant-manager.ts +49 -8
- package/src/memory/query-builder.ts +1 -1
- package/src/memory/raw-query.ts +119 -0
- package/src/memory/recall-cache.ts +4 -1
- package/src/memory/retriever.ts +165 -47
- package/src/memory/schema-migration.ts +25 -984
- package/src/memory/schema.ts +228 -7
- package/src/memory/search/entity.ts +205 -31
- package/src/memory/search/lexical.ts +81 -52
- package/src/memory/search/ranking.ts +27 -23
- package/src/memory/search/semantic.ts +157 -19
- package/src/memory/search/types.ts +24 -0
- package/src/memory/shared-app-links-store.ts +4 -5
- package/src/memory/validation.ts +19 -0
- package/src/messaging/draft-store.ts +5 -6
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +201 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
- package/src/messaging/providers/whatsapp/adapter.ts +136 -0
- package/src/messaging/providers/whatsapp/client.ts +67 -0
- package/src/messaging/style-analyzer.ts +5 -4
- package/src/messaging/thread-summarizer.ts +61 -69
- package/src/messaging/triage-engine.ts +62 -71
- package/src/migrations/config-merge.ts +53 -0
- package/src/migrations/data-layout.ts +68 -0
- package/src/migrations/data-merge.ts +33 -0
- package/src/migrations/hooks-merge.ts +90 -0
- package/src/migrations/index.ts +6 -0
- package/src/migrations/log.ts +23 -0
- package/src/migrations/skills-merge.ts +33 -0
- package/src/migrations/workspace-layout.ts +79 -0
- package/src/permissions/checker.ts +133 -11
- package/src/permissions/prompter.ts +14 -0
- package/src/permissions/shell-identity.ts +31 -1
- package/src/permissions/trust-store.ts +21 -1
- package/src/providers/anthropic/client.ts +4 -4
- package/src/providers/failover.ts +2 -2
- package/src/providers/model-intents.ts +70 -0
- package/src/providers/ollama/client.ts +2 -1
- package/src/providers/provider-send-message.ts +176 -0
- package/src/providers/registry.ts +71 -30
- package/src/providers/retry.ts +35 -1
- package/src/providers/types.ts +12 -1
- package/src/runtime/approval-conversation-turn.ts +97 -0
- package/src/runtime/approval-message-composer.ts +253 -0
- package/src/runtime/channel-approval-parser.ts +36 -2
- package/src/runtime/channel-approvals.ts +11 -24
- package/src/runtime/channel-guardian-service.ts +88 -21
- package/src/runtime/channel-readiness-service.ts +418 -0
- package/src/runtime/channel-readiness-types.ts +35 -0
- package/src/runtime/channel-retry-sweep.ts +184 -0
- package/src/runtime/guardian-context-resolver.ts +108 -0
- package/src/runtime/http-server.ts +275 -717
- package/src/runtime/http-types.ts +59 -3
- package/src/runtime/middleware/auth.ts +116 -0
- package/src/runtime/middleware/error-handler.ts +33 -0
- package/src/runtime/middleware/twilio-validation.ts +127 -0
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/call-routes.ts +51 -7
- package/src/runtime/routes/channel-delivery-routes.ts +170 -0
- package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
- package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
- package/src/runtime/routes/channel-route-shared.ts +144 -0
- package/src/runtime/routes/channel-routes.ts +32 -1588
- package/src/runtime/routes/conversation-routes.ts +50 -7
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/identity-routes.ts +126 -0
- package/src/runtime/routes/pairing-routes.ts +143 -0
- package/src/runtime/routes/run-routes.ts +15 -1
- package/src/runtime/run-orchestrator.ts +86 -35
- package/src/schedule/schedule-store.ts +36 -32
- package/src/schedule/scheduler.ts +3 -3
- package/src/security/encrypted-store.ts +5 -7
- package/src/security/oauth2.ts +45 -15
- package/src/security/parental-control-store.ts +183 -0
- package/src/security/secret-allowlist.ts +4 -3
- package/src/security/secret-scanner.ts +5 -5
- package/src/security/secure-keys.ts +1 -1
- package/src/security/token-manager.ts +3 -2
- package/src/services/vercel-deploy.ts +6 -2
- package/src/skills/tool-manifest.ts +3 -3
- package/src/skills/vellum-catalog-remote.ts +75 -16
- package/src/slack/slack-webhook.ts +2 -1
- package/src/swarm/orchestrator.ts +92 -1
- package/src/swarm/router-planner.ts +6 -9
- package/src/swarm/worker-prompts.ts +9 -12
- package/src/tasks/task-compiler.ts +19 -28
- package/src/tasks/task-runner.ts +1 -1
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/assets/search.ts +15 -14
- package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
- package/src/tools/browser/auto-navigate.ts +1 -0
- package/src/tools/browser/browser-execution.ts +10 -1
- package/src/tools/browser/browser-manager.ts +119 -4
- package/src/tools/browser/network-recorder.ts +5 -0
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/broker.ts +11 -2
- package/src/tools/credentials/metadata-store.ts +18 -14
- package/src/tools/credentials/post-connect-hooks.ts +61 -0
- package/src/tools/credentials/vault.ts +49 -23
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +68 -9
- package/src/tools/host-terminal/cli-discover.ts +1 -1
- package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
- package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
- package/src/tools/network/script-proxy/server.ts +1 -1
- package/src/tools/network/script-proxy/session-manager.ts +6 -5
- package/src/tools/network/web-fetch.ts +18 -2
- package/src/tools/network/web-search.ts +8 -4
- package/src/tools/reminder/reminder-store.ts +14 -15
- package/src/tools/schedule/create.ts +1 -0
- package/src/tools/schedule/list.ts +2 -1
- package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
- package/src/tools/skills/skill-script-runner.ts +24 -9
- package/src/tools/skills/skill-tool-factory.ts +1 -0
- package/src/tools/tasks/work-item-enqueue.ts +2 -2
- package/src/tools/terminal/evaluate-typescript.ts +21 -12
- package/src/tools/terminal/parser.ts +50 -0
- package/src/tools/types.ts +2 -0
- package/src/tools/watcher/delete.ts +6 -0
- package/src/tools/weather/service.ts +1 -1
- package/src/twitter/client.ts +190 -24
- package/src/twitter/router.ts +1 -1
- package/src/twitter/session.ts +4 -3
- package/src/util/clipboard.ts +1 -1
- package/src/util/errors.ts +65 -8
- package/src/util/fs.ts +40 -0
- package/src/util/json.ts +10 -0
- package/src/util/log-redact.ts +189 -0
- package/src/util/logger.ts +19 -17
- package/src/util/object.ts +3 -0
- package/src/util/platform.ts +105 -363
- package/src/util/pricing.ts +1 -1
- package/src/util/promise-guard.ts +1 -1
- package/src/util/retry.ts +19 -0
- package/src/util/row-mapper.ts +79 -0
- package/src/util/silently.ts +21 -0
- package/src/watcher/engine.ts +5 -1
- package/src/watcher/provider-types.ts +20 -0
- package/src/watcher/providers/github.ts +156 -0
- package/src/watcher/providers/gmail.ts +1 -0
- package/src/watcher/providers/google-calendar.ts +1 -0
- package/src/watcher/providers/linear.ts +460 -0
- package/src/watcher/providers/slack.ts +1 -0
- package/src/work-items/work-item-runner.ts +1 -1
- package/src/workspace/git-service.ts +1 -1
- package/src/workspace/provider-commit-message-generator.ts +51 -22
- package/src/__tests__/call-bridge.test.ts +0 -517
- package/src/__tests__/session-process-bridge.test.ts +0 -244
- package/src/calls/call-bridge.ts +0 -168
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
|
@@ -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', () => ({
|
|
@@ -38,9 +38,12 @@ mock.module('../config/user-reference.js', () => ({
|
|
|
38
38
|
// ── Config mock ─────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
40
|
let mockCallModel: string | undefined = undefined;
|
|
41
|
+
let mockDisclosure: { enabled: boolean; text: string } = { enabled: false, text: '' };
|
|
41
42
|
|
|
42
43
|
mock.module('../config/loader.js', () => ({
|
|
43
44
|
getConfig: () => ({
|
|
45
|
+
provider: 'anthropic',
|
|
46
|
+
providerOrder: ['anthropic'],
|
|
44
47
|
apiKeys: { anthropic: 'test-key' },
|
|
45
48
|
calls: {
|
|
46
49
|
enabled: true,
|
|
@@ -49,58 +52,84 @@ mock.module('../config/loader.js', () => ({
|
|
|
49
52
|
userConsultTimeoutSeconds: 90,
|
|
50
53
|
userConsultationTimeoutSeconds: 90,
|
|
51
54
|
silenceTimeoutSeconds: 30,
|
|
52
|
-
disclosure:
|
|
55
|
+
disclosure: mockDisclosure,
|
|
53
56
|
safety: { denyCategories: [] },
|
|
54
57
|
model: mockCallModel,
|
|
55
58
|
},
|
|
59
|
+
memory: { enabled: false },
|
|
56
60
|
}),
|
|
57
61
|
}));
|
|
58
62
|
|
|
59
|
-
// ── Helpers for building mock
|
|
63
|
+
// ── Helpers for building mock provider responses ────────────────────
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
|
-
* Creates a mock
|
|
63
|
-
* 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.
|
|
64
68
|
*/
|
|
65
|
-
function
|
|
66
|
-
const emitter = new EventEmitter();
|
|
69
|
+
function createMockProviderResponse(tokens: string[]) {
|
|
67
70
|
const fullText = tokens.join('');
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
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
|
+
};
|
|
84
87
|
};
|
|
85
|
-
|
|
86
|
-
return stream;
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
// ──
|
|
90
|
+
// ── Provider registry mock ──────────────────────────────────────────
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
let mockSendMessage: Mock<any>;
|
|
92
94
|
|
|
93
|
-
mock.module('
|
|
94
|
-
|
|
95
|
+
mock.module('../providers/registry.js', () => {
|
|
96
|
+
mockSendMessage = mock(createMockProviderResponse(['Hello', ' there']));
|
|
95
97
|
return {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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',
|
|
99
111
|
};
|
|
112
|
+
return defaults[providerName] ?? defaults.anthropic;
|
|
100
113
|
},
|
|
101
114
|
};
|
|
102
115
|
});
|
|
103
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
|
+
|
|
104
133
|
// ── Import source modules after all mocks are registered ────────────
|
|
105
134
|
|
|
106
135
|
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
@@ -176,9 +205,13 @@ function ensureConversation(id: string): void {
|
|
|
176
205
|
|
|
177
206
|
function resetTables() {
|
|
178
207
|
const db = getDb();
|
|
208
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
209
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
179
210
|
db.run('DELETE FROM call_pending_questions');
|
|
180
211
|
db.run('DELETE FROM call_events');
|
|
181
212
|
db.run('DELETE FROM call_sessions');
|
|
213
|
+
db.run('DELETE FROM tool_invocations');
|
|
214
|
+
db.run('DELETE FROM messages');
|
|
182
215
|
db.run('DELETE FROM conversations');
|
|
183
216
|
ensuredConvIds = new Set();
|
|
184
217
|
}
|
|
@@ -206,14 +239,15 @@ describe('call-orchestrator', () => {
|
|
|
206
239
|
resetTables();
|
|
207
240
|
mockCallModel = undefined;
|
|
208
241
|
mockUserReference = 'my human';
|
|
209
|
-
|
|
210
|
-
|
|
242
|
+
mockDisclosure = { enabled: false, text: '' };
|
|
243
|
+
// Reset the provider mock to default behaviour
|
|
244
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hello', ' there']));
|
|
211
245
|
});
|
|
212
246
|
|
|
213
247
|
// ── handleCallerUtterance ─────────────────────────────────────────
|
|
214
248
|
|
|
215
249
|
test('handleCallerUtterance: streams tokens via sendTextToken', async () => {
|
|
216
|
-
|
|
250
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hi', ', how', ' are you?']));
|
|
217
251
|
const { relay, orchestrator } = setupOrchestrator();
|
|
218
252
|
|
|
219
253
|
await orchestrator.handleCallerUtterance('Hello');
|
|
@@ -229,7 +263,7 @@ describe('call-orchestrator', () => {
|
|
|
229
263
|
});
|
|
230
264
|
|
|
231
265
|
test('handleCallerUtterance: sends last=true at end of turn', async () => {
|
|
232
|
-
|
|
266
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Simple response.']));
|
|
233
267
|
const { relay, orchestrator } = setupOrchestrator();
|
|
234
268
|
|
|
235
269
|
await orchestrator.handleCallerUtterance('Test');
|
|
@@ -242,12 +276,18 @@ describe('call-orchestrator', () => {
|
|
|
242
276
|
});
|
|
243
277
|
|
|
244
278
|
test('handleCallerUtterance: includes speaker context in model message', async () => {
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const userMessage =
|
|
248
|
-
|
|
249
|
-
expect(
|
|
250
|
-
|
|
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
|
+
};
|
|
251
291
|
});
|
|
252
292
|
|
|
253
293
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -262,12 +302,89 @@ describe('call-orchestrator', () => {
|
|
|
262
302
|
orchestrator.destroy();
|
|
263
303
|
});
|
|
264
304
|
|
|
265
|
-
|
|
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
|
+
});
|
|
266
322
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
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
|
+
));
|
|
271
388
|
const { session, relay, orchestrator } = setupOrchestrator('Book appointment');
|
|
272
389
|
|
|
273
390
|
await orchestrator.handleCallerUtterance('I need to schedule something');
|
|
@@ -282,9 +399,34 @@ describe('call-orchestrator', () => {
|
|
|
282
399
|
const updatedSession = getCallSession(session.id);
|
|
283
400
|
expect(updatedSession!.status).toBe('waiting_on_user');
|
|
284
401
|
|
|
285
|
-
// The
|
|
402
|
+
// The ASK_GUARDIAN marker text should NOT appear in the relay tokens
|
|
403
|
+
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
404
|
+
expect(allText).not.toContain('[ASK_GUARDIAN:');
|
|
405
|
+
|
|
406
|
+
orchestrator.destroy();
|
|
407
|
+
});
|
|
408
|
+
|
|
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
|
+
]));
|
|
417
|
+
const { relay, orchestrator } = setupOrchestrator();
|
|
418
|
+
|
|
419
|
+
await orchestrator.handleCallerUtterance('Any update?');
|
|
420
|
+
|
|
286
421
|
const allText = relay.sentTokens.map((t) => t.token).join('');
|
|
287
|
-
expect(allText).
|
|
422
|
+
expect(allText).toContain('Thanks for waiting.');
|
|
423
|
+
expect(allText).toContain('I can confirm 3 PM works.');
|
|
424
|
+
expect(allText).not.toContain('[USER_ANSWERED:');
|
|
425
|
+
expect(allText).not.toContain('[USER_INSTRUCTION:');
|
|
426
|
+
expect(allText).not.toContain('[CALL_OPENING_ACK]');
|
|
427
|
+
expect(allText).not.toContain('USER_ANSWERED');
|
|
428
|
+
expect(allText).not.toContain('USER_INSTRUCTION');
|
|
429
|
+
expect(allText).not.toContain('CALL_OPENING_ACK');
|
|
288
430
|
|
|
289
431
|
orchestrator.destroy();
|
|
290
432
|
});
|
|
@@ -292,9 +434,9 @@ describe('call-orchestrator', () => {
|
|
|
292
434
|
// ── END_CALL pattern ──────────────────────────────────────────────
|
|
293
435
|
|
|
294
436
|
test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
);
|
|
437
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
438
|
+
['Thank you for calling, goodbye! ', '[END_CALL]'],
|
|
439
|
+
));
|
|
298
440
|
const { session, relay, orchestrator } = setupOrchestrator();
|
|
299
441
|
|
|
300
442
|
await orchestrator.handleCallerUtterance('That is all, thanks');
|
|
@@ -317,21 +459,31 @@ describe('call-orchestrator', () => {
|
|
|
317
459
|
// ── handleUserAnswer ──────────────────────────────────────────────
|
|
318
460
|
|
|
319
461
|
test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
|
|
320
|
-
// First utterance triggers
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
);
|
|
462
|
+
// First utterance triggers ASK_GUARDIAN
|
|
463
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
464
|
+
['Hold on. [ASK_GUARDIAN: Preferred time?]'],
|
|
465
|
+
));
|
|
324
466
|
const { relay, orchestrator } = setupOrchestrator();
|
|
325
467
|
|
|
326
468
|
await orchestrator.handleCallerUtterance('I need an appointment');
|
|
327
469
|
|
|
328
470
|
// Now provide the answer — reset mock for second LLM call
|
|
329
|
-
|
|
471
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
|
|
330
472
|
// Verify the messages include the USER_ANSWERED marker
|
|
331
|
-
const
|
|
332
|
-
const lastUserMsg =
|
|
333
|
-
expect(lastUserMsg?.content).toContain('[USER_ANSWERED: 3pm tomorrow]');
|
|
334
|
-
|
|
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
|
+
};
|
|
335
487
|
});
|
|
336
488
|
|
|
337
489
|
const accepted = await orchestrator.handleUserAnswer('3pm tomorrow');
|
|
@@ -352,9 +504,9 @@ describe('call-orchestrator', () => {
|
|
|
352
504
|
|
|
353
505
|
test('mid-call question flow: unavailable time → ask user → user confirms → resumed call', async () => {
|
|
354
506
|
// Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
);
|
|
507
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
508
|
+
['I\'m sorry, 7:30 is not available. ', '[ASK_GUARDIAN: Is 8:00 okay instead?]'],
|
|
509
|
+
));
|
|
358
510
|
|
|
359
511
|
const { session, relay, orchestrator } = setupOrchestrator('Schedule a haircut');
|
|
360
512
|
|
|
@@ -371,9 +523,9 @@ describe('call-orchestrator', () => {
|
|
|
371
523
|
expect(midSession!.status).toBe('waiting_on_user');
|
|
372
524
|
|
|
373
525
|
// Step 2: User answers "Yes, 8:00 works"
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
);
|
|
526
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
527
|
+
['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]'],
|
|
528
|
+
));
|
|
377
529
|
|
|
378
530
|
const accepted = await orchestrator.handleUserAnswer('Yes, 8:00 works for me');
|
|
379
531
|
expect(accepted).toBe(true);
|
|
@@ -395,16 +547,9 @@ describe('call-orchestrator', () => {
|
|
|
395
547
|
// ── Provider / LLM failure paths ───────────────────────────────
|
|
396
548
|
|
|
397
549
|
test('LLM error: sends error message to caller and returns to idle', async () => {
|
|
398
|
-
// Make
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
return {
|
|
402
|
-
on: (event: string, handler: (...args: unknown[]) => void) => {
|
|
403
|
-
emitter.on(event, handler);
|
|
404
|
-
return { on: () => ({ on: () => ({}) }) };
|
|
405
|
-
},
|
|
406
|
-
finalMessage: () => Promise.reject(new Error('API rate limit exceeded')),
|
|
407
|
-
};
|
|
550
|
+
// Make sendMessage reject with an error
|
|
551
|
+
mockSendMessage.mockImplementation(async () => {
|
|
552
|
+
throw new Error('API rate limit exceeded');
|
|
408
553
|
});
|
|
409
554
|
|
|
410
555
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -424,19 +569,10 @@ describe('call-orchestrator', () => {
|
|
|
424
569
|
});
|
|
425
570
|
|
|
426
571
|
test('LLM APIUserAbortError: treats as expected abort without technical-issue fallback', async () => {
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
emitter.on(event, handler);
|
|
432
|
-
return { on: () => ({ on: () => ({}) }) };
|
|
433
|
-
},
|
|
434
|
-
finalMessage: () => {
|
|
435
|
-
const err = new Error('user abort');
|
|
436
|
-
err.name = 'APIUserAbortError';
|
|
437
|
-
return Promise.reject(err);
|
|
438
|
-
},
|
|
439
|
-
};
|
|
572
|
+
mockSendMessage.mockImplementation(async () => {
|
|
573
|
+
const err = new Error('user abort');
|
|
574
|
+
err.name = 'APIUserAbortError';
|
|
575
|
+
throw err;
|
|
440
576
|
});
|
|
441
577
|
|
|
442
578
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -451,22 +587,23 @@ describe('call-orchestrator', () => {
|
|
|
451
587
|
|
|
452
588
|
test('stale superseded turn errors do not emit technical-issue fallback', async () => {
|
|
453
589
|
let callCount = 0;
|
|
454
|
-
|
|
590
|
+
mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
|
|
455
591
|
callCount++;
|
|
456
592
|
if (callCount === 1) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
emitter.on(event, handler);
|
|
461
|
-
return { on: () => ({ on: () => ({}) }) };
|
|
462
|
-
},
|
|
463
|
-
finalMessage: () =>
|
|
464
|
-
new Promise((_, reject) => {
|
|
465
|
-
setTimeout(() => reject(new Error('stale stream failure')), 20);
|
|
466
|
-
}),
|
|
467
|
-
};
|
|
593
|
+
return new Promise((_, reject) => {
|
|
594
|
+
setTimeout(() => reject(new Error('stale stream failure')), 20);
|
|
595
|
+
});
|
|
468
596
|
}
|
|
469
|
-
|
|
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
|
+
};
|
|
470
607
|
});
|
|
471
608
|
|
|
472
609
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -485,39 +622,102 @@ describe('call-orchestrator', () => {
|
|
|
485
622
|
orchestrator.destroy();
|
|
486
623
|
});
|
|
487
624
|
|
|
488
|
-
test('
|
|
625
|
+
test('barge-in cleanup never sends empty user turns to provider', async () => {
|
|
489
626
|
let callCount = 0;
|
|
490
|
-
|
|
627
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
|
|
491
628
|
callCount++;
|
|
629
|
+
|
|
630
|
+
// Initial outbound opener
|
|
492
631
|
if (callCount === 1) {
|
|
493
|
-
const
|
|
494
|
-
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
|
+
}
|
|
495
636
|
return {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
finalMessage: () =>
|
|
501
|
-
new Promise((_, reject) => {
|
|
502
|
-
options?.signal?.addEventListener('abort', () => {
|
|
503
|
-
const err = new Error('aborted');
|
|
504
|
-
err.name = 'AbortError';
|
|
505
|
-
reject(err);
|
|
506
|
-
}, { once: true });
|
|
507
|
-
}),
|
|
637
|
+
content: [{ type: 'text', text: tokens.join('') }],
|
|
638
|
+
model: 'claude-sonnet-4-20250514',
|
|
639
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
640
|
+
stopReason: 'end_turn',
|
|
508
641
|
};
|
|
509
642
|
}
|
|
510
643
|
|
|
511
|
-
|
|
512
|
-
|
|
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);
|
|
513
704
|
for (let i = 1; i < roles.length; i++) {
|
|
514
705
|
expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
|
|
515
706
|
}
|
|
516
|
-
const userMessages =
|
|
707
|
+
const userMessages = msgs.filter((m) => m.role === 'user');
|
|
517
708
|
const lastUser = userMessages[userMessages.length - 1];
|
|
518
|
-
expect(lastUser?.content).toContain('First caller utterance');
|
|
519
|
-
expect(lastUser?.content).toContain('Second caller utterance');
|
|
520
|
-
|
|
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
|
+
};
|
|
521
721
|
});
|
|
522
722
|
|
|
523
723
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -535,37 +735,37 @@ describe('call-orchestrator', () => {
|
|
|
535
735
|
|
|
536
736
|
test('interrupt then next caller prompt still preserves role alternation', async () => {
|
|
537
737
|
let callCount = 0;
|
|
538
|
-
|
|
738
|
+
mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
|
|
539
739
|
callCount++;
|
|
540
740
|
if (callCount === 1) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
finalMessage: () =>
|
|
549
|
-
new Promise((_, reject) => {
|
|
550
|
-
options?.signal?.addEventListener('abort', () => {
|
|
551
|
-
const err = new Error('aborted');
|
|
552
|
-
err.name = 'AbortError';
|
|
553
|
-
reject(err);
|
|
554
|
-
}, { once: true });
|
|
555
|
-
}),
|
|
556
|
-
};
|
|
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
|
+
});
|
|
557
748
|
}
|
|
558
749
|
|
|
559
|
-
const
|
|
560
|
-
const roles =
|
|
750
|
+
const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
|
|
751
|
+
const roles = msgs.map((m) => m.role);
|
|
561
752
|
for (let i = 1; i < roles.length; i++) {
|
|
562
753
|
expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
|
|
563
754
|
}
|
|
564
|
-
const userMessages =
|
|
755
|
+
const userMessages = msgs.filter((m) => m.role === 'user');
|
|
565
756
|
const lastUser = userMessages[userMessages.length - 1];
|
|
566
|
-
expect(lastUser?.content).toContain('First caller utterance');
|
|
567
|
-
expect(lastUser?.content).toContain('Second caller utterance');
|
|
568
|
-
|
|
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
|
+
};
|
|
569
769
|
});
|
|
570
770
|
|
|
571
771
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -605,24 +805,18 @@ describe('call-orchestrator', () => {
|
|
|
605
805
|
});
|
|
606
806
|
|
|
607
807
|
test('handleInterrupt: increments llmRunVersion to suppress stale turn side effects', async () => {
|
|
608
|
-
// Use a
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
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.' });
|
|
614
815
|
return {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
finalMessage: () => {
|
|
620
|
-
// Emit some tokens synchronously
|
|
621
|
-
emitter.emit('text', 'Stale response that should be suppressed.');
|
|
622
|
-
return Promise.resolve({
|
|
623
|
-
content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
|
|
624
|
-
});
|
|
625
|
-
},
|
|
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',
|
|
626
820
|
};
|
|
627
821
|
});
|
|
628
822
|
|
|
@@ -631,7 +825,7 @@ describe('call-orchestrator', () => {
|
|
|
631
825
|
// Start an LLM turn (don't await — we want to interrupt mid-flight)
|
|
632
826
|
const turnPromise = orchestrator.handleCallerUtterance('Hello');
|
|
633
827
|
|
|
634
|
-
// Interrupt immediately. Because
|
|
828
|
+
// Interrupt immediately. Because sendMessage resolves as a microtask,
|
|
635
829
|
// its continuation hasn't run yet. handleInterrupt increments
|
|
636
830
|
// llmRunVersion so the continuation's isCurrentRun check will fail.
|
|
637
831
|
orchestrator.handleInterrupt();
|
|
@@ -654,23 +848,14 @@ describe('call-orchestrator', () => {
|
|
|
654
848
|
});
|
|
655
849
|
|
|
656
850
|
test('handleInterrupt: sends turn terminator when interrupting active speech', async () => {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
finalMessage: () =>
|
|
666
|
-
new Promise((_, reject) => {
|
|
667
|
-
options?.signal?.addEventListener('abort', () => {
|
|
668
|
-
const err = new Error('aborted');
|
|
669
|
-
err.name = 'AbortError';
|
|
670
|
-
reject(err);
|
|
671
|
-
}, { once: true });
|
|
672
|
-
}),
|
|
673
|
-
};
|
|
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
|
+
});
|
|
674
859
|
});
|
|
675
860
|
|
|
676
861
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -709,12 +894,22 @@ describe('call-orchestrator', () => {
|
|
|
709
894
|
|
|
710
895
|
// ── Model override from config ──────────────────────────────────────
|
|
711
896
|
|
|
712
|
-
test('
|
|
897
|
+
test('does not override model when calls.model is not set (preserves cross-provider failover)', async () => {
|
|
713
898
|
mockCallModel = undefined;
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
+
};
|
|
718
913
|
});
|
|
719
914
|
|
|
720
915
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -724,10 +919,18 @@ describe('call-orchestrator', () => {
|
|
|
724
919
|
|
|
725
920
|
test('uses calls.model override from config when set', async () => {
|
|
726
921
|
mockCallModel = 'claude-haiku-4-5-20251001';
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
};
|
|
731
934
|
});
|
|
732
935
|
|
|
733
936
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -735,12 +938,21 @@ describe('call-orchestrator', () => {
|
|
|
735
938
|
orchestrator.destroy();
|
|
736
939
|
});
|
|
737
940
|
|
|
738
|
-
test('treats empty string calls.model as unset and
|
|
941
|
+
test('treats empty string calls.model as unset and omits model override', async () => {
|
|
739
942
|
mockCallModel = '';
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
expect(
|
|
743
|
-
|
|
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
|
+
};
|
|
744
956
|
});
|
|
745
957
|
|
|
746
958
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -748,12 +960,21 @@ describe('call-orchestrator', () => {
|
|
|
748
960
|
orchestrator.destroy();
|
|
749
961
|
});
|
|
750
962
|
|
|
751
|
-
test('treats whitespace-only calls.model as unset and
|
|
963
|
+
test('treats whitespace-only calls.model as unset and omits model override', async () => {
|
|
752
964
|
mockCallModel = ' ';
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
expect(
|
|
756
|
-
|
|
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
|
+
};
|
|
757
978
|
});
|
|
758
979
|
|
|
759
980
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -764,14 +985,23 @@ describe('call-orchestrator', () => {
|
|
|
764
985
|
// ── handleUserInstruction ─────────────────────────────────────────
|
|
765
986
|
|
|
766
987
|
test('handleUserInstruction: injects instruction marker into conversation history and triggers LLM when idle', async () => {
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
const instructionMsg =
|
|
770
|
-
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:'),
|
|
771
992
|
);
|
|
772
993
|
expect(instructionMsg).toBeDefined();
|
|
773
|
-
expect(instructionMsg!.content).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
|
|
774
|
-
|
|
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
|
+
};
|
|
775
1005
|
});
|
|
776
1006
|
|
|
777
1007
|
const { relay, orchestrator } = setupOrchestrator();
|
|
@@ -787,30 +1017,38 @@ describe('call-orchestrator', () => {
|
|
|
787
1017
|
|
|
788
1018
|
test('handleUserInstruction: does not break existing answer flow', async () => {
|
|
789
1019
|
// Step 1: Caller says something, LLM responds normally
|
|
790
|
-
|
|
1020
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hello! How can I help you today?']));
|
|
791
1021
|
const { session: _session, relay, orchestrator } = setupOrchestrator('Book appointment');
|
|
792
1022
|
|
|
793
1023
|
await orchestrator.handleCallerUtterance('Hi there');
|
|
794
1024
|
|
|
795
1025
|
// Step 2: Inject an instruction while idle
|
|
796
|
-
|
|
797
|
-
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 }> }>;
|
|
798
1028
|
// Verify the history contains both the original exchange and the instruction
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
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:'),
|
|
803
1032
|
);
|
|
804
1033
|
expect(instructionMsg).toBeDefined();
|
|
805
|
-
|
|
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
|
+
};
|
|
806
1044
|
});
|
|
807
1045
|
|
|
808
1046
|
await orchestrator.handleUserInstruction('Mention the weekend special');
|
|
809
1047
|
|
|
810
1048
|
// Step 3: Caller speaks again — the flow should continue normally
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
);
|
|
1049
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
1050
|
+
['Great choice! The weekend special is 20% off.'],
|
|
1051
|
+
));
|
|
814
1052
|
|
|
815
1053
|
await orchestrator.handleCallerUtterance('Tell me more about that');
|
|
816
1054
|
|
|
@@ -826,7 +1064,7 @@ describe('call-orchestrator', () => {
|
|
|
826
1064
|
});
|
|
827
1065
|
|
|
828
1066
|
test('handleUserInstruction: emits user_instruction_relayed event', async () => {
|
|
829
|
-
|
|
1067
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Understood, adjusting approach.']));
|
|
830
1068
|
|
|
831
1069
|
const { session, orchestrator } = setupOrchestrator();
|
|
832
1070
|
|
|
@@ -843,20 +1081,25 @@ describe('call-orchestrator', () => {
|
|
|
843
1081
|
});
|
|
844
1082
|
|
|
845
1083
|
test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
|
|
846
|
-
// First, trigger
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
);
|
|
1084
|
+
// First, trigger ASK_GUARDIAN so orchestrator enters waiting_on_user
|
|
1085
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(
|
|
1086
|
+
['Hold on. [ASK_GUARDIAN: What time?]'],
|
|
1087
|
+
));
|
|
850
1088
|
|
|
851
1089
|
const { session, orchestrator } = setupOrchestrator();
|
|
852
1090
|
await orchestrator.handleCallerUtterance('I need an appointment');
|
|
853
1091
|
expect(orchestrator.getState()).toBe('waiting_on_user');
|
|
854
1092
|
|
|
855
|
-
// Track how many times the
|
|
1093
|
+
// Track how many times the provider mock is called
|
|
856
1094
|
let streamCallCount = 0;
|
|
857
|
-
|
|
1095
|
+
mockSendMessage.mockImplementation(async () => {
|
|
858
1096
|
streamCallCount++;
|
|
859
|
-
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
|
+
};
|
|
860
1103
|
});
|
|
861
1104
|
|
|
862
1105
|
// Inject instruction while in waiting_on_user state
|
|
@@ -876,10 +1119,18 @@ describe('call-orchestrator', () => {
|
|
|
876
1119
|
// ── System prompt: identity phrasing ────────────────────────────────
|
|
877
1120
|
|
|
878
1121
|
test('system prompt contains resolved user reference (default)', async () => {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
+
};
|
|
883
1134
|
});
|
|
884
1135
|
|
|
885
1136
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -889,10 +1140,18 @@ describe('call-orchestrator', () => {
|
|
|
889
1140
|
|
|
890
1141
|
test('system prompt contains resolved user reference when set to a name', async () => {
|
|
891
1142
|
mockUserReference = 'John';
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
+
};
|
|
896
1155
|
});
|
|
897
1156
|
|
|
898
1157
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -902,11 +1161,19 @@ describe('call-orchestrator', () => {
|
|
|
902
1161
|
|
|
903
1162
|
test('system prompt does not hardcode "your user" in the opening line', async () => {
|
|
904
1163
|
mockUserReference = 'Alice';
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
expect(
|
|
908
|
-
|
|
909
|
-
|
|
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
|
+
};
|
|
910
1177
|
});
|
|
911
1178
|
|
|
912
1179
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -915,11 +1182,40 @@ describe('call-orchestrator', () => {
|
|
|
915
1182
|
});
|
|
916
1183
|
|
|
917
1184
|
test('system prompt includes assistant identity bias rule', async () => {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
expect(
|
|
921
|
-
|
|
922
|
-
|
|
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
|
+
};
|
|
923
1219
|
});
|
|
924
1220
|
|
|
925
1221
|
const { orchestrator } = setupOrchestrator();
|
|
@@ -928,19 +1224,273 @@ describe('call-orchestrator', () => {
|
|
|
928
1224
|
});
|
|
929
1225
|
|
|
930
1226
|
test('assistant identity rule appears before disclosure rule in prompt', async () => {
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
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;
|
|
934
1229
|
const identityIdx = prompt.indexOf('refer to yourself as an assistant');
|
|
935
1230
|
const disclosureIdx = prompt.indexOf('Be concise');
|
|
936
1231
|
expect(identityIdx).toBeGreaterThan(-1);
|
|
937
1232
|
expect(disclosureIdx).toBeGreaterThan(-1);
|
|
938
1233
|
expect(identityIdx).toBeLessThan(disclosureIdx);
|
|
939
|
-
|
|
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
|
+
};
|
|
940
1244
|
});
|
|
941
1245
|
|
|
942
1246
|
const { orchestrator } = setupOrchestrator();
|
|
943
1247
|
await orchestrator.handleCallerUtterance('Test');
|
|
944
1248
|
orchestrator.destroy();
|
|
945
1249
|
});
|
|
1250
|
+
|
|
1251
|
+
test('system prompt uses disclosure text when disclosure is enabled', async () => {
|
|
1252
|
+
mockDisclosure = {
|
|
1253
|
+
enabled: true,
|
|
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".',
|
|
1255
|
+
};
|
|
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
|
+
};
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
const { orchestrator } = setupOrchestrator();
|
|
1272
|
+
await orchestrator.handleCallerUtterance('Who is this?');
|
|
1273
|
+
orchestrator.destroy();
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test('system prompt falls back to "Begin the conversation naturally" when disclosure is disabled', async () => {
|
|
1277
|
+
mockDisclosure = { enabled: false, text: '' };
|
|
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
|
+
};
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
const { orchestrator } = setupOrchestrator();
|
|
1294
|
+
await orchestrator.handleCallerUtterance('Hi');
|
|
1295
|
+
orchestrator.destroy();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test('system prompt does not use "AI assistant" as a self-identity label', async () => {
|
|
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
|
+
};
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
const { orchestrator } = setupOrchestrator();
|
|
1314
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
1315
|
+
orchestrator.destroy();
|
|
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
|
+
});
|
|
946
1496
|
});
|