@vellumai/assistant 0.11.3 → 0.11.4-staging.2

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 (341) 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 +139 -37
  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 +17 -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 +177 -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__/ui-shape-teaching.test.ts +33 -0
  67. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  68. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  69. package/src/__tests__/visible-app-context.test.ts +16 -9
  70. package/src/__tests__/voice-config-update.test.ts +40 -0
  71. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  72. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  73. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  74. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  75. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  76. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  77. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  78. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  79. package/src/acp/__tests__/auth-required.test.ts +161 -0
  80. package/src/acp/acp-claude-oauth.ts +19 -2
  81. package/src/acp/agent-process.test.ts +100 -0
  82. package/src/acp/agent-process.ts +29 -26
  83. package/src/acp/auth-required.ts +102 -0
  84. package/src/acp/session-manager.test.ts +119 -0
  85. package/src/acp/session-manager.ts +68 -2
  86. package/src/api/events/acp-auth-required.ts +55 -0
  87. package/src/api/index.ts +7 -0
  88. package/src/api/surfaces.ts +7 -3
  89. package/src/apps/app-store.ts +3 -0
  90. package/src/bundler/package-resolver.ts +2 -30
  91. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  92. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  93. package/src/calls/call-controller.ts +19 -3
  94. package/src/calls/call-setup-flow.ts +0 -1
  95. package/src/calls/media-stream-stt-session.ts +15 -0
  96. package/src/calls/voice-session-bridge.ts +71 -16
  97. package/src/calls/voice-triage-escalate.ts +104 -2
  98. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  99. package/src/channels/config.ts +13 -0
  100. package/src/channels/plugin-channel-declarations.ts +108 -0
  101. package/src/channels/types.ts +30 -0
  102. package/src/cli/AGENTS.md +5 -2
  103. package/src/cli/commands/credentials.help.ts +2 -2
  104. package/src/cli/commands/inference-providers.ts +1 -1
  105. package/src/cli/commands/mcp.help.ts +13 -4
  106. package/src/cli/commands/mcp.ts +9 -0
  107. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  108. package/src/cli/commands/memory/index.help.ts +43 -1
  109. package/src/cli/commands/memory/memory-v3.ts +64 -0
  110. package/src/cli/commands/stt.help.ts +27 -2
  111. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  112. package/src/cli/lib/bundled-marketplace.json +13 -0
  113. package/src/cli/lib/upgrade-plugin.ts +42 -0
  114. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  115. package/src/config/__tests__/default-provider.test.ts +6 -1
  116. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  117. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  118. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  119. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  120. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  121. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  122. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  123. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  124. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  125. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  126. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  127. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  128. package/src/config/call-site-defaults.ts +4 -5
  129. package/src/config/default-profile-catalog.ts +83 -12
  130. package/src/config/default-profile-names.ts +4 -1
  131. package/src/config/default-provider-resolution.ts +4 -0
  132. package/src/config/llm-context-resolution.ts +11 -3
  133. package/src/config/llm-resolver.ts +28 -1
  134. package/src/config/profile-materialization.ts +70 -22
  135. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  136. package/src/config/schemas/call-site-catalog.ts +4 -4
  137. package/src/config/schemas/live-voice.ts +57 -23
  138. package/src/config/schemas/llm.ts +59 -32
  139. package/src/config/schemas/mcp.ts +23 -0
  140. package/src/config/schemas/plugin-updates.ts +6 -2
  141. package/src/config/schemas/stt.ts +1 -0
  142. package/src/context/outbound-sanitize.ts +96 -1
  143. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  144. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  145. package/src/daemon/conversation-agent-loop.ts +17 -6
  146. package/src/daemon/conversation-messaging.ts +5 -1
  147. package/src/daemon/conversation-notifiers.ts +9 -1
  148. package/src/daemon/conversation-process.ts +36 -6
  149. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  150. package/src/daemon/conversation-surfaces.ts +27 -5
  151. package/src/daemon/conversation-tool-setup.ts +1 -2
  152. package/src/daemon/conversation.ts +48 -0
  153. package/src/daemon/interactive-turn-sender.ts +59 -0
  154. package/src/daemon/mcp-reload-service.ts +36 -6
  155. package/src/daemon/process-message.ts +24 -24
  156. package/src/daemon/providers-setup.ts +6 -3
  157. package/src/daemon/trust-context-types.ts +29 -0
  158. package/src/daemon/wake-conversation-ops.ts +3 -2
  159. package/src/documents/document-store.ts +138 -5
  160. package/src/hooks/hook-loader.ts +3 -3
  161. package/src/hooks/registry.ts +50 -6
  162. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  163. package/src/inbound/oauth-callback-url.ts +61 -0
  164. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  165. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  166. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  167. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  168. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  169. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  170. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  171. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  172. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  173. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  174. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  175. package/src/live-voice/live-voice-archive.ts +2 -0
  176. package/src/live-voice/live-voice-metrics.ts +57 -32
  177. package/src/live-voice/live-voice-photo.ts +1 -2
  178. package/src/live-voice/live-voice-session.ts +535 -314
  179. package/src/live-voice/progress-narration.ts +277 -0
  180. package/src/live-voice/protocol.ts +21 -1
  181. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  182. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  183. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  184. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  185. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  186. package/src/mcp/client.ts +16 -11
  187. package/src/mcp/effective-config.ts +113 -0
  188. package/src/mcp/manager.ts +11 -6
  189. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  190. package/src/mcp/mcp-oauth-provider.ts +205 -240
  191. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  192. package/src/monitoring/plugin-auto-update.ts +128 -24
  193. package/src/notifications/signal.ts +1 -0
  194. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  195. package/src/permissions/confirmation-guardian-request.ts +2 -2
  196. package/src/permissions/question-guardian-request.test.ts +14 -6
  197. package/src/permissions/question-guardian-request.ts +1 -2
  198. package/src/persistence/attachments-store.ts +8 -1
  199. package/src/persistence/bookmark-crud.ts +3 -7
  200. package/src/persistence/conversation-attention-store.ts +16 -45
  201. package/src/persistence/conversation-crud.ts +33 -4
  202. package/src/persistence/conversation-lineage.ts +9 -0
  203. package/src/persistence/conversation-queries.ts +108 -41
  204. package/src/persistence/delivery-crud.ts +38 -29
  205. package/src/persistence/external-conversation-store.ts +32 -4
  206. package/src/persistence/llm-request-log-store.ts +4 -10
  207. package/src/persistence/llm-usage-store.ts +8 -3
  208. package/src/persistence/message-reads.test.ts +197 -0
  209. package/src/persistence/message-reads.ts +211 -0
  210. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  211. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  212. package/src/persistence/real-user-turn-filter.ts +27 -3
  213. package/src/persistence/steps.ts +9 -0
  214. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  215. package/src/plugin-api/conversation-turn.ts +168 -5
  216. package/src/plugin-api/index.ts +21 -5
  217. package/src/plugin-api/vision-support.test.ts +1 -1
  218. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  219. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  220. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  224. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  225. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  226. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  227. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  228. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  229. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  230. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  231. package/src/plugins/defaults/memory/indexer.ts +3 -1
  232. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  233. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  234. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  238. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  239. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  240. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  241. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  242. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  243. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  244. package/src/plugins/defaults/memory/worker.ts +6 -3
  245. package/src/plugins/external-plugin-loader.ts +47 -0
  246. package/src/plugins/mcp-servers.ts +361 -0
  247. package/src/plugins/mtime-cache.ts +23 -49
  248. package/src/plugins/worker-plugin-surface.ts +33 -0
  249. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  250. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  251. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  252. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  253. package/src/providers/call-site-routing.ts +30 -3
  254. package/src/providers/connection-resolution.ts +194 -11
  255. package/src/providers/inference/auth.ts +6 -0
  256. package/src/providers/inference/connection-availability.ts +24 -2
  257. package/src/providers/inference/connections.ts +2 -0
  258. package/src/providers/model-catalog.ts +3 -3
  259. package/src/providers/model-intents.ts +28 -8
  260. package/src/providers/openai/chat-completions-provider.ts +5 -6
  261. package/src/providers/openai/codex-models.ts +2 -1
  262. package/src/providers/provider-send-message.ts +32 -3
  263. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  264. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  265. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  266. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  267. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  268. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  269. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  270. package/src/providers/speech-to-text/resolve.ts +25 -2
  271. package/src/routes/worker.ts +17 -5
  272. package/src/runtime/access-request-helper.ts +9 -12
  273. package/src/runtime/agent-wake.ts +3 -3
  274. package/src/runtime/pre-first-message-gate.ts +4 -0
  275. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  276. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  277. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  278. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  279. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  280. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  281. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  282. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  283. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  284. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  285. package/src/runtime/routes/channel-route-shared.ts +0 -6
  286. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  287. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  288. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  289. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  290. package/src/runtime/routes/default-provider-routes.ts +15 -0
  291. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  292. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  294. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  295. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  296. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  297. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  298. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  299. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  300. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  301. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  302. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  303. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  304. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  305. package/src/runtime/routes/log-export-routes.ts +3 -0
  306. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  307. package/src/runtime/routes/plugins-routes.ts +21 -3
  308. package/src/runtime/routes/stt-routes.ts +31 -25
  309. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  310. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  311. package/src/runtime/routes/user-route-import.ts +108 -0
  312. package/src/schedule/worker.ts +6 -0
  313. package/src/security/oauth2.ts +6 -22
  314. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  315. package/src/stt/__tests__/types.test.ts +94 -0
  316. package/src/stt/daemon-batch-transcriber.ts +10 -0
  317. package/src/stt/stt-stream-session.ts +8 -4
  318. package/src/stt/types.ts +103 -0
  319. package/src/subagent/manager.ts +1 -3
  320. package/src/subagent/types.ts +7 -6
  321. package/src/tools/acp/spawn.test.ts +97 -0
  322. package/src/tools/acp/spawn.ts +32 -0
  323. package/src/tools/document/document-tool.ts +12 -3
  324. package/src/tools/registry.ts +2 -1
  325. package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
  326. package/src/tools/workflows/run-workflow.ts +1 -2
  327. package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
  328. package/src/tts/reasoning-tag-filter.ts +89 -0
  329. package/src/util/think-tag-stream.ts +95 -0
  330. package/src/workspace/byok-default-profile-ensure.ts +76 -24
  331. package/src/workspace/custom-profile-ensure.ts +4 -24
  332. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  333. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  334. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  335. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  336. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  337. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  338. package/src/workspace/migrations/registry.ts +10 -0
  339. package/src/workspace/provider-commit-message-generator.ts +7 -5
  340. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  341. package/src/live-voice/front-decision.ts +0 -476
@@ -0,0 +1,932 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { setConfig } from "../../__tests__/helpers/set-config.js";
4
+ import type {
5
+ VoiceTurnCallbacks,
6
+ VoiceTurnOptions,
7
+ } from "../../calls/voice-session-bridge.js";
8
+ import type { LiveVoiceFluxConfig } from "../../config/schemas/live-voice.js";
9
+ import type {
10
+ StreamingTranscriber,
11
+ SttProviderId,
12
+ SttStreamServerEvent,
13
+ } from "../../stt/types.js";
14
+ import {
15
+ LiveVoiceSession,
16
+ type LiveVoiceTtsStreamer,
17
+ } from "../live-voice-session.js";
18
+ import type { LiveVoiceSessionFactoryContext } from "../live-voice-session-manager.js";
19
+ import type {
20
+ LiveVoiceTtsOptions,
21
+ LiveVoiceTtsResult,
22
+ } from "../live-voice-tts.js";
23
+ import {
24
+ createLiveVoiceServerFrameSequencer,
25
+ type LiveVoiceClientStartFrame,
26
+ type LiveVoiceServerFrame,
27
+ } from "../protocol.js";
28
+
29
+ const SAMPLE_RATE = 24_000;
30
+
31
+ const VAD_START_FRAME = {
32
+ type: "start",
33
+ conversationId: "conversation-123",
34
+ turnDetection: "server_vad",
35
+ audio: {
36
+ mimeType: "audio/pcm",
37
+ sampleRate: SAMPLE_RATE,
38
+ channels: 1,
39
+ },
40
+ } as const satisfies LiveVoiceClientStartFrame;
41
+
42
+ function pcm(amplitude: number, sampleCount = 240): Uint8Array {
43
+ const buffer = Buffer.alloc(sampleCount * 2);
44
+ for (let index = 0; index < sampleCount; index += 1) {
45
+ buffer.writeInt16LE(amplitude, index * 2);
46
+ }
47
+ return new Uint8Array(buffer);
48
+ }
49
+
50
+ // 10 ms of speech at 24 kHz.
51
+ const LOUD_CHUNK = pcm(8_000);
52
+ // 300 ms of speech at 24 kHz, comfortably past the default sustained-speech
53
+ // barge-in guard (250 ms) in a single chunk.
54
+ const SUSTAINED_LOUD_CHUNK = pcm(8_000, 7_200);
55
+
56
+ /**
57
+ * Flux-shaped streaming transcriber: no `finalizeUtterance` (Flux owns turn
58
+ * boundaries, so the session runs it per cycle), and every transcript event is
59
+ * scripted by the test. `stop()` closes without inventing a trailing final,
60
+ * exactly as Flux does: its transcript is committed by EndOfTurn.
61
+ */
62
+ class MockFluxTranscriber implements StreamingTranscriber {
63
+ readonly boundaryId = "daemon-streaming" as const;
64
+ readonly received: Buffer[] = [];
65
+ stopped = false;
66
+ private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
67
+
68
+ constructor(readonly providerId: SttProviderId) {}
69
+
70
+ async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
71
+ this.onEvent = onEvent;
72
+ }
73
+
74
+ sendAudio(chunk: Buffer): void {
75
+ this.received.push(Buffer.from(chunk));
76
+ }
77
+
78
+ stop(): void {
79
+ if (this.stopped) {
80
+ return;
81
+ }
82
+ this.stopped = true;
83
+ this.onEvent?.({ type: "closed" });
84
+ }
85
+
86
+ emit(event: SttStreamServerEvent): void {
87
+ this.onEvent?.(event);
88
+ }
89
+
90
+ // Flux's StartOfTurn frame, numbering the turn it opens.
91
+ startOfTurn(turnIndex: number): void {
92
+ this.emit({ type: "turn-start", turnIndex });
93
+ }
94
+
95
+ // Flux's EndOfTurn frame maps onto a `final` followed by a `turn-end`.
96
+ endOfTurn(text: string, turnIndex?: number): void {
97
+ this.emit({ type: "final", text });
98
+ this.emit({
99
+ type: "turn-end",
100
+ text,
101
+ confidence: 0.9,
102
+ ...(turnIndex !== undefined ? { turnIndex } : {}),
103
+ });
104
+ }
105
+ }
106
+
107
+ function createHarness(options: {
108
+ providerId?: SttProviderId;
109
+ fluxConfig?: Partial<LiveVoiceFluxConfig>;
110
+ silenceThresholdMs?: number;
111
+ startVoiceTurn?: (options: VoiceTurnOptions) => Promise<{
112
+ turnId: string;
113
+ abort: () => void;
114
+ }>;
115
+ streamTtsAudio?: LiveVoiceTtsStreamer | null;
116
+ emitMetrics?: boolean;
117
+ // Holds the STT dial open so a test can drive a whole utterance through the
118
+ // window between `ready` and the resolved provider.
119
+ resolveGate?: Promise<unknown>;
120
+ }) {
121
+ const sequencer = createLiveVoiceServerFrameSequencer();
122
+ const frames: LiveVoiceServerFrame[] = [];
123
+ const context: LiveVoiceSessionFactoryContext = {
124
+ sessionId: "session-123",
125
+ startFrame: VAD_START_FRAME,
126
+ sendFrame: mock(async (payload) => {
127
+ const frame = sequencer.next(payload);
128
+ frames.push(frame);
129
+ return frame;
130
+ }),
131
+ };
132
+
133
+ const transcribers: MockFluxTranscriber[] = [];
134
+ const resolveTranscriber = mock(async () => {
135
+ if (options.resolveGate) {
136
+ await options.resolveGate;
137
+ }
138
+ const transcriber = new MockFluxTranscriber(
139
+ options.providerId ?? "deepgram-flux",
140
+ );
141
+ transcribers.push(transcriber);
142
+ return transcriber;
143
+ });
144
+
145
+ const turnCalls: VoiceTurnOptions[] = [];
146
+ const startVoiceTurn = mock(async (turnOptions: VoiceTurnOptions) => {
147
+ turnCalls.push(turnOptions);
148
+ if (options.startVoiceTurn) {
149
+ return await options.startVoiceTurn(turnOptions);
150
+ }
151
+ return { turnId: `bridge-turn-${turnCalls.length}`, abort: mock() };
152
+ });
153
+
154
+ const session = new LiveVoiceSession(context, {
155
+ resolveTranscriber,
156
+ startVoiceTurn,
157
+ streamTtsAudio: options.streamTtsAudio ?? null,
158
+ emitMetrics: options.emitMetrics ?? false,
159
+ spawnBackgroundContinuation: mock(async () => ""),
160
+ turnDetectorConfig: {
161
+ silenceThresholdMs: options.silenceThresholdMs ?? 40,
162
+ },
163
+ ...(options.fluxConfig ? { fluxConfig: options.fluxConfig } : {}),
164
+ });
165
+
166
+ return { frames, session, transcribers, turnCalls };
167
+ }
168
+
169
+ function makeTextDelta(
170
+ text: string,
171
+ ): Parameters<NonNullable<VoiceTurnCallbacks["assistant_text_delta"]>>[0] {
172
+ return {
173
+ type: "assistant_text_delta",
174
+ text,
175
+ conversationId: "conversation-123",
176
+ };
177
+ }
178
+
179
+ function makeMessageComplete(): Parameters<
180
+ NonNullable<VoiceTurnCallbacks["message_complete"]>
181
+ >[0] {
182
+ return {
183
+ type: "message_complete",
184
+ conversationId: "conversation-123",
185
+ messageId: "assistant-message-123",
186
+ };
187
+ }
188
+
189
+ // Answers immediately: one text delta (which commits a speculative leg), then
190
+ // message_complete.
191
+ function autoCompletingTurn(
192
+ reply = "Okay.",
193
+ ): (
194
+ options: VoiceTurnOptions,
195
+ ) => Promise<{ turnId: string; abort: () => void }> {
196
+ let count = 0;
197
+ return async (options) => {
198
+ count += 1;
199
+ options.callbacks?.assistant_text_delta?.(makeTextDelta(reply));
200
+ options.callbacks?.message_complete?.(makeMessageComplete());
201
+ return { turnId: `bridge-turn-${count}`, abort: mock() };
202
+ };
203
+ }
204
+
205
+ async function waitFor(
206
+ predicate: () => boolean,
207
+ message = "Timed out waiting for a live voice Flux test condition",
208
+ ): Promise<void> {
209
+ const deadline = Date.now() + 4_000;
210
+ while (Date.now() < deadline) {
211
+ if (predicate()) {
212
+ return;
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, 5));
215
+ }
216
+ throw new Error(message);
217
+ }
218
+
219
+ async function flushAsyncCallbacks(): Promise<void> {
220
+ await new Promise((resolve) => setTimeout(resolve, 10));
221
+ }
222
+
223
+ async function sleep(ms: number): Promise<void> {
224
+ await new Promise((resolve) => setTimeout(resolve, ms));
225
+ }
226
+
227
+ // Holds the STT dial open until the test opens it, so the whole
228
+ // ready-before-resolve window is under the test's control.
229
+ function createDialGate(): { promise: Promise<void>; open: () => void } {
230
+ let open = (): void => {};
231
+ const promise = new Promise<void>((resolve) => {
232
+ open = () => resolve();
233
+ });
234
+ return { promise, open };
235
+ }
236
+
237
+ function countFrames(
238
+ frames: LiveVoiceServerFrame[],
239
+ type: LiveVoiceServerFrame["type"],
240
+ ): number {
241
+ return frames.filter((frame) => frame.type === type).length;
242
+ }
243
+
244
+ // Waits for the completed turn's metrics frame and hands it back narrowed, so
245
+ // the assertions below read the endpoint fields without a type guard.
246
+ async function waitForTurnMetrics(
247
+ frames: LiveVoiceServerFrame[],
248
+ ): Promise<Extract<LiveVoiceServerFrame, { type: "metrics" }>> {
249
+ const isTurnMetrics = (
250
+ frame: LiveVoiceServerFrame,
251
+ ): frame is Extract<LiveVoiceServerFrame, { type: "metrics" }> =>
252
+ frame.type === "metrics" && frame.event === "turn_completed";
253
+ await waitFor(
254
+ () => frames.some(isTurnMetrics),
255
+ "The completed turn never reported its metrics",
256
+ );
257
+ const metricsFrame = frames.find(isTurnMetrics);
258
+ if (!metricsFrame) {
259
+ throw new Error("The completed turn never reported its metrics");
260
+ }
261
+ return metricsFrame;
262
+ }
263
+
264
+ // Long enough for the 40 ms local silence timer to fire and hand the boundary
265
+ // to Flux (which arms the fail-open deadline and commits nothing).
266
+ const PAST_SILENCE_BOUNDARY_MS = 150;
267
+
268
+ const FLUX_ON = {
269
+ turnEnd: { enabled: true },
270
+ eotTimeoutMs: 500,
271
+ } as const satisfies Partial<LiveVoiceFluxConfig>;
272
+
273
+ describe("LiveVoiceSession Flux end-of-turn", () => {
274
+ test("commits the turn on turn-end without an endpoint-decision leg", async () => {
275
+ const { frames, session, transcribers, turnCalls } = createHarness({
276
+ fluxConfig: FLUX_ON,
277
+ // Long enough that the local silence boundary cannot be what commits.
278
+ silenceThresholdMs: 10_000,
279
+ startVoiceTurn: autoCompletingTurn(),
280
+ });
281
+
282
+ await session.start();
283
+ await session.handleBinaryAudio(LOUD_CHUNK);
284
+ await waitFor(() => transcribers.length > 0);
285
+ transcribers[0]?.endOfTurn("what is the weather");
286
+
287
+ await waitFor(() => turnCalls.length === 1);
288
+ expect(turnCalls[0]?.content).toBe("what is the weather");
289
+ // Non-speculative dispatch: the front-door leg's decision rule is built
290
+ // with includeHold false, so the hold token is never taught.
291
+ expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
292
+ expect(turnCalls[0]?.routingLeg).toBe("front-door");
293
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
294
+
295
+ await session.close("client_end");
296
+ });
297
+
298
+ test("still escalates on the [1] verdict after a flux commit", async () => {
299
+ const { session, transcribers, turnCalls } = createHarness({
300
+ fluxConfig: FLUX_ON,
301
+ silenceThresholdMs: 10_000,
302
+ startVoiceTurn: async (turnOptions) => {
303
+ if (turnOptions.routingLeg === "front-door") {
304
+ turnOptions.callbacks?.assistant_text_delta?.(
305
+ makeTextDelta("[1] Let me check that for you."),
306
+ );
307
+ }
308
+ return { turnId: "bridge-turn", abort: mock() };
309
+ },
310
+ });
311
+
312
+ await session.start();
313
+ await session.handleBinaryAudio(LOUD_CHUNK);
314
+ await waitFor(() => transcribers.length > 0);
315
+ transcribers[0]?.endOfTurn("what is on my calendar");
316
+
317
+ await waitFor(
318
+ () => turnCalls.some((call) => call.routingLeg === "escalated"),
319
+ "The escalate verdict never handed off to the escalated leg",
320
+ );
321
+
322
+ await session.close("client_end");
323
+ });
324
+
325
+ test("ignores turn-end when the flag is off and keeps the hold path", async () => {
326
+ const { frames, session, transcribers, turnCalls } = createHarness({
327
+ startVoiceTurn: autoCompletingTurn(),
328
+ });
329
+
330
+ await session.start();
331
+ await session.handleBinaryAudio(LOUD_CHUNK);
332
+ await waitFor(() => transcribers.length > 0);
333
+ transcribers[0]?.emit({ type: "partial", text: "hello there" });
334
+ transcribers[0]?.endOfTurn("hello there");
335
+ await flushAsyncCallbacks();
336
+
337
+ // The turn-end event committed nothing: the silence boundary still owns
338
+ // the commit, and it dispatches speculatively (the hold path).
339
+ await waitFor(() => turnCalls.length === 1);
340
+ expect(turnCalls[0]?.unifiedVerdict).toBe(true);
341
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
342
+
343
+ await session.close("client_end");
344
+ });
345
+
346
+ test("ignores turn-end from a provider that is not deepgram-flux", async () => {
347
+ const { session, transcribers, turnCalls } = createHarness({
348
+ providerId: "deepgram",
349
+ fluxConfig: FLUX_ON,
350
+ silenceThresholdMs: 10_000,
351
+ startVoiceTurn: autoCompletingTurn(),
352
+ });
353
+
354
+ await session.start();
355
+ await session.handleBinaryAudio(LOUD_CHUNK);
356
+ await waitFor(() => transcribers.length > 0);
357
+ transcribers[0]?.endOfTurn("hello there");
358
+ await flushAsyncCallbacks();
359
+
360
+ expect(turnCalls).toHaveLength(0);
361
+
362
+ await session.close("client_end");
363
+ });
364
+
365
+ test("reports the endpoint decision as flux", async () => {
366
+ const { frames, session, transcribers, turnCalls } = createHarness({
367
+ fluxConfig: FLUX_ON,
368
+ silenceThresholdMs: 10_000,
369
+ emitMetrics: true,
370
+ startVoiceTurn: autoCompletingTurn(),
371
+ });
372
+
373
+ await session.start();
374
+ await session.handleBinaryAudio(LOUD_CHUNK);
375
+ await waitFor(() => transcribers.length > 0);
376
+ transcribers[0]?.endOfTurn("hello there");
377
+ await waitFor(() => turnCalls.length === 1);
378
+
379
+ const metricsFrame = await waitForTurnMetrics(frames);
380
+ expect(metricsFrame.endpointDecisionSource).toBe("provider");
381
+ expect(metricsFrame.endpointHoldCount).toBe(0);
382
+
383
+ await session.close("client_end");
384
+ });
385
+
386
+ test("anchors the flux commit latency at the local speech-stop mark", async () => {
387
+ const { frames, session, transcribers, turnCalls } = createHarness({
388
+ fluxConfig: { turnEnd: { enabled: true }, eotTimeoutMs: 30_000 },
389
+ // Long enough that the local silence boundary cannot be what commits,
390
+ // and that the fail-open deadline stays far away.
391
+ silenceThresholdMs: 10_000,
392
+ emitMetrics: true,
393
+ startVoiceTurn: autoCompletingTurn(),
394
+ });
395
+
396
+ await session.start();
397
+ await session.handleBinaryAudio(LOUD_CHUNK);
398
+ await waitFor(() => transcribers.length > 0);
399
+ // Silence between the caller's last above-gate chunk and the commit. A
400
+ // dispatch-anchored number would not see it; the speech-stop anchor must.
401
+ await sleep(250);
402
+ transcribers[0]?.endOfTurn("hello there");
403
+ await waitFor(() => turnCalls.length === 1);
404
+
405
+ const metricsFrame = await waitForTurnMetrics(frames);
406
+ expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(250);
407
+
408
+ await session.close("client_end");
409
+ });
410
+
411
+ test("records the commit latency on the front-door path too, with no hold", async () => {
412
+ const { frames, session, transcribers, turnCalls } = createHarness({
413
+ // No flux config: the latch is down and the local silence boundary owns
414
+ // the commit, which is arm A of the measurement run.
415
+ silenceThresholdMs: 40,
416
+ emitMetrics: true,
417
+ startVoiceTurn: autoCompletingTurn(),
418
+ });
419
+
420
+ await session.start();
421
+ await session.handleBinaryAudio(LOUD_CHUNK);
422
+ await waitFor(() => transcribers.length > 0);
423
+ transcribers[0]?.emit({ type: "partial", text: "hello there" });
424
+ await waitFor(() => turnCalls.length === 1);
425
+
426
+ // The turn committed straight through, so it records no endpoint decision
427
+ // at all. The commit latency is recorded regardless, which is what makes
428
+ // the two arms one population rather than two.
429
+ const metricsFrame = await waitForTurnMetrics(frames);
430
+ expect(metricsFrame.endpointDecisionSource).toBeUndefined();
431
+ expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(40);
432
+
433
+ await session.close("client_end");
434
+ });
435
+
436
+ test("records no commit latency for a turn that never committed", async () => {
437
+ const { frames, session, transcribers } = createHarness({
438
+ fluxConfig: { turnEnd: { enabled: true }, eotTimeoutMs: 30_000 },
439
+ silenceThresholdMs: 10_000,
440
+ emitMetrics: true,
441
+ startVoiceTurn: autoCompletingTurn(),
442
+ });
443
+
444
+ await session.start();
445
+ await session.handleBinaryAudio(LOUD_CHUNK);
446
+ await waitFor(() => transcribers.length > 0);
447
+ transcribers[0]?.emit({ type: "partial", text: "hello the" });
448
+ // Nothing ends this turn: no flux turn-end, no silence boundary, and the
449
+ // fail-open deadline is 30 s away.
450
+ await flushAsyncCallbacks();
451
+ await session.close("client_end");
452
+
453
+ const metricsFrames = frames.filter((frame) => frame.type === "metrics");
454
+ expect(metricsFrames.length).toBeGreaterThan(0);
455
+ for (const frame of metricsFrames) {
456
+ expect(frame).not.toHaveProperty("endpointCommitLatencyMs");
457
+ }
458
+ });
459
+
460
+ test("falls back to the silence-boundary path when no turn-end arrives", async () => {
461
+ const { session, transcribers, turnCalls } = createHarness({
462
+ fluxConfig: FLUX_ON,
463
+ startVoiceTurn: autoCompletingTurn(),
464
+ });
465
+
466
+ await session.start();
467
+ await session.handleBinaryAudio(LOUD_CHUNK);
468
+ await waitFor(() => transcribers.length > 0);
469
+ // Flux is transcribing but its EndOfTurn never arrives.
470
+ transcribers[0]?.emit({ type: "partial", text: "are you still there" });
471
+
472
+ // The local silence boundary (40 ms) passes without committing anything:
473
+ // the fallback deadline (eotTimeoutMs plus the margin) owns this
474
+ // utterance.
475
+ await new Promise((resolve) => setTimeout(resolve, 300));
476
+ expect(turnCalls).toHaveLength(0);
477
+
478
+ await waitFor(
479
+ () => turnCalls.length === 1,
480
+ "Flux fallback never replayed the silence boundary",
481
+ );
482
+ // Back on the existing path: a speculative dispatch that knows the hold
483
+ // token, judging the partial the silence boundary saw.
484
+ expect(turnCalls[0]?.content).toBe("are you still there");
485
+ expect(turnCalls[0]?.unifiedVerdict).toBe(true);
486
+
487
+ await session.close("client_end");
488
+ }, 10_000);
489
+
490
+ test("commits a turn-end that arrives after the local silence boundary", async () => {
491
+ const { frames, session, transcribers, turnCalls } = createHarness({
492
+ fluxConfig: FLUX_ON,
493
+ silenceThresholdMs: 40,
494
+ startVoiceTurn: autoCompletingTurn(),
495
+ });
496
+
497
+ await session.start();
498
+ await session.handleBinaryAudio(LOUD_CHUNK);
499
+ await waitFor(() => transcribers.length > 0);
500
+
501
+ // The boundary passes and commits nothing: Flux owns it.
502
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
503
+ expect(turnCalls).toHaveLength(0);
504
+
505
+ // No resumed speech, so this turn-end is the one the boundary was waiting
506
+ // for and it commits exactly as before.
507
+ transcribers[0]?.endOfTurn("what is the weather");
508
+ await waitFor(
509
+ () => turnCalls.length === 1,
510
+ "A non-stale turn-end after the silence boundary never committed",
511
+ );
512
+ expect(turnCalls[0]?.content).toBe("what is the weather");
513
+ expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
514
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
515
+
516
+ await session.close("client_end");
517
+ });
518
+
519
+ test("drops a turn-end that arrives after the caller resumed speaking", async () => {
520
+ const { frames, session, transcribers, turnCalls } = createHarness({
521
+ fluxConfig: FLUX_ON,
522
+ silenceThresholdMs: 40,
523
+ startVoiceTurn: autoCompletingTurn(),
524
+ });
525
+
526
+ await session.start();
527
+ await session.handleBinaryAudio(LOUD_CHUNK);
528
+ await waitFor(() => transcribers.length > 0);
529
+ const transcriber = transcribers[0];
530
+ transcriber?.emit({ type: "partial", text: "what is the" });
531
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
532
+
533
+ // The caller draws breath and keeps going before the provider's turn-end
534
+ // for the speech that boundary closed reaches the session.
535
+ await session.handleBinaryAudio(LOUD_CHUNK);
536
+ await waitFor(
537
+ () => countFrames(frames, "speech_started") === 2,
538
+ "The resumed speech never re-opened a detector turn",
539
+ );
540
+ const receivedBeforeResume = transcriber?.received.length ?? 0;
541
+
542
+ transcriber?.emit({
543
+ type: "turn-end",
544
+ text: "what is the",
545
+ confidence: 0.9,
546
+ });
547
+ await flushAsyncCallbacks();
548
+
549
+ // Dropped: no boundary frame, the transcriber keeps running, no turn.
550
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(false);
551
+ expect(transcriber?.stopped).toBe(false);
552
+ expect(turnCalls).toHaveLength(0);
553
+
554
+ // ...and the resumed speech still routes into the same open utterance.
555
+ await session.handleBinaryAudio(LOUD_CHUNK);
556
+ await waitFor(
557
+ () => (transcriber?.received.length ?? 0) > receivedBeforeResume,
558
+ "The resumed speech stopped reaching the transcriber",
559
+ );
560
+
561
+ await session.close("client_end");
562
+ });
563
+
564
+ test("commits a fast turn-end for the still-open turn after a mid-thought pause", async () => {
565
+ const { frames, session, transcribers, turnCalls } = createHarness({
566
+ fluxConfig: FLUX_ON,
567
+ silenceThresholdMs: 40,
568
+ emitMetrics: true,
569
+ startVoiceTurn: autoCompletingTurn(),
570
+ });
571
+
572
+ await session.start();
573
+ await session.handleBinaryAudio(LOUD_CHUNK);
574
+ await waitFor(() => transcribers.length > 0);
575
+ const transcriber = transcribers[0];
576
+ transcriber?.startOfTurn(0);
577
+ transcriber?.emit({ type: "partial", text: "what is the" });
578
+
579
+ // A mid-thought pause, long enough for the local silence boundary to hand
580
+ // the cycle to Flux, and then the caller keeps going.
581
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
582
+ await session.handleBinaryAudio(LOUD_CHUNK);
583
+ await waitFor(
584
+ () => countFrames(frames, "speech_started") === 2,
585
+ "The resumed speech never re-opened a detector turn",
586
+ );
587
+
588
+ // Flux ends the turn it kept open across the pause, beating the next local
589
+ // silence boundary: the fast commit this feature exists for. Nothing here
590
+ // sleeps past that boundary, on purpose.
591
+ const emittedAtMs = Date.now();
592
+ transcriber?.endOfTurn("what is the weather today", 0);
593
+ await waitFor(
594
+ () => turnCalls.length === 1,
595
+ "The fast turn-end after a mid-thought pause never committed",
596
+ );
597
+ const commitLatencyMs = Date.now() - emittedAtMs;
598
+
599
+ // The fail-open budget is eotTimeoutMs (500 ms) plus the margin (1000 ms).
600
+ // A commit anywhere near it means the turn-end was dropped and the
601
+ // deadline committed in its place.
602
+ expect(commitLatencyMs).toBeLessThan(300);
603
+ expect(turnCalls[0]?.content).toBe("what is the weather today");
604
+ // The Flux path, not the speculative hold path the deadline falls back to.
605
+ expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
606
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
607
+
608
+ const metricsFrame = await waitForTurnMetrics(frames);
609
+ expect(metricsFrame.endpointDecisionSource).toBe("provider");
610
+
611
+ await session.close("client_end");
612
+ });
613
+
614
+ test("drops a turn-end for a turn flux has already superseded", async () => {
615
+ const { frames, session, transcribers, turnCalls } = createHarness({
616
+ fluxConfig: FLUX_ON,
617
+ silenceThresholdMs: 40,
618
+ startVoiceTurn: autoCompletingTurn(),
619
+ });
620
+
621
+ await session.start();
622
+ await session.handleBinaryAudio(LOUD_CHUNK);
623
+ await waitFor(() => transcribers.length > 0);
624
+ const transcriber = transcribers[0];
625
+ transcriber?.startOfTurn(0);
626
+ transcriber?.emit({ type: "partial", text: "what is the" });
627
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
628
+
629
+ await session.handleBinaryAudio(LOUD_CHUNK);
630
+ await waitFor(
631
+ () => countFrames(frames, "speech_started") === 2,
632
+ "The resumed speech never re-opened a detector turn",
633
+ );
634
+ const receivedBeforeResume = transcriber?.received.length ?? 0;
635
+
636
+ // Flux closed turn 0 and opened turn 1 for the resumed speech, so the
637
+ // end-of-turn for turn 0 trailing behind describes speech the caller has
638
+ // moved past.
639
+ transcriber?.startOfTurn(1);
640
+ transcriber?.emit({
641
+ type: "turn-end",
642
+ text: "what is the",
643
+ confidence: 0.9,
644
+ turnIndex: 0,
645
+ });
646
+ await flushAsyncCallbacks();
647
+
648
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(false);
649
+ expect(transcriber?.stopped).toBe(false);
650
+ expect(turnCalls).toHaveLength(0);
651
+ await session.handleBinaryAudio(LOUD_CHUNK);
652
+ await waitFor(
653
+ () => (transcriber?.received.length ?? 0) > receivedBeforeResume,
654
+ "The resumed speech stopped reaching the transcriber",
655
+ );
656
+
657
+ // Turn 1's own end-of-turn is the one that commits.
658
+ transcriber?.endOfTurn("what is the weather today", 1);
659
+ await waitFor(
660
+ () => turnCalls.length === 1,
661
+ "The superseding turn never committed",
662
+ );
663
+ expect(turnCalls[0]?.content).toBe("what is the weather today");
664
+ expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
665
+
666
+ await session.close("client_end");
667
+ });
668
+
669
+ test("commits the resumed turn on the next turn-end after dropping a stale one", async () => {
670
+ const { frames, session, transcribers, turnCalls } = createHarness({
671
+ fluxConfig: FLUX_ON,
672
+ silenceThresholdMs: 40,
673
+ startVoiceTurn: autoCompletingTurn(),
674
+ });
675
+
676
+ await session.start();
677
+ await session.handleBinaryAudio(LOUD_CHUNK);
678
+ await waitFor(() => transcribers.length > 0);
679
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
680
+
681
+ await session.handleBinaryAudio(LOUD_CHUNK);
682
+ await waitFor(() => countFrames(frames, "speech_started") === 2);
683
+ transcribers[0]?.emit({
684
+ type: "turn-end",
685
+ text: "what is the",
686
+ confidence: 0.9,
687
+ });
688
+ await flushAsyncCallbacks();
689
+ expect(turnCalls).toHaveLength(0);
690
+
691
+ // The caller finishes for real: the next silence boundary re-stamps the
692
+ // cycle, so Flux's turn-end for the resumed speech commits it.
693
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
694
+ transcribers[0]?.endOfTurn("what is the weather today");
695
+
696
+ await waitFor(
697
+ () => turnCalls.length === 1,
698
+ "The resumed turn never committed after the stale turn-end was dropped",
699
+ );
700
+ expect(turnCalls[0]?.content).toBe("what is the weather today");
701
+ expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
702
+ expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
703
+
704
+ await session.close("client_end");
705
+ });
706
+
707
+ test("falls back to the deadline when nothing follows the dropped turn-end", async () => {
708
+ const { session, transcribers, turnCalls } = createHarness({
709
+ fluxConfig: FLUX_ON,
710
+ silenceThresholdMs: 40,
711
+ startVoiceTurn: autoCompletingTurn(),
712
+ });
713
+
714
+ await session.start();
715
+ await session.handleBinaryAudio(LOUD_CHUNK);
716
+ await waitFor(() => transcribers.length > 0);
717
+ transcribers[0]?.emit({ type: "partial", text: "are you still there" });
718
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
719
+
720
+ await session.handleBinaryAudio(LOUD_CHUNK);
721
+ transcribers[0]?.emit({
722
+ type: "turn-end",
723
+ text: "are you still there",
724
+ confidence: 0.9,
725
+ });
726
+ await flushAsyncCallbacks();
727
+ expect(turnCalls).toHaveLength(0);
728
+
729
+ // Flux says nothing more. The next silence boundary re-arms the fail-open
730
+ // deadline, so the utterance still commits on the silence path instead of
731
+ // hanging open forever.
732
+ await waitFor(
733
+ () => turnCalls.length === 1,
734
+ "The utterance hung after the stale turn-end was dropped",
735
+ );
736
+ expect(turnCalls[0]?.content).toBe("are you still there");
737
+ expect(turnCalls[0]?.unifiedVerdict).toBe(true);
738
+
739
+ await session.close("client_end");
740
+ }, 10_000);
741
+
742
+ for (const flagOn of [false, true]) {
743
+ test(`barge-in during playback fires from local VAD (flux turn end ${
744
+ flagOn ? "on" : "off"
745
+ })`, async () => {
746
+ const streamTtsAudio = mock(async (ttsOptions: LiveVoiceTtsOptions) => {
747
+ ttsOptions.onAudioChunk({
748
+ type: "tts_audio",
749
+ contentType: "audio/pcm",
750
+ sampleRate: SAMPLE_RATE,
751
+ dataBase64: Buffer.from("assistant audio").toString("base64"),
752
+ });
753
+ return {
754
+ provider: "fish-audio",
755
+ contentType: "audio/pcm",
756
+ sampleRate: SAMPLE_RATE,
757
+ chunks: 1,
758
+ bytes: 15,
759
+ } satisfies LiveVoiceTtsResult;
760
+ });
761
+ const { frames, session, transcribers } = createHarness({
762
+ ...(flagOn ? { fluxConfig: FLUX_ON } : {}),
763
+ silenceThresholdMs: 10_000,
764
+ streamTtsAudio,
765
+ // The leg never completes, so the turn is still in flight when the
766
+ // caller speaks over it.
767
+ startVoiceTurn: async (turnOptions) => {
768
+ turnOptions.callbacks?.assistant_text_delta?.(
769
+ makeTextDelta("Let me tell you a long story."),
770
+ );
771
+ return { turnId: "bridge-turn-1", abort: mock() };
772
+ },
773
+ });
774
+
775
+ await session.start();
776
+ await session.handleBinaryAudio(LOUD_CHUNK);
777
+ await waitFor(() => transcribers.length > 0);
778
+ if (flagOn) {
779
+ transcribers[0]?.endOfTurn("tell me a story");
780
+ } else {
781
+ transcribers[0]?.emit({ type: "final", text: "tell me a story" });
782
+ await session.handleClientFrame({ type: "ptt_release" });
783
+ }
784
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
785
+
786
+ // Local energy detection, not the provider's turn model, is what
787
+ // interrupts the assistant.
788
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
789
+ await waitFor(
790
+ () => frames.some((frame) => frame.type === "speech_started"),
791
+ "Local VAD barge-in never fired",
792
+ );
793
+ await waitFor(() =>
794
+ frames.some((frame) => frame.type === "turn_cancelled"),
795
+ );
796
+
797
+ await session.close("client_end");
798
+ });
799
+ }
800
+ });
801
+
802
+ /**
803
+ * The window between `ready` and the resolved transcriber. `start()` arms the
804
+ * utterance in the background so the client's mic acquisition overlaps the STT
805
+ * handshake, which means a fast caller can speak AND fall silent before the
806
+ * dial answers. These tests seed `services.stt.provider` for real, because the
807
+ * latch is armed from the configured provider before the dial and reconciled
808
+ * with the resolved one after it.
809
+ */
810
+ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
811
+ beforeEach(() => {
812
+ setConfig("services", {
813
+ stt: { provider: "deepgram-flux", providers: {} },
814
+ });
815
+ });
816
+
817
+ afterEach(() => {
818
+ setConfig("services", { stt: { provider: "deepgram", providers: {} } });
819
+ });
820
+
821
+ test("waits for turn-end when the boundary lands before the dial resolves", async () => {
822
+ const gate = createDialGate();
823
+ const { frames, session, transcribers, turnCalls } = createHarness({
824
+ fluxConfig: FLUX_ON,
825
+ silenceThresholdMs: 40,
826
+ resolveGate: gate.promise,
827
+ startVoiceTurn: autoCompletingTurn(),
828
+ });
829
+
830
+ await session.start();
831
+ expect(transcribers).toHaveLength(0);
832
+
833
+ // The caller speaks and falls silent entirely inside the handshake
834
+ // window, so the whole first turn is decided before any provider answers.
835
+ await session.handleBinaryAudio(LOUD_CHUNK);
836
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
837
+
838
+ // The silence boundary passed and committed nothing: the latch was seeded
839
+ // from the configured provider, so Flux owns this boundary like any other.
840
+ expect(transcribers).toHaveLength(0);
841
+ expect(countFrames(frames, "utterance_end")).toBe(0);
842
+ expect(turnCalls).toHaveLength(0);
843
+
844
+ gate.open();
845
+ await waitFor(() => transcribers.length > 0);
846
+ transcribers[0]?.endOfTurn("what is the weather");
847
+
848
+ await waitFor(
849
+ () => turnCalls.length === 1,
850
+ "The first utterance never committed on the Flux end-of-turn",
851
+ );
852
+ // Committed by Flux, not by the silence path: non-speculative dispatch.
853
+ expect(turnCalls[0]?.content).toBe("what is the weather");
854
+ expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
855
+ expect(countFrames(frames, "utterance_end")).toBe(1);
856
+
857
+ await session.close("client_end");
858
+ });
859
+
860
+ test("unwinds the seeded latch when the dial resolves to another provider", async () => {
861
+ const gate = createDialGate();
862
+ const { frames, session, transcribers, turnCalls } = createHarness({
863
+ // Config names Flux, so the latch is seeded, but the dial answers with
864
+ // another provider: a fallback, or a resolver reading a config the
865
+ // session no longer matches. That provider never sends an end-of-turn.
866
+ providerId: "deepgram",
867
+ // Far enough out that the fail-open deadline alone cannot be what
868
+ // releases this utterance inside the test's budget.
869
+ fluxConfig: { turnEnd: { enabled: true }, eotTimeoutMs: 30_000 },
870
+ silenceThresholdMs: 40,
871
+ resolveGate: gate.promise,
872
+ startVoiceTurn: autoCompletingTurn(),
873
+ });
874
+
875
+ await session.start();
876
+ await session.handleBinaryAudio(LOUD_CHUNK);
877
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
878
+ // Deferred under the seeded latch, exactly as a real Flux session would.
879
+ expect(countFrames(frames, "utterance_end")).toBe(0);
880
+
881
+ gate.open();
882
+ await waitFor(() => transcribers.length > 0);
883
+
884
+ // The deferred boundary is replayed as soon as the dial disproves the
885
+ // seed, instead of being stranded on a deadline 30 s away.
886
+ await waitFor(
887
+ () => countFrames(frames, "utterance_end") === 1,
888
+ "The utterance deferred under the seeded latch never released",
889
+ );
890
+ await waitFor(
891
+ () => transcribers[0]?.stopped === true,
892
+ "The utterance never finished releasing",
893
+ );
894
+ // ...and nothing routed through the Flux commit path.
895
+ expect(turnCalls.every((call) => call.unifiedVerdict !== undefined)).toBe(
896
+ true,
897
+ );
898
+
899
+ await session.close("client_end");
900
+ });
901
+
902
+ test("never seeds the latch when the flag is off", async () => {
903
+ const gate = createDialGate();
904
+ const { frames, session, transcribers, turnCalls } = createHarness({
905
+ // No fluxConfig: `turnEnd.enabled` keeps its schema default of false
906
+ // while config still names deepgram-flux as the provider.
907
+ silenceThresholdMs: 40,
908
+ resolveGate: gate.promise,
909
+ startVoiceTurn: autoCompletingTurn(),
910
+ });
911
+
912
+ await session.start();
913
+ await session.handleBinaryAudio(LOUD_CHUNK);
914
+
915
+ // Unchanged from today: the silence boundary releases during the dial.
916
+ await waitFor(
917
+ () => countFrames(frames, "utterance_end") === 1,
918
+ "The flag-off silence boundary stopped releasing during the dial",
919
+ );
920
+ expect(turnCalls).toHaveLength(0);
921
+
922
+ gate.open();
923
+ await waitFor(() => transcribers.length > 0);
924
+ transcribers[0]?.endOfTurn("hello there");
925
+ await flushAsyncCallbacks();
926
+
927
+ // The turn-end commits nothing on top of the boundary that already ran.
928
+ expect(countFrames(frames, "utterance_end")).toBe(1);
929
+
930
+ await session.close("client_end");
931
+ });
932
+ });