@vellumai/assistant 0.12.3-staging.1 → 0.12.3-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 (560) hide show
  1. package/ARCHITECTURE.md +43 -29
  2. package/Dockerfile +63 -0
  3. package/docs/architecture/security.md +10 -5
  4. package/docs/architecture/turn-actor.md +2 -0
  5. package/docs/desktop-browser-cli.md +45 -4
  6. package/docs/live-voice-task-outcomes.md +100 -0
  7. package/docs/mode-sessions.md +183 -0
  8. package/docs/voice-input-diagnostics.md +58 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  10. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  11. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  15. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +20 -0
  16. package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
  17. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
  18. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +5 -6
  19. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  20. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  21. package/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  22. package/node_modules/@vellumai/slack-text/src/index.ts +4 -8
  23. package/openapi.yaml +665 -14
  24. package/package.json +1 -1
  25. package/scripts/smoke-desktop-browser-cli.ts +10 -26
  26. package/scripts/smoke-desktop-window-theme.ts +2 -3
  27. package/src/__tests__/agent-loop-output-hooks.test.ts +150 -1
  28. package/src/__tests__/agent-loop-provider-error-recording.test.ts +87 -19
  29. package/src/__tests__/annotate-activity-metadata.test.ts +3 -1
  30. package/src/__tests__/annotate-risk-options.test.ts +2 -0
  31. package/src/__tests__/answered-question-persistence.test.ts +2 -0
  32. package/src/__tests__/anthropic-gateway-shared.test.ts +22 -0
  33. package/src/__tests__/approval-message-composer.test.ts +1 -118
  34. package/src/__tests__/approval-routes-http.test.ts +3 -0
  35. package/src/__tests__/assistant-event-hub-connection-history.test.ts +111 -0
  36. package/src/__tests__/assistant-event-hub-targeted.test.ts +50 -0
  37. package/src/__tests__/assistant-event-targeting.test.ts +186 -0
  38. package/src/__tests__/assistant-stream-state.test.ts +29 -9
  39. package/src/__tests__/browser-fill-credential.test.ts +11 -22
  40. package/src/__tests__/btw-routes.test.ts +4 -1
  41. package/src/__tests__/build-persisted-content.test.ts +1 -1
  42. package/src/__tests__/call-conversation-messages.test.ts +5 -0
  43. package/src/__tests__/call-recovery.test.ts +7 -2
  44. package/src/__tests__/call-routes-http.test.ts +12 -0
  45. package/src/__tests__/call-store.test.ts +7 -2
  46. package/src/__tests__/channel-retry-sweep.test.ts +9 -0
  47. package/src/__tests__/chat-reveal-guard-priming.test.ts +2 -0
  48. package/src/__tests__/checker.test.ts +11 -3
  49. package/src/__tests__/client-os-metadata-persistence.test.ts +35 -1
  50. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +0 -25
  51. package/src/__tests__/computer-use-tools.test.ts +1 -233
  52. package/src/__tests__/config-model-image-provider.test.ts +39 -0
  53. package/src/__tests__/config-schema.test.ts +16 -0
  54. package/src/__tests__/config-watcher.test.ts +24 -0
  55. package/src/__tests__/context-search-agent-runner.test.ts +260 -1
  56. package/src/__tests__/context-search-fanout.test.ts +1 -1
  57. package/src/__tests__/context-search-types.test.ts +7 -7
  58. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +8 -2
  59. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +2 -0
  60. package/src/__tests__/conversation-agent-loop.test.ts +546 -16
  61. package/src/__tests__/conversation-app-control-lifecycle.test.ts +53 -1
  62. package/src/__tests__/conversation-evictor.test.ts +62 -37
  63. package/src/__tests__/conversation-history-boundary-repair-failure.test.ts +174 -0
  64. package/src/__tests__/conversation-loop-db-lock-resilience.test.ts +2 -0
  65. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +2 -0
  66. package/src/__tests__/conversation-retry-route.test.ts +22 -0
  67. package/src/__tests__/conversation-routes-disk-view.test.ts +2 -0
  68. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +2 -0
  69. package/src/__tests__/conversation-routes-guardian-reply.test.ts +10 -0
  70. package/src/__tests__/conversation-routes-hidden-queue.test.ts +2 -0
  71. package/src/__tests__/conversation-routes-slash-commands.test.ts +2 -0
  72. package/src/__tests__/conversation-runtime-assembly.test.ts +29 -0
  73. package/src/__tests__/conversation-surface-snapshots.test.ts +543 -0
  74. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +151 -0
  75. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +63 -1
  76. package/src/__tests__/conversation-tool-setup-attribution.test.ts +23 -0
  77. package/src/__tests__/conversation-undo.test.ts +61 -0
  78. package/src/__tests__/credential-execution-client.test.ts +6 -6
  79. package/src/__tests__/credential-prompt-route.test.ts +35 -3
  80. package/src/__tests__/cu-unified-flow.test.ts +82 -1
  81. package/src/__tests__/db-mode-sessions-migration.test.ts +175 -0
  82. package/src/__tests__/deterministic-verification-control-plane.test.ts +2 -0
  83. package/src/__tests__/dm-backfill.test.ts +91 -1
  84. package/src/__tests__/document-create-route.test.ts +88 -0
  85. package/src/__tests__/document-sync-tags.test.ts +27 -0
  86. package/src/__tests__/emit-signal-routing-intent.test.ts +142 -2
  87. package/src/__tests__/events-tail-route.test.ts +25 -0
  88. package/src/__tests__/evict-conversations-for-reload.test.ts +57 -8
  89. package/src/__tests__/guardian-action-sweep.test.ts +16 -0
  90. package/src/__tests__/guardian-dispatch.test.ts +13 -0
  91. package/src/__tests__/headless-browser-interactions.test.ts +64 -20
  92. package/src/__tests__/heartbeat-service.test.ts +2 -59
  93. package/src/__tests__/helpers/mock-conversation.ts +21 -0
  94. package/src/__tests__/helpers/native-web-search-harness.ts +2 -0
  95. package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
  96. package/src/__tests__/host-cu-proxy.test.ts +46 -2
  97. package/src/__tests__/http-user-message-parity.test.ts +2 -0
  98. package/src/__tests__/image-credentials.test.ts +49 -0
  99. package/src/__tests__/image-service-dispatcher.test.ts +45 -0
  100. package/src/__tests__/inference-profile-session-handler.test.ts +1 -1
  101. package/src/__tests__/interrupt-on-send.test.ts +57 -43
  102. package/src/__tests__/interrupt-turn-note.test.ts +5 -6
  103. package/src/__tests__/llm-catalog-parity.test.ts +2 -2
  104. package/src/__tests__/media-generate-image.test.ts +49 -1
  105. package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
  106. package/src/__tests__/migration-export-to-gcs.test.ts +142 -0
  107. package/src/__tests__/mode-session-history.test.ts +245 -0
  108. package/src/__tests__/mode-session-recovery.test.ts +222 -0
  109. package/src/__tests__/mode-session-wire.test.ts +141 -0
  110. package/src/__tests__/notification-guardian-path.test.ts +7 -0
  111. package/src/__tests__/notification-telegram-adapter.test.ts +2 -2
  112. package/src/__tests__/notification-vellum-adapter.test.ts +34 -0
  113. package/src/__tests__/oauth-provider-serializer.test.ts +12 -0
  114. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  115. package/src/__tests__/outbound-slack-persistence.test.ts +2 -0
  116. package/src/__tests__/persistence-secret-redaction.test.ts +2 -0
  117. package/src/__tests__/plugin-api-provider.test.ts +5 -0
  118. package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
  119. package/src/__tests__/plugin-import-boundary-guard.test.ts +9 -0
  120. package/src/__tests__/process-message-display-content.test.ts +56 -0
  121. package/src/__tests__/reaction-persistence.test.ts +5 -0
  122. package/src/__tests__/request-secret-standalone-channel-gate.test.ts +25 -9
  123. package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
  124. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
  125. package/src/__tests__/secret-ingress-http.test.ts +2 -0
  126. package/src/__tests__/secure-keys.test.ts +43 -1
  127. package/src/__tests__/send-endpoint-busy.test.ts +163 -1
  128. package/src/__tests__/server-history-render.test.ts +91 -1
  129. package/src/__tests__/settings-routes.test.ts +50 -1
  130. package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
  131. package/src/__tests__/subagent-fork-notifications.test.ts +1 -2
  132. package/src/__tests__/subagent-manager-notify.test.ts +1 -2
  133. package/src/__tests__/subagent-notify-parent.test.ts +146 -39
  134. package/src/__tests__/subagent-notify-stale-rebuild.test.ts +2 -4
  135. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +2 -0
  136. package/src/__tests__/thread-backfill.test.ts +53 -0
  137. package/src/__tests__/tool-preview-lifecycle.test.ts +2 -0
  138. package/src/__tests__/tool-result-metadata-plumbing.test.ts +3 -1
  139. package/src/__tests__/tool-start-timestamp.test.ts +2 -0
  140. package/src/__tests__/twilio-routes.test.ts +1 -0
  141. package/src/__tests__/web-search-backend-failure.test.ts +1 -1
  142. package/src/activation/progress-store.test.ts +12 -7
  143. package/src/agent/loop.ts +99 -41
  144. package/src/api/events/assistant-turn-start.ts +3 -0
  145. package/src/api/events/generation-handoff.ts +2 -0
  146. package/src/api/events/message-complete.ts +2 -0
  147. package/src/api/events/notification-conversation-created.ts +14 -16
  148. package/src/api/events/notification-intent.ts +4 -2
  149. package/src/api/events/tool-result.ts +85 -0
  150. package/src/api/events/tool-use-start.ts +3 -0
  151. package/src/api/events/user-message-echo.ts +3 -0
  152. package/src/api/index.ts +31 -6
  153. package/src/api/mode-session.test.ts +131 -0
  154. package/src/api/mode-session.ts +148 -0
  155. package/src/api/remember-tool.ts +15 -0
  156. package/src/api/responses/conversation-message.ts +6 -0
  157. package/src/approvals/AGENTS.md +4 -2
  158. package/src/browser/__tests__/operations.test.ts +29 -0
  159. package/src/browser/operation-meta.ts +2 -1
  160. package/src/browser/operations.ts +29 -0
  161. package/src/browser/virtual-desktop-target.ts +2 -8
  162. package/src/bundler/bundle-signer.ts +1 -3
  163. package/src/calls/__tests__/barge-in-guard.test.ts +3 -0
  164. package/src/calls/__tests__/call-controller.test.ts +94 -0
  165. package/src/calls/__tests__/call-funnel-telemetry.test.ts +204 -0
  166. package/src/calls/__tests__/voice-control-protocol.test.ts +15 -0
  167. package/src/calls/__tests__/voice-escalation-judge.test.ts +209 -0
  168. package/src/calls/__tests__/voice-leg-coordinator.test.ts +168 -0
  169. package/src/{live-voice/__tests__/live-voice-metrics.test.ts → calls/__tests__/voice-metrics.test.ts} +32 -38
  170. package/src/calls/__tests__/voice-session-bridge.test.ts +576 -122
  171. package/src/calls/barge-in-guard.ts +5 -0
  172. package/src/calls/call-controller.ts +196 -1
  173. package/src/calls/call-domain.ts +4 -0
  174. package/src/calls/call-store.ts +87 -2
  175. package/src/calls/guardian-action-sweep.ts +22 -8
  176. package/src/calls/media-stream-audio-transcode.ts +1 -3
  177. package/src/calls/typesafe-noul.ts +156 -0
  178. package/src/calls/voice-control-protocol.ts +6 -0
  179. package/src/calls/voice-escalation-judge.ts +145 -0
  180. package/src/calls/voice-escalation-target.ts +10 -0
  181. package/src/calls/voice-leg-coordinator.ts +121 -3
  182. package/src/{live-voice/live-voice-metrics.ts → calls/voice-metrics.ts} +94 -87
  183. package/src/calls/voice-session-bridge.ts +256 -112
  184. package/src/calls/voice-triage-escalate.ts +7 -0
  185. package/src/cli/__tests__/roadmap-cli.test.ts +4 -41
  186. package/src/cli/commands/__tests__/notifications.test.ts +35 -10
  187. package/src/cli/commands/browser.help.ts +4 -5
  188. package/src/cli/commands/browser.ts +0 -1
  189. package/src/cli/commands/clients.help.ts +46 -0
  190. package/src/cli/commands/clients.ts +116 -0
  191. package/src/cli/commands/credentials.help.ts +6 -1
  192. package/src/cli/commands/notifications.help.ts +16 -2
  193. package/src/cli/commands/notifications.ts +30 -8
  194. package/src/cli/commands/roadmap.help.ts +18 -21
  195. package/src/cli/commands/roadmap.ts +4 -21
  196. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +5 -2
  197. package/src/cli/lib/bundled-marketplace.json +84 -34
  198. package/src/cli/lib/plugin-marketplace.ts +8 -0
  199. package/src/cli/lib/publish-plugin.ts +1 -2
  200. package/src/config/__tests__/default-provider.test.ts +3 -1
  201. package/src/config/__tests__/memory-retrospective-schema.test.ts +14 -1
  202. package/src/config/bundled-skills/computer-use/SKILL.md +1 -1
  203. package/src/config/bundled-skills/computer-use/TOOLS.json +89 -1
  204. package/src/config/bundled-skills/image-studio/SKILL.md +17 -2
  205. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +7 -11
  206. package/src/config/bundled-skills/sequences/TOOLS.json +1 -5
  207. package/src/config/call-site-defaults.ts +9 -0
  208. package/src/config/feature-flag-registry.json +16 -0
  209. package/src/config/profile-text-generation.test.ts +2 -2
  210. package/src/config/schemas/__tests__/tools.test.ts +31 -0
  211. package/src/config/schemas/call-site-catalog.ts +14 -0
  212. package/src/config/schemas/channels.ts +0 -18
  213. package/src/config/schemas/llm.ts +3 -1
  214. package/src/config/schemas/mcp.ts +2 -1
  215. package/src/config/schemas/memory-retrospective.ts +11 -2
  216. package/src/config/schemas/services.ts +15 -6
  217. package/src/config/schemas/tools.ts +10 -0
  218. package/src/config/session-groups-gate.ts +21 -0
  219. package/src/config/skills.ts +2 -6
  220. package/src/conversations/__tests__/message-consolidation.test.ts +149 -0
  221. package/src/conversations/message-consolidation.ts +102 -5
  222. package/src/credential-execution/ces-connect.test.ts +69 -0
  223. package/src/credential-execution/ces-connect.ts +115 -0
  224. package/src/credential-execution/ces-runtime.ts +55 -93
  225. package/src/credential-execution/executable-discovery.ts +1 -11
  226. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +10 -1
  227. package/src/daemon/__tests__/inference-profile-notification.test.ts +2 -0
  228. package/src/daemon/browser-mode-session.test.ts +402 -0
  229. package/src/daemon/browser-mode-session.ts +186 -0
  230. package/src/daemon/computer-use-mode-session.test.ts +210 -0
  231. package/src/daemon/computer-use-mode-session.ts +105 -0
  232. package/src/daemon/config-watcher.ts +10 -0
  233. package/src/daemon/conversation-agent-loop-handlers.ts +58 -10
  234. package/src/daemon/conversation-agent-loop.ts +219 -25
  235. package/src/daemon/conversation-evictor.ts +5 -24
  236. package/src/daemon/conversation-history.ts +10 -0
  237. package/src/daemon/conversation-interrupt-repair.ts +4 -43
  238. package/src/daemon/conversation-interrupt.ts +7 -11
  239. package/src/daemon/conversation-messaging.ts +58 -5
  240. package/src/daemon/conversation-mode-session.test.ts +1667 -0
  241. package/src/daemon/conversation-mode-session.ts +1008 -0
  242. package/src/daemon/conversation-process.ts +5 -0
  243. package/src/daemon/conversation-runtime-assembly.ts +10 -20
  244. package/src/daemon/conversation-skill-tools.ts +1 -0
  245. package/src/daemon/conversation-store.ts +4 -4
  246. package/src/daemon/conversation-surface-snapshots.ts +135 -0
  247. package/src/daemon/conversation-surfaces.ts +113 -5
  248. package/src/daemon/conversation-tool-setup.ts +8 -3
  249. package/src/daemon/conversation.ts +143 -41
  250. package/src/daemon/handlers/config-model.ts +7 -5
  251. package/src/daemon/handlers/shared.ts +55 -16
  252. package/src/daemon/host-cu-proxy.ts +51 -22
  253. package/src/daemon/lifecycle.ts +30 -13
  254. package/src/daemon/message-protocol.ts +0 -1
  255. package/src/daemon/message-provenance.ts +28 -4
  256. package/src/daemon/mode-session-startup-recovery.ts +23 -0
  257. package/src/daemon/mode-session-tracking.ts +54 -0
  258. package/src/daemon/process-message.ts +13 -4
  259. package/src/daemon/virtual-desktop-context.ts +21 -0
  260. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  261. package/src/desktop/desktop-accessibility-script.ts +88 -0
  262. package/src/desktop/desktop-accessibility.test.ts +160 -0
  263. package/src/desktop/desktop-accessibility.ts +169 -0
  264. package/src/desktop/desktop-automation-lease.test.ts +76 -68
  265. package/src/desktop/desktop-automation-lease.ts +41 -50
  266. package/src/desktop/desktop-browser-endpoint.ts +4 -0
  267. package/src/desktop/desktop-computer-use-routing.test.ts +330 -0
  268. package/src/desktop/desktop-computer-use.test.ts +449 -0
  269. package/src/desktop/desktop-computer-use.ts +502 -0
  270. package/src/desktop/desktop-dependencies.ts +47 -293
  271. package/src/desktop/desktop-display.ts +2 -2
  272. package/src/desktop/desktop-panel-config.test.ts +13 -3
  273. package/src/desktop/desktop-panel-config.ts +109 -15
  274. package/src/desktop/desktop-session-manager.test.ts +69 -21
  275. package/src/desktop/desktop-session-manager.ts +76 -22
  276. package/src/desktop/desktop-stream-bridge.test.ts +11 -122
  277. package/src/desktop/desktop-stream-bridge.ts +6 -8
  278. package/src/desktop/desktop-window-theme.ts +5 -25
  279. package/src/desktop/virtual-desktop-feature.ts +52 -2
  280. package/src/desktop/virtual-desktop-platform.test.ts +5 -9
  281. package/src/documents/document-store.ts +23 -0
  282. package/src/heartbeat/__tests__/heartbeat-service.test.ts +22 -26
  283. package/src/heartbeat/heartbeat-service.ts +5 -33
  284. package/src/ipc/__tests__/browser-ipc.test.ts +215 -6
  285. package/src/ipc/assistant-server.ts +2 -0
  286. package/src/ipc/routes/__tests__/channel-reply-ipc-routes.test.ts +111 -0
  287. package/src/ipc/routes/channel-reply-ipc-routes.ts +73 -0
  288. package/src/live-voice/__tests__/continuation-judge.test.ts +151 -0
  289. package/src/live-voice/__tests__/live-activity-reporter.test.ts +154 -15
  290. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +743 -31
  291. package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +191 -10
  292. package/src/live-voice/__tests__/live-voice-session-groups-gate.test.ts +87 -0
  293. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +5 -1
  294. package/src/live-voice/__tests__/live-voice-sight-frame-echo.test.ts +41 -0
  295. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +1017 -9
  296. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +224 -1
  297. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +101 -0
  298. package/src/live-voice/__tests__/live-voice-tts.test.ts +44 -0
  299. package/src/live-voice/__tests__/live-voice-vad.test.ts +939 -75
  300. package/src/live-voice/__tests__/microphone-idle-gate.test.ts +87 -0
  301. package/src/live-voice/__tests__/session-controls.test.ts +23 -0
  302. package/src/live-voice/__tests__/task-outcomes.test.ts +117 -0
  303. package/src/live-voice/__tests__/voice-input-diagnostics.test.ts +117 -0
  304. package/src/live-voice/camera-mode-session.test.ts +265 -0
  305. package/src/live-voice/camera-mode-session.ts +277 -0
  306. package/src/live-voice/continuation-judge.ts +123 -0
  307. package/src/live-voice/live-activity-reporter.ts +115 -6
  308. package/src/live-voice/live-voice-manager.ts +36 -1
  309. package/src/live-voice/live-voice-photo.ts +88 -7
  310. package/src/live-voice/live-voice-session-manager.ts +15 -0
  311. package/src/live-voice/live-voice-session.ts +1443 -747
  312. package/src/live-voice/live-voice-tts.ts +22 -4
  313. package/src/live-voice/microphone-idle-gate.ts +56 -0
  314. package/src/live-voice/protocol.ts +171 -4
  315. package/src/live-voice/session-controls.ts +11 -3
  316. package/src/live-voice/task-outcomes.ts +131 -0
  317. package/src/live-voice/voice-input-diagnostics.ts +117 -0
  318. package/src/mcp/__tests__/manager-tool-caps.test.ts +63 -18
  319. package/src/mcp/__tests__/tool-caps.test.ts +28 -1
  320. package/src/mcp/manager.ts +3 -0
  321. package/src/mcp/tool-caps.ts +12 -4
  322. package/src/media/__tests__/image-models.test.ts +58 -0
  323. package/src/media/image-credentials.ts +5 -0
  324. package/src/media/image-models.ts +52 -0
  325. package/src/media/image-service.ts +8 -0
  326. package/src/media/openrouter-image-service.test.ts +133 -0
  327. package/src/media/openrouter-image-service.ts +153 -0
  328. package/src/media/types.ts +1 -1
  329. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +65 -11
  330. package/src/messaging/providers/discord/send.ts +4 -17
  331. package/src/messaging/providers/discord/transport.ts +2 -2
  332. package/src/messaging/providers/outlook/client.ts +16 -22
  333. package/src/messaging/providers/telegram-bot/send.test.ts +236 -1
  334. package/src/messaging/providers/telegram-bot/send.ts +10 -7
  335. package/src/messaging/providers/telegram-bot/transport.ts +3 -10
  336. package/src/messaging/providers/whatsapp/send.test.ts +169 -4
  337. package/src/monitoring/__tests__/control.test.ts +17 -5
  338. package/src/monitoring/control.ts +15 -5
  339. package/src/notifications/AGENTS.md +1 -1
  340. package/src/notifications/README.md +83 -99
  341. package/src/notifications/__tests__/broadcaster.test.ts +1 -1
  342. package/src/notifications/__tests__/channel-allowlist.test.ts +39 -0
  343. package/src/notifications/__tests__/copy-composer.test.ts +1 -1
  344. package/src/notifications/__tests__/decision-engine.test.ts +79 -2
  345. package/src/notifications/__tests__/deterministic-checks.test.ts +10 -10
  346. package/src/notifications/__tests__/home-feed-side-effect.test.ts +93 -4
  347. package/src/notifications/__tests__/notification-utils.test.ts +18 -0
  348. package/src/notifications/adapters/macos.ts +37 -46
  349. package/src/notifications/broadcaster.ts +5 -6
  350. package/src/notifications/channel-allowlist.ts +44 -0
  351. package/src/notifications/copy-composer.ts +0 -23
  352. package/src/notifications/decision-engine.ts +24 -14
  353. package/src/notifications/deterministic-checks.ts +1 -1
  354. package/src/notifications/emit-signal.ts +123 -37
  355. package/src/notifications/home-feed-side-effect.ts +9 -7
  356. package/src/notifications/notification-utils.ts +19 -0
  357. package/src/notifications/signal.ts +4 -19
  358. package/src/notifications/types.ts +15 -0
  359. package/src/oauth/connection-resolver.ts +1 -2
  360. package/src/oauth/oauth-store.ts +1 -3
  361. package/src/oauth/provider-serializer.ts +7 -0
  362. package/src/oauth/seed-providers.ts +30 -0
  363. package/src/onboarding/onboarding-events-store.ts +99 -12
  364. package/src/persistence/__tests__/client-connection-events-store.test.ts +322 -0
  365. package/src/persistence/client-connection-events-store.ts +377 -0
  366. package/src/persistence/conversation-crud.ts +55 -6
  367. package/src/persistence/conversation-mode-sessions.test.ts +424 -0
  368. package/src/persistence/conversation-mode-sessions.ts +460 -0
  369. package/src/persistence/conversation-queries.ts +6 -19
  370. package/src/persistence/llm-usage-store.ts +3 -9
  371. package/src/persistence/message-metadata.ts +25 -0
  372. package/src/persistence/migrations/381-create-conversation-mode-sessions.ts +41 -0
  373. package/src/persistence/migrations/382-create-client-connection-events.ts +40 -0
  374. package/src/persistence/migrations/__tests__/382-create-client-connection-events.test.ts +56 -0
  375. package/src/persistence/schema/conversations.ts +67 -0
  376. package/src/persistence/schema/infrastructure.ts +29 -0
  377. package/src/persistence/steps.ts +17 -0
  378. package/src/plugin-api/__tests__/plugin-name-env.test.ts +249 -0
  379. package/src/plugin-api/effective-context-window.ts +33 -0
  380. package/src/plugin-api/index.ts +24 -5
  381. package/src/plugin-api/plugin-name-env.ts +125 -0
  382. package/src/plugin-api/resolve-credential.ts +79 -20
  383. package/src/plugin-api/vision-support.test.ts +22 -0
  384. package/src/plugin-api/vision-support.ts +17 -5
  385. package/src/plugins/__tests__/active-plugin-env.test.ts +136 -0
  386. package/src/plugins/active-plugin-env.ts +51 -0
  387. package/src/plugins/defaults/index.ts +24 -0
  388. package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
  389. package/src/plugins/defaults/memory/__tests__/worker-control.test.ts +19 -6
  390. package/src/plugins/defaults/memory/context-search/agent-protocol.ts +9 -12
  391. package/src/plugins/defaults/memory/context-search/agent-runner.ts +78 -22
  392. package/src/plugins/defaults/memory/context-search/format.ts +51 -7
  393. package/src/plugins/defaults/memory/context-search/limits.ts +10 -12
  394. package/src/plugins/defaults/memory/context-search/recall-input.ts +38 -0
  395. package/src/plugins/defaults/memory/context-search/search.ts +8 -14
  396. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +1 -0
  397. package/src/plugins/defaults/memory/context-search/types.ts +1 -11
  398. package/src/plugins/defaults/memory/graph/__tests__/handle-remember-v2.test.ts +11 -4
  399. package/src/plugins/defaults/memory/graph/tool-handlers.ts +12 -21
  400. package/src/plugins/defaults/memory/graph/tools.ts +35 -64
  401. package/src/plugins/defaults/memory/src/memory-eval-routes.ts +2 -3
  402. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +5 -0
  403. package/src/plugins/defaults/memory/src/memory-item-routes.ts +2 -1
  404. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
  405. package/src/plugins/defaults/memory/tools.test.ts +124 -9
  406. package/src/plugins/defaults/memory/tools.ts +38 -11
  407. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +165 -27
  408. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +626 -27
  409. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +46 -27
  410. package/src/plugins/defaults/memory/v3/orchestrate.ts +78 -38
  411. package/src/plugins/defaults/memory/v3/pool-log-store.ts +65 -79
  412. package/src/plugins/defaults/memory/v3/pool-select.test.ts +14 -5
  413. package/src/plugins/defaults/memory/v3/pool-select.ts +683 -44
  414. package/src/plugins/defaults/memory/worker-control.ts +15 -5
  415. package/src/plugins/defaults/task-progress/__tests__/registration.test.ts +70 -0
  416. package/src/plugins/defaults/task-progress/__tests__/task-progress-context.test.ts +380 -0
  417. package/src/plugins/defaults/task-progress/hooks/post-compact.ts +19 -0
  418. package/src/plugins/defaults/task-progress/hooks/user-prompt-submit.ts +24 -0
  419. package/src/plugins/defaults/task-progress/package.json +14 -0
  420. package/src/plugins/defaults/task-progress/src/apply-task-progress-context.ts +93 -0
  421. package/src/plugins/defaults/task-progress/src/format-task-progress-context.ts +67 -0
  422. package/src/providers/__tests__/context-overflow-error.test.ts +10 -0
  423. package/src/providers/__tests__/search-provider-catalog.test.ts +18 -0
  424. package/src/providers/anthropic/client.ts +10 -1
  425. package/src/providers/anthropic-gateway-shared.ts +6 -0
  426. package/src/providers/fetch-provider-catalog.ts +11 -0
  427. package/src/providers/gemini/client.ts +25 -8
  428. package/src/providers/inference/adapter-factory.ts +1 -1
  429. package/src/providers/jev/client.test.ts +10 -0
  430. package/src/providers/jev/client.ts +20 -4
  431. package/src/providers/model-catalog.ts +4 -3
  432. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +94 -1
  433. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +106 -1
  434. package/src/providers/openai/__tests__/chat-template-flatten.test.ts +24 -0
  435. package/src/providers/openai/__tests__/google-thought-signature.test.ts +1 -2
  436. package/src/providers/openai/chat-completions-provider.ts +100 -29
  437. package/src/providers/openai/google-thought-signature.ts +0 -6
  438. package/src/providers/openai/responses-provider.ts +23 -1
  439. package/src/providers/ratelimit.ts +1 -3
  440. package/src/providers/search-provider-catalog.ts +24 -0
  441. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +24 -0
  442. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +7 -1
  443. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +50 -1
  444. package/src/providers/speech-to-text/deepgram-flux-frames.ts +11 -0
  445. package/src/providers/speech-to-text/vellum-managed-flux-realtime.ts +13 -0
  446. package/src/providers/types.ts +5 -0
  447. package/src/routes/control.ts +7 -5
  448. package/src/runtime/AGENTS.md +4 -2
  449. package/src/runtime/actor-trust-resolver.ts +3 -3
  450. package/src/runtime/agent-wake.ts +1 -1
  451. package/src/runtime/approval-message-composer.ts +3 -69
  452. package/src/runtime/assistant-event-hub.ts +73 -75
  453. package/src/runtime/assistant-event-publish-options.ts +7 -0
  454. package/src/runtime/assistant-event-targeting.ts +79 -0
  455. package/src/runtime/assistant-stream-state.ts +13 -100
  456. package/src/runtime/background-job-runner.ts +5 -5
  457. package/src/runtime/channel-retry-sweep.ts +1 -0
  458. package/src/runtime/guardian-action-message-composer.ts +8 -161
  459. package/src/runtime/guardian-reply-router.ts +28 -32
  460. package/src/runtime/http-types.ts +6 -0
  461. package/src/runtime/message-composer-types.ts +3 -61
  462. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +67 -1
  463. package/src/runtime/migrations/vbundle-builder.ts +24 -1
  464. package/src/runtime/migrations/vbundle-import-analyzer.ts +13 -4
  465. package/src/runtime/migrations/vbundle-import-policy.ts +10 -0
  466. package/src/runtime/migrations/vbundle-importer.ts +4 -1
  467. package/src/runtime/migrations/vbundle-streaming-importer.ts +4 -1
  468. package/src/runtime/migrations/vbundle-validator.ts +1 -0
  469. package/src/runtime/pending-interactions-mode-session.test.ts +62 -0
  470. package/src/runtime/pending-interactions.ts +29 -0
  471. package/src/runtime/routes/__tests__/client-history-routes.test.ts +104 -0
  472. package/src/runtime/routes/__tests__/image-generation-routes.test.ts +97 -0
  473. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +1 -1
  474. package/src/runtime/routes/__tests__/roadmap-routes.test.ts +29 -6
  475. package/src/runtime/routes/browser-context.ts +31 -11
  476. package/src/runtime/routes/browser-routes.ts +43 -4
  477. package/src/runtime/routes/btw-routes.ts +9 -4
  478. package/src/runtime/routes/client-routes.ts +108 -0
  479. package/src/runtime/routes/conversation-management-routes.ts +4 -1
  480. package/src/runtime/routes/conversation-routes.ts +113 -27
  481. package/src/runtime/routes/credential-prompt-routes.ts +4 -3
  482. package/src/runtime/routes/credential-routes.ts +1 -2
  483. package/src/runtime/routes/desktop-setup-routes.test.ts +9 -18
  484. package/src/runtime/routes/desktop-setup-routes.ts +4 -6
  485. package/src/runtime/routes/documents-routes.ts +51 -0
  486. package/src/runtime/routes/email-routes.ts +3 -7
  487. package/src/runtime/routes/events-routes.ts +75 -54
  488. package/src/runtime/routes/identity-routes.ts +1 -5
  489. package/src/runtime/routes/image-generation-routes.ts +8 -14
  490. package/src/runtime/routes/inbound-message-handler.ts +35 -38
  491. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +168 -0
  492. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +96 -0
  493. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +38 -9
  494. package/src/runtime/routes/inbound-stages/background-dispatch.ts +2 -5
  495. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
  496. package/src/runtime/routes/llm-context-normalization.ts +1 -7
  497. package/src/runtime/routes/migration-routes.ts +73 -2
  498. package/src/runtime/routes/notification-routes.ts +10 -0
  499. package/src/runtime/routes/oauth-providers.ts +7 -0
  500. package/src/runtime/routes/plugins-routes.ts +6 -0
  501. package/src/runtime/routes/roadmap-routes.ts +47 -8
  502. package/src/runtime/routes/secret-routes.ts +1 -1
  503. package/src/runtime/routes/settings-routes.ts +10 -4
  504. package/src/runtime/routes/watcher-routes.ts +1 -3
  505. package/src/runtime/trust-class.test.ts +7 -6
  506. package/src/runtime/trust-class.ts +9 -17
  507. package/src/schedule/__tests__/worker-control.test.ts +36 -5
  508. package/src/schedule/worker-control.ts +15 -5
  509. package/src/security/secure-keys.ts +53 -103
  510. package/src/skills/clawhub.ts +3 -8
  511. package/src/skills/managed-store.ts +1 -5
  512. package/src/stt/types.ts +4 -0
  513. package/src/subagent/notify.ts +54 -34
  514. package/src/subagent/parent-notification.ts +6 -0
  515. package/src/telemetry/__tests__/phone-call-funnel.test.ts +72 -0
  516. package/src/telemetry/phone-call-funnel.ts +144 -0
  517. package/src/tools/__tests__/tool-schema-root-combinator-guard.test.ts +81 -0
  518. package/src/tools/ask-question/ask-question-tool.ts +6 -4
  519. package/src/tools/browser/browser-execution.ts +22 -59
  520. package/src/tools/browser/cdp-client/__tests__/cdp-dom-helpers.test.ts +561 -39
  521. package/src/tools/browser/cdp-client/cdp-dom-helpers.ts +435 -39
  522. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +1 -8
  523. package/src/tools/client-os.ts +15 -0
  524. package/src/tools/computer-use/skill-proxy-bridge.ts +1 -1
  525. package/src/tools/computer-use/target.ts +42 -0
  526. package/src/tools/document/document-tool.ts +13 -18
  527. package/src/tools/execution-target.ts +14 -16
  528. package/src/tools/host-terminal/host-shell.ts +4 -1
  529. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +250 -0
  530. package/src/tools/network/__tests__/web-search.test.ts +203 -0
  531. package/src/tools/network/web-fetch.ts +274 -7
  532. package/src/tools/network/web-search.ts +468 -6
  533. package/src/tools/permission-checker.ts +3 -3
  534. package/src/tools/policy-context.ts +3 -1
  535. package/src/tools/skills/load.ts +2 -1
  536. package/src/tools/skills/sandbox-runner.ts +11 -2
  537. package/src/tools/skills/skill-script-runner.ts +7 -0
  538. package/src/tools/skills/skill-tool-factory.ts +15 -1
  539. package/src/tools/terminal/__tests__/safe-env.test.ts +11 -0
  540. package/src/tools/terminal/safe-env.ts +4 -0
  541. package/src/tools/terminal/shell.ts +2 -0
  542. package/src/tools/tool-approval-handler.ts +5 -1
  543. package/src/tools/tool-types.ts +3 -3
  544. package/src/tools/types.ts +8 -2
  545. package/src/util/__tests__/worker-process-command.test.ts +5 -4
  546. package/src/util/abort-reasons.ts +18 -26
  547. package/src/util/canonicalize-identity.test.ts +21 -0
  548. package/src/util/canonicalize-identity.ts +16 -0
  549. package/src/util/errors.ts +13 -3
  550. package/src/util/provider-error-patterns.ts +3 -0
  551. package/src/util/worker-process.ts +77 -35
  552. package/src/workspace/migrations/157-add-desktop-files-launcher.ts +94 -0
  553. package/src/workspace/migrations/__tests__/156-extract-workspace-mcp-json.test.ts +4 -10
  554. package/src/workspace/migrations/__tests__/157-add-desktop-files-launcher.test.ts +113 -0
  555. package/src/workspace/migrations/registry.ts +2 -0
  556. package/src/__tests__/computer-use-window-schema.test.ts +0 -24
  557. package/src/api/events/heartbeat-alert.ts +0 -20
  558. package/src/daemon/message-types/web-activity.ts +0 -65
  559. package/src/desktop/desktop-dependencies.test.ts +0 -131
  560. package/src/tools/computer-use/definitions.ts +0 -607
@@ -13,6 +13,7 @@ import type {
13
13
  } from "../../stt/types.js";
14
14
  import {
15
15
  LiveVoiceSession,
16
+ type LiveVoiceSessionAudioArchiver,
16
17
  type LiveVoiceTtsStreamer,
17
18
  } from "../live-voice-session.js";
18
19
  import type { LiveVoiceSessionFactoryContext } from "../live-voice-session-manager.js";
@@ -54,15 +55,15 @@ const LOUD_CHUNK = pcm(8_000);
54
55
  const SUSTAINED_LOUD_CHUNK = pcm(8_000, 7_200);
55
56
 
56
57
  /**
57
- * Flux-shaped streaming transcriber: no `finalizeUtterance` (Flux owns turn
58
- * boundaries, so the session runs it per cycle), and every transcript event is
59
- * scripted by the test. `stop()` closes without inventing a trailing final,
60
- * exactly as Flux does: its transcript is committed by EndOfTurn.
58
+ * Flux has no `finalizeUtterance`: the session keeps the stream open when
59
+ * provider end-of-turn owns the boundary, otherwise it closes each cycle.
60
+ * Events are scripted, and `stop()` only flushes explicitly supplied text.
61
61
  */
62
62
  class MockFluxTranscriber implements StreamingTranscriber {
63
63
  readonly boundaryId = "daemon-streaming" as const;
64
64
  readonly received: Buffer[] = [];
65
65
  stopped = false;
66
+ onAudio: ((chunk: Buffer) => void) | null = null;
66
67
  private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
67
68
 
68
69
  constructor(readonly providerId: SttProviderId) {}
@@ -73,17 +74,25 @@ class MockFluxTranscriber implements StreamingTranscriber {
73
74
 
74
75
  sendAudio(chunk: Buffer): void {
75
76
  this.received.push(Buffer.from(chunk));
77
+ this.onAudio?.(chunk);
76
78
  }
77
79
 
78
80
  // Transcript Flux answers `CloseStream` with, for a turn still in flight
79
81
  // when the caller released. Null models a stop with nothing left to flush.
80
82
  pendingFlushText: string | null = null;
83
+ holdStopEvents = false;
81
84
 
82
85
  stop(): void {
83
86
  if (this.stopped) {
84
87
  return;
85
88
  }
86
89
  this.stopped = true;
90
+ if (!this.holdStopEvents) {
91
+ this.flushStopEvents();
92
+ }
93
+ }
94
+
95
+ flushStopEvents(): void {
87
96
  if (this.pendingFlushText !== null) {
88
97
  this.onEvent?.({ type: "final", text: this.pendingFlushText });
89
98
  }
@@ -112,15 +121,19 @@ class MockFluxTranscriber implements StreamingTranscriber {
112
121
  }
113
122
 
114
123
  function createHarness(options: {
124
+ startFrame?: LiveVoiceClientStartFrame;
115
125
  providerId?: SttProviderId;
116
126
  fluxConfig?: Partial<LiveVoiceFluxConfig>;
117
127
  silenceThresholdMs?: number;
128
+ continuationAnnounceSilenceMs?: number;
118
129
  startVoiceTurn?: (options: VoiceTurnOptions) => Promise<{
119
130
  turnId: string;
120
131
  abort: () => void;
121
132
  }>;
122
133
  streamTtsAudio?: LiveVoiceTtsStreamer | null;
123
134
  emitMetrics?: boolean;
135
+ archiveAudio?: LiveVoiceSessionAudioArchiver;
136
+ bargeInMinSpeechMs?: number;
124
137
  // Holds the STT dial open so a test can drive a whole utterance through the
125
138
  // window between `ready` and the resolved provider.
126
139
  resolveGate?: Promise<unknown>;
@@ -129,7 +142,7 @@ function createHarness(options: {
129
142
  const frames: LiveVoiceServerFrame[] = [];
130
143
  const context: LiveVoiceSessionFactoryContext = {
131
144
  sessionId: "session-123",
132
- startFrame: VAD_START_FRAME,
145
+ startFrame: options.startFrame ?? VAD_START_FRAME,
133
146
  sendFrame: mock(async (payload) => {
134
147
  const frame = sequencer.next(payload);
135
148
  frames.push(frame);
@@ -163,6 +176,9 @@ function createHarness(options: {
163
176
  startVoiceTurn,
164
177
  streamTtsAudio: options.streamTtsAudio ?? null,
165
178
  emitMetrics: options.emitMetrics ?? false,
179
+ archiveAudio: options.archiveAudio,
180
+ bargeInMinSpeechMs: options.bargeInMinSpeechMs,
181
+ continuationAnnounceSilenceMs: options.continuationAnnounceSilenceMs,
166
182
  spawnBackgroundContinuation: mock(async () => ""),
167
183
  turnDetectorConfig: {
168
184
  silenceThresholdMs: options.silenceThresholdMs ?? 40,
@@ -285,6 +301,405 @@ const FLUX_OFF = {
285
301
  } as const satisfies Partial<LiveVoiceFluxConfig>;
286
302
 
287
303
  describe("LiveVoiceSession Flux end-of-turn", () => {
304
+ test("holds a task outcome through a Flux pause and reply, then announces over idle input", async () => {
305
+ const completeTurn = autoCompletingTurn(
306
+ "The investigation found the cause.",
307
+ );
308
+ const { session, transcribers, turnCalls, frames } = createHarness({
309
+ fluxConfig: { ...FLUX_ON, eotTimeoutMs: 2_000 },
310
+ silenceThresholdMs: 30,
311
+ continuationAnnounceSilenceMs: 10,
312
+ startVoiceTurn: async (options) => {
313
+ if (options.subagentNotification) {
314
+ return completeTurn(options);
315
+ }
316
+ return { turnId: "question-turn", abort: mock() };
317
+ },
318
+ streamTtsAudio: async (options) => {
319
+ const audio = pcm(100);
320
+ options.onAudioChunk({
321
+ type: "tts_audio",
322
+ contentType: "audio/pcm",
323
+ sampleRate: SAMPLE_RATE,
324
+ dataBase64: Buffer.from(audio).toString("base64"),
325
+ });
326
+ return {
327
+ provider: "fish-audio",
328
+ contentType: "audio/pcm",
329
+ sampleRate: SAMPLE_RATE,
330
+ chunks: 1,
331
+ bytes: audio.byteLength,
332
+ };
333
+ },
334
+ });
335
+ const feedQuietAudio = async () => {
336
+ for (let index = 0; index < 12; index += 1) {
337
+ await session.handleBinaryAudio(pcm(100));
338
+ await sleep(10);
339
+ }
340
+ };
341
+ try {
342
+ await session.start();
343
+ await waitFor(() => transcribers.length === 1);
344
+ const transcriber = transcribers[0]!;
345
+ await session.handleBinaryAudio(LOUD_CHUNK);
346
+ transcriber.startOfTurn(0);
347
+ transcriber.emit({ type: "partial", text: "Could you explain" });
348
+ session.receiveSubagentNotification({
349
+ taskId: "task-1",
350
+ message: "The investigation found the cause.",
351
+ metadata: {
352
+ subagentNotification: {
353
+ subagentId: "task-1",
354
+ label: "Investigation",
355
+ status: "completed",
356
+ },
357
+ },
358
+ });
359
+
360
+ const submittedBeforePause = transcriber.received.length;
361
+ await feedQuietAudio();
362
+ expect(transcriber.received.length - submittedBeforePause).toBe(12);
363
+ expect(turnCalls).toHaveLength(0);
364
+
365
+ await session.handleBinaryAudio(LOUD_CHUNK);
366
+ transcriber.endOfTurn("Could you explain how this works?", 0);
367
+ await waitFor(() => turnCalls.length === 1);
368
+ expect(turnCalls[0]?.content).toBe("Could you explain how this works?");
369
+ await feedQuietAudio();
370
+ expect(turnCalls).toHaveLength(1);
371
+
372
+ turnCalls[0]?.callbacks?.assistant_text_delta?.(
373
+ makeTextDelta("Here is how it works."),
374
+ );
375
+ turnCalls[0]?.callbacks?.message_complete?.(makeMessageComplete());
376
+ await feedQuietAudio();
377
+ await waitFor(() => countFrames(frames, "tts_done") === 2);
378
+ expect(turnCalls).toHaveLength(2);
379
+ expect(turnCalls[1]?.hiddenSyntheticPrompt).toBe(true);
380
+ expect(turnCalls[1]?.subagentNotification?.taskId).toBe("task-1");
381
+ expect(transcribers).toHaveLength(1);
382
+ } finally {
383
+ await session.close("client_end");
384
+ }
385
+ });
386
+
387
+ test("keeps continuous idle audio out of the request recording", async () => {
388
+ const recordings: Buffer[] = [];
389
+ const { session, transcribers } = createHarness({
390
+ fluxConfig: FLUX_ON,
391
+ startVoiceTurn: autoCompletingTurn(),
392
+ archiveAudio: async (input) => {
393
+ recordings.push(Buffer.from(input.audio.dataBase64, "base64"));
394
+ return {
395
+ type: "warning",
396
+ warning: {
397
+ code: "message_id_unavailable",
398
+ message: "No persisted message in this test.",
399
+ },
400
+ };
401
+ },
402
+ });
403
+ try {
404
+ await session.start();
405
+ await waitFor(() => transcribers.length === 1);
406
+ for (let index = 0; index < 50; index++) {
407
+ await session.handleBinaryAudio(pcm(100));
408
+ }
409
+ await session.handleBinaryAudio(LOUD_CHUNK);
410
+ transcribers[0]!.endOfTurn("a complete question", 0);
411
+ await waitFor(() => recordings.length === 1);
412
+ expect(recordings).toEqual([Buffer.from(LOUD_CHUNK)]);
413
+ } finally {
414
+ await session.close("client_end");
415
+ }
416
+ });
417
+
418
+ test("replaces confirmed playback echo with equal-duration silence for Flux", async () => {
419
+ const echo = Buffer.alloc(SAMPLE_RATE * 2 * 2);
420
+ for (let index = 0; index < echo.byteLength / 2; index++) {
421
+ echo.writeInt16LE(
422
+ Math.round(4_700 * Math.sin((2 * Math.PI * 200 * index) / SAMPLE_RATE)),
423
+ index * 2,
424
+ );
425
+ }
426
+ const { session, frames, transcribers } = createHarness({
427
+ fluxConfig: FLUX_ON,
428
+ startVoiceTurn: async (options) => {
429
+ options.callbacks?.assistant_text_delta?.(
430
+ makeTextDelta("Here is an answer."),
431
+ );
432
+ return { turnId: "bridge-turn", abort: mock() };
433
+ },
434
+ streamTtsAudio: async (options) => {
435
+ options.onAudioChunk({
436
+ type: "tts_audio",
437
+ contentType: "audio/pcm",
438
+ sampleRate: SAMPLE_RATE,
439
+ dataBase64: echo.toString("base64"),
440
+ });
441
+ return {
442
+ provider: "fish-audio",
443
+ contentType: "audio/pcm",
444
+ sampleRate: SAMPLE_RATE,
445
+ chunks: 1,
446
+ bytes: echo.byteLength,
447
+ };
448
+ },
449
+ });
450
+ try {
451
+ await session.start();
452
+ await session.handleBinaryAudio(LOUD_CHUNK);
453
+ await waitFor(() => transcribers.length === 1);
454
+ const transcriber = transcribers[0]!;
455
+ transcriber.endOfTurn("a complete question", 0);
456
+ await waitFor(() => countFrames(frames, "tts_audio") === 1);
457
+ const before = transcriber.received.length;
458
+ const echoChunk = echo.subarray(0, 480);
459
+ for (let index = 0; index < 40; index++) {
460
+ await session.handleBinaryAudio(echoChunk);
461
+ }
462
+ const submitted = Buffer.concat(transcriber.received.slice(before));
463
+ expect(submitted.byteLength).toBe(echoChunk.byteLength * 40);
464
+ expect(submitted.every((byte) => byte === 0)).toBe(true);
465
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
466
+ } finally {
467
+ await session.close("client_end");
468
+ }
469
+ });
470
+
471
+ test("keeps idle quiet audio flowing without starting a user request", async () => {
472
+ const { frames, session, transcribers, turnCalls } = createHarness({
473
+ fluxConfig: FLUX_ON,
474
+ });
475
+ try {
476
+ await session.start();
477
+ await waitFor(() => transcribers.length === 1);
478
+ const quiet = pcm(100);
479
+ for (let index = 0; index < 30; index++) {
480
+ await session.handleBinaryAudio(quiet);
481
+ }
482
+ expect(transcribers[0]?.received).toEqual(
483
+ Array.from({ length: 30 }, () => Buffer.from(quiet)),
484
+ );
485
+ expect(turnCalls).toHaveLength(0);
486
+ expect(countFrames(frames, "speech_started")).toBe(0);
487
+ expect(countFrames(frames, "utterance_end")).toBe(0);
488
+ } finally {
489
+ await session.close("client_end");
490
+ }
491
+ });
492
+
493
+ test("gates idle room audio after one second while keeping Flux supplied with silence", async () => {
494
+ const { frames, session, transcribers, turnCalls } = createHarness({
495
+ fluxConfig: FLUX_ON,
496
+ });
497
+ try {
498
+ await session.start();
499
+ await waitFor(() => transcribers.length === 1);
500
+ for (let index = 0; index < 200; index += 1) {
501
+ await session.handleBinaryAudio(pcm(100));
502
+ }
503
+ const submitted = Buffer.concat(transcribers[0]!.received);
504
+ expect(submitted).toEqual(
505
+ Buffer.concat([
506
+ Buffer.from(pcm(100, SAMPLE_RATE)),
507
+ Buffer.alloc((SAMPLE_RATE * 2 * 800) / 1_000),
508
+ ]),
509
+ );
510
+ expect(transcribers[0]!.stopped).toBe(false);
511
+ expect(turnCalls).toHaveLength(0);
512
+ expect(countFrames(frames, "speech_started")).toBe(0);
513
+ expect(countFrames(frames, "utterance_end")).toBe(0);
514
+ } finally {
515
+ await session.close("client_end");
516
+ }
517
+ });
518
+
519
+ test("a long pause gates noise without releasing the question or clipping resumed speech", async () => {
520
+ const { frames, session, transcribers, turnCalls } = createHarness({
521
+ fluxConfig: { ...FLUX_ON, eotTimeoutMs: 5_000 },
522
+ startVoiceTurn: autoCompletingTurn(),
523
+ });
524
+ try {
525
+ await session.start();
526
+ await waitFor(() => transcribers.length === 1);
527
+ const transcriber = transcribers[0]!;
528
+ await session.handleBinaryAudio(LOUD_CHUNK);
529
+ transcriber.startOfTurn(0);
530
+ transcriber.emit({ type: "partial", text: "Could you explain" });
531
+ for (let index = 0; index < 200; index += 1) {
532
+ await session.handleBinaryAudio(pcm(100));
533
+ }
534
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
535
+ expect(turnCalls).toHaveLength(0);
536
+ expect(countFrames(frames, "utterance_end")).toBe(0);
537
+
538
+ const onset = pcm(300, SAMPLE_RATE / 5);
539
+ await session.handleBinaryAudio(onset);
540
+ await session.handleBinaryAudio(LOUD_CHUNK);
541
+ expect(transcriber.received.at(-1)).toEqual(
542
+ Buffer.concat([Buffer.from(onset), Buffer.from(LOUD_CHUNK)]),
543
+ );
544
+ const submitted = Buffer.concat(transcriber.received);
545
+ expect(submitted.byteLength).toBe(
546
+ LOUD_CHUNK.byteLength * 2 + SAMPLE_RATE * 2 * 2 + onset.byteLength,
547
+ );
548
+ transcriber.endOfTurn("Could you explain how penguins stay warm?", 0);
549
+ await waitFor(() => turnCalls.length === 1);
550
+ expect(turnCalls[0]?.content).toBe(
551
+ "Could you explain how penguins stay warm?",
552
+ );
553
+ expect(transcribers).toHaveLength(1);
554
+ } finally {
555
+ await session.close("client_end");
556
+ }
557
+ });
558
+
559
+ test("a replacement Flux stream does not receive the closed stream's buffered room audio", async () => {
560
+ const { session, transcribers } = createHarness({ fluxConfig: FLUX_ON });
561
+ try {
562
+ await session.start();
563
+ await waitFor(() => transcribers.length === 1);
564
+ for (let index = 0; index < 200; index += 1) {
565
+ await session.handleBinaryAudio(pcm(100));
566
+ }
567
+ transcribers[0]!.emit({ type: "closed" });
568
+ await flushAsyncCallbacks();
569
+ await session.handleBinaryAudio(LOUD_CHUNK);
570
+ await waitFor(() => transcribers.length === 2);
571
+ await waitFor(() => transcribers[1]!.received.length > 0);
572
+ expect(Buffer.concat(transcribers[1]!.received)).toEqual(
573
+ Buffer.from(LOUD_CHUNK),
574
+ );
575
+ } finally {
576
+ await session.close("client_end");
577
+ }
578
+ });
579
+
580
+ test("a provider can finish from trailing audio after the local silence boundary", async () => {
581
+ const { session, transcribers, turnCalls } = createHarness({
582
+ fluxConfig: FLUX_ON,
583
+ startVoiceTurn: autoCompletingTurn(),
584
+ });
585
+ try {
586
+ await session.start();
587
+ await session.handleBinaryAudio(LOUD_CHUNK);
588
+ await waitFor(() => transcribers.length === 1);
589
+ const transcriber = transcribers[0]!;
590
+ transcriber.emit({ type: "partial", text: "a complete question" });
591
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
592
+ let quietChunks = 0;
593
+ transcriber.onAudio = (chunk) => {
594
+ if (chunk.every((byte) => byte === 0)) {
595
+ quietChunks += 1;
596
+ if (quietChunks === 20) {
597
+ transcriber.endOfTurn("a complete question", 0);
598
+ }
599
+ }
600
+ };
601
+ for (let index = 0; index < 20; index++) {
602
+ await session.handleBinaryAudio(pcm(0));
603
+ }
604
+ await waitFor(() => turnCalls.length === 1);
605
+ expect(turnCalls[0]?.content).toBe("a complete question");
606
+ expect(transcriber.stopped).toBe(false);
607
+ expect(transcribers).toHaveLength(1);
608
+ } finally {
609
+ await session.close("client_end");
610
+ }
611
+ });
612
+
613
+ test("preserves quiet continuation audio across a pause until Flux commits", async () => {
614
+ const { frames, session, transcribers, turnCalls } = createHarness({
615
+ fluxConfig: FLUX_ON,
616
+ startVoiceTurn: autoCompletingTurn(),
617
+ });
618
+ try {
619
+ await session.start();
620
+ await session.handleBinaryAudio(LOUD_CHUNK);
621
+ await waitFor(() => transcribers.length === 1);
622
+ const transcriber = transcribers[0]!;
623
+ transcriber.emit({ type: "partial", text: "did they help invent" });
624
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
625
+ const continuation = [pcm(0), pcm(120, 7_200), pcm(0)];
626
+ const before = transcriber.received.length;
627
+ for (const chunk of continuation) {
628
+ await session.handleBinaryAudio(chunk);
629
+ }
630
+ expect(transcriber.received.slice(before)).toEqual(
631
+ continuation.map((chunk) => Buffer.from(chunk)),
632
+ );
633
+ expect(turnCalls).toHaveLength(0);
634
+ expect(countFrames(frames, "utterance_end")).toBe(0);
635
+ transcriber.endOfTurn("did they help invent ChatGPT", 0);
636
+ await waitFor(() => turnCalls.length === 1);
637
+ expect(turnCalls[0]?.content).toBe("did they help invent ChatGPT");
638
+ } finally {
639
+ await session.close("client_end");
640
+ }
641
+ });
642
+
643
+ test("gates room noise during an active answer while preserving real barge-in", async () => {
644
+ const { frames, session, transcribers, turnCalls } = createHarness({
645
+ fluxConfig: FLUX_ON,
646
+ });
647
+ try {
648
+ await session.start();
649
+ await session.handleBinaryAudio(LOUD_CHUNK);
650
+ await waitFor(() => transcribers.length === 1);
651
+ const transcriber = transcribers[0]!;
652
+ transcriber.endOfTurn("a complete question", 0);
653
+ await waitFor(() => turnCalls.length === 1);
654
+ const before = transcriber.received.length;
655
+ for (let index = 0; index < 200; index++) {
656
+ await session.handleBinaryAudio(pcm(100));
657
+ }
658
+ expect(Buffer.concat(transcriber.received.slice(before))).toEqual(
659
+ Buffer.concat([
660
+ Buffer.from(pcm(100, SAMPLE_RATE)),
661
+ Buffer.alloc((SAMPLE_RATE * 2 * 800) / 1_000),
662
+ ]),
663
+ );
664
+ expect(transcribers).toHaveLength(1);
665
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
666
+ expect(turnCalls).toHaveLength(1);
667
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
668
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
669
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
670
+ transcriber.startOfTurn(1);
671
+ await waitFor(() => countFrames(frames, "turn_cancelled") === 1);
672
+ expect(transcriber.received.at(-1)).toEqual(
673
+ Buffer.concat([
674
+ Buffer.from(pcm(100, SAMPLE_RATE / 5)),
675
+ Buffer.from(SUSTAINED_LOUD_CHUNK),
676
+ ]),
677
+ );
678
+ } finally {
679
+ await session.close("client_end");
680
+ }
681
+ });
682
+
683
+ test.each([
684
+ { providerId: "deepgram-flux" as const, fluxConfig: FLUX_OFF },
685
+ { providerId: "deepgram" as const, fluxConfig: FLUX_ON },
686
+ ])("locally endpointed streams retain idle pre-roll: %j", async (options) => {
687
+ const { session, transcribers } = createHarness(options);
688
+ try {
689
+ await session.start();
690
+ await waitFor(() => transcribers.length === 1);
691
+ await session.handleBinaryAudio(pcm(0));
692
+ expect(transcribers[0]?.received).toHaveLength(0);
693
+ await session.handleBinaryAudio(LOUD_CHUNK);
694
+ expect(transcribers[0]?.received).toEqual([
695
+ Buffer.from(pcm(0)),
696
+ Buffer.from(LOUD_CHUNK),
697
+ ]);
698
+ } finally {
699
+ await session.close("client_end");
700
+ }
701
+ });
702
+
288
703
  test("commits the turn on turn-end without an endpoint-decision leg", async () => {
289
704
  const { frames, session, transcribers, turnCalls } = createHarness({
290
705
  fluxConfig: FLUX_ON,
@@ -555,10 +970,7 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
555
970
  // The caller draws breath and keeps going before the provider's turn-end
556
971
  // for the speech that boundary closed reaches the session.
557
972
  await session.handleBinaryAudio(LOUD_CHUNK);
558
- await waitFor(
559
- () => countFrames(frames, "speech_started") === 2,
560
- "The resumed speech never re-opened a detector turn",
561
- );
973
+ expect(countFrames(frames, "speech_started")).toBe(0);
562
974
  const receivedBeforeResume = transcriber?.received.length ?? 0;
563
975
 
564
976
  transcriber?.emit({
@@ -602,10 +1014,7 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
602
1014
  // the cycle to Flux, and then the caller keeps going.
603
1015
  await sleep(PAST_SILENCE_BOUNDARY_MS);
604
1016
  await session.handleBinaryAudio(LOUD_CHUNK);
605
- await waitFor(
606
- () => countFrames(frames, "speech_started") === 2,
607
- "The resumed speech never re-opened a detector turn",
608
- );
1017
+ expect(countFrames(frames, "speech_started")).toBe(1);
609
1018
 
610
1019
  // Flux ends the turn it kept open across the pause, beating the next local
611
1020
  // silence boundary: the fast commit this feature exists for. Nothing here
@@ -649,10 +1058,7 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
649
1058
  await sleep(PAST_SILENCE_BOUNDARY_MS);
650
1059
 
651
1060
  await session.handleBinaryAudio(LOUD_CHUNK);
652
- await waitFor(
653
- () => countFrames(frames, "speech_started") === 2,
654
- "The resumed speech never re-opened a detector turn",
655
- );
1061
+ expect(countFrames(frames, "speech_started")).toBe(1);
656
1062
  const receivedBeforeResume = transcriber?.received.length ?? 0;
657
1063
 
658
1064
  // Flux closed turn 0 and opened turn 1 for the resumed speech, so the
@@ -701,7 +1107,7 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
701
1107
  await sleep(PAST_SILENCE_BOUNDARY_MS);
702
1108
 
703
1109
  await session.handleBinaryAudio(LOUD_CHUNK);
704
- await waitFor(() => countFrames(frames, "speech_started") === 2);
1110
+ expect(countFrames(frames, "speech_started")).toBe(0);
705
1111
  transcribers[0]?.emit({
706
1112
  type: "turn-end",
707
1113
  text: "what is the",
@@ -761,10 +1167,14 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
761
1167
  await session.close("client_end");
762
1168
  }, 10_000);
763
1169
 
764
- for (const flagOn of [false, true]) {
765
- test(`barge-in during playback fires from local VAD (flux turn end ${
766
- flagOn ? "on" : "off"
767
- })`, async () => {
1170
+ test.each([
1171
+ { providerId: "deepgram-flux" as const, flagOn: true },
1172
+ { providerId: "deepgram-flux" as const, flagOn: false },
1173
+ { providerId: "vellum-flux" as const, flagOn: true },
1174
+ { providerId: "vellum-flux" as const, flagOn: false },
1175
+ ])(
1176
+ "only provider speech starts interrupt playback: %j",
1177
+ async ({ providerId, flagOn }) => {
768
1178
  const streamTtsAudio = mock(async (ttsOptions: LiveVoiceTtsOptions) => {
769
1179
  ttsOptions.onAudioChunk({
770
1180
  type: "tts_audio",
@@ -781,7 +1191,8 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
781
1191
  } satisfies LiveVoiceTtsResult;
782
1192
  });
783
1193
  const { frames, session, transcribers } = createHarness({
784
- ...(flagOn ? { fluxConfig: FLUX_ON } : {}),
1194
+ providerId,
1195
+ fluxConfig: flagOn ? FLUX_ON : FLUX_OFF,
785
1196
  silenceThresholdMs: 10_000,
786
1197
  streamTtsAudio,
787
1198
  // The leg never completes, so the turn is still in flight when the
@@ -797,28 +1208,182 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
797
1208
  await session.start();
798
1209
  await session.handleBinaryAudio(LOUD_CHUNK);
799
1210
  await waitFor(() => transcribers.length > 0);
1211
+ transcribers[0]?.startOfTurn(0);
800
1212
  if (flagOn) {
801
1213
  transcribers[0]?.endOfTurn("tell me a story");
802
1214
  } else {
803
1215
  transcribers[0]?.emit({ type: "final", text: "tell me a story" });
804
1216
  await session.handleClientFrame({ type: "ptt_release" });
805
1217
  }
806
- await waitFor(() => frames.some((frame) => frame.type === "thinking"));
1218
+ await waitFor(() => frames.some((frame) => frame.type === "tts_audio"));
807
1219
 
808
- // Local energy detection, not the provider's turn model, is what
809
- // interrupts the assistant.
1220
+ const speechStarts = countFrames(frames, "speech_started");
810
1221
  await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1222
+ await flushAsyncCallbacks();
1223
+ expect(countFrames(frames, "speech_started")).toBe(speechStarts);
1224
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1225
+
1226
+ // Per-cycle streams restart the provider's turn numbering.
1227
+ transcribers.at(-1)?.startOfTurn(flagOn ? 1 : 0);
811
1228
  await waitFor(
812
- () => frames.some((frame) => frame.type === "speech_started"),
813
- "Local VAD barge-in never fired",
1229
+ () => countFrames(frames, "speech_started") === speechStarts + 1,
1230
+ "Provider speech start did not interrupt playback",
814
1231
  );
815
1232
  await waitFor(() =>
816
1233
  frames.some((frame) => frame.type === "turn_cancelled"),
817
1234
  );
1235
+ const types = frames.map((frame) => frame.type);
1236
+ expect(types.lastIndexOf("speech_started")).toBeLessThan(
1237
+ types.indexOf("turn_cancelled"),
1238
+ );
1239
+
1240
+ await session.close("client_end");
1241
+ },
1242
+ );
1243
+
1244
+ test("provider speech starts are immediate and ignore duplicate or older turn indices", async () => {
1245
+ const { frames, session, transcribers } = createHarness({
1246
+ fluxConfig: FLUX_ON,
1247
+ bargeInMinSpeechMs: 5_000,
1248
+ });
1249
+ try {
1250
+ await session.start();
1251
+ await waitFor(() => transcribers.length === 1);
1252
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1253
+ expect(countFrames(frames, "speech_started")).toBe(0);
818
1254
 
1255
+ const transcriber = transcribers[0]!;
1256
+ transcriber.startOfTurn(2);
1257
+ await waitFor(() => countFrames(frames, "speech_started") === 1);
1258
+ transcriber.startOfTurn(2);
1259
+ transcriber.startOfTurn(1);
1260
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1261
+ expect(countFrames(frames, "speech_started")).toBe(1);
1262
+ transcriber.startOfTurn(3);
1263
+ await waitFor(() => countFrames(frames, "speech_started") === 2);
1264
+ } finally {
819
1265
  await session.close("client_end");
1266
+ }
1267
+ });
1268
+
1269
+ test("provider-confirmed quiet speech interrupts a thinking turn", async () => {
1270
+ const abort = mock();
1271
+ const { frames, session, transcribers, turnCalls } = createHarness({
1272
+ fluxConfig: FLUX_ON,
1273
+ bargeInMinSpeechMs: 5_000,
1274
+ startVoiceTurn: async () => ({ turnId: "thinking-turn", abort }),
820
1275
  });
821
- }
1276
+ try {
1277
+ await session.start();
1278
+ await waitFor(() => transcribers.length === 1);
1279
+ const transcriber = transcribers[0]!;
1280
+ await session.handleBinaryAudio(LOUD_CHUNK);
1281
+ transcriber.startOfTurn(0);
1282
+ transcriber.endOfTurn("start an investigation", 0);
1283
+ await waitFor(() => turnCalls.length === 1);
1284
+ await session.handleBinaryAudio(pcm(100));
1285
+
1286
+ // The previous turn's duplicate cannot interrupt the reply it caused.
1287
+ transcriber.startOfTurn(0);
1288
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1289
+ transcriber.startOfTurn(1);
1290
+ await waitFor(() => countFrames(frames, "turn_cancelled") === 1);
1291
+ expect(countFrames(frames, "speech_started")).toBe(2);
1292
+ expect(abort).toHaveBeenCalledTimes(1);
1293
+ } finally {
1294
+ await session.close("client_end");
1295
+ }
1296
+ });
1297
+
1298
+ test("provider speech start flushes the playback tail of a completed turn", async () => {
1299
+ const { frames, session, transcribers } = createHarness({
1300
+ fluxConfig: FLUX_ON,
1301
+ startVoiceTurn: autoCompletingTurn(),
1302
+ streamTtsAudio: async (options) => {
1303
+ const audio = pcm(100, SAMPLE_RATE * 2);
1304
+ options.onAudioChunk({
1305
+ type: "tts_audio",
1306
+ contentType: "audio/pcm",
1307
+ sampleRate: SAMPLE_RATE,
1308
+ dataBase64: Buffer.from(audio).toString("base64"),
1309
+ });
1310
+ return {
1311
+ provider: "fish-audio",
1312
+ contentType: "audio/pcm",
1313
+ sampleRate: SAMPLE_RATE,
1314
+ chunks: 1,
1315
+ bytes: audio.byteLength,
1316
+ };
1317
+ },
1318
+ });
1319
+ try {
1320
+ await session.start();
1321
+ await waitFor(() => transcribers.length === 1);
1322
+ const transcriber = transcribers[0]!;
1323
+ await session.handleBinaryAudio(LOUD_CHUNK);
1324
+ transcriber.startOfTurn(0);
1325
+ transcriber.endOfTurn("tell me a story", 0);
1326
+ await waitFor(() => countFrames(frames, "tts_done") === 1);
1327
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1328
+ expect(countFrames(frames, "speech_started")).toBe(1);
1329
+
1330
+ transcriber.startOfTurn(1);
1331
+ await waitFor(() => countFrames(frames, "speech_started") === 2);
1332
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1333
+ } finally {
1334
+ await session.close("client_end");
1335
+ }
1336
+ });
1337
+
1338
+ test("provider speech start does not interrupt manual sessions", async () => {
1339
+ const { frames, session, transcribers } = createHarness({
1340
+ startFrame: { ...VAD_START_FRAME, turnDetection: "manual" },
1341
+ fluxConfig: FLUX_ON,
1342
+ });
1343
+ try {
1344
+ await session.start();
1345
+ await waitFor(() => transcribers.length === 1);
1346
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1347
+ transcribers[0]?.startOfTurn(0);
1348
+ await flushAsyncCallbacks();
1349
+ expect(countFrames(frames, "speech_started")).toBe(0);
1350
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1351
+ } finally {
1352
+ await session.close("client_end");
1353
+ }
1354
+ });
1355
+
1356
+ test("provider speech start discards a pending speculative reply", async () => {
1357
+ const discard = mock(async () => {});
1358
+ const { frames, session, transcribers, turnCalls } = createHarness({
1359
+ fluxConfig: FLUX_OFF,
1360
+ startVoiceTurn: async () => ({
1361
+ turnId: "speculative-turn",
1362
+ abort: mock(),
1363
+ discard,
1364
+ }),
1365
+ });
1366
+ try {
1367
+ await session.start();
1368
+ await waitFor(() => transcribers.length === 1);
1369
+ const transcriber = transcribers[0]!;
1370
+ await session.handleBinaryAudio(LOUD_CHUNK);
1371
+ transcriber.startOfTurn(0);
1372
+ transcriber.emit({ type: "final", text: "what is the" });
1373
+ await waitFor(() => turnCalls.length === 1);
1374
+ expect(turnCalls[0]?.unifiedVerdict).toBeDefined();
1375
+
1376
+ await session.handleBinaryAudio(LOUD_CHUNK);
1377
+ expect(discard).not.toHaveBeenCalled();
1378
+ transcriber.startOfTurn(1);
1379
+ await waitFor(() => discard.mock.calls.length === 1);
1380
+ await flushAsyncCallbacks();
1381
+ expect(countFrames(frames, "speech_started")).toBe(2);
1382
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1383
+ } finally {
1384
+ await session.close("client_end");
1385
+ }
1386
+ });
822
1387
  });
823
1388
 
824
1389
  /**
@@ -843,6 +1408,29 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
843
1408
  setConfig("services", { stt: { provider: "deepgram", providers: {} } });
844
1409
  });
845
1410
 
1411
+ test("empty Flux updates do not prevent a language change during idle input", async () => {
1412
+ const { session, transcribers } = createHarness({ fluxConfig: FLUX_ON });
1413
+ try {
1414
+ await session.start();
1415
+ await waitFor(() => transcribers.length === 1);
1416
+ transcribers[0]!.emit({ type: "partial", text: "" });
1417
+ setConfig("services", {
1418
+ stt: {
1419
+ provider: "deepgram",
1420
+ providers: { deepgram: { model: "flux" } },
1421
+ language: "es",
1422
+ },
1423
+ });
1424
+ await session.handleBinaryAudio(pcm(0));
1425
+ await waitFor(() => transcribers.length === 2);
1426
+ expect(transcribers[0]!.stopped).toBe(true);
1427
+ await session.handleBinaryAudio(pcm(100));
1428
+ expect(transcribers[1]!.received.at(-1)).toEqual(Buffer.from(pcm(100)));
1429
+ } finally {
1430
+ await session.close("client_end");
1431
+ }
1432
+ });
1433
+
846
1434
  test("waits for turn-end when the boundary lands before the dial resolves", async () => {
847
1435
  const gate = createDialGate();
848
1436
  const { frames, session, transcribers, turnCalls } = createHarness({
@@ -912,6 +1500,7 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
912
1500
  () => countFrames(frames, "utterance_end") === 1,
913
1501
  "The utterance deferred under the seeded latch never released",
914
1502
  );
1503
+ expect(countFrames(frames, "speech_started")).toBe(1);
915
1504
  await waitFor(
916
1505
  () => transcribers[0]?.stopped === true,
917
1506
  "The utterance never finished releasing",
@@ -924,6 +1513,127 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
924
1513
  await session.close("client_end");
925
1514
  });
926
1515
 
1516
+ test.each([true, false])(
1517
+ "replays local onset when the dial falls back from Flux (turn-end %s)",
1518
+ async (flagOn) => {
1519
+ const gate = createDialGate();
1520
+ const { frames, session } = createHarness({
1521
+ providerId: "vellum",
1522
+ fluxConfig: flagOn ? FLUX_ON : FLUX_OFF,
1523
+ silenceThresholdMs: 10_000,
1524
+ resolveGate: gate.promise,
1525
+ });
1526
+ try {
1527
+ await session.start();
1528
+ await session.handleBinaryAudio(LOUD_CHUNK);
1529
+ expect(countFrames(frames, "speech_started")).toBe(0);
1530
+ gate.open();
1531
+ await waitFor(() => countFrames(frames, "speech_started") === 1);
1532
+ await session.handleBinaryAudio(LOUD_CHUNK);
1533
+ expect(countFrames(frames, "speech_started")).toBe(1);
1534
+ } finally {
1535
+ gate.open();
1536
+ await session.close("client_end");
1537
+ }
1538
+ },
1539
+ );
1540
+
1541
+ test.each([
1542
+ { providerId: "vellum" as const, audio: LOUD_CHUNK, interrupts: false },
1543
+ {
1544
+ providerId: "vellum" as const,
1545
+ audio: SUSTAINED_LOUD_CHUNK,
1546
+ interrupts: true,
1547
+ },
1548
+ {
1549
+ providerId: "deepgram-flux" as const,
1550
+ audio: LOUD_CHUNK,
1551
+ interrupts: true,
1552
+ },
1553
+ ])(
1554
+ "speech released before the dial interrupts only after provider or guard confirmation: $providerId",
1555
+ async ({ providerId, audio, interrupts }) => {
1556
+ const gate = createDialGate();
1557
+ const abort = mock();
1558
+ const { frames, session, transcribers } = createHarness({
1559
+ providerId,
1560
+ fluxConfig: FLUX_OFF,
1561
+ resolveGate: gate.promise,
1562
+ startVoiceTurn: async () => ({ turnId: "greeting-turn", abort }),
1563
+ });
1564
+ try {
1565
+ await session.start();
1566
+ await session.handleClientFrame({
1567
+ type: "text",
1568
+ text: "Say hello.",
1569
+ hidden: true,
1570
+ });
1571
+ await waitFor(() => countFrames(frames, "thinking") === 1);
1572
+ await session.handleBinaryAudio(audio);
1573
+ await waitFor(() => countFrames(frames, "utterance_end") === 1);
1574
+ expect(countFrames(frames, "speech_started")).toBe(0);
1575
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1576
+
1577
+ gate.open();
1578
+ await gate.promise;
1579
+ const transcriber = transcribers[0]!;
1580
+ if (providerId === "deepgram-flux") {
1581
+ transcriber.onAudio = () => {
1582
+ transcriber.startOfTurn(0);
1583
+ };
1584
+ }
1585
+ await waitFor(() => transcriber.stopped);
1586
+ await flushAsyncCallbacks();
1587
+ expect(countFrames(frames, "speech_started")).toBe(interrupts ? 1 : 0);
1588
+ expect(countFrames(frames, "turn_cancelled")).toBe(interrupts ? 1 : 0);
1589
+ expect(abort).toHaveBeenCalledTimes(interrupts ? 1 : 0);
1590
+ } finally {
1591
+ gate.open();
1592
+ await session.close("client_end");
1593
+ }
1594
+ },
1595
+ );
1596
+
1597
+ test("a fully parked follow-up retains its guard when the next dial falls back", async () => {
1598
+ const abort = mock();
1599
+ const options = {
1600
+ providerId: "deepgram-flux" as SttProviderId,
1601
+ fluxConfig: FLUX_ON,
1602
+ startVoiceTurn: async () => ({ turnId: "reply-turn", abort }),
1603
+ };
1604
+ const { frames, session, transcribers } = createHarness(options);
1605
+ try {
1606
+ await session.start();
1607
+ await waitFor(() => transcribers.length === 1);
1608
+ const first = transcribers[0]!;
1609
+ first.holdStopEvents = true;
1610
+ first.pendingFlushText = "tell me a story";
1611
+ await session.handleBinaryAudio(LOUD_CHUNK);
1612
+ first.startOfTurn(0);
1613
+ await session.handleClientFrame({ type: "ptt_release" });
1614
+ await waitFor(() => first.stopped);
1615
+
1616
+ // The first input is released but cannot dispatch until its final arrives.
1617
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1618
+ await sleep(PAST_SILENCE_BOUNDARY_MS);
1619
+ options.providerId = "vellum";
1620
+ first.flushStopEvents();
1621
+ await waitFor(() => countFrames(frames, "thinking") === 1);
1622
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1623
+
1624
+ // Idle input arms the parked follow-up without any further speech.
1625
+ await session.handleBinaryAudio(pcm(0));
1626
+ await waitFor(() => transcribers.length === 2);
1627
+ await waitFor(() => countFrames(frames, "turn_cancelled") === 1);
1628
+ expect(abort).toHaveBeenCalledTimes(1);
1629
+ expect(transcribers[1]!.received).toContainEqual(
1630
+ Buffer.from(SUSTAINED_LOUD_CHUNK),
1631
+ );
1632
+ } finally {
1633
+ await session.close("client_end");
1634
+ }
1635
+ });
1636
+
927
1637
  test("seeds the latch from the live-voice role, not the global provider", async () => {
928
1638
  // The configuration roles exist for: live voice on flux while the global
929
1639
  // stays on a family that owns no turn boundary. Seeding from the global
@@ -950,6 +1660,7 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
950
1660
 
951
1661
  // Deferred under the seeded latch: Flux owns this boundary.
952
1662
  expect(countFrames(frames, "utterance_end")).toBe(0);
1663
+ expect(countFrames(frames, "speech_started")).toBe(0);
953
1664
  expect(turnCalls).toHaveLength(0);
954
1665
 
955
1666
  gate.open();
@@ -966,7 +1677,7 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
966
1677
  await session.close("client_end");
967
1678
  });
968
1679
 
969
- test("never seeds the latch when the flag is off", async () => {
1680
+ test("seeds only provider starts when provider end-of-turn is disabled", async () => {
970
1681
  const gate = createDialGate();
971
1682
  const { frames, session, transcribers, turnCalls } = createHarness({
972
1683
  // Turn-end explicitly off while config still selects the flux family.
@@ -979,12 +1690,13 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
979
1690
  await session.start();
980
1691
  await session.handleBinaryAudio(LOUD_CHUNK);
981
1692
 
982
- // Unchanged from today: the silence boundary releases during the dial.
1693
+ // The local silence boundary releases, but local onset cannot interrupt.
983
1694
  await waitFor(
984
1695
  () => countFrames(frames, "utterance_end") === 1,
985
1696
  "The flag-off silence boundary stopped releasing during the dial",
986
1697
  );
987
1698
  expect(turnCalls).toHaveLength(0);
1699
+ expect(countFrames(frames, "speech_started")).toBe(0);
988
1700
 
989
1701
  gate.open();
990
1702
  await waitFor(() => transcribers.length > 0);