@vellumai/assistant 0.11.3 → 0.11.4-dev.202608190019.b94dbf2

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