@vellumai/assistant 0.3.4 → 0.3.6

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