@vellumai/assistant 0.11.3 → 0.11.4-staging.1

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 (324) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/docs/architecture/memory.md +11 -0
  3. package/docs/architecture/turn-actor.md +70 -0
  4. package/docs/flux-turn-detection-spike.md +243 -0
  5. package/docs/stt-provider-onboarding.md +3 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  8. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/openapi.yaml +140 -38
  12. package/package.json +1 -1
  13. package/scripts/voice-ttft-spike.ts +3 -3
  14. package/src/__tests__/app-compiler.test.ts +38 -3
  15. package/src/__tests__/attachments-store.test.ts +21 -12
  16. package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
  17. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  18. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  19. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  20. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  21. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  22. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  23. package/src/__tests__/config-schema.test.ts +25 -10
  24. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  26. package/src/__tests__/conversation-agent-loop.test.ts +28 -20
  27. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  28. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  29. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  30. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  31. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  32. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  33. package/src/__tests__/conversation-queue.test.ts +55 -4
  34. package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
  35. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  36. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  37. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  38. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  39. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  40. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  41. package/src/__tests__/edit-propagation.test.ts +0 -7
  42. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  43. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  44. package/src/__tests__/injector-chain.test.ts +63 -41
  45. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  46. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  47. package/src/__tests__/llm-schema.test.ts +5 -2
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  49. package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
  50. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  51. package/src/__tests__/mtime-cache.test.ts +1 -1
  52. package/src/__tests__/non-member-access-request.test.ts +0 -20
  53. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
  55. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  56. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  57. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  58. package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
  59. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  60. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  61. package/src/__tests__/skills.test.ts +32 -0
  62. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  63. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  64. package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
  65. package/src/__tests__/turn-events-store.test.ts +43 -0
  66. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  67. package/src/__tests__/visible-app-context.test.ts +16 -9
  68. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  69. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  70. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  71. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  72. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  73. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  74. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  75. package/src/acp/__tests__/auth-required.test.ts +161 -0
  76. package/src/acp/acp-claude-oauth.ts +19 -2
  77. package/src/acp/agent-process.test.ts +100 -0
  78. package/src/acp/agent-process.ts +29 -26
  79. package/src/acp/auth-required.ts +102 -0
  80. package/src/acp/session-manager.test.ts +119 -0
  81. package/src/acp/session-manager.ts +68 -2
  82. package/src/api/events/acp-auth-required.ts +55 -0
  83. package/src/api/index.ts +7 -0
  84. package/src/apps/app-store.ts +3 -0
  85. package/src/bundler/package-resolver.ts +2 -30
  86. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  87. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  88. package/src/calls/call-controller.ts +9 -2
  89. package/src/calls/call-setup-flow.ts +0 -1
  90. package/src/calls/media-stream-stt-session.ts +15 -0
  91. package/src/calls/voice-session-bridge.ts +71 -16
  92. package/src/calls/voice-triage-escalate.ts +104 -2
  93. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  94. package/src/channels/config.ts +13 -0
  95. package/src/channels/plugin-channel-declarations.ts +108 -0
  96. package/src/channels/types.ts +30 -0
  97. package/src/cli/AGENTS.md +5 -2
  98. package/src/cli/commands/credentials.help.ts +2 -2
  99. package/src/cli/commands/inference-providers.ts +1 -1
  100. package/src/cli/commands/mcp.help.ts +13 -4
  101. package/src/cli/commands/mcp.ts +9 -0
  102. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  103. package/src/cli/commands/memory/index.help.ts +43 -1
  104. package/src/cli/commands/memory/memory-v3.ts +64 -0
  105. package/src/cli/commands/stt.help.ts +27 -2
  106. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  107. package/src/cli/lib/bundled-marketplace.json +13 -0
  108. package/src/cli/lib/upgrade-plugin.ts +42 -0
  109. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  110. package/src/config/__tests__/default-provider.test.ts +6 -1
  111. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  112. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  113. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  114. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  115. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  116. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  117. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  118. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  119. package/src/config/call-site-defaults.ts +4 -5
  120. package/src/config/default-profile-catalog.ts +82 -11
  121. package/src/config/default-profile-names.ts +4 -1
  122. package/src/config/default-provider-resolution.ts +4 -0
  123. package/src/config/llm-context-resolution.ts +11 -3
  124. package/src/config/llm-resolver.ts +28 -1
  125. package/src/config/profile-materialization.ts +70 -22
  126. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  127. package/src/config/schemas/call-site-catalog.ts +4 -4
  128. package/src/config/schemas/live-voice.ts +57 -23
  129. package/src/config/schemas/llm.ts +59 -32
  130. package/src/config/schemas/mcp.ts +23 -0
  131. package/src/config/schemas/plugin-updates.ts +6 -2
  132. package/src/config/schemas/stt.ts +1 -0
  133. package/src/context/outbound-sanitize.ts +96 -1
  134. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  135. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  136. package/src/daemon/conversation-agent-loop.ts +17 -6
  137. package/src/daemon/conversation-messaging.ts +5 -1
  138. package/src/daemon/conversation-notifiers.ts +9 -1
  139. package/src/daemon/conversation-process.ts +9 -6
  140. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  141. package/src/daemon/conversation-tool-setup.ts +1 -2
  142. package/src/daemon/conversation.ts +48 -0
  143. package/src/daemon/mcp-reload-service.ts +36 -6
  144. package/src/daemon/process-message.ts +13 -3
  145. package/src/daemon/providers-setup.ts +6 -3
  146. package/src/daemon/trust-context-types.ts +29 -0
  147. package/src/daemon/wake-conversation-ops.ts +3 -2
  148. package/src/documents/document-store.ts +138 -5
  149. package/src/hooks/hook-loader.ts +3 -3
  150. package/src/hooks/registry.ts +50 -6
  151. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  152. package/src/inbound/oauth-callback-url.ts +61 -0
  153. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  154. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  155. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  156. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  157. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  158. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  159. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  160. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  161. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  162. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  163. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  164. package/src/live-voice/live-voice-archive.ts +2 -0
  165. package/src/live-voice/live-voice-metrics.ts +57 -32
  166. package/src/live-voice/live-voice-photo.ts +1 -2
  167. package/src/live-voice/live-voice-session.ts +535 -314
  168. package/src/live-voice/progress-narration.ts +277 -0
  169. package/src/live-voice/protocol.ts +21 -1
  170. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  171. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  172. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  173. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  174. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  175. package/src/mcp/client.ts +16 -11
  176. package/src/mcp/effective-config.ts +113 -0
  177. package/src/mcp/manager.ts +11 -6
  178. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  179. package/src/mcp/mcp-oauth-provider.ts +205 -240
  180. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  181. package/src/monitoring/plugin-auto-update.ts +128 -24
  182. package/src/notifications/signal.ts +1 -0
  183. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  184. package/src/permissions/confirmation-guardian-request.ts +2 -2
  185. package/src/permissions/question-guardian-request.test.ts +14 -6
  186. package/src/permissions/question-guardian-request.ts +1 -2
  187. package/src/persistence/attachments-store.ts +8 -1
  188. package/src/persistence/bookmark-crud.ts +3 -7
  189. package/src/persistence/conversation-attention-store.ts +16 -45
  190. package/src/persistence/conversation-crud.ts +33 -4
  191. package/src/persistence/conversation-lineage.ts +9 -0
  192. package/src/persistence/conversation-queries.ts +108 -41
  193. package/src/persistence/delivery-crud.ts +38 -29
  194. package/src/persistence/external-conversation-store.ts +32 -4
  195. package/src/persistence/llm-request-log-store.ts +4 -10
  196. package/src/persistence/llm-usage-store.ts +8 -3
  197. package/src/persistence/message-reads.test.ts +197 -0
  198. package/src/persistence/message-reads.ts +211 -0
  199. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  200. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  201. package/src/persistence/real-user-turn-filter.ts +27 -3
  202. package/src/persistence/steps.ts +9 -0
  203. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  204. package/src/plugin-api/conversation-turn.ts +168 -5
  205. package/src/plugin-api/index.ts +21 -5
  206. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  207. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  208. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  209. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  210. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  211. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  212. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  213. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  214. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  215. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  216. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  217. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  218. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  219. package/src/plugins/defaults/memory/indexer.ts +3 -1
  220. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  221. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  222. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  223. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  224. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  225. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  226. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  227. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  228. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  229. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  230. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  231. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  232. package/src/plugins/defaults/memory/worker.ts +6 -3
  233. package/src/plugins/external-plugin-loader.ts +47 -0
  234. package/src/plugins/mcp-servers.ts +361 -0
  235. package/src/plugins/mtime-cache.ts +23 -49
  236. package/src/plugins/worker-plugin-surface.ts +33 -0
  237. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  238. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  239. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  240. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  241. package/src/providers/call-site-routing.ts +30 -3
  242. package/src/providers/connection-resolution.ts +194 -11
  243. package/src/providers/inference/auth.ts +6 -0
  244. package/src/providers/inference/connection-availability.ts +24 -2
  245. package/src/providers/inference/connections.ts +2 -0
  246. package/src/providers/model-intents.ts +26 -6
  247. package/src/providers/openai/codex-models.ts +2 -1
  248. package/src/providers/provider-send-message.ts +32 -3
  249. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  250. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  251. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  252. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  253. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  254. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  255. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  256. package/src/providers/speech-to-text/resolve.ts +25 -2
  257. package/src/routes/worker.ts +17 -5
  258. package/src/runtime/access-request-helper.ts +9 -12
  259. package/src/runtime/agent-wake.ts +3 -3
  260. package/src/runtime/pre-first-message-gate.ts +4 -0
  261. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  262. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  263. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  264. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  265. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  266. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  267. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  268. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  269. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  270. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  271. package/src/runtime/routes/channel-route-shared.ts +0 -6
  272. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  273. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  274. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  275. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  276. package/src/runtime/routes/default-provider-routes.ts +15 -0
  277. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  278. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  279. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  280. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  281. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  282. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  283. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  284. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  285. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  286. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  287. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  288. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  289. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  291. package/src/runtime/routes/log-export-routes.ts +3 -0
  292. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  293. package/src/runtime/routes/plugins-routes.ts +21 -3
  294. package/src/runtime/routes/stt-routes.ts +31 -25
  295. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  296. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  297. package/src/runtime/routes/user-route-import.ts +108 -0
  298. package/src/schedule/worker.ts +6 -0
  299. package/src/security/oauth2.ts +6 -22
  300. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  301. package/src/stt/__tests__/types.test.ts +94 -0
  302. package/src/stt/daemon-batch-transcriber.ts +10 -0
  303. package/src/stt/stt-stream-session.ts +8 -4
  304. package/src/stt/types.ts +103 -0
  305. package/src/subagent/manager.ts +1 -3
  306. package/src/subagent/types.ts +7 -6
  307. package/src/tools/acp/spawn.test.ts +97 -0
  308. package/src/tools/acp/spawn.ts +32 -0
  309. package/src/tools/document/document-tool.ts +12 -3
  310. package/src/tools/registry.ts +2 -1
  311. package/src/tools/workflows/run-workflow.ts +1 -2
  312. package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
  313. package/src/tts/reasoning-tag-filter.ts +110 -0
  314. package/src/workspace/byok-default-profile-ensure.ts +61 -18
  315. package/src/workspace/custom-profile-ensure.ts +4 -24
  316. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  317. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  318. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  319. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  320. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  321. package/src/workspace/migrations/registry.ts +8 -0
  322. package/src/workspace/provider-commit-message-generator.ts +7 -5
  323. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  324. package/src/live-voice/front-decision.ts +0 -476
@@ -825,6 +825,38 @@ describe("always-candidate frontmatter parsing", () => {
825
825
  .map(({ id, chars }) => `${id}: ${chars}`);
826
826
  expect(overrun).toEqual([]);
827
827
  });
828
+
829
+ test("the bundled acp skill's card fits the default budget", async () => {
830
+ // `acp` owns Claude Code and Codex setup, sign-in, and delegation, and the
831
+ // terms that route those turns to it live in this card. The card is
832
+ // hard-truncated at the default budget, so an overrun drops the trailing
833
+ // hints and the whole avoid-when list from what the selector sees.
834
+ const { DEFAULT_CARD_CHARS, buildSkillContent } =
835
+ await import("../plugins/defaults/memory/substrate/skill-content.js");
836
+ const acp = loadSkillCatalog().find((skill) => skill.id === "acp");
837
+ expect(acp).toBeDefined();
838
+
839
+ // A card that overran is cut to exactly the budget, so staying under it is
840
+ // what proves nothing was dropped. `avoid-when` is appended last and is
841
+ // therefore the first content a truncation eats.
842
+ const card = buildSkillContent(acp!);
843
+ expect(card.length).toBeLessThan(DEFAULT_CARD_CHARS);
844
+ expect(card).toContain("Avoid when:");
845
+ });
846
+
847
+ test("the bundled acp card carries its setup and sign-in vocabulary", async () => {
848
+ // A "configure Claude Code" or "let's do the auth" turn only reaches this
849
+ // skill if the card actually says it covers setup and authentication;
850
+ // delegation-only wording routes those turns to a terminal skill instead.
851
+ const { buildSkillContent } =
852
+ await import("../plugins/defaults/memory/substrate/skill-content.js");
853
+ const acp = loadSkillCatalog().find((skill) => skill.id === "acp");
854
+ const card = buildSkillContent(acp!).toLowerCase();
855
+
856
+ for (const term of ["set up", "install", "authenticate", "connect"]) {
857
+ expect(card).toContain(term);
858
+ }
859
+ });
828
860
  });
829
861
 
830
862
  describe("bundled skill categories", () => {
@@ -59,7 +59,6 @@ async function applyEdit(eventId: string, content: string): Promise<void> {
59
59
  conversationExternalId: CHANNEL,
60
60
  externalMessageId: eventId,
61
61
  sourceMessageId: ORIGINAL_TS,
62
- canonicalAssistantId: "self",
63
62
  assistantId: "self",
64
63
  content,
65
64
  });
@@ -184,8 +184,11 @@ import {
184
184
  clearConversations,
185
185
  setConversation,
186
186
  } from "../daemon/conversation-registry.js";
187
+ import type { TrustContext } from "../daemon/trust-context-types.js";
187
188
  import { CallSiteRoutingProvider } from "../providers/call-site-routing.js";
188
189
  import { SubagentManager } from "../subagent/manager.js";
190
+ import { mockAuthContext } from "./helpers/mock-actor-context.js";
191
+ import { asConversation } from "./helpers/mock-conversation.js";
189
192
 
190
193
  // Seed the workspace `llm` config per-case. The real loader schema-merges the
191
194
  // raw fragment over defaults exactly as the code path reads it.
@@ -304,26 +307,29 @@ describe("SubagentManager — provider call-site routing", () => {
304
307
  test("copies parent guardian and auth context into spawned conversation", async () => {
305
308
  setLlmConfig(makeLlmFixture());
306
309
 
307
- const parentTrustContext = {
310
+ const parentTrustContext: TrustContext = {
308
311
  sourceChannel: "vellum",
309
312
  trustClass: "guardian",
310
313
  guardianPrincipalId: "guardian-1",
311
314
  guardianExternalUserId: "guardian-1",
312
315
  };
313
- const parentAuthContext = {
316
+ const parentAuthContext = mockAuthContext({
314
317
  subject: "local:self:parent-perms",
315
318
  actorPrincipalId: "guardian-1",
316
- };
319
+ });
317
320
 
318
321
  capturedConversations.length = 0;
319
322
  clearConversations();
320
323
  const manager = new SubagentManager();
321
- setConversation("parent-perms", {
322
- trustContext: parentTrustContext,
323
- getAuthContext: () => parentAuthContext,
324
- assistantId: "self",
325
- getCurrentSystemPrompt: () => "parent system",
326
- } as any);
324
+ setConversation(
325
+ "parent-perms",
326
+ asConversation({
327
+ trustContext: parentTrustContext,
328
+ getAuthContext: () => parentAuthContext,
329
+ assistantId: "self",
330
+ getCurrentSystemPrompt: () => "parent system",
331
+ }),
332
+ );
327
333
 
328
334
  await manager.spawn(
329
335
  {
@@ -354,11 +360,14 @@ describe("SubagentManager — provider call-site routing", () => {
354
360
  capturedConversations.length = 0;
355
361
  clearConversations();
356
362
  const manager = new SubagentManager();
357
- setConversation("parent-scoped", {
358
- enabledPlugins: parentScope,
359
- getAuthContext: () => undefined,
360
- getCurrentSystemPrompt: () => "parent system",
361
- } as any);
363
+ setConversation(
364
+ "parent-scoped",
365
+ asConversation({
366
+ enabledPlugins: parentScope,
367
+ getAuthContext: () => undefined,
368
+ getCurrentSystemPrompt: () => "parent system",
369
+ }),
370
+ );
362
371
 
363
372
  await manager.spawn(
364
373
  {
@@ -383,11 +392,14 @@ describe("SubagentManager — provider call-site routing", () => {
383
392
  capturedConversations.length = 0;
384
393
  clearConversations();
385
394
  const manager = new SubagentManager();
386
- setConversation("parent-unscoped", {
387
- enabledPlugins: null,
388
- getAuthContext: () => undefined,
389
- getCurrentSystemPrompt: () => "parent system",
390
- } as any);
395
+ setConversation(
396
+ "parent-unscoped",
397
+ asConversation({
398
+ enabledPlugins: null,
399
+ getAuthContext: () => undefined,
400
+ getCurrentSystemPrompt: () => "parent system",
401
+ }),
402
+ );
391
403
 
392
404
  await manager.spawn(
393
405
  {
@@ -207,6 +207,7 @@ import {
207
207
  setConversation,
208
208
  } from "../daemon/conversation-registry.js";
209
209
  import { SubagentAbortedError, SubagentManager } from "../subagent/manager.js";
210
+ import { asConversation } from "./helpers/mock-conversation.js";
210
211
 
211
212
  function makeConfig(overrides: Record<string, unknown> = {}) {
212
213
  return {
@@ -229,16 +230,19 @@ function registerFakeParent(parentConversationId: string): {
229
230
  enqueuedCount: () => number;
230
231
  } {
231
232
  let enqueued = 0;
232
- setConversation(parentConversationId, {
233
- // Accessors read by setUpSubagent when copying trust/auth context.
234
- trustContext: undefined,
235
- getAuthContext: () => undefined,
236
- assistantId: undefined,
237
- enqueueMessage: () => {
238
- enqueued += 1;
239
- return { rejected: false, queued: true };
240
- },
241
- } as never);
233
+ setConversation(
234
+ parentConversationId,
235
+ asConversation({
236
+ // Accessors read by setUpSubagent when copying trust/auth context.
237
+ trustContext: undefined,
238
+ getAuthContext: () => undefined,
239
+ assistantId: undefined,
240
+ enqueueMessage: () => {
241
+ enqueued += 1;
242
+ return { rejected: false, queued: true, requestId: "req-fake" };
243
+ },
244
+ }),
245
+ );
242
246
  return { enqueuedCount: () => enqueued };
243
247
  }
244
248
 
@@ -331,3 +331,46 @@ describe("queryUnreportedTurnEvents", () => {
331
331
  expect(() => stampTurnOutcome("no-such-message", "failed")).not.toThrow();
332
332
  });
333
333
  });
334
+
335
+ describe("unfinalized rows and turn accounting", () => {
336
+ test("an in-flight grouped tool-result row is not a real user turn", async () => {
337
+ const conv = createConversation({ conversationType: "standard" });
338
+ const first = await insertUserMessageAt(conv.id, "real question", 1000);
339
+ // The grouped tool-result row: user-role, reserved unfinalized while
340
+ // tool output streams, content a { ref } that does not contain the
341
+ // tool_result text the content exclusions match. Without the finalized
342
+ // predicate it counts as a phantom user turn until the fold.
343
+ const inflight = await insertUserMessageAt(
344
+ conv.id,
345
+ JSON.stringify({ ref: "conversations/conv/inflight/x.jsonl" }),
346
+ 2000,
347
+ );
348
+ getDb().run(`UPDATE messages SET finalized = 0 WHERE id = '${inflight}'`);
349
+ const second = await insertUserMessageAt(conv.id, "second question", 3000);
350
+
351
+ const events = queryUnreportedTurnEvents(0, undefined, 100);
352
+ const ids = events
353
+ .filter((e) => e.conversationId === conv.id)
354
+ .map((e) => e.id);
355
+
356
+ expect(ids).toEqual([first, second]);
357
+ const byId = Object.fromEntries(events.map((e) => [e.id, e]));
358
+ expect(byId[first].turnIndex).toBe(1);
359
+ expect(byId[second].turnIndex).toBe(2);
360
+ });
361
+
362
+ test("a persisted empty-display user row still counts as a turn", async () => {
363
+ const conv = createConversation({ conversationType: "standard" });
364
+ const real = await insertUserMessageAt(conv.id, "real question", 1000);
365
+ // A hidden send persists its user row with [] content (displayContent "")
366
+ // and still runs a turn, so [] rows must stay in turn accounting.
367
+ const hidden = await insertUserMessageAt(conv.id, "[]", 2000);
368
+
369
+ const events = queryUnreportedTurnEvents(0, undefined, 100);
370
+ const ids = events
371
+ .filter((e) => e.conversationId === conv.id)
372
+ .map((e) => e.id);
373
+
374
+ expect(ids).toEqual([real, hidden]);
375
+ });
376
+ });
@@ -23,9 +23,9 @@ import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { beforeEach, describe, expect, test } from "bun:test";
25
25
 
26
+ import { getUserHooksFor } from "../hooks/registry.js";
26
27
  import {
27
28
  getCachedUserTools,
28
- getUserHooksFor,
29
29
  resetPluginCacheForTests,
30
30
  } from "../plugins/mtime-cache.js";
31
31
  import { loadUserPlugins } from "../plugins/user-loader.js";
@@ -25,6 +25,7 @@ import {
25
25
  import { registerDefaultPluginInjectors } from "../plugins/defaults/index.js";
26
26
  import type { Message } from "../providers/types.js";
27
27
  import { getWorkspacePluginsDir } from "../util/platform.js";
28
+ import { asConversation } from "./helpers/mock-conversation.js";
28
29
 
29
30
  // The injector chain is registered by the daemon bootstrap in production; do
30
31
  // the same here so `applyRuntimeInjections` walks a non-empty chain.
@@ -151,15 +152,21 @@ describe("visible_app injection", () => {
151
152
  ];
152
153
 
153
154
  function seedConversation(currentTurnVisibleAppId: string | undefined): void {
154
- setConversation(CONVERSATION_ID, {
155
- conversationId: CONVERSATION_ID,
156
- workingDir: "/sandbox",
157
- workspaceTopLevelContext: "",
158
- workspaceTopLevelDirty: false,
159
- surfaceState: new Map(),
160
- currentTurnVisibleAppId,
161
- currentTurnTemporalSnapshot: { clientTimezone: null },
162
- } as never);
155
+ setConversation(
156
+ CONVERSATION_ID,
157
+ asConversation({
158
+ conversationId: CONVERSATION_ID,
159
+ workingDir: "/sandbox",
160
+ workspaceTopLevelContext: "",
161
+ workspaceTopLevelDirty: false,
162
+ surfaceState: new Map(),
163
+ currentTurnVisibleAppId,
164
+ currentTurnTemporalSnapshot: {
165
+ clientTimezone: null,
166
+ timeSinceLastMessage: null,
167
+ },
168
+ }),
169
+ );
163
170
  }
164
171
 
165
172
  afterEach(() => {
@@ -18,10 +18,18 @@ import { Glob } from "bun";
18
18
  * - start the PID-file identity guard — the PID file is a worker's sole
19
19
  * tracking handle, so a worker the file stops naming can never be
20
20
  * stopped externally and must evict itself.
21
+ *
22
+ * The subset that hosts real agent conversations
23
+ * ({@link CONVERSATION_RUNNING_ENTRYPOINTS}) must additionally register the
24
+ * default plugins' hooks and injectors, since hook dispatch is process-global
25
+ * and a worker that skips it runs conversations with no plugin behavior at all
26
+ * (no image captioning or vision-rejection recovery, no tool-result
27
+ * truncation, no runtime injections).
21
28
  */
22
29
 
23
30
  const DISABLE_CALL = "disableStreamSeqStamping()";
24
31
  const PID_GUARD_CALL = "startWorkerPidFileGuard(";
32
+ const PLUGIN_SURFACE_CALL = "registerWorkerPluginSurface(";
25
33
 
26
34
  /** Shape of a shutdown that schedules `process.exit` instead of calling it. */
27
35
  const DEFERRED_EXIT = ".finally(() => process.exit";
@@ -44,6 +52,16 @@ const WORK_START_MARKERS: Record<string, string> = {
44
52
  "routes/worker.ts": 'server.on("connection"',
45
53
  };
46
54
 
55
+ /**
56
+ * The entrypoints whose work wakes real agent conversations. `monitoring` (a
57
+ * resource sampler) and `routes` (an HTTP route host) run no conversations, so
58
+ * they carry no plugin surface.
59
+ */
60
+ const CONVERSATION_RUNNING_ENTRYPOINTS: ReadonlySet<string> = new Set([
61
+ "schedule/worker.ts",
62
+ "plugins/defaults/memory/worker.ts",
63
+ ]);
64
+
47
65
  function findWorkerEntrypoints(): string[] {
48
66
  const srcRoot = join(process.cwd(), "src");
49
67
  const glob = new Glob("**/worker.ts");
@@ -137,4 +155,40 @@ describe("worker entrypoint guards", () => {
137
155
  "worker superseded at startup before it runs orphaned work.",
138
156
  ).toEqual([]);
139
157
  });
158
+
159
+ test("the conversation-running entrypoints are all discovered workers", () => {
160
+ const discovered = new Set(findWorkerEntrypoints());
161
+ const unknown = [...CONVERSATION_RUNNING_ENTRYPOINTS].filter(
162
+ (file) => !discovered.has(file),
163
+ );
164
+ expect(
165
+ unknown,
166
+ "CONVERSATION_RUNNING_ENTRYPOINTS names an entrypoint the glob does " +
167
+ "not find — the worker moved or was renamed.",
168
+ ).toEqual([]);
169
+ });
170
+
171
+ test("every conversation-running entrypoint registers the default plugin surface before starting work", () => {
172
+ const offenders = [...CONVERSATION_RUNNING_ENTRYPOINTS]
173
+ .sort()
174
+ .filter((file) => {
175
+ const source = readFileSync(join(process.cwd(), "src", file), "utf8");
176
+ const marker = WORK_START_MARKERS[file];
177
+ if (marker == null) {
178
+ return true;
179
+ }
180
+ const registerAt = source.indexOf(PLUGIN_SURFACE_CALL);
181
+ const workAt = source.indexOf(marker);
182
+ return registerAt < 0 || workAt < 0 || registerAt > workAt;
183
+ });
184
+ expect(
185
+ offenders,
186
+ `A worker that wakes agent conversations must call ` +
187
+ `${PLUGIN_SURFACE_CALL}) before its work-start call (see ` +
188
+ "WORK_START_MARKERS). Hook dispatch is process-global, so without it " +
189
+ "the conversations this process runs get no default plugin behavior: " +
190
+ "no vision-rejection recovery, no tool-result truncation, no runtime " +
191
+ "injections.",
192
+ ).toEqual([]);
193
+ });
140
194
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tests for {@link registerWorkerPluginSurface}, the default-plugin surface
3
+ * that sidecar worker processes install at startup.
4
+ *
5
+ * A worker that wakes real agent conversations shares the daemon's
6
+ * process-global hook and injector registries, so registering the defaults is
7
+ * what gives those conversations the same behavior as daemon conversations.
8
+ * Registration must populate both tables and stay idempotent, and it must do so
9
+ * without running any plugin `init` hook (the memory plugin's init starts the
10
+ * memory jobs worker process itself).
11
+ */
12
+
13
+ import { beforeEach, describe, expect, test } from "bun:test";
14
+
15
+ import { getHookEntriesFor } from "../hooks/registry.js";
16
+ import {
17
+ clearInjectorRegistry,
18
+ getRegisteredInjectors,
19
+ } from "../plugins/injector-registry.js";
20
+ import { resetPluginRegistryForTests } from "../plugins/registry.js";
21
+ import { registerWorkerPluginSurface } from "../plugins/worker-plugin-surface.js";
22
+
23
+ async function hookOwnersFor(name: string): Promise<string[]> {
24
+ const entries = await getHookEntriesFor(name);
25
+ return entries.map((entry) => entry.owner.id);
26
+ }
27
+
28
+ describe("registerWorkerPluginSurface", () => {
29
+ beforeEach(() => {
30
+ resetPluginRegistryForTests();
31
+ clearInjectorRegistry();
32
+ });
33
+
34
+ test("serves the default plugins' hooks from the process-global registry", async () => {
35
+ expect(await hookOwnersFor("post-model-call")).not.toContain(
36
+ "default-image-fallback",
37
+ );
38
+
39
+ registerWorkerPluginSurface();
40
+
41
+ // The vision-rejection recovery that a worker without this surface never
42
+ // ran, plus an oversized-tool-result guard on the other hook surface.
43
+ expect(await hookOwnersFor("post-model-call")).toContain(
44
+ "default-image-fallback",
45
+ );
46
+ expect(await hookOwnersFor("post-tool-use")).toContain(
47
+ "default-tool-result-truncate",
48
+ );
49
+ });
50
+
51
+ test("registers the injector-only defaults' runtime injectors", () => {
52
+ expect(
53
+ getRegisteredInjectors().some((i) => i.name === "unified-turn-context"),
54
+ ).toBe(false);
55
+
56
+ registerWorkerPluginSurface();
57
+
58
+ // `default-turn-context` contributes no hooks, so its injections only reach
59
+ // a worker's conversations through the injector registry.
60
+ expect(
61
+ getRegisteredInjectors().some((i) => i.name === "unified-turn-context"),
62
+ ).toBe(true);
63
+ });
64
+
65
+ test("is idempotent: a second call neither throws nor duplicates", async () => {
66
+ registerWorkerPluginSurface();
67
+ const hooksAfterFirst = await hookOwnersFor("post-model-call");
68
+ const injectorsAfterFirst = getRegisteredInjectors().map((i) => i.name);
69
+
70
+ expect(() => registerWorkerPluginSurface()).not.toThrow();
71
+
72
+ expect(await hookOwnersFor("post-model-call")).toEqual(hooksAfterFirst);
73
+ expect(getRegisteredInjectors().map((i) => i.name)).toEqual(
74
+ injectorsAfterFirst,
75
+ );
76
+ });
77
+ });
@@ -0,0 +1,158 @@
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { consolidateVoiceFrontDoorMigration } from "../workspace/migrations/142-consolidate-voice-front-door.js";
13
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
14
+
15
+ let workspaceDir: string;
16
+ let configPath: string;
17
+ let previousDefaultWorkspaceConfigPath: string | undefined;
18
+
19
+ beforeEach(() => {
20
+ previousDefaultWorkspaceConfigPath =
21
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
22
+ delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
23
+ workspaceDir = mkdtempSync(join(tmpdir(), "vellum-migration-142-test-"));
24
+ configPath = join(workspaceDir, "config.json");
25
+ });
26
+
27
+ afterEach(() => {
28
+ if (previousDefaultWorkspaceConfigPath === undefined) {
29
+ delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
30
+ } else {
31
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
32
+ previousDefaultWorkspaceConfigPath;
33
+ }
34
+ if (existsSync(workspaceDir)) {
35
+ rmSync(workspaceDir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ function writeConfig(config: Record<string, unknown>): void {
40
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
41
+ }
42
+
43
+ function readConfig(): Record<string, unknown> {
44
+ return JSON.parse(readFileSync(configPath, "utf-8"));
45
+ }
46
+
47
+ describe("142-consolidate-voice-front-door migration", () => {
48
+ test("is registered in order", () => {
49
+ expect(consolidateVoiceFrontDoorMigration.id).toBe(
50
+ "142-consolidate-voice-front-door",
51
+ );
52
+ // getLastWorkspaceMigrationId() reports the final entry as the registry
53
+ // ceiling, so ordering matters: this must be registered, and everything
54
+ // after it must carry a higher id. Asserting it stays literally last
55
+ // would break on every migration appended after it.
56
+ const index = WORKSPACE_MIGRATIONS.findIndex(
57
+ (migration) => migration.id === "142-consolidate-voice-front-door",
58
+ );
59
+ expect(index).toBeGreaterThan(-1);
60
+ for (const later of WORKSPACE_MIGRATIONS.slice(index + 1)) {
61
+ expect(later.id > "142").toBe(true);
62
+ }
63
+ });
64
+
65
+ test("moves progress overrides and removes generated-ack tuning", () => {
66
+ writeConfig({
67
+ llm: {
68
+ callSites: {
69
+ voiceFrontDecision: { profile: "cost-optimized", effort: "low" },
70
+ voiceFrontDoor: { profile: "latency-optimized" },
71
+ },
72
+ },
73
+ liveVoice: {
74
+ frontModel: {
75
+ endpointDecisionTimeoutMs: 900,
76
+ ackFirstDeltaTimeoutMs: 2500,
77
+ ackGenerationTimeoutMs: 600,
78
+ progress: { enabled: true },
79
+ },
80
+ },
81
+ });
82
+
83
+ consolidateVoiceFrontDoorMigration.run(workspaceDir);
84
+
85
+ const config = readConfig();
86
+ const llm = config.llm as Record<string, unknown>;
87
+ const callSites = llm.callSites as Record<string, unknown>;
88
+ expect(callSites.voiceProgressNarration).toEqual({
89
+ profile: "cost-optimized",
90
+ effort: "low",
91
+ });
92
+ expect(callSites).not.toHaveProperty("voiceFrontDecision");
93
+ expect(callSites.voiceFrontDoor).toEqual({ profile: "latency-optimized" });
94
+
95
+ const liveVoice = config.liveVoice as Record<string, unknown>;
96
+ const frontModel = liveVoice.frontModel as Record<string, unknown>;
97
+ expect(frontModel).not.toHaveProperty("ackFirstDeltaTimeoutMs");
98
+ expect(frontModel).not.toHaveProperty("ackGenerationTimeoutMs");
99
+ expect(frontModel.endpointDecisionTimeoutMs).toBe(900);
100
+ expect(frontModel.progress).toEqual({ enabled: true });
101
+ });
102
+
103
+ test("keeps an existing progress override when both keys exist", () => {
104
+ writeConfig({
105
+ llm: {
106
+ callSites: {
107
+ voiceFrontDecision: { profile: "cost-optimized" },
108
+ voiceProgressNarration: { profile: "latency-optimized" },
109
+ },
110
+ },
111
+ });
112
+
113
+ consolidateVoiceFrontDoorMigration.run(workspaceDir);
114
+
115
+ const config = readConfig();
116
+ const llm = config.llm as Record<string, unknown>;
117
+ const callSites = llm.callSites as Record<string, unknown>;
118
+ expect(callSites.voiceProgressNarration).toEqual({
119
+ profile: "latency-optimized",
120
+ });
121
+ expect(callSites).not.toHaveProperty("voiceFrontDecision");
122
+ });
123
+
124
+ test("migrates saved overrides when a default config overlay is configured", () => {
125
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = "/tmp/overlay.json";
126
+ writeConfig({
127
+ llm: {
128
+ callSites: {
129
+ voiceFrontDecision: { profile: "cost-optimized" },
130
+ },
131
+ },
132
+ });
133
+
134
+ consolidateVoiceFrontDoorMigration.run(workspaceDir);
135
+
136
+ const config = readConfig();
137
+ const llm = config.llm as Record<string, unknown>;
138
+ const callSites = llm.callSites as Record<string, unknown>;
139
+ expect(callSites.voiceProgressNarration).toEqual({
140
+ profile: "cost-optimized",
141
+ });
142
+ expect(callSites).not.toHaveProperty("voiceFrontDecision");
143
+ });
144
+
145
+ test("is idempotent", () => {
146
+ writeConfig({
147
+ llm: {
148
+ callSites: { voiceFrontDecision: { profile: "cost-optimized" } },
149
+ },
150
+ });
151
+
152
+ consolidateVoiceFrontDoorMigration.run(workspaceDir);
153
+ const first = readFileSync(configPath, "utf-8");
154
+ consolidateVoiceFrontDoorMigration.run(workspaceDir);
155
+
156
+ expect(readFileSync(configPath, "utf-8")).toBe(first);
157
+ });
158
+ });