@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
@@ -17,7 +17,6 @@ import type {
17
17
  import {
18
18
  getConversationTurnTeardown,
19
19
  resolveProcessingWaitMs,
20
- VOICE_NO_SETUP_FLOWS_RULE,
21
20
  waitForPriorTurnTeardown,
22
21
  } from "../calls/voice-session-bridge.js";
23
22
  import {
@@ -33,6 +32,8 @@ import {
33
32
  } from "../calls/voice-triage-escalate.js";
34
33
  import { getConfig } from "../config/loader.js";
35
34
  import {
35
+ type LiveVoiceFluxConfig,
36
+ LiveVoiceFluxConfigSchema,
36
37
  type LiveVoiceFrontModelConfig,
37
38
  LiveVoiceFrontModelConfigSchema,
38
39
  } from "../config/schemas/live-voice.js";
@@ -49,6 +50,7 @@ import {
49
50
  listProviderIds,
50
51
  pinnedListeningLanguage,
51
52
  supportsBoundary,
53
+ supportsProviderTurnDetection,
52
54
  } from "../providers/speech-to-text/provider-catalog.js";
53
55
  import type { ResolveStreamingTranscriberOptions } from "../providers/speech-to-text/resolve.js";
54
56
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
@@ -71,6 +73,10 @@ import type {
71
73
  import { getSubagentManager } from "../subagent/index.js";
72
74
  import { liveVoiceEndScreen } from "../telemetry/live-voice-funnel.js";
73
75
  import { getToolOwner } from "../tools/registry.js";
76
+ import {
77
+ createReasoningTagFilter,
78
+ type ReasoningTagFilter,
79
+ } from "../tts/reasoning-tag-filter.js";
74
80
  import { extractSpeakableSegments } from "../tts/speakable-segments.js";
75
81
  import { createAbortReason } from "../util/abort-reasons.js";
76
82
  import { hasLocalizedEntry } from "../util/language-subtag.js";
@@ -81,11 +87,6 @@ import {
81
87
  dismissesUiSurface,
82
88
  revealsUiSurface,
83
89
  } from "./activity-label.js";
84
- import {
85
- createVoiceFrontDecider,
86
- type VoiceFrontDecider,
87
- type VoiceProgressTextInput,
88
- } from "./front-decision.js";
89
90
  import { LiveActivityReporter } from "./live-activity-reporter.js";
90
91
  import type {
91
92
  LiveVoiceAudioArchiveResult,
@@ -97,9 +98,9 @@ import {
97
98
  type LiveVoiceMetricsClock,
98
99
  LiveVoiceMetricsCollector,
99
100
  type LiveVoiceMetricsEvent,
100
- type LiveVoiceSpokenAckKind,
101
101
  type LiveVoiceTurnSeedMarks,
102
102
  type VoiceEndpointAction,
103
+ type VoiceEndpointSource,
103
104
  } from "./live-voice-metrics.js";
104
105
  import { persistLiveVoicePhoto } from "./live-voice-photo.js";
105
106
  import {
@@ -113,6 +114,11 @@ import type {
113
114
  LiveVoiceTtsOptions,
114
115
  LiveVoiceTtsResult,
115
116
  } from "./live-voice-tts.js";
117
+ import {
118
+ createVoiceProgressNarrator,
119
+ type VoiceProgressNarrator,
120
+ type VoiceProgressTextInput,
121
+ } from "./progress-narration.js";
116
122
  import {
117
123
  APPROVAL_PENDING_PHRASE_BY_LANGUAGE,
118
124
  approvalPendingPhraseFor,
@@ -161,6 +167,14 @@ const FINALIZE_GRACE_MS = 1_000;
161
167
  // cancellation cannot kill a reply mid-sentence. Mirrors the
162
168
  // liveVoice.vad.bargeInMinSpeechMs schema default; 0 disables the guard for
163
169
  // instant barge-in.
170
+ //
171
+ // Local energy detection owns barge-in in every mode, including when a
172
+ // turn-detecting provider owns the turn boundary: an interrupt during TTS has
173
+ // to feel instant, and no provider roundtrip beats a local gate on the audio
174
+ // already in hand. The echo-adaptive part of this guard also has to stay
175
+ // upstream of the provider, which hears only the microphone: it cannot tell
176
+ // our own TTS bleeding through imperfect echo cancellation from the caller,
177
+ // and would report a user turn nobody took.
164
178
  const DEFAULT_BARGE_IN_MIN_SPEECH_MS = 250;
165
179
  // The playback echo gate learns microphone energy while assistant audio is
166
180
  // expected at the speaker. Input must rise above the learned level by this
@@ -203,6 +217,12 @@ const BARGE_IN_GAP_TOLERANCE_MS = 200;
203
217
  // (1 / (1 + ratio) ≈ 20%): once the run is mostly silence it resets, so genuine
204
218
  // choppy speech still lands but periodic noise cannot accumulate into one.
205
219
  const BARGE_IN_MAX_TOLERATED_SILENCE_RATIO = 4;
220
+ // Slack added to the configured end-of-turn timeout before the session stops
221
+ // waiting for an event that is not coming and falls the utterance back onto
222
+ // the silence-boundary path. A turn-detecting provider force-ends its own turn
223
+ // at that timeout, so anything past it plus a frame's network and parse hop
224
+ // means the stream is gone, not slow.
225
+ const PROVIDER_TURN_END_FALLBACK_MARGIN_MS = 1_000;
206
226
  // At most this many TTS segment jobs are open (provider stream started,
207
227
  // frames not yet fully emitted) per turn: the emitting job plus one
208
228
  // prefetching job. The prefetch buffers its chunks in memory until promoted;
@@ -328,19 +348,24 @@ export interface LiveVoiceSessionOptions {
328
348
  */
329
349
  finalizeGraceMs?: number;
330
350
  /**
331
- * Front-model presence tuning (semantic endpointing + spoken acks). The
351
+ * Voice front-door endpointing and progress narration tuning. The
332
352
  * production factory seeds this from `liveVoice.frontModel` config when
333
353
  * unset; absent fields fall back to the schema defaults (the constructor
334
354
  * schema-parses the partial into a complete config).
335
355
  */
336
356
  frontModelConfig?: Partial<LiveVoiceFrontModelConfig>;
337
357
  /**
338
- * Front-model phrasing service: spoken acks and progress narration. The
339
- * production factory constructs it from `liveVoice.frontModel` config when
340
- * unset; `null` disables front-model LLM calls, so no ack is ever spoken
341
- * and narration falls back to its static phrases.
358
+ * Deepgram Flux turn-detection tuning. The production factory seeds this
359
+ * from `liveVoice.flux` config when unset; absent fields fall back to the
360
+ * schema defaults, which leave `turnEnd.enabled` false.
342
361
  */
343
- frontDecider?: VoiceFrontDecider | null;
362
+ fluxConfig?: Partial<LiveVoiceFluxConfig>;
363
+ /**
364
+ * Progress narration service. The production factory constructs it from
365
+ * `liveVoice.frontModel.progress` config when unset. `null` disables
366
+ * generated narration, so progress updates use their static fallback.
367
+ */
368
+ progressNarrator?: VoiceProgressNarrator | null;
344
369
  /**
345
370
  * Spawns the background continuation for a barged-in turn. The factory wires
346
371
  * the real SubagentManager-backed implementation; tests inject a stub.
@@ -418,6 +443,22 @@ interface UtteranceCycle {
418
443
  // Consecutive semantic-endpointing "hold" extensions this utterance has
419
444
  // consumed, bounded by `endpointMaxExtensions`.
420
445
  endpointExtensionCount: number;
446
+ // The provider's end-of-turn never arrived for this cycle, so it fell back
447
+ // to the silence-boundary path and stays there: a late event must not
448
+ // commit a boundary the fallback already owns.
449
+ providerTurnEndTimedOut: boolean;
450
+ // `vadSpeechGeneration` as of the local silence boundary that handed this
451
+ // cycle to the provider, or null while no boundary has fired yet (a turn
452
+ // model routinely beats the trailing-silence countdown, and that fast commit
453
+ // is the point).
454
+ // The staleness signal of last resort: it stands in for the provider's own
455
+ // turn numbering when the provider sends none (see isStaleProviderTurnEnd).
456
+ turnBoundaryGeneration: number | null;
457
+ // Index of the newest provider turn opened in this cycle, from the turn
458
+ // model's own numbering, or null when the provider does not number its
459
+ // turns. An end-of-turn closing an older index describes a turn the
460
+ // provider has already superseded (see isStaleProviderTurnEnd).
461
+ openProviderTurnIndex: number | null;
421
462
  // The transcript the most recent hold verdict judged (unified front-door).
422
463
  // A final segment arriving during the extension window that extends this
423
464
  // text replays the boundary immediately — the hold was judged on stale
@@ -668,27 +709,11 @@ interface ActiveAssistantTurn {
668
709
  // context. A bump since then means a stop/interrupt/supersede deliberately
669
710
  // dropped the continuation, so a rollback must not resurrect it.
670
711
  pendingContextStopGeneration: number;
671
- // The agent run started a definitive tool use this turn — tool use implies
672
- // a guaranteed-slow turn, so acknowledgment logic can key off this.
673
- toolUseStarted: boolean;
674
- // The model's first streamed delta reached this session — the spoken-ack
675
- // timer is moot once real output is flowing.
676
- firstDeltaSeen: boolean;
677
712
  // Counts assistant text deltas seen this turn. A narration generation
678
713
  // captures it at launch and discards its result if it moved: text the model
679
714
  // produced mid-generation makes the narration stale, and proves the model
680
715
  // is alive — which is exactly what narration exists to paper over.
681
716
  deltaEpoch: number;
682
- // A spoken ack was enqueued this turn. Every ack trigger shares this
683
- // one-per-turn budget so a slow turn never stacks fillers.
684
- ackSpoken: boolean;
685
- // An ack generation is awaiting the decider. While true, the ack has not
686
- // yet stamped `lastFloorHolderAtMs`, so narration must treat it as a
687
- // floor-holder-in-waiting and stand down —
688
- // otherwise a progress phrase could land back-to-back with the ack.
689
- ackGenerationPending: boolean;
690
- // Pending slow-first-delta ack timer; null once cleared or fired.
691
- ackTimer: ReturnType<typeof setTimeout> | null;
692
717
  // Triage-and-escalate (Voice Mode): the front-door leg gave the escalate
693
718
  // verdict and the strong "escalated" leg has taken over this same turn.
694
719
  // Guards the
@@ -696,6 +721,10 @@ interface ActiveAssistantTurn {
696
721
  // the hand-off idempotent.
697
722
  escalationHandedOff: boolean;
698
723
  ttsBuffer: string;
724
+ // Strips inline <think> reasoning spans from the spoken stream. Stateful
725
+ // per turn because spans and tags cross delta boundaries; display frames
726
+ // keep the raw text.
727
+ ttsReasoningFilter: ReasoningTagFilter;
699
728
  // A non-empty speakable segment reached the TTS queue — gates the eager
700
729
  // first-segment flush that trades clause quality for speech onset.
701
730
  ttsSegmentEnqueued: boolean;
@@ -783,6 +812,20 @@ const LIVE_VOICE_CONTROL_PROMPT_BASE =
783
812
  const LIVE_VOICE_SCREEN_REVEAL_TEACHING =
784
813
  "The call renders as a full-screen overlay covering the app. Whenever you put something on screen, the overlay minimizes by itself as soon as you finish speaking, and the user is looking at what you made. So speak as if you are showing it to them right now (for example, close with something like: take a look), and never say you cannot show it, that this is a voice call, or that they should check it later. Never emit bracketed markers of any kind. ";
785
814
 
815
+ // The setup-flow case, spelled out because it is the one the model gets wrong
816
+ // on its own: connecting an account reads as something a call cannot do, so it
817
+ // declines and offers to do it later in text, which is the exact thing the base
818
+ // prompt above forbids.
819
+ //
820
+ // It can do it. The connect card is a ui surface like any other, so showing it
821
+ // minimizes the room on the same latch (`revealsUiSurface`), and the user is at
822
+ // the screen the browser window opens on. Appended alongside the screen-reveal
823
+ // teaching, to the same legs, for the same reason: the front-door leg is
824
+ // toolless, so a setup flow is not its to run, and its capability digest
825
+ // already tells it to escalate anything needing a tool rather than refuse.
826
+ const LIVE_VOICE_SETUP_FLOW_TEACHING =
827
+ "This includes connecting accounts. If a task needs an account connected or a sign-in completed, put the connection up on screen and let the user do it now. Do not decline it, and do not defer it to text chat. Say what you are connecting and that it is in front of them. ";
828
+
786
829
  // System-level guidance appended to a barge-in turn's control prompt so the
787
830
  // model treats the new utterance as a continuation of the request it was cut
788
831
  // off answering, rather than a fresh follow-up. Reaches the model only; it is
@@ -839,21 +882,21 @@ function buildLiveDeliveryNote(request: string, answer: string): string {
839
882
  return `The user interrupted you earlier, and in the background you finished ${what}. The call is still live and nobody is speaking, so tell them briefly that it is done and give them the result. Do not re-run any tool calls; the work is already complete, and do not repeat it verbatim if it no longer fits. What you produced was:\n\n${answer}`;
840
883
  }
841
884
 
842
- // Assemble a leg's model-facing control prompt: the base live-voice rules,
843
- // the screen-reveal teaching (withheld from the front-door leg, see
844
- // LIVE_VOICE_SCREEN_REVEAL_TEACHING), the shared no-setup-flows rule, plus
845
- // any pending barge-in merge context, completed-continuation context, and/or
846
- // the announcement instruction. A turn can carry several (a barge-in follow-up
847
- // that also has a continuation result waiting); the notes are model-only and
848
- // never render as user bubbles.
885
+ // Assemble a leg's model-facing control prompt: the base live-voice rules, the
886
+ // screen-reveal and setup-flow teaching (both withheld from the front-door leg,
887
+ // see LIVE_VOICE_SCREEN_REVEAL_TEACHING), plus any pending barge-in merge
888
+ // context, completed-continuation context, and/or the announcement instruction.
889
+ // A turn can carry several (a barge-in follow-up that also has a continuation
890
+ // result waiting); the notes are model-only and never render as user bubbles.
849
891
  function buildVoiceControlPrompt(
850
892
  turn: ActiveAssistantTurn,
851
893
  leg: { frontDoor?: boolean },
852
894
  ): string {
853
895
  let prompt =
854
896
  LIVE_VOICE_CONTROL_PROMPT_BASE +
855
- (leg.frontDoor === true ? "" : LIVE_VOICE_SCREEN_REVEAL_TEACHING) +
856
- VOICE_NO_SETUP_FLOWS_RULE;
897
+ (leg.frontDoor === true
898
+ ? ""
899
+ : LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING);
857
900
  if (turn.language !== undefined) {
858
901
  prompt = `${prompt}\n\nThe caller has been speaking the language with code "${turn.language}" this turn. Reply in that language unless they clearly switch to another.`;
859
902
  }
@@ -904,6 +947,9 @@ function createUtteranceCycle(): UtteranceCycle {
904
947
  finalTranscriptSegments: [],
905
948
  latestPartialText: null,
906
949
  endpointExtensionCount: 0,
950
+ providerTurnEndTimedOut: false,
951
+ turnBoundaryGeneration: null,
952
+ openProviderTurnIndex: null,
907
953
  heldSpeculativeContent: null,
908
954
  languageTally: new Map(),
909
955
  latestPartialLanguages: null,
@@ -1200,13 +1246,38 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1200
1246
  private readonly finalizeGraceMs: number;
1201
1247
  // Rotates through the progress fallback phrases across the session's turns.
1202
1248
  private progressPhraseCounter = 0;
1203
- // Front-model service: phrases spoken acks and progress narration; null
1204
- // disables both (the turn then holds the floor with silence).
1205
- private readonly frontDecider: VoiceFrontDecider | null;
1249
+ // Progress-only phrasing service. It never makes routing decisions and
1250
+ // never emits an answer.
1251
+ private readonly progressNarrator: VoiceProgressNarrator | null;
1206
1252
  // Complete front-model tunables: the constructor schema-parses the partial
1207
1253
  // option once, so every field carries its `liveVoice.frontModel` schema
1208
1254
  // default when unset.
1209
1255
  private readonly frontModelConfig: LiveVoiceFrontModelConfig;
1256
+ // Complete Flux tunables (the constructor schema-parses the partial option
1257
+ // once, so every field carries its `liveVoice.flux` schema default).
1258
+ private readonly fluxConfig: LiveVoiceFluxConfig;
1259
+ /**
1260
+ * Per-session latch: the provider's committed end-of-turn owns the turn
1261
+ * boundary instead of the silence boundary's front-door hold verdict. Set
1262
+ * when the config flag is on AND the resolved streaming provider declares
1263
+ * `turnDetection: "provider"` in the STT catalog AND the session runs server
1264
+ * VAD. Push-to-talk is excluded deliberately: there the client's release IS
1265
+ * the boundary, and answering while the caller still holds the button is not
1266
+ * turn detection, it is a bug. False leaves every other code path exactly as
1267
+ * it is, with no provider turn detection in the picture.
1268
+ */
1269
+ private providerTurnEndActive = false;
1270
+ // Wall-clock of the newest above-gate audio chunk, tracked in every
1271
+ // server-VAD session. It is the local VAD's speech-stop mark: the one anchor
1272
+ // the reported end-of-turn latency is measured from whichever decider
1273
+ // commits the turn, and the anchor for the provider fallback deadline. Null
1274
+ // in push-to-talk, where no local VAD runs.
1275
+ private localSpeechStopAtMs: number | null = null;
1276
+ // Fail-open deadline for a provider end-of-turn that never arrives. On
1277
+ // expiry the utterance falls back to the silence-boundary path, so a dead
1278
+ // provider stream degrades to the silence-path behavior instead of a hung
1279
+ // turn.
1280
+ private providerTurnEndTimer: ReturnType<typeof setTimeout> | null = null;
1210
1281
  // Effective trailing-silence threshold, mirroring the detector's private
1211
1282
  // copy (constructor seed + update_config), reported to the endpoint decider.
1212
1283
  private silenceThresholdMs: number;
@@ -1276,10 +1347,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1276
1347
  this.echoDrainSlackMs =
1277
1348
  options.echoDrainSlackMs ?? DEFAULT_ECHO_DRAIN_SLACK_MS;
1278
1349
  this.finalizeGraceMs = options.finalizeGraceMs ?? FINALIZE_GRACE_MS;
1279
- this.frontDecider = options.frontDecider ?? null;
1350
+ this.progressNarrator = options.progressNarrator ?? null;
1280
1351
  this.frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
1281
1352
  options.frontModelConfig ?? {},
1282
1353
  );
1354
+ this.fluxConfig = LiveVoiceFluxConfigSchema.parse(options.fluxConfig ?? {});
1283
1355
  const turnDetectorConfig: TurnDetectorConfig = {
1284
1356
  ...(options.turnDetectorConfig ?? {}),
1285
1357
  ...(context.startFrame.silenceThresholdMs !== undefined
@@ -1455,6 +1527,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1455
1527
  this.liveActivityReporter.end();
1456
1528
  this.turnDetector?.dispose();
1457
1529
  this.clearEndpointExtensionTimer();
1530
+ this.clearProviderTurnEndTimer();
1458
1531
  // There is no longer anyone on the call to speak to, so a queued
1459
1532
  // announcement cannot be spoken — but the answer behind it is finished
1460
1533
  // work the user asked for, so it takes the same conversation route a
@@ -1506,11 +1579,28 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1506
1579
  }
1507
1580
 
1508
1581
  try {
1509
- // One language snapshot serves both the dial and the re-arm
1510
- // comparison: passing it to the resolver keeps the stream's actual
1511
- // language and the recorded sharedTranscriberLanguage identical even
1512
- // when config changes while the resolver awaits credentials.
1513
- const sttLanguage = getConfig().services.stt.language;
1582
+ // One config snapshot serves the dial, the re-arm comparison and the
1583
+ // latch seed below: passing the language to the resolver keeps the
1584
+ // stream's actual language and the recorded sharedTranscriberLanguage
1585
+ // identical even when config changes while the resolver awaits
1586
+ // credentials.
1587
+ const stt = getConfig().services.stt;
1588
+ const sttLanguage = stt.language;
1589
+ // Seed the latch from the CONFIGURED provider before the dial, not
1590
+ // only from the resolved one after it. `start()` sends `ready` without
1591
+ // waiting on this resolve, so the caller can speak and close an entire
1592
+ // silence boundary while the provider handshake is still in flight. A
1593
+ // latch still on its default `false` at that boundary hands the first
1594
+ // turn of the session to the silence path and then ignores the
1595
+ // provider end-of-turn that follows, which is invisible from the
1596
+ // outside: the session looks like a turn-detecting session while its
1597
+ // opening turn is not one. The resolved provider reconciles this guess
1598
+ // below.
1599
+ this.setProviderTurnEndActive(
1600
+ this.fluxConfig.turnEnd.enabled &&
1601
+ supportsProviderTurnDetection(stt.provider as SttProviderId) &&
1602
+ this.turnDetector !== null,
1603
+ );
1514
1604
  const transcriber = await this.resolveTranscriber({
1515
1605
  sampleRate: this.context.startFrame.audio.sampleRate,
1516
1606
  ...(sttLanguage ? { language: sttLanguage } : {}),
@@ -1522,6 +1612,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1522
1612
  }
1523
1613
 
1524
1614
  if (!transcriber) {
1615
+ // No stream answered, so no end-of-turn ever will: the guess above
1616
+ // must not outlive the dial that disproved it.
1617
+ this.setProviderTurnEndActive(false);
1525
1618
  return {
1526
1619
  status: "unavailable",
1527
1620
  message: unavailableTranscriberMessage(),
@@ -1530,6 +1623,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1530
1623
 
1531
1624
  utterance.transcriber = transcriber;
1532
1625
  utterance.dialedSttProvider = transcriber.providerId;
1626
+ // Reconcile the pre-dial guess with the provider that actually
1627
+ // answered. The resolver reads the provider from config, which cannot
1628
+ // change under a live session, so this normally confirms the guess; it
1629
+ // clears it when the dial fell back to another provider or resolved
1630
+ // one the config did not name.
1631
+ this.setProviderTurnEndActive(
1632
+ this.fluxConfig.turnEnd.enabled &&
1633
+ supportsProviderTurnDetection(transcriber.providerId) &&
1634
+ this.turnDetector !== null,
1635
+ );
1533
1636
  if (
1534
1637
  this.turnDetector &&
1535
1638
  typeof transcriber.finalizeUtterance === "function"
@@ -1563,6 +1666,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1563
1666
  if (this.isUtteranceStale(utterance)) {
1564
1667
  return { status: "stale" };
1565
1668
  }
1669
+ // The dial threw, so the pre-dial guess is disproved the same way the
1670
+ // unavailable case disproves it: nothing will send an end-of-turn.
1671
+ this.setProviderTurnEndActive(false);
1566
1672
  return {
1567
1673
  status: "error",
1568
1674
  message: `Live voice transcription could not be started: ${errorMessage(
@@ -1690,6 +1796,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1690
1796
 
1691
1797
  this.state = "failed";
1692
1798
  this.clearEndpointExtensionTimer();
1799
+ this.clearProviderTurnEndTimer();
1693
1800
  await this.sendFrame({
1694
1801
  type: "error",
1695
1802
  code:
@@ -1763,6 +1870,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1763
1870
  const hasSpeech = energyClassification === "speech";
1764
1871
  detector.onMediaChunk(hasSpeech);
1765
1872
  this.trackBargeInGuard(energyClassification, chunk);
1873
+ if (hasSpeech) {
1874
+ this.localSpeechStopAtMs = Date.now();
1875
+ }
1766
1876
 
1767
1877
  // Playback echo is neither user audio nor useful pre-roll. Dropping it
1768
1878
  // prevents the assistant's reply from reaching transcription as a ghost
@@ -2163,6 +2273,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2163
2273
  // utterance keeps accumulating and the detector fires a fresh turn-end.
2164
2274
  this.vadSpeechGeneration += 1;
2165
2275
  this.clearEndpointExtensionTimer();
2276
+ // ...and so is a fallback deadline armed for the boundary the caller just
2277
+ // spoke through. The next silence boundary arms a fresh one.
2278
+ this.clearProviderTurnEndTimer();
2166
2279
 
2167
2280
  // Speech resumed while a speculative leg was awaiting its verdict: the
2168
2281
  // pause was mid-thought after all. Discard silently (no frames were ever
@@ -2319,7 +2432,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2319
2432
  // Tagged reason: provider catch-sites classify untagged caller aborts as
2320
2433
  // retryable transport failures (ERROR log + futile retry against the
2321
2434
  // aborted signal). This signal reaches the brain leg and any in-flight
2322
- // ack generation.
2435
+ // progress narration.
2323
2436
  turn.abortController.abort(
2324
2437
  createAbortReason("voice_session_aborted", "live-voice-barge-in"),
2325
2438
  );
@@ -3072,8 +3185,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3072
3185
  this.pendingBargeIn = null;
3073
3186
  if (reason === "max-duration") {
3074
3187
  // A max-duration boundary always releases: drop any pending hold
3075
- // replay so it cannot re-fire a boundary this one already owns.
3188
+ // replay (or provider fallback deadline) so it cannot re-fire a
3189
+ // boundary this one already owns.
3076
3190
  this.clearEndpointExtensionTimer();
3191
+ this.clearProviderTurnEndTimer();
3077
3192
  }
3078
3193
  const utterance = this.currentUtterance;
3079
3194
  if (!utterance || utterance.released || utterance.completed) {
@@ -3095,6 +3210,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3095
3210
  // release (ptt_release forced the boundary) is the user saying "answer
3096
3211
  // now" — never second-guess it.
3097
3212
  if (reason === "silence" && !manualRelease) {
3213
+ // The provider owns this boundary: its end-of-turn commits the
3214
+ // utterance, so the silence timer only arms the fail-open deadline. An
3215
+ // utterance whose deadline already elapsed falls through to the hold
3216
+ // path below, which is the whole point of the fallback.
3217
+ if (this.providerTurnEndActive && !utterance.providerTurnEndTimedOut) {
3218
+ // Stamp the speech run this boundary closed. This is the staleness
3219
+ // signal of last resort, used only for a provider that does not
3220
+ // number its turns: where the provider's own turn index is on the
3221
+ // wire it answers the question directly (see
3222
+ // isStaleProviderTurnEnd).
3223
+ utterance.turnBoundaryGeneration = this.vadSpeechGeneration;
3224
+ this.armProviderTurnEndFallbackTimer(utterance);
3225
+ return;
3226
+ }
3098
3227
  if (await this.launchSpeculativeAssistantTurn(utterance)) {
3099
3228
  return;
3100
3229
  }
@@ -3125,6 +3254,217 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3125
3254
  }, this.frontModelConfig.endpointExtensionMs);
3126
3255
  }
3127
3256
 
3257
+ // Arms the fail-open deadline for a provider end-of-turn. A turn-detecting
3258
+ // provider force-ends its own turn after `eotTimeoutMs` of silence, so once
3259
+ // that budget plus a margin has passed since the caller stopped speaking, no
3260
+ // end-of-turn is coming and the utterance replays this boundary on the
3261
+ // silence path.
3262
+ // `waitMsOverride` collapses that wait when the caller already knows no
3263
+ // end-of-turn is coming (see setProviderTurnEndActive).
3264
+ private armProviderTurnEndFallbackTimer(
3265
+ utterance: UtteranceCycle,
3266
+ waitMsOverride?: number,
3267
+ ): void {
3268
+ this.clearProviderTurnEndTimer();
3269
+ const budgetMs =
3270
+ this.fluxConfig.eotTimeoutMs + PROVIDER_TURN_END_FALLBACK_MARGIN_MS;
3271
+ const waitMs =
3272
+ waitMsOverride ?? Math.max(0, budgetMs - this.msSinceLocalSpeechStop());
3273
+ this.providerTurnEndTimer = setTimeout(() => {
3274
+ this.providerTurnEndTimer = null;
3275
+ if (
3276
+ this.currentUtterance !== utterance ||
3277
+ utterance.released ||
3278
+ utterance.completed ||
3279
+ utterance.assistantTurnStarted ||
3280
+ // Speech resumed (the detector owns the next boundary). Onset also
3281
+ // clears this timer, so this is a belt to that suspender.
3282
+ this.turnDetector?.isActive
3283
+ ) {
3284
+ return;
3285
+ }
3286
+ utterance.providerTurnEndTimedOut = true;
3287
+ log.warn(
3288
+ { budgetMs, waitMs },
3289
+ "No provider end-of-turn is coming; falling back to the silence boundary for this utterance",
3290
+ );
3291
+ this.handleVadUtteranceEnd("silence");
3292
+ }, waitMs);
3293
+ }
3294
+
3295
+ /**
3296
+ * Flips the provider end-of-turn latch, unwinding an optimistic arm.
3297
+ *
3298
+ * `beginUtterance` arms the latch from the configured provider before the
3299
+ * dial resolves, so a silence boundary can already have deferred to the
3300
+ * provider by the time the resolved one says otherwise. That deferred
3301
+ * boundary is parked on the fail-open deadline, a whole end-of-turn budget
3302
+ * away, waiting for an event that will now never arrive. Collapse the wait to
3303
+ * zero rather than burn the budget: the deadline body re-checks the cycle
3304
+ * and replays the silence boundary, which with the latch down takes the
3305
+ * ordinary hold path. Replaying through the deadline instead of calling
3306
+ * `handleVadUtteranceEnd` directly keeps the release off the arming
3307
+ * caller's stack, which is still mid-dial.
3308
+ *
3309
+ * A cleared latch with no deadline armed needs no unwind: either no
3310
+ * boundary ever deferred, or the caller resumed speaking and
3311
+ * `handleVadSpeechStart` already dropped the deadline, leaving the detector
3312
+ * owning the next boundary. The cycle's `turnBoundaryGeneration` stamp is
3313
+ * left as it is: `isStaleProviderTurnEnd` is only ever consulted from
3314
+ * `handleProviderTurnEnd`, which returns immediately once the latch is down.
3315
+ */
3316
+ private setProviderTurnEndActive(active: boolean): void {
3317
+ if (active === this.providerTurnEndActive) {
3318
+ return;
3319
+ }
3320
+ this.providerTurnEndActive = active;
3321
+ if (active || this.providerTurnEndTimer === null) {
3322
+ return;
3323
+ }
3324
+ const utterance = this.currentUtterance;
3325
+ this.clearProviderTurnEndTimer();
3326
+ if (utterance && !utterance.released && !utterance.completed) {
3327
+ this.armProviderTurnEndFallbackTimer(utterance, 0);
3328
+ }
3329
+ }
3330
+
3331
+ /**
3332
+ * Record the provider turn a cycle is currently inside. Providers without
3333
+ * turn numbering send no index, which leaves the cycle on the local speech
3334
+ * generation as its only staleness signal (see isStaleProviderTurnEnd).
3335
+ */
3336
+ private recordProviderTurnStart(
3337
+ utterance: UtteranceCycle,
3338
+ turnIndex: number | undefined,
3339
+ ): void {
3340
+ if (turnIndex === undefined) {
3341
+ return;
3342
+ }
3343
+ utterance.openProviderTurnIndex = turnIndex;
3344
+ }
3345
+
3346
+ /**
3347
+ * A provider end-of-turn that lost its race with the caller's next breath:
3348
+ * it describes speech the caller has already spoken past. Acting on one
3349
+ * would send utterance_end and stop the transcriber while local VAD is
3350
+ * still routing the resumed speech into this same cycle, cutting off the
3351
+ * next words or folding them into the wrong turn.
3352
+ *
3353
+ * The provider's own turn numbering answers this directly and is preferred
3354
+ * wherever it is available: the cycle records the newest turn the provider
3355
+ * has opened, so an end-of-turn for an older index closes a turn the
3356
+ * provider itself has already superseded. An end-of-turn for the turn still
3357
+ * in progress is never stale, however many times the caller drew breath
3358
+ * inside it: the mid-thought pause is exactly the case a provider's turn
3359
+ * model exists to judge, and its verdict covers the resumed speech too.
3360
+ *
3361
+ * With no turn numbering the local speech generation is the only signal
3362
+ * left. The silence boundary stamps the cycle with the generation it
3363
+ * closed and `handleVadSpeechStart` bumps that generation when the caller
3364
+ * resumes, so a turn-end arriving on a newer generation is treated as
3365
+ * stale. That is conservative: it also drops the fast end-of-turn this
3366
+ * feature exists for, which the turn index would have committed. Before
3367
+ * any boundary has fired the stamp is null and nothing is stale.
3368
+ */
3369
+ private isStaleProviderTurnEnd(
3370
+ utterance: UtteranceCycle,
3371
+ turnIndex: number | undefined,
3372
+ ): boolean {
3373
+ if (turnIndex !== undefined && utterance.openProviderTurnIndex !== null) {
3374
+ return turnIndex < utterance.openProviderTurnIndex;
3375
+ }
3376
+ return (
3377
+ utterance.turnBoundaryGeneration !== null &&
3378
+ utterance.turnBoundaryGeneration !== this.vadSpeechGeneration
3379
+ );
3380
+ }
3381
+
3382
+ private clearProviderTurnEndTimer(): void {
3383
+ if (this.providerTurnEndTimer !== null) {
3384
+ clearTimeout(this.providerTurnEndTimer);
3385
+ this.providerTurnEndTimer = null;
3386
+ }
3387
+ }
3388
+
3389
+ // Time since the local VAD last heard above-gate audio: the speech-stop
3390
+ // mark both the fallback deadline and the reported end-of-turn latency are
3391
+ // measured from. Zero when no speech has been heard at all.
3392
+ private msSinceLocalSpeechStop(): number {
3393
+ if (this.localSpeechStopAtMs === null) {
3394
+ return 0;
3395
+ }
3396
+ return Math.max(0, Date.now() - this.localSpeechStopAtMs);
3397
+ }
3398
+
3399
+ /**
3400
+ * The provider committed an end of turn: the caller has finished, so the utterance
3401
+ * commits now instead of waiting out the trailing-silence timer and asking
3402
+ * the front door whether the pause was mid-thought. This runs the ordinary
3403
+ * post-release path (utterance_end, then release), so the front-door leg it
3404
+ * dispatches is non-speculative: `speculativeHoldAllowed` is false, its
3405
+ * decision rule is built with `includeHold: false`, and the model is told
3406
+ * the caller has finished. Escalation and progress narration are untouched;
3407
+ * only the hold verdict is bypassed.
3408
+ */
3409
+ private async handleProviderTurnEnd(
3410
+ utterance: UtteranceCycle,
3411
+ turnIndex: number | undefined,
3412
+ ): Promise<void> {
3413
+ if (
3414
+ !this.providerTurnEndActive ||
3415
+ this.currentUtterance !== utterance ||
3416
+ utterance.released ||
3417
+ utterance.completed ||
3418
+ utterance.assistantTurnStarted ||
3419
+ // The fallback already took this utterance back to the silence path.
3420
+ utterance.providerTurnEndTimedOut
3421
+ ) {
3422
+ return;
3423
+ }
3424
+ if (this.isStaleProviderTurnEnd(utterance, turnIndex)) {
3425
+ // At `info`, not `debug`: a drop is rare and is the only signal of the
3426
+ // one known latency-outlier mode, so an operator measuring the two
3427
+ // paths has to be able to see it at the default log level.
3428
+ log.info(
3429
+ {
3430
+ turnIndex,
3431
+ openTurnIndex: utterance.openProviderTurnIndex,
3432
+ boundaryGeneration: utterance.turnBoundaryGeneration,
3433
+ speechGeneration: this.vadSpeechGeneration,
3434
+ },
3435
+ "Dropping a stale provider end-of-turn: the caller resumed speaking past the boundary it closed",
3436
+ );
3437
+ // The cycle stays open and local VAD keeps routing the resumed speech
3438
+ // into it. The detector's next silence boundary re-stamps the
3439
+ // generation and re-arms the fail-open deadline, so the turn still
3440
+ // commits: on the end-of-turn for the resumed speech, or on that
3441
+ // deadline. Re-arm here only if the detector has somehow already gone
3442
+ // idle, so the deadline handleVadSpeechStart cleared can never strand
3443
+ // the cycle with nothing left to close it.
3444
+ if (this.turnDetector?.isActive !== true) {
3445
+ utterance.turnBoundaryGeneration = this.vadSpeechGeneration;
3446
+ this.armProviderTurnEndFallbackTimer(utterance);
3447
+ }
3448
+ return;
3449
+ }
3450
+ this.clearProviderTurnEndTimer();
3451
+ this.markEndpointDecision(
3452
+ utterance,
3453
+ "release",
3454
+ this.msSinceLocalSpeechStop(),
3455
+ "provider",
3456
+ );
3457
+ await this.sendFrame({ type: "utterance_end", reason: "silence" });
3458
+ await this.releaseUtterance();
3459
+ // Leave the local detector idle, which is where every other commit path
3460
+ // leaves it. A provider can close a turn while the trailing-silence
3461
+ // countdown is still running, and barge-in fires from the detector's speech
3462
+ // ONSET: a detector left mid-turn reports no onset, so the caller could
3463
+ // not interrupt the reply they just triggered. The forced boundary
3464
+ // reaches an already-released utterance and returns.
3465
+ this.turnDetector?.forceEnd();
3466
+ }
3467
+
3128
3468
  /**
3129
3469
  * Unified front-door: dispatch the assistant turn speculatively at the
3130
3470
  * silence boundary, judging the transcript accumulated so far — the same
@@ -3204,13 +3544,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3204
3544
  if (!alreadyReleased) {
3205
3545
  void this.releaseUtterance();
3206
3546
  }
3207
- if (this.streamTtsAudio) {
3208
- this.armAckTimer(turn);
3209
- }
3210
3547
  if (
3211
3548
  this.frontModelConfig.progress.enabled &&
3212
3549
  this.streamTtsAudio &&
3213
- this.frontDecider
3550
+ this.progressNarrator
3214
3551
  ) {
3215
3552
  this.armProgressIdleTimer(turn);
3216
3553
  }
@@ -3344,6 +3681,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3344
3681
  !utterance.completed
3345
3682
  ) {
3346
3683
  this.clearEndpointExtensionTimer();
3684
+ this.clearProviderTurnEndTimer();
3347
3685
  await this.sendFrame({ type: "utterance_end", reason: "silence" });
3348
3686
  }
3349
3687
  await this.releaseUtterance();
@@ -3358,6 +3696,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3358
3696
  if (utterance.phase === "transcriber_closed") {
3359
3697
  utterance.released = true;
3360
3698
  this.markUtteranceReleased(utterance);
3699
+ this.markEndpointCommit(utterance);
3361
3700
  await this.startAssistantTurnIfReady();
3362
3701
  await this.drainOutboundFrames();
3363
3702
  return;
@@ -3369,6 +3708,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3369
3708
 
3370
3709
  utterance.released = true;
3371
3710
  this.markUtteranceReleased(utterance);
3711
+ this.markEndpointCommit(utterance);
3372
3712
 
3373
3713
  if (utterance.phase === "pending") {
3374
3714
  // The transcriber is still starting; beginUtterance completes the release.
@@ -3546,6 +3886,24 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3546
3886
  // Per-cycle transcribers are torn down with stop(); the finalize
3547
3887
  // completion signal has no cycle to advance here.
3548
3888
  return;
3889
+ case "turn-start":
3890
+ // Barge-in is deliberately untouched: local VAD still owns it,
3891
+ // because a provider roundtrip cannot beat a local energy gate on an
3892
+ // interrupt during playback (see DEFAULT_BARGE_IN_MIN_SPEECH_MS). The
3893
+ // index is recorded so a later end-of-turn can be told apart from one
3894
+ // this turn superseded (see isStaleProviderTurnEnd).
3895
+ this.recordProviderTurnStart(utterance, event.turnIndex);
3896
+ return;
3897
+ case "eager-turn-end":
3898
+ case "turn-resumed":
3899
+ // Speculative end-of-turn stays off (the config leaves
3900
+ // `eagerEotThreshold` unset, so Deepgram never emits these). Wiring
3901
+ // them onto the speculative dispatch machinery is a deferred
3902
+ // follow-up.
3903
+ return;
3904
+ case "turn-end":
3905
+ await this.handleProviderTurnEnd(utterance, event.turnIndex);
3906
+ return;
3549
3907
  case "error":
3550
3908
  await this.sendTranscriberErrorFrame(event);
3551
3909
  return;
@@ -3567,6 +3925,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3567
3925
  }
3568
3926
  await this.startAssistantTurnIfReady();
3569
3927
  return;
3928
+ default: {
3929
+ const _exhaustive: never = event;
3930
+ return;
3931
+ }
3570
3932
  }
3571
3933
  }
3572
3934
 
@@ -3670,6 +4032,34 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3670
4032
  await this.startAssistantTurnIfReady();
3671
4033
  return;
3672
4034
  }
4035
+ case "turn-start": {
4036
+ // Barge-in is deliberately untouched: local VAD still owns it,
4037
+ // because a provider roundtrip cannot beat a local energy gate on an
4038
+ // interrupt during playback (see DEFAULT_BARGE_IN_MIN_SPEECH_MS). The
4039
+ // index is recorded so a later end-of-turn can be told apart from one
4040
+ // this turn superseded (see isStaleProviderTurnEnd).
4041
+ const target = this.pendingTranscriptCycle();
4042
+ if (target) {
4043
+ this.recordProviderTurnStart(target, event.turnIndex);
4044
+ }
4045
+ return;
4046
+ }
4047
+ case "eager-turn-end":
4048
+ case "turn-resumed":
4049
+ // Speculative end-of-turn stays off (the config leaves
4050
+ // `eagerEotThreshold` unset, so Deepgram never emits these). Wiring
4051
+ // them onto the speculative dispatch machinery is a deferred
4052
+ // follow-up.
4053
+ return;
4054
+ case "turn-end": {
4055
+ // Same routing as transcript events: the cycle awaiting its
4056
+ // transcript owns the turn the provider just closed.
4057
+ const target = this.pendingTranscriptCycle();
4058
+ if (target) {
4059
+ await this.handleProviderTurnEnd(target, event.turnIndex);
4060
+ }
4061
+ return;
4062
+ }
3673
4063
  case "error":
3674
4064
  await this.sendTranscriberErrorFrame(event);
3675
4065
  return;
@@ -3709,6 +4099,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3709
4099
  await this.startAssistantTurnIfReady();
3710
4100
  return;
3711
4101
  }
4102
+ default: {
4103
+ const _exhaustive: never = event;
4104
+ return;
4105
+ }
3712
4106
  }
3713
4107
  }
3714
4108
 
@@ -3844,8 +4238,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3844
4238
  this.assistantPlaybackTailUntilMs = 0;
3845
4239
  this.takeVadPreRoll();
3846
4240
  this.vadPendingTurnEnd = null;
3847
- // ...and abandons any semantic-endpointing hold still awaiting replay.
4241
+ // ...and abandons any semantic-endpointing hold still awaiting replay,
4242
+ // along with any provider end-of-turn the session was still waiting on.
3848
4243
  this.clearEndpointExtensionTimer();
4244
+ this.clearProviderTurnEndTimer();
3849
4245
  // A client interrupt is a hard reset: any barge-in merge context waiting for
3850
4246
  // the next turn is now stale (the interrupted utterance may be discarded
3851
4247
  // without ever reaching finalizePendingUtterance).
@@ -4112,14 +4508,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4112
4508
  consumedAnnouncement: pending?.announcement ?? null,
4113
4509
  pendingContextStopGeneration: this.detachStopGeneration,
4114
4510
  continuationDelivery: opts?.continuationDelivery ?? null,
4115
- toolUseStarted: false,
4116
- firstDeltaSeen: false,
4117
4511
  deltaEpoch: 0,
4118
- ackSpoken: false,
4119
- ackGenerationPending: false,
4120
- ackTimer: null,
4121
4512
  escalationHandedOff: false,
4122
4513
  ttsBuffer: "",
4514
+ ttsReasoningFilter: createReasoningTagFilter(),
4123
4515
  ttsSegmentEnqueued: false,
4124
4516
  ttsJobs: [],
4125
4517
  ttsQueue: Promise.resolve(),
@@ -4142,8 +4534,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4142
4534
  // Verdict-deadline fail-open: the deferred-everything window is only
4143
4535
  // safe while the verdict is fast. If the leg produces no verdict
4144
4536
  // within the endpoint budget (provider TTFT tail), commit anyway so
4145
- // the thinking frame shows and the ack timer arms — bounded dead air
4146
- // instead of unbounded structural silence. A verdict that arrives
4537
+ // the thinking frame shows and progress narration can begin, bounding
4538
+ // the structural silence. A verdict that arrives
4147
4539
  // after the commit still works: escalate hands off normally, and a
4148
4540
  // late hold token is stripped like any stray token (the utterance is
4149
4541
  // already released, so holding is no longer possible).
@@ -4166,22 +4558,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4166
4558
  }, this.frontModelConfig.endpointDecisionTimeoutMs);
4167
4559
  }
4168
4560
 
4169
- // Slow-first-delta spoken ack: if the model produces no delta within the
4170
- // budget, a short front-model-phrased sentence holds the floor. Only
4171
- // meaningful on TTS sessions — a text-only session has no audio gap to
4172
- // bridge.
4173
- if (this.streamTtsAudio && !opts?.speculative) {
4174
- this.armAckTimer(activeTurn);
4175
- }
4176
-
4177
4561
  // Progress narration speaks into the turn's audible dead air on a
4178
- // cadence, wherever in the turn it occurs; without TTS or a decider there
4562
+ // cadence, wherever in the turn it occurs; without TTS or a narrator there
4179
4563
  // is nothing to speak (the idle trigger's static fallback still needs a
4180
4564
  // generation attempt to fall back from).
4181
4565
  if (
4182
4566
  this.frontModelConfig.progress.enabled &&
4183
4567
  this.streamTtsAudio &&
4184
- this.frontDecider &&
4568
+ this.progressNarrator &&
4185
4569
  !opts?.speculative
4186
4570
  ) {
4187
4571
  this.armProgressIdleTimer(activeTurn);
@@ -4218,9 +4602,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4218
4602
  * escalate verdict the post-verdict stream buffers into the capped bridge
4219
4603
  * and `escalateTurn` starts a second "escalated" leg that shares this same
4220
4604
  * ActiveAssistantTurn. The persisted assistant row is reduced to the capped
4221
- * bridge by the bridge's teardown transcript-hygiene pass; the shared
4222
- * conversation-hub broadcast still streams the raw verdict deltas mid-turn
4223
- * until the turn-end resync (the remaining scope of issue #37850).
4605
+ * bridge by the bridge's teardown transcript-hygiene pass, and the shared
4606
+ * conversation-hub broadcast releases the same capped bridge through the
4607
+ * bridge's front-door stream gate, so no verdict token reaches a hub
4608
+ * subscriber mid-turn.
4224
4609
  */
4225
4610
  private async startAssistantLeg(
4226
4611
  activeTurn: ActiveAssistantTurn,
@@ -4254,7 +4639,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4254
4639
  return;
4255
4640
  }
4256
4641
  this.markFirstAssistantDelta(utterance, turnId);
4257
- this.markFirstDeltaForAck(activeTurn);
4642
+ this.markAssistantDelta(activeTurn);
4258
4643
  // Send-time abort gate: a delta queued behind a backed-up outbound
4259
4644
  // frame must not be written once barge-in aborts the turn, or the
4260
4645
  // cancelled reply's text leaks ahead of turn_cancelled. Key off this
@@ -4492,7 +4877,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4492
4877
  if (current?.token !== token) {
4493
4878
  return;
4494
4879
  }
4495
- current.toolUseStarted = true;
4496
4880
  // Foreground wins the workspace: the continuation runs with full
4497
4881
  // subagent abilities (it can write files, run commands), so the
4498
4882
  // moment a live turn starts a consequential tool the two could
@@ -4541,21 +4925,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4541
4925
  current.progress.stateEpoch += 1;
4542
4926
  this.refreshActivity(current);
4543
4927
  log.debug({ turnId, toolName }, "Live voice turn started tool use");
4544
- // Definitive tool use means the turn is guaranteed slow: speak
4545
- // the floor-holding ack now instead of waiting out the
4546
- // first-delta timer (same one-ack-per-turn `ackSpoken` budget).
4547
- // Post-handoff the escalation bridge phrase holds the floor and
4548
- // front-door deltas never set firstDeltaSeen, so the escalated
4549
- // leg's tool starts must not stack another filler on the bridge.
4550
- if (
4551
- this.streamTtsAudio &&
4552
- !current.firstDeltaSeen &&
4553
- !current.ackSpoken &&
4554
- !current.escalationHandedOff
4555
- ) {
4556
- this.clearAckTimer(current);
4557
- this.speakAck(current, "tool_use", toolName);
4558
- }
4559
4928
  this.maybeNarrateProgress(current, "ops");
4560
4929
  },
4561
4930
  tool_result: (event) => {
@@ -4709,9 +5078,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4709
5078
  return;
4710
5079
  }
4711
5080
  activeTurn.escalationHandedOff = true;
4712
- // The escalation bridge below holds the floor, so a pending
4713
- // slow-first-delta ack or progress narration would only stack a second
4714
- // filler on top of it.
5081
+ // The escalation bridge below holds the floor, so pending progress
5082
+ // narration would only stack a second filler on top of it.
4715
5083
  this.clearFillerTimers(activeTurn);
4716
5084
  // Abort the front-door leg so a model that keeps generating past the
4717
5085
  // bridge cap adds no latency before the escalated leg starts.
@@ -4728,7 +5096,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4728
5096
  : cappedBridge;
4729
5097
  if (!usesFallbackBridge) {
4730
5098
  this.markFirstAssistantDelta(activeTurn.utterance, activeTurn.turnId);
4731
- this.markFirstDeltaForAck(activeTurn);
5099
+ this.markAssistantDelta(activeTurn);
4732
5100
  void this.sendFrame(
4733
5101
  { type: "assistant_text_delta", text: spokenBridge },
4734
5102
  () => !activeTurn.abortController.signal.aborted && !this.isClosed,
@@ -4774,7 +5142,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4774
5142
  if (
4775
5143
  this.frontModelConfig.progress.enabled &&
4776
5144
  this.streamTtsAudio &&
4777
- this.frontDecider
5145
+ this.progressNarrator
4778
5146
  ) {
4779
5147
  this.armProgressIdleTimer(activeTurn);
4780
5148
  }
@@ -4846,42 +5214,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4846
5214
  );
4847
5215
  }
4848
5216
 
4849
- // Arms the slow-first-delta ack timer for a just-launched turn: on expiry,
4850
- // if no assistant delta has arrived and the turn is still live, one short
4851
- // phrase is spoken to hold the floor.
4852
- private armAckTimer(activeTurn: ActiveAssistantTurn): void {
4853
- const { token } = activeTurn;
4854
- activeTurn.ackTimer = setTimeout(() => {
4855
- activeTurn.ackTimer = null;
4856
- if (
4857
- !this.isActiveAssistantTurn(token) ||
4858
- activeTurn.firstDeltaSeen ||
4859
- activeTurn.ackSpoken
4860
- ) {
4861
- return;
4862
- }
4863
- this.speakAck(activeTurn, "first_delta");
4864
- }, this.frontModelConfig.ackFirstDeltaTimeoutMs);
4865
- }
4866
-
4867
- private clearAckTimer(turn: ActiveAssistantTurn): void {
4868
- if (turn.ackTimer !== null) {
4869
- clearTimeout(turn.ackTimer);
4870
- turn.ackTimer = null;
4871
- }
4872
- }
4873
-
4874
- // The model produced real output for this turn: the pending slow-first-delta
4875
- // ack is moot, and any in-flight narration generation is stale (it captures
4876
- // deltaEpoch to notice). The narration idle timer stays armed — the dead air
4877
- // narration exists to fill is almost always MID-turn (between tool phases,
4878
- // during long silent LLM calls after the model's opening words), so it must
4879
- // keep watching after the model speaks and re-trigger once the audio drains
4880
- // and silence stretches again.
4881
- private markFirstDeltaForAck(turn: ActiveAssistantTurn): void {
4882
- turn.firstDeltaSeen = true;
5217
+ // A real assistant delta makes any in-flight progress narration stale. The
5218
+ // idle timer stays armed so it can detect a later stretch of mid-turn
5219
+ // silence.
5220
+ private markAssistantDelta(turn: ActiveAssistantTurn): void {
4883
5221
  turn.deltaEpoch += 1;
4884
- this.clearAckTimer(turn);
4885
5222
  }
4886
5223
 
4887
5224
  // Arms (or re-arms) the dead-air narration timer. The countdown measures
@@ -4957,14 +5294,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4957
5294
  }
4958
5295
  }
4959
5296
 
4960
- // Clears the turn's filler timers (slow-first-delta ack, dead-air
4961
- // narration, verdict deadline) for events that end the current filler
4962
- // lifecycle: escalation hand-off (which then re-arms narration for the
4963
- // escalated leg), barge-in, cancellation, tts-completion, finalize. Real
4964
- // output moots only the ack (markFirstDeltaForAck) — the narration timer
4965
- // keeps watching for mid-turn dead air.
5297
+ // Clears progress narration and verdict timers for events that end the
5298
+ // current filler lifecycle.
4966
5299
  private clearFillerTimers(turn: ActiveAssistantTurn): void {
4967
- this.clearAckTimer(turn);
4968
5300
  this.clearProgressIdleTimer(turn);
4969
5301
  if (turn.verdictDeadlineTimer !== null) {
4970
5302
  clearTimeout(turn.verdictDeadlineTimer);
@@ -4972,26 +5304,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4972
5304
  }
4973
5305
  }
4974
5306
 
4975
- // A spoken ack may only speak into a live turn's pre-first-delta dead air.
4976
- // Once the turn is stale, the brain's first delta has arrived, an
4977
- // escalation hand-off has enqueued its bridge phrase (which holds the floor
4978
- // itself), or the turn has completed outright — a tool-only/no-text turn
4979
- // completes with firstDeltaSeen still false — an ack is moot and must not
4980
- // speak over real output, stack on the escalation bridge, or voice a
4981
- // finished turn.
4982
- private turnCanSpeakFiller(turn: ActiveAssistantTurn): boolean {
4983
- return (
4984
- this.isActiveAssistantTurn(turn.token) &&
4985
- !turn.firstDeltaSeen &&
4986
- !turn.escalationHandedOff &&
4987
- !turn.assistantCompleted
4988
- );
4989
- }
4990
-
4991
- // Narration, unlike the ack, is not confined to the pre-first-delta window:
4992
- // the dead air it exists to fill is almost always mid-turn, after the
4993
- // model's opening words. It may speak whenever the live turn is audibly
4994
- // silent right now — nothing streaming, queued, or still playing — and has
5307
+ // Narration may speak whenever the live turn is audibly silent and has
4995
5308
  // not completed. Escalated turns qualify too: the bridge phrase holds the
4996
5309
  // floor only while its audio plays (covered by the audible-idle check),
4997
5310
  // and the strong leg's tool loops are the longest dead air in the system.
@@ -5028,31 +5341,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5028
5341
  return false;
5029
5342
  }
5030
5343
 
5031
- // Gatekeeper for spoken progress narration: it speaks only while the turn
5032
- // is audibly silent, spaced `minGapMs` from any spoken floor-holder (ack or
5033
- // narration), one generation at a time, and — per trigger — only once the
5034
- // ops trigger has `opsThreshold` ops accumulated or the idle trigger has
5035
- // something new to report. No per-turn count cap: the cadence guards bound
5036
- // the rate, and going quiet deep into a long turn is the failure mode
5037
- // narration exists to prevent. Every failing guard short-circuits silently;
5038
- // a skipped ops trigger keeps its accumulated count, so the next tool event
5039
- // or idle tick retries.
5344
+ // Gatekeeper for progress narration. It speaks only while the turn is
5345
+ // audibly silent, with one generation at a time and the configured spacing.
5040
5346
  private maybeNarrateProgress(
5041
5347
  turn: ActiveAssistantTurn,
5042
5348
  trigger: "ops" | "idle" | "op_complete",
5043
5349
  ): void {
5044
5350
  const cfg = this.frontModelConfig.progress;
5045
5351
  const { progress } = turn;
5046
- const frontDecider = this.frontDecider;
5352
+ const progressNarrator = this.progressNarrator;
5047
5353
  if (
5048
5354
  !cfg.enabled ||
5049
5355
  !this.streamTtsAudio ||
5050
- !frontDecider ||
5356
+ !progressNarrator ||
5051
5357
  !this.turnCanNarrateProgress(turn) ||
5052
- // A pending ack generation is a floor-holder-in-waiting: starting a
5053
- // narration generation now would only be discarded by the post-await
5054
- // re-check once the ack enqueues — a guaranteed wasted provider call.
5055
- turn.ackGenerationPending ||
5056
5358
  progress.narrationInFlight ||
5057
5359
  (progress.lastFloorHolderAtMs !== null &&
5058
5360
  Date.now() - progress.lastFloorHolderAtMs < cfg.minGapMs) ||
@@ -5061,25 +5363,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5061
5363
  ) {
5062
5364
  return;
5063
5365
  }
5064
- void this.speakProgressUpdate(turn, frontDecider, trigger);
5366
+ void this.speakProgressUpdate(turn, progressNarrator, trigger);
5065
5367
  }
5066
5368
 
5067
- // Generate and speak one progress narration. Like the ack, it is audio-only
5068
- // — no assistant_text_delta frame — and enqueues as a standalone sentence
5069
- // on the turn's ordered TTS queue. The decider internally bounds the call
5070
- // by `progress.generationTimeoutMs` and resolves null on every failure
5071
- // mode; on null the idle trigger falls back to a static phrase (silence is
5072
- // actively harmful there) while the tool-activity triggers stay silent — a
5073
- // generic filler is not worth it when narration was merely opportunistic.
5369
+ // Generate and speak one audio-only progress narration. On a null result,
5370
+ // idle narration falls back to a static phrase while activity-triggered
5371
+ // narration stays silent.
5074
5372
  private async speakProgressUpdate(
5075
5373
  turn: ActiveAssistantTurn,
5076
- frontDecider: VoiceFrontDecider,
5374
+ progressNarrator: VoiceProgressNarrator,
5077
5375
  trigger: "ops" | "idle" | "op_complete",
5078
5376
  ): Promise<void> {
5079
5377
  const { progress } = turn;
5080
5378
  progress.narrationInFlight = true;
5081
- // Any delta that lands while the decider call is in flight makes the
5082
- // generated text stale — and proves the model is speaking again.
5379
+ // Any delta that lands while generation is in flight makes the text stale.
5083
5380
  const deltaEpochAtLaunch = turn.deltaEpoch;
5084
5381
  // The activity this update describes. Tool events that land mid-generation
5085
5382
  // are news the generated text cannot carry, so they must leave the idle
@@ -5097,7 +5394,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5097
5394
  (op): op is TurnProgressOp & { completedAtMs: number } =>
5098
5395
  op.completedAtMs !== undefined,
5099
5396
  )
5100
- // `ops` is in start order; the decider contract wants completion
5397
+ // `ops` is in start order; the narrator wants completion
5101
5398
  // order, which differs when parallel tools finish out of order.
5102
5399
  .sort((a, b) => a.completedAtMs - b.completedAtMs)
5103
5400
  .map((op) => ({
@@ -5117,26 +5414,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5117
5414
  updateIndex: progress.updatesSpoken + 1,
5118
5415
  ...(turn.language !== undefined ? { languageHint: turn.language } : {}),
5119
5416
  };
5120
- const generated = await frontDecider
5417
+ const generated = await progressNarrator
5121
5418
  .generateProgressText(input, turn.abortController.signal)
5122
- // The decider contract never rejects; belt-and-braces for a stub.
5419
+ // The narrator contract never rejects. Keep test stubs fail-soft too.
5123
5420
  .catch(() => null);
5124
- // Liveness re-check after the await: any turnCanNarrateProgress
5125
- // condition may have flipped while generating, and a delta that landed
5126
- // mid-generation (deltaEpoch moved) makes the text stale even when the
5127
- // resulting audio has already drained by now. The ack-generation and
5128
- // floor re-checks close the race with a generated ack that
5129
- // STARTED after this generation did — the entry guard rejects a
5130
- // narration while an ack generation is already pending, but cannot see
5131
- // one that begins mid-generation: while that ack is pending it has not
5132
- // yet stamped `lastFloorHolderAtMs`, and once it enqueues the minGapMs
5133
- // spacing must be re-applied from that stamp — either way, enqueueing
5134
- // now would stack back-to-back filler. A bail here is silent and keeps
5135
- // the update budget, exactly like the stale-turn bail.
5421
+ // Re-check liveness and staleness after the provider call.
5136
5422
  if (
5137
5423
  !this.turnCanNarrateProgress(turn) ||
5138
5424
  turn.deltaEpoch !== deltaEpochAtLaunch ||
5139
- turn.ackGenerationPending ||
5140
5425
  (progress.lastFloorHolderAtMs !== null &&
5141
5426
  Date.now() - progress.lastFloorHolderAtMs <
5142
5427
  this.frontModelConfig.progress.minGapMs)
@@ -5144,7 +5429,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5144
5429
  return;
5145
5430
  }
5146
5431
  let raw = generated;
5147
- // Decider text is generated in the turn's language; only the static
5432
+ // Generated text is in the turn's language; only the static
5148
5433
  // fallback comes from a localized table and may need the "en" override.
5149
5434
  let fillerLanguage: string | undefined;
5150
5435
  if (raw === null) {
@@ -5163,8 +5448,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5163
5448
  progress.opsSinceNarration = 0;
5164
5449
  progress.narratedEpoch = stateEpochAtLaunch;
5165
5450
  progress.updatesSpoken += 1;
5166
- // Like the ack mark, recorded only when narration audio actually
5167
- // enqueued (decider text or static fallback alike).
5451
+ // Record only when narration audio actually enqueued.
5168
5452
  this.markProgressSpoken(turn);
5169
5453
  // Restart the dead-air countdown from this narration.
5170
5454
  this.armProgressIdleTimer(turn);
@@ -5173,93 +5457,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5173
5457
  }
5174
5458
  }
5175
5459
 
5176
- // Speak the turn's one floor-holding ack (every ack trigger shares the
5177
- // `ackSpoken` budget). The ack is audio-only — no assistant_text_delta frame
5178
- // — so captions and the persisted assistant message carry only the model's
5179
- // own output. It enqueues as a complete standalone sentence on the turn's
5180
- // ordered TTS queue, so the model's audio always follows it cleanly; it
5181
- // never consumes the eager first-clause flush reserved for the model's real
5182
- // first segment.
5183
- private speakAck(
5184
- activeTurn: ActiveAssistantTurn,
5185
- kind: LiveVoiceSpokenAckKind,
5186
- toolName?: string,
5187
- ): void {
5188
- if (!this.frontDecider) {
5189
- // No decider, no ack: a canned line is worse than the silence, which
5190
- // progress narration already covers once the turn runs long.
5191
- return;
5192
- }
5193
- activeTurn.ackSpoken = true;
5194
- // Optimistic ackSpoken above keeps the one-per-turn budget honest for
5195
- // any other ack trigger while the generation is in flight; the async
5196
- // path undoes it if the ack turns out moot.
5197
- void this.speakGeneratedAck(activeTurn, this.frontDecider, kind, toolName);
5198
- }
5199
-
5200
- // Ask the front model for one short contextual sentence. The decider
5201
- // internally bounds the call by `ackGenerationTimeoutMs` and resolves null
5202
- // on every failure mode, so the enqueue never waits beyond that budget; a
5203
- // null generation speaks nothing.
5204
- private async speakGeneratedAck(
5205
- activeTurn: ActiveAssistantTurn,
5206
- frontDecider: VoiceFrontDecider,
5207
- kind: LiveVoiceSpokenAckKind,
5208
- toolName?: string,
5209
- ): Promise<void> {
5210
- activeTurn.ackGenerationPending = true;
5211
- try {
5212
- const transcript = activeTurn.utterance.finalTranscriptSegments
5213
- .join(" ")
5214
- .trim();
5215
- const generated = await frontDecider
5216
- .generateAckText(
5217
- {
5218
- transcriptSoFar: transcript,
5219
- toolName,
5220
- ...(activeTurn.language !== undefined
5221
- ? { languageHint: activeTurn.language }
5222
- : {}),
5223
- },
5224
- activeTurn.abortController.signal,
5225
- )
5226
- // The decider contract never rejects; belt-and-braces for a stub.
5227
- .catch(() => null);
5228
- // Liveness re-check after the await: any turnCanSpeakFiller condition
5229
- // may have flipped while generating, mooting the ack. A null generation
5230
- // (no provider, timeout, unusable output) speaks nothing. Either way,
5231
- // undo the optimistic ackSpoken so the flag reflects reality and a
5232
- // later trigger this turn can still hold the floor.
5233
- if (generated === null || !this.turnCanSpeakFiller(activeTurn)) {
5234
- activeTurn.ackSpoken = false;
5235
- return;
5236
- }
5237
- this.enqueueAckPhrase(activeTurn, generated, kind);
5238
- } finally {
5239
- activeTurn.ackGenerationPending = false;
5240
- }
5241
- }
5242
-
5243
- // Records the ack-spoken metric only when a phrase actually enqueues; a
5244
- // phrase that sanitizes away releases the turn's ack budget again.
5245
- private enqueueAckPhrase(
5246
- activeTurn: ActiveAssistantTurn,
5247
- raw: string,
5248
- kind: LiveVoiceSpokenAckKind,
5249
- ): void {
5250
- if (this.enqueueFillerPhrase(activeTurn, raw)) {
5251
- this.markAckSpoken(activeTurn, kind);
5252
- return;
5253
- }
5254
- activeTurn.ackSpoken = false;
5255
- }
5256
-
5257
- // Sanitize and enqueue one filler sentence (spoken ack or progress
5258
- // narration) on the turn's ordered TTS queue — the shared tail of every
5259
- // filler path. Returns whether a phrase actually enqueued; per-kind metric
5260
- // marks and bookkeeping are the caller's. `language` is a per-segment
5261
- // hint override (see fixedPhraseLanguage); omit it for generated text,
5262
- // which is already in the turn's language.
5460
+ // Sanitize and enqueue one progress sentence on the ordered TTS queue.
5263
5461
  private enqueueFillerPhrase(
5264
5462
  turn: ActiveAssistantTurn,
5265
5463
  raw: string,
@@ -5303,7 +5501,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5303
5501
  return;
5304
5502
  }
5305
5503
 
5306
- activeTurn.ttsBuffer += text;
5504
+ activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.push(text);
5307
5505
  this.flushTtsBuffer(token, false);
5308
5506
  }
5309
5507
 
@@ -5314,6 +5512,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5314
5512
  }
5315
5513
 
5316
5514
  this.clearFillerTimers(activeTurn);
5515
+ activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.flush();
5317
5516
  this.flushTtsBuffer(token, true);
5318
5517
  activeTurn.ttsQueue = activeTurn.ttsQueue
5319
5518
  .catch(() => {})
@@ -5420,8 +5619,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5420
5619
  return;
5421
5620
  }
5422
5621
 
5423
- // A spoken ack (countsAsFirstSegment: false) leaves the eager
5424
- // first-clause flush available for the model's real first segment.
5622
+ // Progress narration leaves the eager first-clause flush available for
5623
+ // the model's real first segment.
5425
5624
  if (options.countsAsFirstSegment ?? true) {
5426
5625
  activeTurn.ttsSegmentEnqueued = true;
5427
5626
  }
@@ -5724,23 +5923,42 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5724
5923
  utterance: UtteranceCycle,
5725
5924
  action: VoiceEndpointAction,
5726
5925
  latencyMs: number,
5926
+ // Absent means the front door decided, which is what the collector
5927
+ // defaults to; the provider path names itself so one metrics frame can
5928
+ // compare the two.
5929
+ source?: VoiceEndpointSource,
5727
5930
  ): void {
5728
5931
  const turnId = this.ensureTurnId(utterance);
5729
5932
  if (!this.startMetricsTurnIfNeeded(utterance, turnId)) {
5730
5933
  return;
5731
5934
  }
5732
- this.metrics.markEndpointDecision(turnId, { action, latencyMs });
5935
+ this.metrics.markEndpointDecision(turnId, {
5936
+ action,
5937
+ latencyMs,
5938
+ ...(source ? { source } : {}),
5939
+ });
5733
5940
  }
5734
5941
 
5735
- private markAckSpoken(
5736
- activeTurn: ActiveAssistantTurn,
5737
- kind: LiveVoiceSpokenAckKind,
5738
- ): void {
5739
- const { utterance } = activeTurn;
5740
- if (!this.startMetricsTurnIfNeeded(utterance, activeTurn.turnId)) {
5942
+ /**
5943
+ * The turn's end-of-turn latency, stamped at the moment it commits. Called
5944
+ * from `releaseUtterance`, which every committed turn passes through
5945
+ * whichever decider released it, so the front-door and provider samples span
5946
+ * the same thing from the same anchor over the same population. That is
5947
+ * what `endpointDecisionMaxLatencyMs` cannot offer, and why this exists
5948
+ * alongside it.
5949
+ *
5950
+ * Skipped with no speech-stop mark, which is push-to-talk: there the
5951
+ * client's release is the boundary and no local VAD runs.
5952
+ */
5953
+ private markEndpointCommit(utterance: UtteranceCycle): void {
5954
+ if (this.localSpeechStopAtMs === null) {
5955
+ return;
5956
+ }
5957
+ const turnId = this.ensureTurnId(utterance);
5958
+ if (!this.startMetricsTurnIfNeeded(utterance, turnId)) {
5741
5959
  return;
5742
5960
  }
5743
- this.metrics.markAckSpoken(activeTurn.turnId, kind);
5961
+ this.metrics.markEndpointCommit(turnId, this.msSinceLocalSpeechStop());
5744
5962
  }
5745
5963
 
5746
5964
  private markProgressSpoken(activeTurn: ActiveAssistantTurn): void {
@@ -6096,14 +6314,16 @@ export function createLiveVoiceSession(
6096
6314
  options: LiveVoiceSessionOptions = {},
6097
6315
  ): LiveVoiceSession {
6098
6316
  // Workspace-tunable server-VAD thresholds. The `liveVoice.vad` schema
6099
- // defaults match the code defaults (800 energy / 800 ms silence / 30 s max
6100
- // turn / 60 ms barge-in guard), so an unset config leaves behavior
6101
- // unchanged. Optional-chained
6102
- // because hand-built test configs may predate the liveVoice namespace;
6103
- // absent config falls through to the in-code defaults.
6317
+ // defaults are 800 energy / 1200 ms silence / 30 s max turn / 250 ms
6318
+ // barge-in guard. Three of the four match their in-code fallback; the
6319
+ // trailing-silence threshold does not, so a session built with a
6320
+ // `liveVoice` config waits 1200 ms and one built without waits the in-code
6321
+ // DEFAULT_SILENCE_THRESHOLD_MS of 800. Optional-chained because hand-built
6322
+ // test configs may predate the liveVoice namespace; absent config falls
6323
+ // through to the in-code defaults.
6104
6324
  const liveVoiceConfig = getConfig().liveVoice;
6105
6325
  const vadConfig = liveVoiceConfig?.vad;
6106
- // Parsed once here into a complete config, shared by the decider and the
6326
+ // Parsed once here into a complete config, shared by endpointing and the
6107
6327
  // session (the constructor's own parse is then a no-op re-validation).
6108
6328
  const frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
6109
6329
  options.frontModelConfig ?? liveVoiceConfig?.frontModel ?? {},
@@ -6128,13 +6348,14 @@ export function createLiveVoiceSession(
6128
6348
  options.echoEmaHalfLifeMs ?? vadConfig?.echoEmaHalfLifeMs,
6129
6349
  echoDrainSlackMs: options.echoDrainSlackMs ?? vadConfig?.echoDrainSlackMs,
6130
6350
  frontModelConfig,
6131
- // Eager construction is safe even when the `liveVoice.frontModel` config
6132
- // namespace is absent — schema defaults fill the tunables. An explicit
6133
- // option (incl. `null`) always wins — the test seam.
6134
- frontDecider:
6135
- options.frontDecider !== undefined
6136
- ? options.frontDecider
6137
- : createVoiceFrontDecider({ config: frontModelConfig }),
6351
+ // Absent config leaves the schema defaults, which keep provider turn
6352
+ // detection off.
6353
+ fluxConfig: options.fluxConfig ?? liveVoiceConfig?.flux,
6354
+ // An explicit option, including null, always wins for the test seam.
6355
+ progressNarrator:
6356
+ options.progressNarrator !== undefined
6357
+ ? options.progressNarrator
6358
+ : createVoiceProgressNarrator({ config: frontModelConfig.progress }),
6138
6359
  resolveCredentialReadiness:
6139
6360
  options.resolveCredentialReadiness === undefined
6140
6361
  ? defaultResolveLiveVoiceCredentialReadiness