@vellumai/assistant 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (486) hide show
  1. package/README.md +51 -0
  2. package/eslint.config.mjs +31 -0
  3. package/package.json +1 -1
  4. package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
  5. package/scripts/ipc/generate-swift.ts +18 -2
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +338 -1
  7. package/src/__tests__/approval-conversation-turn.test.ts +214 -0
  8. package/src/__tests__/browser-manager.test.ts +1 -0
  9. package/src/__tests__/call-conversation-messages.test.ts +130 -0
  10. package/src/__tests__/call-orchestrator.test.ts +752 -271
  11. package/src/__tests__/call-pointer-messages.test.ts +148 -0
  12. package/src/__tests__/call-recovery.test.ts +3 -0
  13. package/src/__tests__/call-routes-http.test.ts +5 -0
  14. package/src/__tests__/call-store.test.ts +3 -0
  15. package/src/__tests__/channel-approval-routes.test.ts +1260 -85
  16. package/src/__tests__/channel-approval.test.ts +37 -0
  17. package/src/__tests__/channel-approvals.test.ts +4 -65
  18. package/src/__tests__/channel-guardian.test.ts +556 -0
  19. package/src/__tests__/channel-readiness-service.test.ts +74 -7
  20. package/src/__tests__/checker.test.ts +14 -7
  21. package/src/__tests__/clarification-resolver.test.ts +44 -24
  22. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
  23. package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
  24. package/src/__tests__/config-schema.test.ts +12 -7
  25. package/src/__tests__/context-window-manager.test.ts +30 -2
  26. package/src/__tests__/contradiction-checker.test.ts +20 -5
  27. package/src/__tests__/credential-security-invariants.test.ts +6 -2
  28. package/src/__tests__/db-migration-rollback.test.ts +752 -0
  29. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
  30. package/src/__tests__/fuzzy-match-property.test.ts +5 -5
  31. package/src/__tests__/guardian-action-store.test.ts +123 -0
  32. package/src/__tests__/guardian-action-sweep.test.ts +277 -0
  33. package/src/__tests__/guardian-dispatch.test.ts +389 -0
  34. package/src/__tests__/guardian-question-copy.test.ts +47 -0
  35. package/src/__tests__/handlers-telegram-config.test.ts +4 -2
  36. package/src/__tests__/handlers-twilio-config.test.ts +126 -0
  37. package/src/__tests__/intent-routing.test.ts +2 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +228 -1
  39. package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
  40. package/src/__tests__/model-intents.test.ts +96 -0
  41. package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
  42. package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
  43. package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
  44. package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
  45. package/src/__tests__/provider-error-scenarios.test.ts +621 -0
  46. package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
  47. package/src/__tests__/qdrant-manager.test.ts +27 -20
  48. package/src/__tests__/relay-server.test.ts +779 -40
  49. package/src/__tests__/run-orchestrator-assistant-events.test.ts +2 -0
  50. package/src/__tests__/run-orchestrator.test.ts +20 -4
  51. package/src/__tests__/runtime-runs-http.test.ts +17 -1
  52. package/src/__tests__/runtime-runs.test.ts +16 -0
  53. package/src/__tests__/schedule-store.test.ts +18 -4
  54. package/src/__tests__/scheduler-recurrence.test.ts +13 -4
  55. package/src/__tests__/session-abort-tool-results.test.ts +6 -0
  56. package/src/__tests__/session-agent-loop.test.ts +857 -0
  57. package/src/__tests__/session-conflict-gate.test.ts +6 -0
  58. package/src/__tests__/session-pre-run-repair.test.ts +6 -0
  59. package/src/__tests__/session-profile-injection.test.ts +6 -0
  60. package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
  61. package/src/__tests__/session-queue.test.ts +6 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +237 -13
  63. package/src/__tests__/session-slash-known.test.ts +6 -0
  64. package/src/__tests__/session-slash-queue.test.ts +6 -0
  65. package/src/__tests__/session-slash-unknown.test.ts +6 -0
  66. package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
  67. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  68. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  69. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  70. package/src/__tests__/session-workspace-injection.test.ts +6 -0
  71. package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
  72. package/src/__tests__/skills.test.ts +2 -0
  73. package/src/__tests__/sms-messaging-provider.test.ts +2 -1
  74. package/src/__tests__/starter-task-flow.test.ts +2 -0
  75. package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
  76. package/src/__tests__/system-prompt.test.ts +2 -0
  77. package/src/__tests__/task-management-tools.test.ts +2 -2
  78. package/src/__tests__/task-runner.test.ts +14 -4
  79. package/src/__tests__/terminal-tools.test.ts +25 -19
  80. package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
  81. package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
  82. package/src/__tests__/tool-executor.test.ts +23 -24
  83. package/src/__tests__/trust-store.test.ts +3 -3
  84. package/src/__tests__/twilio-rest.test.ts +29 -0
  85. package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
  86. package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
  87. package/src/__tests__/twilio-routes.test.ts +141 -21
  88. package/src/__tests__/user-reference.test.ts +2 -0
  89. package/src/__tests__/voice-quality.test.ts +222 -0
  90. package/src/__tests__/web-search.test.ts +45 -29
  91. package/src/agent/loop.ts +1 -1
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
  93. package/src/amazon/client.ts +1418 -0
  94. package/src/amazon/request-extractor.ts +135 -0
  95. package/src/amazon/session.ts +109 -0
  96. package/src/autonomy/autonomy-store.ts +5 -5
  97. package/src/browser-extension-relay/client.ts +124 -0
  98. package/src/browser-extension-relay/protocol.ts +63 -0
  99. package/src/browser-extension-relay/server.ts +177 -0
  100. package/src/bundler/app-bundler.ts +3 -3
  101. package/src/bundler/bundle-signer.ts +1 -1
  102. package/src/bundler/signature-verifier.ts +1 -1
  103. package/src/calls/call-conversation-messages.ts +33 -0
  104. package/src/calls/call-domain.ts +106 -5
  105. package/src/calls/call-orchestrator.ts +252 -54
  106. package/src/calls/call-pointer-messages.ts +53 -0
  107. package/src/calls/call-recovery.ts +3 -8
  108. package/src/calls/call-store.ts +69 -87
  109. package/src/calls/elevenlabs-config.ts +3 -2
  110. package/src/calls/guardian-action-sweep.ts +105 -0
  111. package/src/calls/guardian-dispatch.ts +203 -0
  112. package/src/calls/guardian-question-copy.ts +133 -0
  113. package/src/calls/relay-server.ts +466 -8
  114. package/src/calls/speaker-identification.ts +1 -1
  115. package/src/calls/twilio-config.ts +7 -5
  116. package/src/calls/twilio-provider.ts +6 -4
  117. package/src/calls/twilio-rest.ts +40 -15
  118. package/src/calls/twilio-routes.ts +60 -45
  119. package/src/calls/types.ts +3 -1
  120. package/src/channels/types.ts +25 -0
  121. package/src/cli/amazon.ts +815 -0
  122. package/src/cli/config-commands.ts +2 -2
  123. package/src/cli/core-commands.ts +4 -3
  124. package/src/cli/influencer.ts +244 -0
  125. package/src/cli/map.ts +89 -6
  126. package/src/cli.ts +1 -1
  127. package/src/config/agent-schema.ts +171 -0
  128. package/src/config/bundled-skills/amazon/SKILL.md +127 -0
  129. package/src/config/bundled-skills/amazon/icon.svg +13 -0
  130. package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
  131. package/src/config/bundled-skills/browser/SKILL.md +1 -0
  132. package/src/config/bundled-skills/browser/TOOLS.json +17 -0
  133. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
  134. package/src/config/bundled-skills/doordash/SKILL.md +51 -51
  135. package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
  136. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
  137. package/src/config/bundled-skills/influencer/SKILL.md +144 -0
  138. package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
  139. package/src/config/bundled-skills/media-processing/SKILL.md +72 -95
  140. package/src/config/bundled-skills/media-processing/TOOLS.json +57 -147
  141. package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
  142. package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
  143. package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
  144. package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
  145. package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
  146. package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
  147. package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
  148. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +7 -9
  149. package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
  150. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +88 -253
  151. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +22 -153
  152. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +2 -2
  153. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +28 -51
  154. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +35 -270
  155. package/src/config/bundled-skills/messaging/SKILL.md +12 -2
  156. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
  157. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
  158. package/src/config/bundled-skills/phone-calls/SKILL.md +86 -21
  159. package/src/config/bundled-skills/twitter/icon.svg +14 -0
  160. package/src/config/bundled-tool-registry.ts +310 -0
  161. package/src/config/calls-schema.ts +181 -0
  162. package/src/config/core-schema.ts +309 -0
  163. package/src/config/defaults.ts +26 -2
  164. package/src/config/env-registry.ts +162 -0
  165. package/src/config/env.ts +175 -0
  166. package/src/config/loader.ts +6 -6
  167. package/src/config/memory-schema.ts +528 -0
  168. package/src/config/sandbox-schema.ts +55 -0
  169. package/src/config/schema.ts +156 -1137
  170. package/src/config/skill-state.ts +1 -1
  171. package/src/config/skills-schema.ts +32 -0
  172. package/src/config/skills.ts +35 -24
  173. package/src/config/system-prompt.ts +107 -56
  174. package/src/config/templates/SOUL.md +1 -1
  175. package/src/config/types.ts +1 -0
  176. package/src/config/user-reference.ts +4 -9
  177. package/src/config/vellum-skills/catalog.json +0 -7
  178. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
  179. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +1 -0
  180. package/src/config/vellum-skills/sms-setup/SKILL.md +112 -14
  181. package/src/context/window-manager.ts +27 -7
  182. package/src/daemon/approval-generators.ts +186 -0
  183. package/src/daemon/approved-devices-store.ts +140 -0
  184. package/src/daemon/assistant-attachments.ts +1 -1
  185. package/src/daemon/classifier.ts +35 -32
  186. package/src/daemon/config-watcher.ts +1 -1
  187. package/src/daemon/daemon-control.ts +217 -0
  188. package/src/daemon/handlers/apps.ts +2 -3
  189. package/src/daemon/handlers/config-channels.ts +158 -0
  190. package/src/daemon/handlers/config-inbox.ts +540 -0
  191. package/src/daemon/handlers/config-ingress.ts +231 -0
  192. package/src/daemon/handlers/config-integrations.ts +258 -0
  193. package/src/daemon/handlers/config-model.ts +143 -0
  194. package/src/daemon/handlers/config-parental.ts +163 -0
  195. package/src/daemon/handlers/config-scheduling.ts +172 -0
  196. package/src/daemon/handlers/config-slack.ts +92 -0
  197. package/src/daemon/handlers/config-telegram.ts +301 -0
  198. package/src/daemon/handlers/config-tools.ts +177 -0
  199. package/src/daemon/handlers/config-trust.ts +104 -0
  200. package/src/daemon/handlers/config-twilio.ts +1080 -0
  201. package/src/daemon/handlers/config.ts +53 -2463
  202. package/src/daemon/handlers/diagnostics.ts +1 -1
  203. package/src/daemon/handlers/dictation.ts +4 -6
  204. package/src/daemon/handlers/documents.ts +18 -32
  205. package/src/daemon/handlers/index.ts +9 -0
  206. package/src/daemon/handlers/misc.ts +3 -5
  207. package/src/daemon/handlers/pairing.ts +98 -0
  208. package/src/daemon/handlers/sessions.ts +54 -5
  209. package/src/daemon/handlers/shared.ts +3 -1
  210. package/src/daemon/handlers/skills.ts +1 -1
  211. package/src/daemon/handlers/twitter-auth.ts +2 -0
  212. package/src/daemon/handlers/work-items.ts +2 -2
  213. package/src/daemon/handlers/workspace-files.ts +4 -3
  214. package/src/daemon/install-cli-launchers.ts +113 -0
  215. package/src/daemon/ipc-contract/apps.ts +356 -0
  216. package/src/daemon/ipc-contract/browser.ts +74 -0
  217. package/src/daemon/ipc-contract/computer-use.ts +151 -0
  218. package/src/daemon/ipc-contract/diagnostics.ts +56 -0
  219. package/src/daemon/ipc-contract/documents.ts +74 -0
  220. package/src/daemon/ipc-contract/inbox.ts +209 -0
  221. package/src/daemon/ipc-contract/integrations.ts +284 -0
  222. package/src/daemon/ipc-contract/memory.ts +48 -0
  223. package/src/daemon/ipc-contract/messages.ts +211 -0
  224. package/src/daemon/ipc-contract/pairing.ts +45 -0
  225. package/src/daemon/ipc-contract/parental-control.ts +95 -0
  226. package/src/daemon/ipc-contract/schedules.ts +97 -0
  227. package/src/daemon/ipc-contract/sessions.ts +315 -0
  228. package/src/daemon/ipc-contract/shared.ts +42 -0
  229. package/src/daemon/ipc-contract/skills.ts +120 -0
  230. package/src/daemon/ipc-contract/subagents.ts +58 -0
  231. package/src/daemon/ipc-contract/surfaces.ts +250 -0
  232. package/src/daemon/ipc-contract/trust.ts +60 -0
  233. package/src/daemon/ipc-contract/work-items.ts +225 -0
  234. package/src/daemon/ipc-contract/workspace.ts +113 -0
  235. package/src/daemon/ipc-contract-inventory.json +60 -0
  236. package/src/daemon/ipc-contract-inventory.ts +55 -29
  237. package/src/daemon/ipc-contract.ts +226 -2527
  238. package/src/daemon/ipc-protocol.ts +1 -1
  239. package/src/daemon/ipc-validate.ts +7 -0
  240. package/src/daemon/lifecycle.ts +97 -379
  241. package/src/daemon/pairing-store.ts +177 -0
  242. package/src/daemon/providers-setup.ts +43 -0
  243. package/src/daemon/ride-shotgun-handler.ts +67 -2
  244. package/src/daemon/server.ts +60 -44
  245. package/src/daemon/session-agent-loop-handlers.ts +421 -0
  246. package/src/daemon/session-agent-loop.ts +113 -275
  247. package/src/daemon/session-dynamic-profile.ts +1 -1
  248. package/src/daemon/session-history.ts +1 -1
  249. package/src/daemon/session-media-retry.ts +1 -1
  250. package/src/daemon/session-messaging.ts +37 -2
  251. package/src/daemon/session-notifiers.ts +5 -25
  252. package/src/daemon/session-process.ts +99 -59
  253. package/src/daemon/session-queue-manager.ts +96 -4
  254. package/src/daemon/session-runtime-assembly.ts +149 -15
  255. package/src/daemon/session-surfaces.ts +19 -4
  256. package/src/daemon/session-tool-setup.ts +28 -30
  257. package/src/daemon/session-workspace.ts +1 -1
  258. package/src/daemon/session.ts +24 -1
  259. package/src/daemon/shutdown-handlers.ts +122 -0
  260. package/src/daemon/trace-emitter.ts +1 -1
  261. package/src/daemon/watch-handler.ts +36 -33
  262. package/src/doordash/cart-queries.ts +787 -0
  263. package/src/doordash/client.ts +144 -127
  264. package/src/doordash/order-queries.ts +85 -0
  265. package/src/doordash/queries.ts +10 -1308
  266. package/src/doordash/search-queries.ts +203 -0
  267. package/src/doordash/session.ts +3 -2
  268. package/src/doordash/store-queries.ts +246 -0
  269. package/src/doordash/types.ts +367 -0
  270. package/src/email/providers/agentmail.ts +2 -1
  271. package/src/email/providers/index.ts +3 -2
  272. package/src/email/service.ts +3 -2
  273. package/src/errors.ts +43 -0
  274. package/src/home-base/prebuilt/seed.ts +1 -1
  275. package/src/hooks/cli.ts +6 -5
  276. package/src/hooks/config.ts +6 -8
  277. package/src/hooks/discovery.ts +6 -5
  278. package/src/hooks/manager.ts +4 -3
  279. package/src/hooks/runner.ts +2 -2
  280. package/src/hooks/templates.ts +5 -5
  281. package/src/inbound/public-ingress-urls.ts +3 -1
  282. package/src/index.ts +4 -2
  283. package/src/influencer/client.ts +1104 -0
  284. package/src/instrument.ts +4 -3
  285. package/src/logfire.ts +4 -3
  286. package/src/memory/admin.ts +25 -35
  287. package/src/memory/attachments-store.ts +4 -7
  288. package/src/memory/channel-delivery-store.ts +30 -1
  289. package/src/memory/channel-guardian-store.ts +200 -1
  290. package/src/memory/clarification-resolver.ts +37 -33
  291. package/src/memory/conflict-store.ts +67 -61
  292. package/src/memory/contradiction-checker.ts +141 -117
  293. package/src/memory/conversation-store.ts +335 -51
  294. package/src/memory/db-connection.ts +27 -4
  295. package/src/memory/db-init.ts +121 -4
  296. package/src/memory/db.ts +14 -1
  297. package/src/memory/embedding-backend.ts +27 -5
  298. package/src/memory/embedding-ollama.ts +2 -1
  299. package/src/memory/entity-extractor.ts +38 -35
  300. package/src/memory/guardian-action-store.ts +430 -0
  301. package/src/memory/inbox-escalation-projection.ts +59 -0
  302. package/src/memory/inbox-thread-store.ts +218 -0
  303. package/src/memory/ingress-invite-store.ts +338 -0
  304. package/src/memory/ingress-member-store.ts +350 -0
  305. package/src/memory/items-extractor.ts +91 -97
  306. package/src/memory/job-handlers/index-maintenance.ts +3 -3
  307. package/src/memory/job-handlers/media-processing.ts +11 -42
  308. package/src/memory/job-handlers/summarization.ts +32 -26
  309. package/src/memory/job-utils.ts +3 -10
  310. package/src/memory/jobs-store.ts +6 -9
  311. package/src/memory/jobs-worker.ts +51 -36
  312. package/src/memory/migrations/001-job-deferrals.ts +45 -0
  313. package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
  314. package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
  315. package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
  316. package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
  317. package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
  318. package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
  319. package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
  320. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
  321. package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
  322. package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
  323. package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
  324. package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
  325. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
  326. package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
  327. package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
  328. package/src/memory/migrations/017-memory-items-indexes.ts +10 -0
  329. package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
  330. package/src/memory/migrations/index.ts +24 -0
  331. package/src/memory/migrations/registry.ts +79 -0
  332. package/src/memory/migrations/validate-migration-state.ts +69 -0
  333. package/src/memory/qdrant-manager.ts +49 -8
  334. package/src/memory/query-builder.ts +1 -1
  335. package/src/memory/raw-query.ts +119 -0
  336. package/src/memory/recall-cache.ts +4 -1
  337. package/src/memory/retriever.ts +160 -47
  338. package/src/memory/schema-migration.ts +25 -984
  339. package/src/memory/schema.ts +130 -7
  340. package/src/memory/search/entity.ts +10 -19
  341. package/src/memory/search/lexical.ts +81 -52
  342. package/src/memory/search/ranking.ts +21 -22
  343. package/src/memory/search/semantic.ts +157 -19
  344. package/src/memory/shared-app-links-store.ts +4 -5
  345. package/src/memory/validation.ts +19 -0
  346. package/src/messaging/draft-store.ts +5 -6
  347. package/src/messaging/providers/sms/adapter.ts +3 -6
  348. package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
  349. package/src/messaging/providers/whatsapp/adapter.ts +136 -0
  350. package/src/messaging/providers/whatsapp/client.ts +67 -0
  351. package/src/messaging/style-analyzer.ts +5 -4
  352. package/src/messaging/thread-summarizer.ts +61 -69
  353. package/src/messaging/triage-engine.ts +62 -71
  354. package/src/migrations/config-merge.ts +53 -0
  355. package/src/migrations/data-layout.ts +68 -0
  356. package/src/migrations/data-merge.ts +33 -0
  357. package/src/migrations/hooks-merge.ts +90 -0
  358. package/src/migrations/index.ts +6 -0
  359. package/src/migrations/log.ts +23 -0
  360. package/src/migrations/skills-merge.ts +33 -0
  361. package/src/migrations/workspace-layout.ts +79 -0
  362. package/src/permissions/checker.ts +119 -11
  363. package/src/permissions/prompter.ts +14 -0
  364. package/src/permissions/shell-identity.ts +31 -1
  365. package/src/permissions/trust-store.ts +21 -1
  366. package/src/providers/anthropic/client.ts +4 -4
  367. package/src/providers/failover.ts +2 -2
  368. package/src/providers/model-intents.ts +70 -0
  369. package/src/providers/ollama/client.ts +2 -1
  370. package/src/providers/provider-send-message.ts +176 -0
  371. package/src/providers/registry.ts +71 -30
  372. package/src/providers/retry.ts +35 -1
  373. package/src/providers/types.ts +12 -1
  374. package/src/runtime/approval-conversation-turn.ts +97 -0
  375. package/src/runtime/approval-message-composer.ts +115 -5
  376. package/src/runtime/channel-approval-parser.ts +36 -2
  377. package/src/runtime/channel-approvals.ts +0 -21
  378. package/src/runtime/channel-guardian-service.ts +48 -7
  379. package/src/runtime/channel-readiness-service.ts +160 -34
  380. package/src/runtime/channel-readiness-types.ts +10 -4
  381. package/src/runtime/channel-retry-sweep.ts +184 -0
  382. package/src/runtime/guardian-context-resolver.ts +108 -0
  383. package/src/runtime/http-server.ts +275 -743
  384. package/src/runtime/http-types.ts +56 -3
  385. package/src/runtime/middleware/auth.ts +116 -0
  386. package/src/runtime/middleware/error-handler.ts +33 -0
  387. package/src/runtime/middleware/twilio-validation.ts +127 -0
  388. package/src/runtime/routes/app-routes.ts +1 -1
  389. package/src/runtime/routes/call-routes.ts +49 -6
  390. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  391. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  392. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  393. package/src/runtime/routes/channel-route-shared.ts +144 -0
  394. package/src/runtime/routes/channel-routes.ts +32 -1634
  395. package/src/runtime/routes/conversation-routes.ts +50 -7
  396. package/src/runtime/routes/events-routes.ts +2 -2
  397. package/src/runtime/routes/identity-routes.ts +126 -0
  398. package/src/runtime/routes/pairing-routes.ts +143 -0
  399. package/src/runtime/routes/run-routes.ts +15 -1
  400. package/src/runtime/run-orchestrator.ts +52 -34
  401. package/src/schedule/schedule-store.ts +36 -32
  402. package/src/schedule/scheduler.ts +3 -3
  403. package/src/security/encrypted-store.ts +5 -7
  404. package/src/security/oauth2.ts +45 -15
  405. package/src/security/parental-control-store.ts +183 -0
  406. package/src/security/secret-allowlist.ts +4 -3
  407. package/src/security/secret-scanner.ts +5 -5
  408. package/src/security/secure-keys.ts +1 -1
  409. package/src/security/token-manager.ts +3 -2
  410. package/src/services/vercel-deploy.ts +6 -2
  411. package/src/skills/tool-manifest.ts +3 -3
  412. package/src/skills/vellum-catalog-remote.ts +75 -16
  413. package/src/slack/slack-webhook.ts +2 -1
  414. package/src/swarm/orchestrator.ts +92 -1
  415. package/src/swarm/router-planner.ts +6 -9
  416. package/src/swarm/worker-prompts.ts +9 -12
  417. package/src/tasks/task-compiler.ts +19 -28
  418. package/src/tasks/task-runner.ts +1 -1
  419. package/src/tools/assets/search.ts +15 -14
  420. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  421. package/src/tools/browser/auto-navigate.ts +1 -0
  422. package/src/tools/browser/browser-execution.ts +10 -1
  423. package/src/tools/browser/browser-manager.ts +119 -4
  424. package/src/tools/browser/network-recorder.ts +5 -0
  425. package/src/tools/credentials/broker.ts +11 -2
  426. package/src/tools/credentials/metadata-store.ts +18 -14
  427. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  428. package/src/tools/credentials/vault.ts +49 -23
  429. package/src/tools/executor.ts +68 -9
  430. package/src/tools/host-terminal/cli-discover.ts +1 -1
  431. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  432. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  433. package/src/tools/network/script-proxy/server.ts +1 -1
  434. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  435. package/src/tools/network/web-fetch.ts +18 -2
  436. package/src/tools/network/web-search.ts +7 -3
  437. package/src/tools/reminder/reminder-store.ts +14 -15
  438. package/src/tools/schedule/create.ts +1 -0
  439. package/src/tools/schedule/list.ts +2 -1
  440. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  441. package/src/tools/skills/skill-script-runner.ts +24 -9
  442. package/src/tools/skills/skill-tool-factory.ts +1 -0
  443. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  444. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  445. package/src/tools/terminal/parser.ts +50 -0
  446. package/src/tools/watcher/delete.ts +6 -0
  447. package/src/tools/weather/service.ts +1 -1
  448. package/src/twitter/client.ts +190 -24
  449. package/src/twitter/session.ts +4 -3
  450. package/src/util/clipboard.ts +1 -1
  451. package/src/util/errors.ts +65 -8
  452. package/src/util/fs.ts +40 -0
  453. package/src/util/json.ts +10 -0
  454. package/src/util/log-redact.ts +189 -0
  455. package/src/util/logger.ts +19 -17
  456. package/src/util/object.ts +3 -0
  457. package/src/util/platform.ts +72 -365
  458. package/src/util/pricing.ts +1 -1
  459. package/src/util/promise-guard.ts +1 -1
  460. package/src/util/retry.ts +19 -0
  461. package/src/util/row-mapper.ts +79 -0
  462. package/src/util/silently.ts +21 -0
  463. package/src/watcher/engine.ts +5 -1
  464. package/src/watcher/provider-types.ts +20 -0
  465. package/src/watcher/providers/github.ts +156 -0
  466. package/src/watcher/providers/gmail.ts +1 -0
  467. package/src/watcher/providers/google-calendar.ts +1 -0
  468. package/src/watcher/providers/linear.ts +460 -0
  469. package/src/watcher/providers/slack.ts +1 -0
  470. package/src/work-items/work-item-runner.ts +1 -1
  471. package/src/workspace/git-service.ts +1 -1
  472. package/src/workspace/provider-commit-message-generator.ts +51 -22
  473. package/src/__tests__/call-bridge.test.ts +0 -517
  474. package/src/__tests__/session-process-bridge.test.ts +0 -244
  475. package/src/calls/call-bridge.ts +0 -168
  476. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +0 -137
  477. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +0 -280
  478. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +0 -144
  479. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +0 -136
  480. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +0 -95
  481. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +0 -267
  482. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +0 -110
  483. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +0 -235
  484. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +0 -142
  485. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +0 -150
  486. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
@@ -2,7 +2,6 @@ import { describe, test, expect, beforeEach, afterAll, mock, type Mock } from 'b
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { EventEmitter } from 'node:events';
6
5
 
7
6
  const testDir = mkdtempSync(join(tmpdir(), 'call-orchestrator-test-'));
8
7
 
@@ -18,6 +17,7 @@ mock.module('../util/platform.js', () => ({
18
17
  getDbPath: () => join(testDir, 'test.db'),
19
18
  getLogPath: () => join(testDir, 'test.log'),
20
19
  ensureDataDir: () => {},
20
+ readHttpToken: () => null,
21
21
  }));
22
22
 
23
23
  mock.module('../util/logger.js', () => ({
@@ -42,6 +42,8 @@ let mockDisclosure: { enabled: boolean; text: string } = { enabled: false, text:
42
42
 
43
43
  mock.module('../config/loader.js', () => ({
44
44
  getConfig: () => ({
45
+ provider: 'anthropic',
46
+ providerOrder: ['anthropic'],
45
47
  apiKeys: { anthropic: 'test-key' },
46
48
  calls: {
47
49
  enabled: true,
@@ -54,54 +56,80 @@ mock.module('../config/loader.js', () => ({
54
56
  safety: { denyCategories: [] },
55
57
  model: mockCallModel,
56
58
  },
59
+ memory: { enabled: false },
57
60
  }),
58
61
  }));
59
62
 
60
- // ── Helpers for building mock streaming responses ───────────────────
63
+ // ── Helpers for building mock provider responses ────────────────────
61
64
 
62
65
  /**
63
- * Creates a mock Anthropic stream object that emits 'text' events
64
- * for each token and resolves `finalMessage()` with the full response.
66
+ * Creates a mock provider sendMessage implementation that emits text_delta
67
+ * events for each token and resolves with the full response.
65
68
  */
66
- function createMockStream(tokens: string[]) {
67
- const emitter = new EventEmitter();
69
+ function createMockProviderResponse(tokens: string[]) {
68
70
  const fullText = tokens.join('');
69
-
70
- const stream = {
71
- on: (event: string, handler: (...args: unknown[]) => void) => {
72
- emitter.on(event, handler);
73
- return stream;
74
- },
75
- finalMessage: () => {
76
- // Emit tokens synchronously so the on('text') handler has fired
77
- // before finalMessage resolves.
78
- for (const token of tokens) {
79
- emitter.emit('text', token);
80
- }
81
- return Promise.resolve({
82
- content: [{ type: 'text', text: fullText }],
83
- });
84
- },
71
+ return async (
72
+ _messages: unknown[],
73
+ _tools: unknown[],
74
+ _systemPrompt: string,
75
+ options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal },
76
+ ) => {
77
+ // Emit text_delta events for each token
78
+ for (const token of tokens) {
79
+ options?.onEvent?.({ type: 'text_delta', text: token });
80
+ }
81
+ return {
82
+ content: [{ type: 'text', text: fullText }],
83
+ model: 'claude-sonnet-4-20250514',
84
+ usage: { inputTokens: 100, outputTokens: 50 },
85
+ stopReason: 'end_turn',
86
+ };
85
87
  };
86
-
87
- return stream;
88
88
  }
89
89
 
90
- // ── Anthropic SDK mock ──────────────────────────────────────────────
90
+ // ── Provider registry mock ──────────────────────────────────────────
91
91
 
92
- let mockStreamFn: Mock<(...args: unknown[]) => unknown>;
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ let mockSendMessage: Mock<any>;
93
94
 
94
- mock.module('@anthropic-ai/sdk', () => {
95
- mockStreamFn = mock((..._args: unknown[]) => createMockStream(['Hello', ' there']));
95
+ mock.module('../providers/registry.js', () => {
96
+ mockSendMessage = mock(createMockProviderResponse(['Hello', ' there']));
96
97
  return {
97
- default: class MockAnthropic {
98
- messages = {
99
- stream: (...args: unknown[]) => mockStreamFn(...args),
98
+ listProviders: () => ['anthropic'],
99
+ getFailoverProvider: () => ({
100
+ name: 'anthropic',
101
+ sendMessage: (...args: unknown[]) => mockSendMessage(...args),
102
+ }),
103
+ getDefaultModel: (providerName: string) => {
104
+ const defaults: Record<string, string> = {
105
+ anthropic: 'claude-opus-4-6',
106
+ openai: 'gpt-5.2',
107
+ gemini: 'gemini-3-flash',
108
+ ollama: 'llama3.2',
109
+ fireworks: 'accounts/fireworks/models/kimi-k2p5',
110
+ openrouter: 'x-ai/grok-4',
100
111
  };
112
+ return defaults[providerName] ?? defaults.anthropic;
101
113
  },
102
114
  };
103
115
  });
104
116
 
117
+ mock.module('../providers/provider-send-message.js', () => ({
118
+ resolveConfiguredProvider: () => ({
119
+ provider: {
120
+ name: 'anthropic',
121
+ sendMessage: (...args: unknown[]) => mockSendMessage(...args),
122
+ },
123
+ configuredProviderName: 'anthropic',
124
+ selectedProviderName: 'anthropic',
125
+ usedFallbackPrimary: false,
126
+ }),
127
+ getConfiguredProvider: () => ({
128
+ name: 'anthropic',
129
+ sendMessage: (...args: unknown[]) => mockSendMessage(...args),
130
+ }),
131
+ }));
132
+
105
133
  // ── Import source modules after all mocks are registered ────────────
106
134
 
107
135
  import { initializeDb, getDb, resetDb } from '../memory/db.js';
@@ -177,9 +205,13 @@ function ensureConversation(id: string): void {
177
205
 
178
206
  function resetTables() {
179
207
  const db = getDb();
208
+ db.run('DELETE FROM guardian_action_deliveries');
209
+ db.run('DELETE FROM guardian_action_requests');
180
210
  db.run('DELETE FROM call_pending_questions');
181
211
  db.run('DELETE FROM call_events');
182
212
  db.run('DELETE FROM call_sessions');
213
+ db.run('DELETE FROM tool_invocations');
214
+ db.run('DELETE FROM messages');
183
215
  db.run('DELETE FROM conversations');
184
216
  ensuredConvIds = new Set();
185
217
  }
@@ -208,14 +240,14 @@ describe('call-orchestrator', () => {
208
240
  mockCallModel = undefined;
209
241
  mockUserReference = 'my human';
210
242
  mockDisclosure = { enabled: false, text: '' };
211
- // Reset the stream mock to default behaviour
212
- mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
243
+ // Reset the provider mock to default behaviour
244
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Hello', ' there']));
213
245
  });
214
246
 
215
247
  // ── handleCallerUtterance ─────────────────────────────────────────
216
248
 
217
249
  test('handleCallerUtterance: streams tokens via sendTextToken', async () => {
218
- mockStreamFn.mockImplementation(() => createMockStream(['Hi', ', how', ' are you?']));
250
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Hi', ', how', ' are you?']));
219
251
  const { relay, orchestrator } = setupOrchestrator();
220
252
 
221
253
  await orchestrator.handleCallerUtterance('Hello');
@@ -231,7 +263,7 @@ describe('call-orchestrator', () => {
231
263
  });
232
264
 
233
265
  test('handleCallerUtterance: sends last=true at end of turn', async () => {
234
- mockStreamFn.mockImplementation(() => createMockStream(['Simple response.']));
266
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Simple response.']));
235
267
  const { relay, orchestrator } = setupOrchestrator();
236
268
 
237
269
  await orchestrator.handleCallerUtterance('Test');
@@ -244,12 +276,18 @@ describe('call-orchestrator', () => {
244
276
  });
245
277
 
246
278
  test('handleCallerUtterance: includes speaker context in model message', async () => {
247
- mockStreamFn.mockImplementation((...args: unknown[]) => {
248
- const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
249
- const userMessage = firstArg.messages.find((m) => m.role === 'user');
250
- expect(userMessage?.content).toContain('[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]');
251
- expect(userMessage?.content).toContain('Can you summarize this meeting?');
252
- return createMockStream(['Sure, here is a summary.']);
279
+ mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
280
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
281
+ const userMessage = msgs.find((m) => m.role === 'user');
282
+ const userText = userMessage?.content?.[0]?.text ?? '';
283
+ expect(userText).toContain('[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]');
284
+ expect(userText).toContain('Can you summarize this meeting?');
285
+ return {
286
+ content: [{ type: 'text', text: 'Sure, here is a summary.' }],
287
+ model: 'claude-sonnet-4-20250514',
288
+ usage: { inputTokens: 100, outputTokens: 50 },
289
+ stopReason: 'end_turn',
290
+ };
253
291
  });
254
292
 
255
293
  const { orchestrator } = setupOrchestrator();
@@ -264,12 +302,89 @@ describe('call-orchestrator', () => {
264
302
  orchestrator.destroy();
265
303
  });
266
304
 
267
- // ── ASK_USER pattern ──────────────────────────────────────────────
305
+ test('startInitialGreeting: generates model-driven opening and strips control marker from speech', async () => {
306
+ mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
307
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
308
+ const firstUser = msgs.find((m) => m.role === 'user');
309
+ expect(firstUser?.content?.[0]?.text).toContain('[CALL_OPENING]');
310
+ const tokens = ['Hi, I am calling about your appointment request. Is now a good time to talk?'];
311
+ const opts = _rest[2] as { onEvent?: (event: { type: string; text?: string }) => void } | undefined;
312
+ for (const token of tokens) {
313
+ opts?.onEvent?.({ type: 'text_delta', text: token });
314
+ }
315
+ return {
316
+ content: [{ type: 'text', text: tokens.join('') }],
317
+ model: 'claude-sonnet-4-20250514',
318
+ usage: { inputTokens: 100, outputTokens: 50 },
319
+ stopReason: 'end_turn',
320
+ };
321
+ });
268
322
 
269
- test('ASK_USER pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
270
- mockStreamFn.mockImplementation(() =>
271
- createMockStream(['Let me check on that. ', '[ASK_USER: What date works best?]']),
272
- );
323
+ const { relay, orchestrator } = setupOrchestrator('Confirm appointment');
324
+
325
+ const callCountBefore = mockSendMessage.mock.calls.length;
326
+ await orchestrator.startInitialGreeting();
327
+ await orchestrator.startInitialGreeting();
328
+
329
+ const allText = relay.sentTokens.map((t) => t.token).join('');
330
+ expect(allText).toContain('appointment request');
331
+ expect(allText).toContain('good time to talk');
332
+ expect(allText).not.toContain('[CALL_OPENING]');
333
+ expect(mockSendMessage.mock.calls.length - callCountBefore).toBe(1);
334
+
335
+ orchestrator.destroy();
336
+ });
337
+
338
+ test('startInitialGreeting: tags only the first caller response with CALL_OPENING_ACK', async () => {
339
+ let callCount = 0;
340
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
341
+ callCount++;
342
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
343
+ const userMessages = msgs.filter((m) => m.role === 'user');
344
+ const lastUser = userMessages[userMessages.length - 1]?.content?.[0]?.text ?? '';
345
+
346
+ let tokens: string[];
347
+ if (callCount === 1) {
348
+ expect(lastUser).toContain('[CALL_OPENING]');
349
+ tokens = ['Hey Noa, it\'s Credence calling about your joke request. Is now okay for a quick one?'];
350
+ } else if (callCount === 2) {
351
+ expect(lastUser).toContain('[CALL_OPENING_ACK]');
352
+ expect(lastUser).toContain('Yeah. Sure. What\'s up?');
353
+ tokens = ['Great, here\'s one right away. Why did the scarecrow win an award?'];
354
+ } else {
355
+ expect(lastUser).not.toContain('[CALL_OPENING_ACK]');
356
+ expect(lastUser).toContain('Tell me the punchline');
357
+ tokens = ['Because he was outstanding in his field.'];
358
+ }
359
+
360
+ for (const token of tokens) {
361
+ options?.onEvent?.({ type: 'text_delta', text: token });
362
+ }
363
+ return {
364
+ content: [{ type: 'text', text: tokens.join('') }],
365
+ model: 'claude-sonnet-4-20250514',
366
+ usage: { inputTokens: 100, outputTokens: 50 },
367
+ stopReason: 'end_turn',
368
+ };
369
+ });
370
+
371
+ const { orchestrator } = setupOrchestrator('Tell a joke immediately');
372
+
373
+ await orchestrator.startInitialGreeting();
374
+ await orchestrator.handleCallerUtterance('Yeah. Sure. What\'s up?');
375
+ await orchestrator.handleCallerUtterance('Tell me the punchline');
376
+
377
+ expect(callCount).toBe(3);
378
+
379
+ orchestrator.destroy();
380
+ });
381
+
382
+ // ── ASK_GUARDIAN pattern ──────────────────────────────────────────
383
+
384
+ test('ASK_GUARDIAN pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
385
+ mockSendMessage.mockImplementation(createMockProviderResponse(
386
+ ['Let me check on that. ', '[ASK_GUARDIAN: What date works best?]'],
387
+ ));
273
388
  const { session, relay, orchestrator } = setupOrchestrator('Book appointment');
274
389
 
275
390
  await orchestrator.handleCallerUtterance('I need to schedule something');
@@ -284,22 +399,21 @@ describe('call-orchestrator', () => {
284
399
  const updatedSession = getCallSession(session.id);
285
400
  expect(updatedSession!.status).toBe('waiting_on_user');
286
401
 
287
- // The ASK_USER marker text should NOT appear in the relay tokens
402
+ // The ASK_GUARDIAN marker text should NOT appear in the relay tokens
288
403
  const allText = relay.sentTokens.map((t) => t.token).join('');
289
- expect(allText).not.toContain('[ASK_USER:');
404
+ expect(allText).not.toContain('[ASK_GUARDIAN:');
290
405
 
291
406
  orchestrator.destroy();
292
407
  });
293
408
 
294
- test('strips USER_ANSWERED and USER_INSTRUCTION markers from spoken output', async () => {
295
- mockStreamFn.mockImplementation(() =>
296
- createMockStream([
297
- 'Thanks for waiting. ',
298
- '[USER_ANSWERED: The guardian said 3 PM works.] ',
299
- '[USER_INSTRUCTION: Keep this short.] ',
300
- 'I can confirm 3 PM works.',
301
- ]),
302
- );
409
+ test('strips internal context markers from spoken output', async () => {
410
+ mockSendMessage.mockImplementation(createMockProviderResponse([
411
+ 'Thanks for waiting. ',
412
+ '[USER_ANSWERED: The guardian said 3 PM works.] ',
413
+ '[USER_INSTRUCTION: Keep this short.] ',
414
+ '[CALL_OPENING_ACK] ',
415
+ 'I can confirm 3 PM works.',
416
+ ]));
303
417
  const { relay, orchestrator } = setupOrchestrator();
304
418
 
305
419
  await orchestrator.handleCallerUtterance('Any update?');
@@ -309,8 +423,10 @@ describe('call-orchestrator', () => {
309
423
  expect(allText).toContain('I can confirm 3 PM works.');
310
424
  expect(allText).not.toContain('[USER_ANSWERED:');
311
425
  expect(allText).not.toContain('[USER_INSTRUCTION:');
426
+ expect(allText).not.toContain('[CALL_OPENING_ACK]');
312
427
  expect(allText).not.toContain('USER_ANSWERED');
313
428
  expect(allText).not.toContain('USER_INSTRUCTION');
429
+ expect(allText).not.toContain('CALL_OPENING_ACK');
314
430
 
315
431
  orchestrator.destroy();
316
432
  });
@@ -318,9 +434,9 @@ describe('call-orchestrator', () => {
318
434
  // ── END_CALL pattern ──────────────────────────────────────────────
319
435
 
320
436
  test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
321
- mockStreamFn.mockImplementation(() =>
322
- createMockStream(['Thank you for calling, goodbye! ', '[END_CALL]']),
323
- );
437
+ mockSendMessage.mockImplementation(createMockProviderResponse(
438
+ ['Thank you for calling, goodbye! ', '[END_CALL]'],
439
+ ));
324
440
  const { session, relay, orchestrator } = setupOrchestrator();
325
441
 
326
442
  await orchestrator.handleCallerUtterance('That is all, thanks');
@@ -343,21 +459,31 @@ describe('call-orchestrator', () => {
343
459
  // ── handleUserAnswer ──────────────────────────────────────────────
344
460
 
345
461
  test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
346
- // First utterance triggers ASK_USER
347
- mockStreamFn.mockImplementation(() =>
348
- createMockStream(['Hold on. [ASK_USER: Preferred time?]']),
349
- );
462
+ // First utterance triggers ASK_GUARDIAN
463
+ mockSendMessage.mockImplementation(createMockProviderResponse(
464
+ ['Hold on. [ASK_GUARDIAN: Preferred time?]'],
465
+ ));
350
466
  const { relay, orchestrator } = setupOrchestrator();
351
467
 
352
468
  await orchestrator.handleCallerUtterance('I need an appointment');
353
469
 
354
470
  // Now provide the answer — reset mock for second LLM call
355
- mockStreamFn.mockImplementation((...args: unknown[]) => {
471
+ mockSendMessage.mockImplementation(async (messages: unknown[], ..._rest: unknown[]) => {
356
472
  // Verify the messages include the USER_ANSWERED marker
357
- const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
358
- const lastUserMsg = firstArg.messages.filter((m: { role: string }) => m.role === 'user').pop();
359
- expect(lastUserMsg?.content).toContain('[USER_ANSWERED: 3pm tomorrow]');
360
- return createMockStream(['Great, I have scheduled for 3pm tomorrow.']);
473
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
474
+ const lastUserMsg = msgs.filter((m) => m.role === 'user').pop();
475
+ expect(lastUserMsg?.content?.[0]?.text).toContain('[USER_ANSWERED: 3pm tomorrow]');
476
+ const tokens = ['Great, I have scheduled for 3pm tomorrow.'];
477
+ const opts = _rest[2] as { onEvent?: (event: { type: string; text?: string }) => void } | undefined;
478
+ for (const token of tokens) {
479
+ opts?.onEvent?.({ type: 'text_delta', text: token });
480
+ }
481
+ return {
482
+ content: [{ type: 'text', text: tokens.join('') }],
483
+ model: 'claude-sonnet-4-20250514',
484
+ usage: { inputTokens: 100, outputTokens: 50 },
485
+ stopReason: 'end_turn',
486
+ };
361
487
  });
362
488
 
363
489
  const accepted = await orchestrator.handleUserAnswer('3pm tomorrow');
@@ -378,9 +504,9 @@ describe('call-orchestrator', () => {
378
504
 
379
505
  test('mid-call question flow: unavailable time → ask user → user confirms → resumed call', async () => {
380
506
  // Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
381
- mockStreamFn.mockImplementation(() =>
382
- createMockStream(['I\'m sorry, 7:30 is not available. ', '[ASK_USER: Is 8:00 okay instead?]']),
383
- );
507
+ mockSendMessage.mockImplementation(createMockProviderResponse(
508
+ ['I\'m sorry, 7:30 is not available. ', '[ASK_GUARDIAN: Is 8:00 okay instead?]'],
509
+ ));
384
510
 
385
511
  const { session, relay, orchestrator } = setupOrchestrator('Schedule a haircut');
386
512
 
@@ -397,9 +523,9 @@ describe('call-orchestrator', () => {
397
523
  expect(midSession!.status).toBe('waiting_on_user');
398
524
 
399
525
  // Step 2: User answers "Yes, 8:00 works"
400
- mockStreamFn.mockImplementation(() =>
401
- createMockStream(['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]']),
402
- );
526
+ mockSendMessage.mockImplementation(createMockProviderResponse(
527
+ ['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]'],
528
+ ));
403
529
 
404
530
  const accepted = await orchestrator.handleUserAnswer('Yes, 8:00 works for me');
405
531
  expect(accepted).toBe(true);
@@ -421,16 +547,9 @@ describe('call-orchestrator', () => {
421
547
  // ── Provider / LLM failure paths ───────────────────────────────
422
548
 
423
549
  test('LLM error: sends error message to caller and returns to idle', async () => {
424
- // Make the stream throw an error on finalMessage
425
- mockStreamFn.mockImplementation(() => {
426
- const emitter = new EventEmitter();
427
- return {
428
- on: (event: string, handler: (...args: unknown[]) => void) => {
429
- emitter.on(event, handler);
430
- return { on: () => ({ on: () => ({}) }) };
431
- },
432
- finalMessage: () => Promise.reject(new Error('API rate limit exceeded')),
433
- };
550
+ // Make sendMessage reject with an error
551
+ mockSendMessage.mockImplementation(async () => {
552
+ throw new Error('API rate limit exceeded');
434
553
  });
435
554
 
436
555
  const { relay, orchestrator } = setupOrchestrator();
@@ -450,19 +569,10 @@ describe('call-orchestrator', () => {
450
569
  });
451
570
 
452
571
  test('LLM APIUserAbortError: treats as expected abort without technical-issue fallback', async () => {
453
- mockStreamFn.mockImplementation(() => {
454
- const emitter = new EventEmitter();
455
- return {
456
- on: (event: string, handler: (...args: unknown[]) => void) => {
457
- emitter.on(event, handler);
458
- return { on: () => ({ on: () => ({}) }) };
459
- },
460
- finalMessage: () => {
461
- const err = new Error('user abort');
462
- err.name = 'APIUserAbortError';
463
- return Promise.reject(err);
464
- },
465
- };
572
+ mockSendMessage.mockImplementation(async () => {
573
+ const err = new Error('user abort');
574
+ err.name = 'APIUserAbortError';
575
+ throw err;
466
576
  });
467
577
 
468
578
  const { relay, orchestrator } = setupOrchestrator();
@@ -477,22 +587,23 @@ describe('call-orchestrator', () => {
477
587
 
478
588
  test('stale superseded turn errors do not emit technical-issue fallback', async () => {
479
589
  let callCount = 0;
480
- mockStreamFn.mockImplementation(() => {
590
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
481
591
  callCount++;
482
592
  if (callCount === 1) {
483
- const emitter = new EventEmitter();
484
- return {
485
- on: (event: string, handler: (...args: unknown[]) => void) => {
486
- emitter.on(event, handler);
487
- return { on: () => ({ on: () => ({}) }) };
488
- },
489
- finalMessage: () =>
490
- new Promise((_, reject) => {
491
- setTimeout(() => reject(new Error('stale stream failure')), 20);
492
- }),
493
- };
593
+ return new Promise((_, reject) => {
594
+ setTimeout(() => reject(new Error('stale stream failure')), 20);
595
+ });
494
596
  }
495
- return createMockStream(['Second turn response.']);
597
+ const tokens = ['Second turn response.'];
598
+ for (const token of tokens) {
599
+ options?.onEvent?.({ type: 'text_delta', text: token });
600
+ }
601
+ return {
602
+ content: [{ type: 'text', text: tokens.join('') }],
603
+ model: 'claude-sonnet-4-20250514',
604
+ usage: { inputTokens: 100, outputTokens: 50 },
605
+ stopReason: 'end_turn',
606
+ };
496
607
  });
497
608
 
498
609
  const { relay, orchestrator } = setupOrchestrator();
@@ -511,39 +622,102 @@ describe('call-orchestrator', () => {
511
622
  orchestrator.destroy();
512
623
  });
513
624
 
514
- test('rapid caller barge-in coalesces contiguous user turns for role alternation', async () => {
625
+ test('barge-in cleanup never sends empty user turns to provider', async () => {
515
626
  let callCount = 0;
516
- mockStreamFn.mockImplementation((...args: unknown[]) => {
627
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
517
628
  callCount++;
629
+
630
+ // Initial outbound opener
518
631
  if (callCount === 1) {
519
- const emitter = new EventEmitter();
520
- const options = args[1] as { signal?: AbortSignal } | undefined;
632
+ const tokens = ['Hey Noa, this is Credence calling.'];
633
+ for (const token of tokens) {
634
+ options?.onEvent?.({ type: 'text_delta', text: token });
635
+ }
521
636
  return {
522
- on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
523
- emitter.on(event, handler);
524
- return { on: () => ({ on: () => ({}) }) };
525
- },
526
- finalMessage: () =>
527
- new Promise((_, reject) => {
528
- options?.signal?.addEventListener('abort', () => {
529
- const err = new Error('aborted');
530
- err.name = 'AbortError';
531
- reject(err);
532
- }, { once: true });
533
- }),
637
+ content: [{ type: 'text', text: tokens.join('') }],
638
+ model: 'claude-sonnet-4-20250514',
639
+ usage: { inputTokens: 100, outputTokens: 50 },
640
+ stopReason: 'end_turn',
534
641
  };
535
642
  }
536
643
 
537
- const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
538
- const roles = firstArg.messages.map((m) => m.role);
644
+ // First caller turn enters an in-flight LLM run that gets interrupted
645
+ if (callCount === 2) {
646
+ return new Promise((_, reject) => {
647
+ options?.signal?.addEventListener('abort', () => {
648
+ const err = new Error('aborted');
649
+ err.name = 'AbortError';
650
+ reject(err);
651
+ }, { once: true });
652
+ });
653
+ }
654
+
655
+ // Second caller turn should never include an empty user message.
656
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
657
+ const userMessages = msgs.filter((m) => m.role === 'user');
658
+ expect(userMessages.length).toBeGreaterThan(0);
659
+ expect(userMessages.every((m) => m.content?.[0]?.text?.trim().length > 0)).toBe(true);
660
+ const tokens = ['Got it, thanks for clarifying.'];
661
+ for (const token of tokens) {
662
+ options?.onEvent?.({ type: 'text_delta', text: token });
663
+ }
664
+ return {
665
+ content: [{ type: 'text', text: tokens.join('') }],
666
+ model: 'claude-sonnet-4-20250514',
667
+ usage: { inputTokens: 100, outputTokens: 50 },
668
+ stopReason: 'end_turn',
669
+ };
670
+ });
671
+
672
+ const { relay, orchestrator } = setupOrchestrator('Quick check-in');
673
+ await orchestrator.startInitialGreeting();
674
+
675
+ const firstTurnPromise = orchestrator.handleCallerUtterance('Hello?');
676
+ await new Promise((r) => setTimeout(r, 5));
677
+ const secondTurnPromise = orchestrator.handleCallerUtterance('What have you been up to lately?');
678
+
679
+ await Promise.all([firstTurnPromise, secondTurnPromise]);
680
+
681
+ const allTokens = relay.sentTokens.map((t) => t.token).join('');
682
+ expect(allTokens).toContain('Got it, thanks for clarifying.');
683
+ expect(allTokens).not.toContain('technical issue');
684
+
685
+ orchestrator.destroy();
686
+ });
687
+
688
+ test('rapid caller barge-in coalesces contiguous user turns for role alternation', async () => {
689
+ let callCount = 0;
690
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
691
+ callCount++;
692
+ if (callCount === 1) {
693
+ return new Promise((_, reject) => {
694
+ options?.signal?.addEventListener('abort', () => {
695
+ const err = new Error('aborted');
696
+ err.name = 'AbortError';
697
+ reject(err);
698
+ }, { once: true });
699
+ });
700
+ }
701
+
702
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
703
+ const roles = msgs.map((m) => m.role);
539
704
  for (let i = 1; i < roles.length; i++) {
540
705
  expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
541
706
  }
542
- const userMessages = firstArg.messages.filter((m) => m.role === 'user');
707
+ const userMessages = msgs.filter((m) => m.role === 'user');
543
708
  const lastUser = userMessages[userMessages.length - 1];
544
- expect(lastUser?.content).toContain('First caller utterance');
545
- expect(lastUser?.content).toContain('Second caller utterance');
546
- return createMockStream(['Merged turn handled.']);
709
+ expect(lastUser?.content?.[0]?.text).toContain('First caller utterance');
710
+ expect(lastUser?.content?.[0]?.text).toContain('Second caller utterance');
711
+ const tokens = ['Merged turn handled.'];
712
+ for (const token of tokens) {
713
+ options?.onEvent?.({ type: 'text_delta', text: token });
714
+ }
715
+ return {
716
+ content: [{ type: 'text', text: tokens.join('') }],
717
+ model: 'claude-sonnet-4-20250514',
718
+ usage: { inputTokens: 100, outputTokens: 50 },
719
+ stopReason: 'end_turn',
720
+ };
547
721
  });
548
722
 
549
723
  const { relay, orchestrator } = setupOrchestrator();
@@ -561,37 +735,37 @@ describe('call-orchestrator', () => {
561
735
 
562
736
  test('interrupt then next caller prompt still preserves role alternation', async () => {
563
737
  let callCount = 0;
564
- mockStreamFn.mockImplementation((...args: unknown[]) => {
738
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
565
739
  callCount++;
566
740
  if (callCount === 1) {
567
- const emitter = new EventEmitter();
568
- const options = args[1] as { signal?: AbortSignal } | undefined;
569
- return {
570
- on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
571
- emitter.on(event, handler);
572
- return { on: () => ({ on: () => ({}) }) };
573
- },
574
- finalMessage: () =>
575
- new Promise((_, reject) => {
576
- options?.signal?.addEventListener('abort', () => {
577
- const err = new Error('aborted');
578
- err.name = 'AbortError';
579
- reject(err);
580
- }, { once: true });
581
- }),
582
- };
741
+ return new Promise((_, reject) => {
742
+ options?.signal?.addEventListener('abort', () => {
743
+ const err = new Error('aborted');
744
+ err.name = 'AbortError';
745
+ reject(err);
746
+ }, { once: true });
747
+ });
583
748
  }
584
749
 
585
- const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
586
- const roles = firstArg.messages.map((m) => m.role);
750
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
751
+ const roles = msgs.map((m) => m.role);
587
752
  for (let i = 1; i < roles.length; i++) {
588
753
  expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
589
754
  }
590
- const userMessages = firstArg.messages.filter((m) => m.role === 'user');
755
+ const userMessages = msgs.filter((m) => m.role === 'user');
591
756
  const lastUser = userMessages[userMessages.length - 1];
592
- expect(lastUser?.content).toContain('First caller utterance');
593
- expect(lastUser?.content).toContain('Second caller utterance');
594
- return createMockStream(['Post-interrupt response.']);
757
+ expect(lastUser?.content?.[0]?.text).toContain('First caller utterance');
758
+ expect(lastUser?.content?.[0]?.text).toContain('Second caller utterance');
759
+ const tokens = ['Post-interrupt response.'];
760
+ for (const token of tokens) {
761
+ options?.onEvent?.({ type: 'text_delta', text: token });
762
+ }
763
+ return {
764
+ content: [{ type: 'text', text: tokens.join('') }],
765
+ model: 'claude-sonnet-4-20250514',
766
+ usage: { inputTokens: 100, outputTokens: 50 },
767
+ stopReason: 'end_turn',
768
+ };
595
769
  });
596
770
 
597
771
  const { relay, orchestrator } = setupOrchestrator();
@@ -631,24 +805,18 @@ describe('call-orchestrator', () => {
631
805
  });
632
806
 
633
807
  test('handleInterrupt: increments llmRunVersion to suppress stale turn side effects', async () => {
634
- // Use a stream whose finalMessage resolves immediately but whose
635
- // continuation (the code after `await stream.finalMessage()`) will
636
- // run asynchronously. This simulates the race where the promise
637
- // microtask is queued right as handleInterrupt fires.
638
- mockStreamFn.mockImplementation(() => {
639
- const emitter = new EventEmitter();
808
+ // Use a sendMessage that resolves immediately but whose continuation
809
+ // (the code after `await provider.sendMessage()`) will run asynchronously.
810
+ // This simulates the race where the promise microtask is queued right
811
+ // as handleInterrupt fires.
812
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
813
+ // Emit some tokens synchronously
814
+ options?.onEvent?.({ type: 'text_delta', text: 'Stale response that should be suppressed.' });
640
815
  return {
641
- on: (event: string, handler: (...args: unknown[]) => void) => {
642
- emitter.on(event, handler);
643
- return { on: () => ({ on: () => ({}) }) };
644
- },
645
- finalMessage: () => {
646
- // Emit some tokens synchronously
647
- emitter.emit('text', 'Stale response that should be suppressed.');
648
- return Promise.resolve({
649
- content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
650
- });
651
- },
816
+ content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
817
+ model: 'claude-sonnet-4-20250514',
818
+ usage: { inputTokens: 100, outputTokens: 50 },
819
+ stopReason: 'end_turn',
652
820
  };
653
821
  });
654
822
 
@@ -657,7 +825,7 @@ describe('call-orchestrator', () => {
657
825
  // Start an LLM turn (don't await — we want to interrupt mid-flight)
658
826
  const turnPromise = orchestrator.handleCallerUtterance('Hello');
659
827
 
660
- // Interrupt immediately. Because finalMessage resolves as a microtask,
828
+ // Interrupt immediately. Because sendMessage resolves as a microtask,
661
829
  // its continuation hasn't run yet. handleInterrupt increments
662
830
  // llmRunVersion so the continuation's isCurrentRun check will fail.
663
831
  orchestrator.handleInterrupt();
@@ -680,23 +848,14 @@ describe('call-orchestrator', () => {
680
848
  });
681
849
 
682
850
  test('handleInterrupt: sends turn terminator when interrupting active speech', async () => {
683
- mockStreamFn.mockImplementation((...args: unknown[]) => {
684
- const emitter = new EventEmitter();
685
- const options = args[1] as { signal?: AbortSignal } | undefined;
686
- return {
687
- on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
688
- emitter.on(event, handler);
689
- return { on: () => ({ on: () => ({}) }) };
690
- },
691
- finalMessage: () =>
692
- new Promise((_, reject) => {
693
- options?.signal?.addEventListener('abort', () => {
694
- const err = new Error('aborted');
695
- err.name = 'AbortError';
696
- reject(err);
697
- }, { once: true });
698
- }),
699
- };
851
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void; signal?: AbortSignal }) => {
852
+ return new Promise((_, reject) => {
853
+ options?.signal?.addEventListener('abort', () => {
854
+ const err = new Error('aborted');
855
+ err.name = 'AbortError';
856
+ reject(err);
857
+ }, { once: true });
858
+ });
700
859
  });
701
860
 
702
861
  const { relay, orchestrator } = setupOrchestrator();
@@ -735,12 +894,22 @@ describe('call-orchestrator', () => {
735
894
 
736
895
  // ── Model override from config ──────────────────────────────────────
737
896
 
738
- test('uses default model when calls.model is not set', async () => {
897
+ test('does not override model when calls.model is not set (preserves cross-provider failover)', async () => {
739
898
  mockCallModel = undefined;
740
- mockStreamFn.mockImplementation((...args: unknown[]) => {
741
- const firstArg = args[0] as { model: string };
742
- expect(firstArg.model).toBe('claude-sonnet-4-20250514');
743
- return createMockStream(['Default model response.']);
899
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
900
+ // When calls.model is unset, no model override should be passed so each
901
+ // provider in the failover chain uses its own default model.
902
+ expect(options?.config?.model).toBeUndefined();
903
+ const tokens = ['Default model response.'];
904
+ for (const token of tokens) {
905
+ options?.onEvent?.({ type: 'text_delta', text: token });
906
+ }
907
+ return {
908
+ content: [{ type: 'text', text: tokens.join('') }],
909
+ model: 'claude-opus-4-6',
910
+ usage: { inputTokens: 100, outputTokens: 50 },
911
+ stopReason: 'end_turn',
912
+ };
744
913
  });
745
914
 
746
915
  const { orchestrator } = setupOrchestrator();
@@ -750,10 +919,18 @@ describe('call-orchestrator', () => {
750
919
 
751
920
  test('uses calls.model override from config when set', async () => {
752
921
  mockCallModel = 'claude-haiku-4-5-20251001';
753
- mockStreamFn.mockImplementation((...args: unknown[]) => {
754
- const firstArg = args[0] as { model: string };
755
- expect(firstArg.model).toBe('claude-haiku-4-5-20251001');
756
- return createMockStream(['Override model response.']);
922
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
923
+ expect(options?.config?.model).toBe('claude-haiku-4-5-20251001');
924
+ const tokens = ['Override model response.'];
925
+ for (const token of tokens) {
926
+ options?.onEvent?.({ type: 'text_delta', text: token });
927
+ }
928
+ return {
929
+ content: [{ type: 'text', text: tokens.join('') }],
930
+ model: 'claude-haiku-4-5-20251001',
931
+ usage: { inputTokens: 100, outputTokens: 50 },
932
+ stopReason: 'end_turn',
933
+ };
757
934
  });
758
935
 
759
936
  const { orchestrator } = setupOrchestrator();
@@ -761,12 +938,21 @@ describe('call-orchestrator', () => {
761
938
  orchestrator.destroy();
762
939
  });
763
940
 
764
- test('treats empty string calls.model as unset and falls back to default', async () => {
941
+ test('treats empty string calls.model as unset and omits model override', async () => {
765
942
  mockCallModel = '';
766
- mockStreamFn.mockImplementation((...args: unknown[]) => {
767
- const firstArg = args[0] as { model: string };
768
- expect(firstArg.model).toBe('claude-sonnet-4-20250514');
769
- return createMockStream(['Fallback model response.']);
943
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
944
+ // Empty string is treated as unset — no model override
945
+ expect(options?.config?.model).toBeUndefined();
946
+ const tokens = ['Fallback model response.'];
947
+ for (const token of tokens) {
948
+ options?.onEvent?.({ type: 'text_delta', text: token });
949
+ }
950
+ return {
951
+ content: [{ type: 'text', text: tokens.join('') }],
952
+ model: 'claude-opus-4-6',
953
+ usage: { inputTokens: 100, outputTokens: 50 },
954
+ stopReason: 'end_turn',
955
+ };
770
956
  });
771
957
 
772
958
  const { orchestrator } = setupOrchestrator();
@@ -774,12 +960,21 @@ describe('call-orchestrator', () => {
774
960
  orchestrator.destroy();
775
961
  });
776
962
 
777
- test('treats whitespace-only calls.model as unset and falls back to default', async () => {
963
+ test('treats whitespace-only calls.model as unset and omits model override', async () => {
778
964
  mockCallModel = ' ';
779
- mockStreamFn.mockImplementation((...args: unknown[]) => {
780
- const firstArg = args[0] as { model: string };
781
- expect(firstArg.model).toBe('claude-sonnet-4-20250514');
782
- return createMockStream(['Fallback model response.']);
965
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { config?: { model?: string }; onEvent?: (event: { type: string; text?: string }) => void }) => {
966
+ // Whitespace-only is treated as unset — no model override
967
+ expect(options?.config?.model).toBeUndefined();
968
+ const tokens = ['Fallback model response.'];
969
+ for (const token of tokens) {
970
+ options?.onEvent?.({ type: 'text_delta', text: token });
971
+ }
972
+ return {
973
+ content: [{ type: 'text', text: tokens.join('') }],
974
+ model: 'claude-opus-4-6',
975
+ usage: { inputTokens: 100, outputTokens: 50 },
976
+ stopReason: 'end_turn',
977
+ };
783
978
  });
784
979
 
785
980
  const { orchestrator } = setupOrchestrator();
@@ -790,14 +985,23 @@ describe('call-orchestrator', () => {
790
985
  // ── handleUserInstruction ─────────────────────────────────────────
791
986
 
792
987
  test('handleUserInstruction: injects instruction marker into conversation history and triggers LLM when idle', async () => {
793
- mockStreamFn.mockImplementation((...args: unknown[]) => {
794
- const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
795
- const instructionMsg = firstArg.messages.find((m) =>
796
- m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
988
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
989
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
990
+ const instructionMsg = msgs.find((m) =>
991
+ m.role === 'user' && m.content?.[0]?.text?.includes('[USER_INSTRUCTION:'),
797
992
  );
798
993
  expect(instructionMsg).toBeDefined();
799
- expect(instructionMsg!.content).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
800
- return createMockStream(['Sure, do you have any weekend plans?']);
994
+ expect(instructionMsg!.content[0].text).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
995
+ const tokens = ['Sure, do you have any weekend plans?'];
996
+ for (const token of tokens) {
997
+ options?.onEvent?.({ type: 'text_delta', text: token });
998
+ }
999
+ return {
1000
+ content: [{ type: 'text', text: tokens.join('') }],
1001
+ model: 'claude-sonnet-4-20250514',
1002
+ usage: { inputTokens: 100, outputTokens: 50 },
1003
+ stopReason: 'end_turn',
1004
+ };
801
1005
  });
802
1006
 
803
1007
  const { relay, orchestrator } = setupOrchestrator();
@@ -813,30 +1017,38 @@ describe('call-orchestrator', () => {
813
1017
 
814
1018
  test('handleUserInstruction: does not break existing answer flow', async () => {
815
1019
  // Step 1: Caller says something, LLM responds normally
816
- mockStreamFn.mockImplementation(() => createMockStream(['Hello! How can I help you today?']));
1020
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Hello! How can I help you today?']));
817
1021
  const { session: _session, relay, orchestrator } = setupOrchestrator('Book appointment');
818
1022
 
819
1023
  await orchestrator.handleCallerUtterance('Hi there');
820
1024
 
821
1025
  // Step 2: Inject an instruction while idle
822
- mockStreamFn.mockImplementation((...args: unknown[]) => {
823
- const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
1026
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], _systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1027
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
824
1028
  // Verify the history contains both the original exchange and the instruction
825
- const messages = firstArg.messages;
826
- expect(messages.length).toBeGreaterThanOrEqual(3); // user utterance + assistant response + instruction
827
- const instructionMsg = messages.find((m) =>
828
- m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
1029
+ expect(msgs.length).toBeGreaterThanOrEqual(3); // user utterance + assistant response + instruction
1030
+ const instructionMsg = msgs.find((m) =>
1031
+ m.role === 'user' && m.content?.[0]?.text?.includes('[USER_INSTRUCTION:'),
829
1032
  );
830
1033
  expect(instructionMsg).toBeDefined();
831
- return createMockStream(['Of course, let me mention the weekend special.']);
1034
+ const tokens = ['Of course, let me mention the weekend special.'];
1035
+ for (const token of tokens) {
1036
+ options?.onEvent?.({ type: 'text_delta', text: token });
1037
+ }
1038
+ return {
1039
+ content: [{ type: 'text', text: tokens.join('') }],
1040
+ model: 'claude-sonnet-4-20250514',
1041
+ usage: { inputTokens: 100, outputTokens: 50 },
1042
+ stopReason: 'end_turn',
1043
+ };
832
1044
  });
833
1045
 
834
1046
  await orchestrator.handleUserInstruction('Mention the weekend special');
835
1047
 
836
1048
  // Step 3: Caller speaks again — the flow should continue normally
837
- mockStreamFn.mockImplementation(() =>
838
- createMockStream(['Great choice! The weekend special is 20% off.']),
839
- );
1049
+ mockSendMessage.mockImplementation(createMockProviderResponse(
1050
+ ['Great choice! The weekend special is 20% off.'],
1051
+ ));
840
1052
 
841
1053
  await orchestrator.handleCallerUtterance('Tell me more about that');
842
1054
 
@@ -852,7 +1064,7 @@ describe('call-orchestrator', () => {
852
1064
  });
853
1065
 
854
1066
  test('handleUserInstruction: emits user_instruction_relayed event', async () => {
855
- mockStreamFn.mockImplementation(() => createMockStream(['Understood, adjusting approach.']));
1067
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Understood, adjusting approach.']));
856
1068
 
857
1069
  const { session, orchestrator } = setupOrchestrator();
858
1070
 
@@ -869,20 +1081,25 @@ describe('call-orchestrator', () => {
869
1081
  });
870
1082
 
871
1083
  test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
872
- // First, trigger ASK_USER so orchestrator enters waiting_on_user
873
- mockStreamFn.mockImplementation(() =>
874
- createMockStream(['Hold on. [ASK_USER: What time?]']),
875
- );
1084
+ // First, trigger ASK_GUARDIAN so orchestrator enters waiting_on_user
1085
+ mockSendMessage.mockImplementation(createMockProviderResponse(
1086
+ ['Hold on. [ASK_GUARDIAN: What time?]'],
1087
+ ));
876
1088
 
877
1089
  const { session, orchestrator } = setupOrchestrator();
878
1090
  await orchestrator.handleCallerUtterance('I need an appointment');
879
1091
  expect(orchestrator.getState()).toBe('waiting_on_user');
880
1092
 
881
- // Track how many times the stream mock is called
1093
+ // Track how many times the provider mock is called
882
1094
  let streamCallCount = 0;
883
- mockStreamFn.mockImplementation(() => {
1095
+ mockSendMessage.mockImplementation(async () => {
884
1096
  streamCallCount++;
885
- return createMockStream(['Response after instruction.']);
1097
+ return {
1098
+ content: [{ type: 'text', text: 'Response after instruction.' }],
1099
+ model: 'claude-sonnet-4-20250514',
1100
+ usage: { inputTokens: 100, outputTokens: 50 },
1101
+ stopReason: 'end_turn',
1102
+ };
886
1103
  });
887
1104
 
888
1105
  // Inject instruction while in waiting_on_user state
@@ -902,10 +1119,18 @@ describe('call-orchestrator', () => {
902
1119
  // ── System prompt: identity phrasing ────────────────────────────────
903
1120
 
904
1121
  test('system prompt contains resolved user reference (default)', async () => {
905
- mockStreamFn.mockImplementation((...args: unknown[]) => {
906
- const firstArg = args[0] as { system: string };
907
- expect(firstArg.system).toContain('on behalf of my human');
908
- return createMockStream(['Hello.']);
1122
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1123
+ expect(systemPrompt as string).toContain('on behalf of my human');
1124
+ const tokens = ['Hello.'];
1125
+ for (const token of tokens) {
1126
+ options?.onEvent?.({ type: 'text_delta', text: token });
1127
+ }
1128
+ return {
1129
+ content: [{ type: 'text', text: tokens.join('') }],
1130
+ model: 'claude-sonnet-4-20250514',
1131
+ usage: { inputTokens: 100, outputTokens: 50 },
1132
+ stopReason: 'end_turn',
1133
+ };
909
1134
  });
910
1135
 
911
1136
  const { orchestrator } = setupOrchestrator();
@@ -915,10 +1140,18 @@ describe('call-orchestrator', () => {
915
1140
 
916
1141
  test('system prompt contains resolved user reference when set to a name', async () => {
917
1142
  mockUserReference = 'John';
918
- mockStreamFn.mockImplementation((...args: unknown[]) => {
919
- const firstArg = args[0] as { system: string };
920
- expect(firstArg.system).toContain('on behalf of John');
921
- return createMockStream(['Hello John\'s contact.']);
1143
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1144
+ expect(systemPrompt as string).toContain('on behalf of John');
1145
+ const tokens = ['Hello John\'s contact.'];
1146
+ for (const token of tokens) {
1147
+ options?.onEvent?.({ type: 'text_delta', text: token });
1148
+ }
1149
+ return {
1150
+ content: [{ type: 'text', text: tokens.join('') }],
1151
+ model: 'claude-sonnet-4-20250514',
1152
+ usage: { inputTokens: 100, outputTokens: 50 },
1153
+ stopReason: 'end_turn',
1154
+ };
922
1155
  });
923
1156
 
924
1157
  const { orchestrator } = setupOrchestrator();
@@ -928,11 +1161,19 @@ describe('call-orchestrator', () => {
928
1161
 
929
1162
  test('system prompt does not hardcode "your user" in the opening line', async () => {
930
1163
  mockUserReference = 'Alice';
931
- mockStreamFn.mockImplementation((...args: unknown[]) => {
932
- const firstArg = args[0] as { system: string };
933
- expect(firstArg.system).not.toContain('on behalf of your user');
934
- expect(firstArg.system).toContain('on behalf of Alice');
935
- return createMockStream(['Hi there.']);
1164
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1165
+ expect(systemPrompt as string).not.toContain('on behalf of your user');
1166
+ expect(systemPrompt as string).toContain('on behalf of Alice');
1167
+ const tokens = ['Hi there.'];
1168
+ for (const token of tokens) {
1169
+ options?.onEvent?.({ type: 'text_delta', text: token });
1170
+ }
1171
+ return {
1172
+ content: [{ type: 'text', text: tokens.join('') }],
1173
+ model: 'claude-sonnet-4-20250514',
1174
+ usage: { inputTokens: 100, outputTokens: 50 },
1175
+ stopReason: 'end_turn',
1176
+ };
936
1177
  });
937
1178
 
938
1179
  const { orchestrator } = setupOrchestrator();
@@ -941,11 +1182,40 @@ describe('call-orchestrator', () => {
941
1182
  });
942
1183
 
943
1184
  test('system prompt includes assistant identity bias rule', async () => {
944
- mockStreamFn.mockImplementation((...args: unknown[]) => {
945
- const firstArg = args[0] as { system: string };
946
- expect(firstArg.system).toContain('refer to yourself as an assistant');
947
- expect(firstArg.system).toContain('Avoid the phrase "AI assistant" unless directly asked');
948
- return createMockStream(['Sure thing.']);
1185
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1186
+ expect(systemPrompt as string).toContain('refer to yourself as an assistant');
1187
+ expect(systemPrompt as string).toContain('Avoid the phrase "AI assistant" unless directly asked');
1188
+ const tokens = ['Sure thing.'];
1189
+ for (const token of tokens) {
1190
+ options?.onEvent?.({ type: 'text_delta', text: token });
1191
+ }
1192
+ return {
1193
+ content: [{ type: 'text', text: tokens.join('') }],
1194
+ model: 'claude-sonnet-4-20250514',
1195
+ usage: { inputTokens: 100, outputTokens: 50 },
1196
+ stopReason: 'end_turn',
1197
+ };
1198
+ });
1199
+
1200
+ const { orchestrator } = setupOrchestrator();
1201
+ await orchestrator.handleCallerUtterance('Hi');
1202
+ orchestrator.destroy();
1203
+ });
1204
+
1205
+ test('system prompt includes opening-ack guidance to avoid duplicate introductions', async () => {
1206
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1207
+ expect(systemPrompt as string).toContain('[CALL_OPENING_ACK]');
1208
+ expect(systemPrompt as string).toContain('without re-introducing yourself');
1209
+ const tokens = ['Understood.'];
1210
+ for (const token of tokens) {
1211
+ options?.onEvent?.({ type: 'text_delta', text: token });
1212
+ }
1213
+ return {
1214
+ content: [{ type: 'text', text: tokens.join('') }],
1215
+ model: 'claude-sonnet-4-20250514',
1216
+ usage: { inputTokens: 100, outputTokens: 50 },
1217
+ stopReason: 'end_turn',
1218
+ };
949
1219
  });
950
1220
 
951
1221
  const { orchestrator } = setupOrchestrator();
@@ -954,15 +1224,23 @@ describe('call-orchestrator', () => {
954
1224
  });
955
1225
 
956
1226
  test('assistant identity rule appears before disclosure rule in prompt', async () => {
957
- mockStreamFn.mockImplementation((...args: unknown[]) => {
958
- const firstArg = args[0] as { system: string };
959
- const prompt = firstArg.system;
1227
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1228
+ const prompt = systemPrompt as string;
960
1229
  const identityIdx = prompt.indexOf('refer to yourself as an assistant');
961
1230
  const disclosureIdx = prompt.indexOf('Be concise');
962
1231
  expect(identityIdx).toBeGreaterThan(-1);
963
1232
  expect(disclosureIdx).toBeGreaterThan(-1);
964
1233
  expect(identityIdx).toBeLessThan(disclosureIdx);
965
- return createMockStream(['OK.']);
1234
+ const tokens = ['OK.'];
1235
+ for (const token of tokens) {
1236
+ options?.onEvent?.({ type: 'text_delta', text: token });
1237
+ }
1238
+ return {
1239
+ content: [{ type: 'text', text: tokens.join('') }],
1240
+ model: 'claude-sonnet-4-20250514',
1241
+ usage: { inputTokens: 100, outputTokens: 50 },
1242
+ stopReason: 'end_turn',
1243
+ };
966
1244
  });
967
1245
 
968
1246
  const { orchestrator } = setupOrchestrator();
@@ -975,11 +1253,19 @@ describe('call-orchestrator', () => {
975
1253
  enabled: true,
976
1254
  text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
977
1255
  };
978
- mockStreamFn.mockImplementation((...args: unknown[]) => {
979
- const firstArg = args[0] as { system: string };
980
- expect(firstArg.system).toContain('introduce yourself as an assistant calling on behalf of the person you represent');
981
- expect(firstArg.system).toContain('Do not say "AI assistant"');
982
- return createMockStream(['Hello, I am calling on behalf of my human.']);
1256
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1257
+ expect(systemPrompt as string).toContain('introduce yourself as an assistant calling on behalf of the person you represent');
1258
+ expect(systemPrompt as string).toContain('Do not say "AI assistant"');
1259
+ const tokens = ['Hello, I am calling on behalf of my human.'];
1260
+ for (const token of tokens) {
1261
+ options?.onEvent?.({ type: 'text_delta', text: token });
1262
+ }
1263
+ return {
1264
+ content: [{ type: 'text', text: tokens.join('') }],
1265
+ model: 'claude-sonnet-4-20250514',
1266
+ usage: { inputTokens: 100, outputTokens: 50 },
1267
+ stopReason: 'end_turn',
1268
+ };
983
1269
  });
984
1270
 
985
1271
  const { orchestrator } = setupOrchestrator();
@@ -989,11 +1275,19 @@ describe('call-orchestrator', () => {
989
1275
 
990
1276
  test('system prompt falls back to "Begin the conversation naturally" when disclosure is disabled', async () => {
991
1277
  mockDisclosure = { enabled: false, text: '' };
992
- mockStreamFn.mockImplementation((...args: unknown[]) => {
993
- const firstArg = args[0] as { system: string };
994
- expect(firstArg.system).toContain('Begin the conversation naturally');
995
- expect(firstArg.system).not.toContain('introduce yourself as an assistant calling on behalf of the person');
996
- return createMockStream(['Hello there.']);
1278
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1279
+ expect(systemPrompt as string).toContain('Begin the conversation naturally');
1280
+ expect(systemPrompt as string).not.toContain('introduce yourself as an assistant calling on behalf of the person');
1281
+ const tokens = ['Hello there.'];
1282
+ for (const token of tokens) {
1283
+ options?.onEvent?.({ type: 'text_delta', text: token });
1284
+ }
1285
+ return {
1286
+ content: [{ type: 'text', text: tokens.join('') }],
1287
+ model: 'claude-sonnet-4-20250514',
1288
+ usage: { inputTokens: 100, outputTokens: 50 },
1289
+ stopReason: 'end_turn',
1290
+ };
997
1291
  });
998
1292
 
999
1293
  const { orchestrator } = setupOrchestrator();
@@ -1002,14 +1296,201 @@ describe('call-orchestrator', () => {
1002
1296
  });
1003
1297
 
1004
1298
  test('system prompt does not use "AI assistant" as a self-identity label', async () => {
1005
- mockStreamFn.mockImplementation((...args: unknown[]) => {
1006
- const firstArg = args[0] as { system: string };
1007
- expect(firstArg.system).not.toMatch(/(?:you are|call yourself|introduce yourself as).*AI assistant/i);
1008
- return createMockStream(['Got it.']);
1299
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1300
+ expect(systemPrompt as string).not.toMatch(/(?:you are|call yourself|introduce yourself as).*AI assistant/i);
1301
+ const tokens = ['Got it.'];
1302
+ for (const token of tokens) {
1303
+ options?.onEvent?.({ type: 'text_delta', text: token });
1304
+ }
1305
+ return {
1306
+ content: [{ type: 'text', text: tokens.join('') }],
1307
+ model: 'claude-sonnet-4-20250514',
1308
+ usage: { inputTokens: 100, outputTokens: 50 },
1309
+ stopReason: 'end_turn',
1310
+ };
1009
1311
  });
1010
1312
 
1011
1313
  const { orchestrator } = setupOrchestrator();
1012
1314
  await orchestrator.handleCallerUtterance('Hello');
1013
1315
  orchestrator.destroy();
1014
1316
  });
1317
+
1318
+ // ── Inbound call orchestration ──────────────────────────────────────
1319
+
1320
+ test('inbound call (no task) uses receptionist-style system prompt', async () => {
1321
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1322
+ // Should contain inbound-specific language
1323
+ expect(systemPrompt as string).toContain('answering an incoming call');
1324
+ expect(systemPrompt as string).toContain('find out what they need');
1325
+ // Should NOT contain outbound-specific language
1326
+ expect(systemPrompt as string).not.toContain('state why you are calling');
1327
+ expect(systemPrompt as string).not.toContain('Task:');
1328
+ const tokens = ['Hello, how can I help you today?'];
1329
+ for (const token of tokens) {
1330
+ options?.onEvent?.({ type: 'text_delta', text: token });
1331
+ }
1332
+ return {
1333
+ content: [{ type: 'text', text: tokens.join('') }],
1334
+ model: 'claude-sonnet-4-20250514',
1335
+ usage: { inputTokens: 100, outputTokens: 50 },
1336
+ stopReason: 'end_turn',
1337
+ };
1338
+ });
1339
+
1340
+ // setupOrchestrator with no task creates an inbound-style session
1341
+ const { orchestrator } = setupOrchestrator(undefined);
1342
+ await orchestrator.handleCallerUtterance('Hi there');
1343
+ orchestrator.destroy();
1344
+ });
1345
+
1346
+ test('outbound call (with task) uses task-driven system prompt', async () => {
1347
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1348
+ expect(systemPrompt as string).toContain('Task: Confirm Friday appointment');
1349
+ expect(systemPrompt as string).toContain('state why you are calling');
1350
+ expect(systemPrompt as string).not.toContain('answering an incoming call');
1351
+ const tokens = ['Hi, I am calling about your appointment.'];
1352
+ for (const token of tokens) {
1353
+ options?.onEvent?.({ type: 'text_delta', text: token });
1354
+ }
1355
+ return {
1356
+ content: [{ type: 'text', text: tokens.join('') }],
1357
+ model: 'claude-sonnet-4-20250514',
1358
+ usage: { inputTokens: 100, outputTokens: 50 },
1359
+ stopReason: 'end_turn',
1360
+ };
1361
+ });
1362
+
1363
+ const { orchestrator } = setupOrchestrator('Confirm Friday appointment');
1364
+ await orchestrator.handleCallerUtterance('Hello?');
1365
+ orchestrator.destroy();
1366
+ });
1367
+
1368
+ test('inbound call initial greeting sends receptionist opener', async () => {
1369
+ mockSendMessage.mockImplementation(async (messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1370
+ // The system prompt should use inbound framing
1371
+ expect(systemPrompt as string).toContain('answering an incoming call');
1372
+ // The opening marker should be present
1373
+ const msgs = messages as Array<{ role: string; content: Array<{ type: string; text: string }> }>;
1374
+ const userMsgs = msgs.filter((m) => m.role === 'user');
1375
+ expect(userMsgs.some((m) => m.content?.[0]?.text?.includes('[CALL_OPENING]'))).toBe(true);
1376
+ const tokens = ['Hello, this is my human\'s assistant. How can I help you?'];
1377
+ for (const token of tokens) {
1378
+ options?.onEvent?.({ type: 'text_delta', text: token });
1379
+ }
1380
+ return {
1381
+ content: [{ type: 'text', text: tokens.join('') }],
1382
+ model: 'claude-sonnet-4-20250514',
1383
+ usage: { inputTokens: 100, outputTokens: 50 },
1384
+ stopReason: 'end_turn',
1385
+ };
1386
+ });
1387
+
1388
+ const { relay, orchestrator } = setupOrchestrator(undefined);
1389
+ await orchestrator.startInitialGreeting();
1390
+
1391
+ const allText = relay.sentTokens.map((t) => t.token).join('');
1392
+ expect(allText).toContain('How can I help you');
1393
+
1394
+ orchestrator.destroy();
1395
+ });
1396
+
1397
+ test('inbound call multi-turn conversation uses inbound prompt consistently', async () => {
1398
+ let turnNumber = 0;
1399
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1400
+ turnNumber++;
1401
+ // Every turn should use the inbound system prompt
1402
+ expect(systemPrompt as string).toContain('answering an incoming call');
1403
+ expect(systemPrompt as string).not.toContain('Task:');
1404
+
1405
+ let tokens: string[];
1406
+ if (turnNumber === 1) tokens = ['Hello, how can I help you?'];
1407
+ else if (turnNumber === 2) tokens = ['Sure, let me help with scheduling.'];
1408
+ else tokens = ['Your meeting is set for 3pm.'];
1409
+ for (const token of tokens) {
1410
+ options?.onEvent?.({ type: 'text_delta', text: token });
1411
+ }
1412
+ return {
1413
+ content: [{ type: 'text', text: tokens.join('') }],
1414
+ model: 'claude-sonnet-4-20250514',
1415
+ usage: { inputTokens: 100, outputTokens: 50 },
1416
+ stopReason: 'end_turn',
1417
+ };
1418
+ });
1419
+
1420
+ const { orchestrator } = setupOrchestrator(undefined);
1421
+
1422
+ await orchestrator.startInitialGreeting();
1423
+ await orchestrator.handleCallerUtterance('I need to schedule a meeting');
1424
+ await orchestrator.handleCallerUtterance('How about 3pm?');
1425
+
1426
+ expect(turnNumber).toBe(3);
1427
+ orchestrator.destroy();
1428
+ });
1429
+
1430
+ test('inbound call system prompt includes greet-the-caller guidance for CALL_OPENING', async () => {
1431
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1432
+ // Should tell the model to greet warmly and ask how to help
1433
+ expect(systemPrompt as string).toContain('greet the caller warmly');
1434
+ expect(systemPrompt as string).toContain('how you can help');
1435
+ const tokens = ['Hello!'];
1436
+ for (const token of tokens) {
1437
+ options?.onEvent?.({ type: 'text_delta', text: token });
1438
+ }
1439
+ return {
1440
+ content: [{ type: 'text', text: tokens.join('') }],
1441
+ model: 'claude-sonnet-4-20250514',
1442
+ usage: { inputTokens: 100, outputTokens: 50 },
1443
+ stopReason: 'end_turn',
1444
+ };
1445
+ });
1446
+
1447
+ const { orchestrator } = setupOrchestrator(undefined);
1448
+ await orchestrator.handleCallerUtterance('Hi');
1449
+ orchestrator.destroy();
1450
+ });
1451
+
1452
+ test('inbound call system prompt respects disclosure setting', async () => {
1453
+ mockDisclosure = {
1454
+ enabled: true,
1455
+ text: 'Disclose that you are an AI at the start.',
1456
+ };
1457
+ mockSendMessage.mockImplementation(async (_messages: unknown[], _tools: unknown[], systemPrompt: unknown, options?: { onEvent?: (event: { type: string; text?: string }) => void }) => {
1458
+ expect(systemPrompt as string).toContain('answering an incoming call');
1459
+ expect(systemPrompt as string).toContain('Disclose that you are an AI at the start.');
1460
+ const tokens = ['Hello, I am an AI assistant.'];
1461
+ for (const token of tokens) {
1462
+ options?.onEvent?.({ type: 'text_delta', text: token });
1463
+ }
1464
+ return {
1465
+ content: [{ type: 'text', text: tokens.join('') }],
1466
+ model: 'claude-sonnet-4-20250514',
1467
+ usage: { inputTokens: 100, outputTokens: 50 },
1468
+ stopReason: 'end_turn',
1469
+ };
1470
+ });
1471
+
1472
+ const { orchestrator } = setupOrchestrator(undefined);
1473
+ await orchestrator.handleCallerUtterance('Who is this?');
1474
+ orchestrator.destroy();
1475
+ });
1476
+
1477
+ test('inbound call persists assistant response to voice conversation', async () => {
1478
+ mockSendMessage.mockImplementation(createMockProviderResponse(['I can definitely help you with that.']));
1479
+
1480
+ const { session, orchestrator } = setupOrchestrator(undefined);
1481
+ await orchestrator.startInitialGreeting();
1482
+
1483
+ // Verify assistant transcript was persisted
1484
+ const messages = (await import('../memory/conversation-store.js')).getMessages('conv-orch-test');
1485
+ const assistantMsgs = messages.filter((m) => m.role === 'assistant');
1486
+ expect(assistantMsgs.length).toBeGreaterThan(0);
1487
+ const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
1488
+ expect(lastAssistant.content).toContain('I can definitely help you with that');
1489
+
1490
+ // Verify event was recorded
1491
+ const events = getCallEvents(session.id).filter((e) => e.eventType === 'assistant_spoke');
1492
+ expect(events.length).toBeGreaterThan(0);
1493
+
1494
+ orchestrator.destroy();
1495
+ });
1015
1496
  });