@vellumai/assistant 0.12.0 → 0.12.1-dev.202609141913.5e16607

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 (441) hide show
  1. package/AGENTS.md +5 -1
  2. package/ARCHITECTURE.md +11 -12
  3. package/docs/architecture/turn-actor.md +15 -8
  4. package/docs/guardian-request-flow.md +16 -0
  5. package/openapi.yaml +292 -50
  6. package/package.json +1 -1
  7. package/scripts/smoke-desktop-window-theme.ts +186 -0
  8. package/src/__tests__/always-loaded-tools-guard.test.ts +18 -10
  9. package/src/__tests__/app-compiler.test.ts +6 -0
  10. package/src/__tests__/app-control-tool-schemas.test.ts +5 -2
  11. package/src/__tests__/app-dir-path-guard.test.ts +1 -3
  12. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +5 -2
  13. package/src/__tests__/computer-use-tools.test.ts +17 -5
  14. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  15. package/src/__tests__/config-schema.test.ts +28 -29
  16. package/src/__tests__/config-watcher.test.ts +23 -8
  17. package/src/__tests__/connection-model-compat.test.ts +2 -2
  18. package/src/__tests__/conversation-key-store-disk-view.test.ts +1 -1
  19. package/src/__tests__/conversation-pairing.test.ts +26 -0
  20. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -10
  21. package/src/__tests__/conversation-queue.test.ts +48 -207
  22. package/src/__tests__/conversation-retry-discard.test.ts +54 -1
  23. package/src/__tests__/conversation-skill-tools.test.ts +27 -0
  24. package/src/__tests__/conversation-surfaces-app-control.test.ts +8 -3
  25. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  26. package/src/__tests__/conversation-title-service.test.ts +14 -10
  27. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +3 -5
  28. package/src/__tests__/conversation-tool-setup-attribution.test.ts +51 -0
  29. package/src/__tests__/credential-record-retire.test.ts +171 -1
  30. package/src/__tests__/credential-routes.test.ts +212 -3
  31. package/src/__tests__/credential-security-invariants.test.ts +4 -2
  32. package/src/__tests__/cu-unified-flow.test.ts +8 -2
  33. package/src/__tests__/document-sync-tags.test.ts +25 -0
  34. package/src/__tests__/drain-kick-guard.test.ts +0 -2
  35. package/src/__tests__/drain-requeue-on-contention.test.ts +0 -6
  36. package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
  37. package/src/__tests__/git-porcelain.test.ts +47 -0
  38. package/src/__tests__/helpers/set-workspace-mcp.ts +11 -0
  39. package/src/__tests__/host-bash-proxy.test.ts +26 -45
  40. package/src/__tests__/host-browser-proxy.test.ts +6 -17
  41. package/src/__tests__/host-file-proxy-targeted.test.ts +23 -0
  42. package/src/__tests__/host-file-proxy.test.ts +11 -23
  43. package/src/__tests__/host-transfer-proxy-targeted.test.ts +30 -9
  44. package/src/__tests__/llm-catalog-parity.test.ts +25 -0
  45. package/src/__tests__/mcp-auth-routes.test.ts +6 -9
  46. package/src/__tests__/mcp-config-secret-boundary.test.ts +78 -136
  47. package/src/__tests__/mcp-health-check.test.ts +8 -11
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +8 -10
  49. package/src/__tests__/message-deleted-hook-dispatch.test.ts +119 -0
  50. package/src/__tests__/messages-after-tiebreaker.test.ts +91 -0
  51. package/src/__tests__/messages-read-boundary-guard.test.ts +1 -1
  52. package/src/__tests__/messaging-send-tool.test.ts +355 -548
  53. package/src/__tests__/model-intents.test.ts +3 -0
  54. package/src/__tests__/notification-vellum-adapter.test.ts +85 -2
  55. package/src/__tests__/outlook-messaging-provider.test.ts +6 -6
  56. package/src/__tests__/platform-callback-registration.test.ts +8 -16
  57. package/src/__tests__/platform-client-verify-credential.test.ts +2 -0
  58. package/src/__tests__/plugin-import-boundary-guard.test.ts +13 -0
  59. package/src/__tests__/resolve-app.test.ts +135 -0
  60. package/src/__tests__/script-proxy-certs.test.ts +35 -0
  61. package/src/__tests__/service-account-token.test.ts +247 -0
  62. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
  63. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  64. package/src/__tests__/source-fingerprint.test.ts +184 -0
  65. package/src/__tests__/subagent-tool-gate-mode.test.ts +19 -3
  66. package/src/__tests__/subagent-tools.test.ts +3 -2
  67. package/src/__tests__/telephony-tts-guard.test.ts +38 -3
  68. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +4 -0
  69. package/src/__tests__/watch-retro-tool-availability.test.ts +8 -17
  70. package/src/__tests__/workspace-git-service.test.ts +26 -0
  71. package/src/__tests__/workspace-migration-154-repair-retired-codex-gpt-5-4-model-ids.test.ts +336 -0
  72. package/src/acp/__tests__/agent-process.test.ts +258 -4
  73. package/src/acp/__tests__/auth-required.test.ts +96 -0
  74. package/src/acp/__tests__/client-handler.test.ts +90 -2
  75. package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
  76. package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
  77. package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
  78. package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
  79. package/src/acp/__tests__/session-manager.test.ts +895 -4
  80. package/src/acp/agent-process.test.ts +4 -1
  81. package/src/acp/agent-process.ts +94 -9
  82. package/src/acp/auth-required.ts +50 -1
  83. package/src/acp/auto-install.test.ts +9 -7
  84. package/src/acp/client-handler.ts +16 -3
  85. package/src/acp/model-config.test.ts +166 -0
  86. package/src/acp/model-config.ts +138 -0
  87. package/src/acp/resolve-agent.test.ts +220 -6
  88. package/src/acp/resolve-agent.ts +88 -23
  89. package/src/acp/session-manager.test.ts +196 -3
  90. package/src/acp/session-manager.ts +441 -18
  91. package/src/acp/types.ts +50 -5
  92. package/src/api/events/acp-session-model-update.test.ts +88 -0
  93. package/src/api/events/acp-session-model-update.ts +46 -0
  94. package/src/api/events/app-files-changed.ts +4 -3
  95. package/src/api/events/notification-intent.ts +2 -0
  96. package/src/api/index.ts +6 -0
  97. package/src/apps/app-store.ts +1 -1
  98. package/src/apps/resolve-app.ts +81 -0
  99. package/src/apps/source-fingerprint.ts +266 -0
  100. package/src/bundler/app-compiler.ts +15 -0
  101. package/src/calls/__tests__/barge-in-guard.test.ts +81 -0
  102. package/src/{__tests__ → calls/__tests__}/call-controller.test.ts +503 -82
  103. package/src/{__tests__ → calls/__tests__}/media-stream-audio-transcode.test.ts +1 -1
  104. package/src/{__tests__ → calls/__tests__}/media-stream-output.test.ts +37 -8
  105. package/src/{__tests__ → calls/__tests__}/media-stream-parser.test.ts +1 -1
  106. package/src/{__tests__ → calls/__tests__}/media-stream-server-integration.test.ts +150 -57
  107. package/src/{__tests__ → calls/__tests__}/media-stream-stt-session.test.ts +5 -5
  108. package/src/{__tests__ → calls/__tests__}/media-turn-detector.test.ts +1 -1
  109. package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
  110. package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
  111. package/src/calls/__tests__/voice-control-protocol.test.ts +58 -25
  112. package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
  113. package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
  114. package/src/{__tests__/voice-session-bridge.test.ts → calls/__tests__/voice-session-bridge-turn-flow.test.ts} +20 -12
  115. package/src/calls/__tests__/voice-session-bridge.test.ts +37 -1
  116. package/src/calls/__tests__/voice-triage-escalate.test.ts +105 -0
  117. package/src/calls/barge-in-guard.ts +118 -0
  118. package/src/calls/call-constants.ts +24 -4
  119. package/src/calls/call-controller.ts +499 -199
  120. package/src/calls/call-transport.ts +16 -0
  121. package/src/calls/media-stream-output.ts +32 -0
  122. package/src/calls/media-stream-server.ts +82 -30
  123. package/src/calls/media-stream-stt-session.ts +15 -0
  124. package/src/{live-voice → calls}/progress-narration.ts +2 -2
  125. package/src/calls/resolve-call-tts-provider.ts +33 -2
  126. package/src/calls/tts-call-strategy.ts +9 -5
  127. package/src/calls/types.ts +2 -0
  128. package/src/calls/voice-control-protocol.ts +53 -21
  129. package/src/calls/voice-leg-coordinator.ts +189 -0
  130. package/src/calls/voice-progress-cadence.ts +423 -0
  131. package/src/calls/voice-session-bridge.ts +59 -46
  132. package/src/calls/voice-triage-escalate.ts +175 -56
  133. package/src/cli/commands/__tests__/apps.test.ts +245 -0
  134. package/src/cli/commands/__tests__/bash.test.ts +53 -0
  135. package/src/cli/commands/__tests__/roadmap.help.test.ts +19 -0
  136. package/src/cli/commands/apps.help.ts +67 -13
  137. package/src/cli/commands/apps.ts +183 -0
  138. package/src/cli/commands/bash.help.ts +8 -13
  139. package/src/cli/commands/bash.ts +7 -25
  140. package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
  141. package/src/cli/commands/channels/index.help.ts +70 -2
  142. package/src/cli/commands/channels/index.ts +16 -3
  143. package/src/cli/commands/channels/send.ts +128 -0
  144. package/src/cli/commands/credentials.help.ts +6 -2
  145. package/src/cli/commands/credentials.ts +2 -2
  146. package/src/cli/commands/mcp.help.ts +8 -8
  147. package/src/cli/commands/mcp.ts +1 -1
  148. package/src/cli/commands/roadmap.help.ts +5 -1
  149. package/src/cli/commands/roadmap.ts +1 -1
  150. package/src/cli/lib/bundled-marketplace.json +103 -90
  151. package/src/config/__tests__/default-profile-catalog.test.ts +22 -0
  152. package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
  153. package/src/config/__tests__/platform-identity.test.ts +255 -0
  154. package/src/config/__tests__/platform-rehydration.test.ts +99 -9
  155. package/src/config/acp-agent-write.ts +43 -0
  156. package/src/config/acp-defaults.test.ts +13 -3
  157. package/src/config/acp-defaults.ts +23 -6
  158. package/src/config/acp-schema.test.ts +81 -0
  159. package/src/config/acp-schema.ts +30 -2
  160. package/src/config/bundled-skills/acp/SKILL.md +22 -14
  161. package/src/config/bundled-skills/acp/TOOLS.json +5 -1
  162. package/src/config/bundled-skills/app-builder/SKILL.md +12 -9
  163. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +1 -1
  164. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  165. package/src/config/bundled-skills/messaging/TOOLS.json +3 -3
  166. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +67 -139
  167. package/src/config/bundled-skills/phone-calls/references/TRANSCRIPTS.md +1 -1
  168. package/src/config/bundled-skills/schedule/SKILL.md +3 -4
  169. package/src/config/bundled-skills/screen-annotation/SKILL.md +8 -1
  170. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +4 -1
  171. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  172. package/src/config/default-profile-catalog.ts +1 -1
  173. package/src/config/env.ts +2 -2
  174. package/src/config/feature-flag-registry.json +24 -12
  175. package/src/config/loader.ts +1 -0
  176. package/src/config/memory-v3-gate.test.ts +31 -0
  177. package/src/config/memory-v3-gate.ts +20 -16
  178. package/src/config/platform-identity.ts +201 -0
  179. package/src/config/platform-rehydration.ts +11 -27
  180. package/src/config/schema.ts +2 -2
  181. package/src/config/schemas/__tests__/live-voice.test.ts +8 -158
  182. package/src/config/schemas/__tests__/voice.test.ts +148 -0
  183. package/src/config/schemas/calls.ts +1 -28
  184. package/src/config/schemas/live-voice.ts +1 -173
  185. package/src/config/schemas/mcp.ts +1 -1
  186. package/src/config/schemas/memory-retrospective.ts +10 -0
  187. package/src/config/schemas/services.ts +8 -0
  188. package/src/config/schemas/skills.ts +0 -14
  189. package/src/config/schemas/voice.ts +150 -0
  190. package/src/credential-execution/process-manager.test.ts +18 -19
  191. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -121
  192. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +4 -1
  193. package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
  194. package/src/daemon/app-change-notify.ts +45 -0
  195. package/src/daemon/config-watcher.ts +13 -194
  196. package/src/daemon/conversation-process.ts +35 -64
  197. package/src/daemon/conversation-skill-tools.ts +13 -31
  198. package/src/daemon/conversation-store.ts +1 -1
  199. package/src/daemon/conversation-surfaces.ts +10 -8
  200. package/src/daemon/conversation-tool-setup.ts +37 -43
  201. package/src/daemon/conversation.ts +0 -3
  202. package/src/daemon/host-bash-proxy.ts +8 -12
  203. package/src/daemon/host-browser-proxy.ts +15 -1
  204. package/src/daemon/host-file-proxy.ts +8 -10
  205. package/src/daemon/host-transfer-proxy.ts +14 -18
  206. package/src/daemon/lifecycle.ts +0 -5
  207. package/src/daemon/mcp-reload-service.ts +5 -5
  208. package/src/daemon/message-types/sync.ts +1 -0
  209. package/src/daemon/orphan-reaper.ts +1 -1
  210. package/src/daemon/providers-setup.ts +1 -1
  211. package/src/daemon/shutdown-handlers.ts +1 -9
  212. package/src/daemon/skill-memory-refresh.ts +64 -0
  213. package/src/daemon/tool-side-effects.ts +7 -18
  214. package/src/desktop/__tests__/fake-desktop.ts +119 -0
  215. package/src/desktop/desktop-chrome-policy.test.ts +71 -0
  216. package/src/desktop/desktop-chrome-policy.ts +42 -0
  217. package/src/desktop/desktop-dependencies.test.ts +102 -0
  218. package/src/desktop/desktop-dependencies.ts +306 -0
  219. package/src/desktop/desktop-feature.test.ts +18 -0
  220. package/src/desktop/desktop-feature.ts +19 -0
  221. package/src/desktop/desktop-panel-config.test.ts +135 -0
  222. package/src/desktop/desktop-panel-config.ts +149 -0
  223. package/src/desktop/desktop-session-manager.test.ts +627 -0
  224. package/src/desktop/desktop-session-manager.ts +814 -0
  225. package/src/desktop/desktop-stream-bridge.test.ts +395 -0
  226. package/src/desktop/desktop-stream-bridge.ts +214 -0
  227. package/src/desktop/desktop-wallpaper.test.ts +191 -0
  228. package/src/desktop/desktop-wallpaper.ts +113 -0
  229. package/src/desktop/desktop-window-theme.ts +170 -0
  230. package/src/home/__tests__/feed-writer.test.ts +22 -0
  231. package/src/home/feed-writer.ts +11 -2
  232. package/src/hooks/types.ts +33 -0
  233. package/src/i18n/AGENTS.md +22 -0
  234. package/src/i18n/__tests__/i18n.test.ts +108 -0
  235. package/src/i18n/index.ts +16 -0
  236. package/src/i18n/locales.ts +93 -0
  237. package/src/i18n/messages.ts +61 -0
  238. package/src/i18n/title.ts +25 -0
  239. package/src/inbound/platform-callback-registration.ts +4 -5
  240. package/src/live-voice/__tests__/live-voice-metrics.test.ts +0 -3
  241. package/src/live-voice/__tests__/live-voice-progress.test.ts +15 -15
  242. package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
  243. package/src/live-voice/__tests__/runtime-websocket-shell.test.ts +13 -69
  244. package/src/live-voice/live-voice-session.ts +213 -693
  245. package/src/live-voice/protocol.ts +0 -2
  246. package/src/mcp/__tests__/effective-config.test.ts +2 -2
  247. package/src/mcp/__tests__/workspace-mcp-config.test.ts +135 -0
  248. package/src/mcp/effective-config.ts +3 -3
  249. package/src/mcp/mcp-header-store.ts +29 -45
  250. package/src/mcp/spec-schema.ts +111 -0
  251. package/src/mcp/startup.ts +8 -4
  252. package/src/mcp/workspace-mcp-config.ts +188 -0
  253. package/src/messaging/provider.ts +10 -3
  254. package/src/messaging/providers/__tests__/proactive-address.test.ts +95 -0
  255. package/src/messaging/providers/callback-routing.ts +18 -0
  256. package/src/messaging/providers/channel-transport.ts +56 -0
  257. package/src/messaging/providers/discord/transport.ts +17 -0
  258. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +6 -17
  259. package/src/messaging/providers/slack/adapter.ts +7 -44
  260. package/src/messaging/providers/slack/transport.ts +16 -0
  261. package/src/messaging/providers/telegram-bot/adapter.ts +1 -48
  262. package/src/messaging/providers/telegram-bot/client.ts +0 -26
  263. package/src/messaging/providers/telegram-bot/transport.ts +20 -0
  264. package/src/messaging/providers/whatsapp/adapter.ts +1 -48
  265. package/src/messaging/providers/whatsapp/transport.ts +18 -0
  266. package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +8 -0
  267. package/src/notifications/__tests__/assistant-reply-producer.test.ts +55 -0
  268. package/src/notifications/__tests__/guardian-feed-projection.test.ts +158 -2
  269. package/src/notifications/__tests__/home-feed-side-effect.test.ts +153 -3
  270. package/src/notifications/__tests__/schedule-result-producer.test.ts +99 -3
  271. package/src/notifications/adapters/macos.ts +3 -0
  272. package/src/notifications/assistant-reply-producer.ts +8 -3
  273. package/src/notifications/conversation-pairing.ts +36 -16
  274. package/src/notifications/delivered-post-record.ts +11 -1
  275. package/src/notifications/guardian-feed-projection.ts +125 -5
  276. package/src/notifications/home-feed-side-effect.ts +55 -7
  277. package/src/notifications/schedule-result-producer.ts +66 -22
  278. package/src/oauth/__tests__/seed-providers-managed.test.ts +54 -0
  279. package/src/oauth/byo-connection.test.ts +45 -0
  280. package/src/oauth/byo-connection.ts +36 -6
  281. package/src/oauth/connection-resolver.test.ts +73 -0
  282. package/src/oauth/connection-resolver.ts +95 -2
  283. package/src/oauth/seed-providers.ts +165 -0
  284. package/src/outbound-proxy/certs.ts +3 -5
  285. package/src/permissions/checker.test.ts +11 -6
  286. package/src/persistence/compaction-log-store-clickhouse.ts +3 -3
  287. package/src/persistence/conversation-crud.ts +71 -63
  288. package/src/persistence/conversation-lineage.ts +12 -4
  289. package/src/persistence/conversation-title-placeholders.ts +23 -0
  290. package/src/persistence/conversation-title-service.ts +9 -26
  291. package/src/persistence/llm-request-log-sink-clickhouse.ts +3 -3
  292. package/src/persistence/llm-request-log-source-clickhouse.ts +3 -3
  293. package/src/persistence/message-cursor.ts +78 -0
  294. package/src/persistence/message-reads.ts +30 -0
  295. package/src/persistence/migrations/157-invite-contact-id.ts +18 -6
  296. package/src/persistence/migrations/__tests__/157-invite-contact-id.test.ts +149 -0
  297. package/src/persistence/schema/memory-core.ts +5 -0
  298. package/src/platform/client.test.ts +4 -2
  299. package/src/platform/client.ts +2 -10
  300. package/src/plugin-api/constants.ts +2 -0
  301. package/src/plugin-api/index.ts +1 -0
  302. package/src/plugin-api/types.ts +2 -0
  303. package/src/plugins/defaults/index.ts +2 -0
  304. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +46 -0
  305. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-backfill.test.ts +118 -0
  306. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-preserve.test.ts +83 -0
  307. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +9 -5
  308. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +20 -10
  309. package/src/plugins/defaults/memory/__tests__/memory-retrospective-state.test.ts +219 -0
  310. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +31 -0
  311. package/src/plugins/defaults/memory/__tests__/memory-retrospective-trigger-check.test.ts +63 -0
  312. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
  313. package/src/plugins/defaults/memory/graph/capability-seed.ts +4 -6
  314. package/src/plugins/defaults/memory/hooks/init.ts +10 -0
  315. package/src/plugins/defaults/memory/hooks/message-deleted.ts +21 -0
  316. package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
  317. package/src/plugins/defaults/memory/jobs-worker.ts +13 -0
  318. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +34 -60
  319. package/src/plugins/defaults/memory/memory-retrospective-cursor-backfill.ts +126 -0
  320. package/src/plugins/defaults/memory/memory-retrospective-cursor-preserve.ts +79 -0
  321. package/src/plugins/defaults/memory/memory-retrospective-cursor.ts +28 -0
  322. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +3 -5
  323. package/src/plugins/defaults/memory/memory-retrospective-job.ts +12 -3
  324. package/src/plugins/defaults/memory/memory-retrospective-state.ts +312 -71
  325. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +2 -1
  326. package/src/plugins/defaults/memory/memory-retrospective-trigger-check.ts +5 -3
  327. package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
  328. package/src/plugins/defaults/memory/startup.ts +6 -1
  329. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +7 -9
  330. package/src/plugins/defaults/memory/substrate/skill-content.ts +2 -2
  331. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +89 -0
  332. package/src/plugins/defaults/memory/worker.ts +6 -0
  333. package/src/plugins/mcp-servers.ts +15 -75
  334. package/src/providers/__tests__/connection-model-compat.test.ts +3 -3
  335. package/src/providers/gemini/client.ts +9 -23
  336. package/src/providers/gemini-thought-signature.test.ts +63 -0
  337. package/src/providers/gemini-thought-signature.ts +44 -0
  338. package/src/providers/inference/auth.ts +2 -3
  339. package/src/providers/inference/connection-availability.ts +27 -8
  340. package/src/providers/inference/resolve-auth.ts +22 -4
  341. package/src/providers/inference/service-account-token.ts +248 -0
  342. package/src/providers/model-catalog.ts +34 -0
  343. package/src/providers/model-intents.ts +10 -5
  344. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +146 -18
  345. package/src/providers/openai/__tests__/chat-completions-provider-thought-signature.test.ts +613 -0
  346. package/src/providers/openai/__tests__/google-thought-signature.test.ts +235 -0
  347. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +83 -1
  348. package/src/providers/openai/chat-completions-provider.ts +192 -78
  349. package/src/providers/openai/codex-models.ts +0 -4
  350. package/src/providers/openai/google-thought-signature.ts +320 -0
  351. package/src/runtime/AGENTS.md +2 -0
  352. package/src/runtime/__tests__/channel-send.test.ts +443 -0
  353. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +89 -0
  354. package/src/runtime/__tests__/live-voice-upgrade.test.ts +58 -0
  355. package/src/runtime/__tests__/runtime-ws-test-utils.ts +72 -0
  356. package/src/runtime/__tests__/watch-stream-upgrade.test.ts +59 -0
  357. package/src/runtime/auth/same-actor.ts +38 -3
  358. package/src/runtime/channel-send.ts +316 -0
  359. package/src/runtime/http-server.ts +160 -151
  360. package/src/runtime/routes/__tests__/acp-routes.test.ts +159 -4
  361. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +205 -0
  362. package/src/runtime/routes/__tests__/channel-send-routes.test.ts +127 -0
  363. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
  364. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +2 -2
  365. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +107 -0
  366. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +16 -12
  367. package/src/runtime/routes/acp-routes.test.ts +15 -0
  368. package/src/runtime/routes/acp-routes.ts +72 -9
  369. package/src/runtime/routes/app-management-routes.ts +73 -0
  370. package/src/runtime/routes/app-routes.ts +1 -1
  371. package/src/runtime/routes/channel-send-routes.ts +85 -0
  372. package/src/runtime/routes/conversation-list-routes.ts +14 -3
  373. package/src/runtime/routes/conversation-query-routes.ts +40 -0
  374. package/src/runtime/routes/credential-routes.ts +19 -10
  375. package/src/runtime/routes/desktop-setup-routes.test.ts +74 -0
  376. package/src/runtime/routes/desktop-setup-routes.ts +35 -0
  377. package/src/runtime/routes/diagnostics-routes.ts +55 -3
  378. package/src/runtime/routes/index.ts +4 -2
  379. package/src/runtime/routes/log-export-routes.ts +2 -0
  380. package/src/runtime/routes/mcp-auth-routes.ts +39 -47
  381. package/src/runtime/routes/migration-routes.ts +6 -20
  382. package/src/runtime/routes/platform-routes.ts +18 -15
  383. package/src/runtime/services/__tests__/conversation-serializer.test.ts +28 -0
  384. package/src/runtime/services/conversation-serializer.ts +16 -3
  385. package/src/runtime/sync/resource-sync-events.ts +2 -1
  386. package/src/schedule/worker.ts +1 -1
  387. package/src/security/__tests__/ces-http-record-backend.test.ts +137 -0
  388. package/src/security/ces-http-record-backend.ts +120 -0
  389. package/src/security/ces-rpc-record-backend.ts +7 -0
  390. package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
  391. package/src/skills/workspace-skill-md-signature.ts +50 -0
  392. package/src/subagent/consult-context.ts +12 -25
  393. package/src/tools/acp/list-agents.test.ts +3 -1
  394. package/src/tools/acp/spawn.test.ts +146 -11
  395. package/src/tools/acp/spawn.ts +21 -3
  396. package/src/tools/acp/status.test.ts +94 -0
  397. package/src/tools/acp/status.ts +40 -3
  398. package/src/tools/app-control/skill-proxy-bridge.ts +2 -1
  399. package/src/tools/capability-offer.test.ts +22 -2
  400. package/src/tools/capability-offer.ts +16 -1
  401. package/src/tools/computer-use/definitions.ts +4 -2
  402. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -1
  403. package/src/tools/credentials/metadata-store.ts +100 -0
  404. package/src/tools/host-filesystem/edit.test.ts +2 -1
  405. package/src/tools/host-filesystem/edit.ts +2 -2
  406. package/src/tools/host-filesystem/read.test.ts +2 -1
  407. package/src/tools/host-filesystem/read.ts +2 -2
  408. package/src/tools/host-filesystem/transfer.test.ts +2 -1
  409. package/src/tools/host-filesystem/transfer.ts +2 -2
  410. package/src/tools/host-filesystem/write.test.ts +2 -1
  411. package/src/tools/host-filesystem/write.ts +2 -2
  412. package/src/tools/host-terminal/host-shell.test.ts +16 -0
  413. package/src/tools/host-terminal/host-shell.ts +4 -20
  414. package/src/tools/policy-context.ts +6 -7
  415. package/src/tools/subagent/spawn.ts +5 -8
  416. package/src/tools/terminal/__tests__/safe-env.test.ts +6 -6
  417. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +57 -0
  418. package/src/tools/terminal/safe-env.ts +23 -3
  419. package/src/tools/terminal/sanitized-bash.ts +105 -0
  420. package/src/tools/watch/watch-retro-report.ts +0 -8
  421. package/src/util/__tests__/language-subtag.test.ts +23 -1
  422. package/src/util/ca-bundle.ts +13 -0
  423. package/src/util/language-subtag.ts +16 -0
  424. package/src/util/platform.ts +8 -0
  425. package/src/workspace/git-porcelain.ts +66 -0
  426. package/src/workspace/git-service.ts +5 -54
  427. package/src/workspace/migrations/154-repair-retired-codex-gpt-5-4-model-ids.ts +258 -0
  428. package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
  429. package/src/workspace/migrations/156-extract-workspace-mcp-json.ts +214 -0
  430. package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
  431. package/src/workspace/migrations/__tests__/156-extract-workspace-mcp-json.test.ts +199 -0
  432. package/src/workspace/migrations/registry.ts +6 -0
  433. package/src/__tests__/app-source-watcher.test.ts +0 -218
  434. package/src/__tests__/config-watcher-skill-reseed.test.ts +0 -359
  435. package/src/__tests__/speaker-identification.test.ts +0 -56
  436. package/src/calls/speaker-identification.ts +0 -233
  437. package/src/daemon/app-source-watcher.ts +0 -218
  438. package/src/messaging/providers/whatsapp/client.ts +0 -29
  439. package/src/runtime/routes/debug-bash-routes.ts +0 -173
  440. package/src/util/fs-watcher-error.ts +0 -36
  441. /package/src/{live-voice → calls}/progress-phrases.ts +0 -0
@@ -1,19 +1,38 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
+ import {
5
+ type BargeInGuard,
6
+ createBargeInGuard,
7
+ DEFAULT_BARGE_IN_MIN_SPEECH_MS,
8
+ } from "../calls/barge-in-guard.js";
4
9
  import {
5
10
  MediaTurnDetector,
6
11
  type TurnDetectorConfig,
7
12
  } from "../calls/media-turn-detector.js";
13
+ import {
14
+ createVoiceProgressNarrator,
15
+ type VoiceProgressNarrator,
16
+ } from "../calls/progress-narration.js";
17
+ import {
18
+ APPROVAL_PENDING_PHRASE_BY_LANGUAGE,
19
+ approvalPendingPhraseFor,
20
+ } from "../calls/progress-phrases.js";
8
21
  import {
9
22
  SPOKEN_REPLY_LENGTH_RULE,
10
23
  SPOKEN_REPLY_PLAIN_TEXT_RULE,
11
24
  } from "../calls/spoken-reply-rules.js";
12
25
  import { sanitizeForTts } from "../calls/tts-text-sanitizer.js";
26
+ import { createControlMarkerHoldback } from "../calls/voice-control-protocol.js";
27
+ import {
28
+ createFrontDoorLegCoordinator,
29
+ type FrontDoorLegCoordinator,
30
+ type SpokenEscalationBridge,
31
+ } from "../calls/voice-leg-coordinator.js";
13
32
  import {
14
- isIncompleteControlMarkerTail,
15
- stripInternalSpeechMarkers,
16
- } from "../calls/voice-control-protocol.js";
33
+ createProgressCadence,
34
+ type ProgressCadence,
35
+ } from "../calls/voice-progress-cadence.js";
17
36
  import type {
18
37
  VoiceTurnHandle,
19
38
  VoiceTurnOptions,
@@ -23,24 +42,16 @@ import {
23
42
  resolveProcessingWaitMs,
24
43
  waitForPriorTurnTeardown,
25
44
  } from "../calls/voice-session-bridge.js";
26
- import {
27
- capEscalationBridge,
28
- classifyFrontDoorLeading,
29
- ESCALATE_VERDICT_TOKEN,
30
- ESCALATION_CONTINUATION_CONTENT,
31
- FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
32
- fallbackEscalationBridgeFor,
33
- isEscalationBridgeComplete,
34
- MIN_SPOKEN_BRIDGE_CHARS,
35
- type VoiceRoutingLeg,
36
- } from "../calls/voice-triage-escalate.js";
45
+ import { type VoiceRoutingLeg } from "../calls/voice-triage-escalate.js";
37
46
  import { getConfig } from "../config/loader.js";
38
47
  import {
39
48
  type LiveVoiceFluxConfig,
40
49
  LiveVoiceFluxConfigSchema,
41
- type LiveVoiceFrontModelConfig,
42
- LiveVoiceFrontModelConfigSchema,
43
50
  } from "../config/schemas/live-voice.js";
51
+ import {
52
+ type VoiceFrontModelConfig,
53
+ VoiceFrontModelConfigSchema,
54
+ } from "../config/schemas/voice.js";
44
55
  import { ABORT_WATCHDOG_MS } from "../daemon/abort-watchdog.js";
45
56
  import { isRefusedInReadOnlyPass } from "../daemon/conversation-tool-setup.js";
46
57
  import type { TrustContext } from "../daemon/trust-context-types.js";
@@ -93,7 +104,7 @@ import {
93
104
  } from "../tts/reasoning-tag-filter.js";
94
105
  import { extractSpeakableSegments } from "../tts/speakable-segments.js";
95
106
  import { createAbortReason } from "../util/abort-reasons.js";
96
- import { hasLocalizedEntry } from "../util/language-subtag.js";
107
+ import { fixedPhraseLanguage } from "../util/language-subtag.js";
97
108
  import { getLogger } from "../util/logger.js";
98
109
  import {
99
110
  activityLabelForTool,
@@ -136,17 +147,6 @@ import type {
136
147
  LiveVoiceTtsOptions,
137
148
  LiveVoiceTtsResult,
138
149
  } from "./live-voice-tts.js";
139
- import {
140
- createVoiceProgressNarrator,
141
- type VoiceProgressNarrator,
142
- type VoiceProgressTextInput,
143
- } from "./progress-narration.js";
144
- import {
145
- APPROVAL_PENDING_PHRASE_BY_LANGUAGE,
146
- approvalPendingPhraseFor,
147
- pickProgressPhrase,
148
- PROGRESS_FALLBACK_PHRASES_BY_LANGUAGE,
149
- } from "./progress-phrases.js";
150
150
  import {
151
151
  type LiveVoiceClientAttachFrameFrame,
152
152
  type LiveVoiceClientAttachImageFrame,
@@ -200,7 +200,6 @@ const FINALIZE_GRACE_MS = 1_000;
200
200
  // upstream of the provider, which hears only the microphone: it cannot tell
201
201
  // our own TTS bleeding through imperfect echo cancellation from the caller,
202
202
  // and would report a user turn nobody took.
203
- const DEFAULT_BARGE_IN_MIN_SPEECH_MS = 250;
204
203
  // The playback echo gate learns microphone energy while assistant audio is
205
204
  // expected at the speaker. Input must rise above the learned level by this
206
205
  // margin to count as user speech.
@@ -236,24 +235,6 @@ const NOISE_FLOOR_MAX_BASE_MULTIPLE = 4;
236
235
  // tracks the effective trailing-silence threshold (the detector keeps its own
237
236
  // copy private) so the endpoint decider can report the pause length.
238
237
  const DEFAULT_SILENCE_THRESHOLD_MS = 800;
239
- // Longest continuous sub-threshold gap the sustained-speech barge-in run
240
- // tolerates without resetting. A gap this short is a syllable boundary, or the
241
- // choppy energy the browser's half-duplex echo canceller produces while the
242
- // assistant is still playing (it ducks the user's near-end voice, so post-AEC
243
- // user speech arrives as intermittent above-gate chunks) — so the run keeps
244
- // accumulating across it and a barge-in during playback still lands. Only a
245
- // longer continuous silence (a real end of speech, or an isolated cough) resets
246
- // the run.
247
- const BARGE_IN_GAP_TOLERANCE_MS = 200;
248
- // Ceiling on cumulative sub-threshold time across a whole barge-in run, as a
249
- // multiple of bargeInMinSpeechMs. Per-gap tolerance alone lets sparse isolated
250
- // blips (e.g. a 10 ms echo spike every 200 ms) each clear the consecutive-gap
251
- // timer while retaining prior speech, so they would sum to the guard over
252
- // several seconds and fire a barge-in with no sustained user speech. Capping the
253
- // run's total tolerated silence imposes a minimum above-gate duty cycle
254
- // (1 / (1 + ratio) ≈ 20%): once the run is mostly silence it resets, so genuine
255
- // choppy speech still lands but periodic noise cannot accumulate into one.
256
- const BARGE_IN_MAX_TOLERATED_SILENCE_RATIO = 4;
257
238
  // Slack added to the configured end-of-turn timeout before the session stops
258
239
  // waiting for an event that is not coming and falls the utterance back onto
259
240
  // the silence-boundary path. A turn-detecting provider force-ends its own turn
@@ -407,11 +388,11 @@ export interface LiveVoiceSessionOptions {
407
388
  finalizeGraceMs?: number;
408
389
  /**
409
390
  * Voice front-door endpointing and progress narration tuning. The
410
- * production factory seeds this from `liveVoice.frontModel` config when
391
+ * production factory seeds this from `voice.frontModel` config when
411
392
  * unset; absent fields fall back to the schema defaults (the constructor
412
393
  * schema-parses the partial into a complete config).
413
394
  */
414
- frontModelConfig?: Partial<LiveVoiceFrontModelConfig>;
395
+ frontModelConfig?: Partial<VoiceFrontModelConfig>;
415
396
  /**
416
397
  * Deepgram Flux turn-detection tuning. The production factory seeds this
417
398
  * from `liveVoice.flux` config when unset; absent fields fall back to the
@@ -421,7 +402,7 @@ export interface LiveVoiceSessionOptions {
421
402
  fluxConfig?: Partial<LiveVoiceFluxConfig>;
422
403
  /**
423
404
  * Progress narration service. The production factory constructs it from
424
- * `liveVoice.frontModel.progress` config when unset. `null` disables
405
+ * `voice.frontModel.progress` config when unset. `null` disables
425
406
  * generated narration, so progress updates use their static fallback.
426
407
  */
427
408
  progressNarrator?: VoiceProgressNarrator | null;
@@ -604,70 +585,6 @@ interface TtsSegmentJob {
604
585
  frames: Promise<void>;
605
586
  }
606
587
 
607
- // One tool operation observed on a turn, fed by the bridge's structured tool
608
- // callbacks. `completedAtMs`/`isError`/`resultPreview` land with tool_result.
609
- interface TurnProgressOp {
610
- toolName: string;
611
- toolUseId?: string;
612
- startedAtMs: number;
613
- completedAtMs?: number;
614
- isError?: boolean;
615
- resultPreview?: string;
616
- }
617
-
618
- // Per-turn tool-activity log and narration cadence state for spoken progress
619
- // updates (liveVoice.frontModel.progress).
620
- interface TurnProgressState {
621
- // Tool operations this turn, in start order.
622
- ops: TurnProgressOp[];
623
- // Ops accumulated toward the next ops-triggered narration. Counted once per
624
- // op, on start (not completion), so a burst of slow tools still trips the
625
- // threshold while they run.
626
- opsSinceNarration: number;
627
- // Bumped by every observable change to the turn's tool activity — an op
628
- // starting or finishing. The idle trigger compares it against
629
- // `narratedEpoch` so a tick with nothing new to report stays silent.
630
- stateEpoch: number;
631
- // The `stateEpoch` the last spoken narration described: the activity the
632
- // user has already been told about.
633
- narratedEpoch: number;
634
- // Narrations actually spoken this turn — the metrics count and the
635
- // decider's 1-based updateIndex. Rate, not count, bounds narration:
636
- // idleIntervalMs/minGapMs cap the cadence and the session duration cap
637
- // bounds the turn.
638
- updatesSpoken: number;
639
- // When the last spoken floor-holder (ack or narration) enqueued — gates the
640
- // progress.minGapMs spacing guard. Null until something speaks.
641
- lastFloorHolderAtMs: number | null;
642
- // When the turn's last TTS segment finished emitting (turn launch until
643
- // anything speaks). Together with the session-level playback-tail estimate
644
- // this anchors the dead-air countdown: idle time is measured from when the
645
- // user last heard something, not from when the turn started.
646
- lastAudibleAtMs: number;
647
- // Self-re-arming dead-air narration timer; null once cleared or fired.
648
- idleTimer: ReturnType<typeof setTimeout> | null;
649
- // A narration generation is awaiting the decider; serializes narrations.
650
- narrationInFlight: boolean;
651
- }
652
-
653
- // Newest incomplete op, optionally restricted to a tool name (the
654
- // tool_result fallback match when no toolUseId correlates).
655
- function findLastIncompleteOp(
656
- ops: TurnProgressOp[],
657
- toolName?: string,
658
- ): TurnProgressOp | undefined {
659
- for (let i = ops.length - 1; i >= 0; i -= 1) {
660
- const op = ops[i];
661
- if (
662
- op.completedAtMs === undefined &&
663
- (toolName === undefined || op.toolName === toolName)
664
- ) {
665
- return op;
666
- }
667
- }
668
- return undefined;
669
- }
670
-
671
588
  interface ActiveAssistantTurn {
672
589
  token: symbol;
673
590
  turnId: string;
@@ -683,7 +600,9 @@ interface ActiveAssistantTurn {
683
600
  handle: VoiceTurnHandle | null;
684
601
  // When the turn launched, for narration's turnElapsedMs.
685
602
  launchedAtMs: number;
686
- progress: TurnProgressState;
603
+ // Tool-activity log and spoken progress narration for the turn
604
+ // (voice.frontModel.progress), shared with the phone driver.
605
+ progress: ProgressCadence;
687
606
  assistantCompleted: boolean;
688
607
  ttsDone: boolean;
689
608
  // Latched when the turn puts something on screen (a ui tool ran); consumed
@@ -803,7 +722,9 @@ interface ActiveAssistantTurn {
803
722
  // Guards the
804
723
  // front-door leg's trailing completion from finalizing the turn, and makes
805
724
  // the hand-off idempotent.
806
- escalationHandedOff: boolean;
725
+ // The front-door leg's coordinator: whether it handed the turn off to the
726
+ // escalated leg. Null until the front-door leg starts.
727
+ frontDoor: FrontDoorLegCoordinator | null;
807
728
  ttsBuffer: string;
808
729
  // What the caller actually hears this turn, summed over the model's own
809
730
  // segments (acks and progress narration do not count). Logged at tts_done
@@ -829,55 +750,6 @@ interface ActiveAssistantTurn {
829
750
  assistantAudioSampleRate?: number;
830
751
  }
831
752
 
832
- /**
833
- * Control-marker hygiene for one model leg's delta stream, shared by the
834
- * front-door answer stage and the default/escalated leg. The returned flush
835
- * forwards the stripped (stripInternalSpeechMarkers) prefix of `raw` that has
836
- * not been emitted yet and cannot contain a still-streaming control marker:
837
- * the flush stops at the first "[" whose tail is an incomplete marker
838
- * (isIncompleteControlMarkerTail) and holds from there until a later delta
839
- * completes or disproves it; `force` (leg completion) emits the held tail so
840
- * real text that merely resembles a marker prefix is not dropped. The scan
841
- * runs forward from the emitted boundary — not from the last "[" — so
842
- * brackets INSIDE a streaming marker body (a JSON array or "]"-bearing string
843
- * in ASK_GUARDIAN_APPROVAL) can neither mask the marker's start nor pass as
844
- * its terminator.
845
- *
846
- * **Markers are stripped, never acted on.** Nothing the model can say
847
- * minimizes the room any more: that is decided by whether a ui tool ran (see
848
- * the `tool_use_start` handler), so the reveal cannot depend on the model
849
- * remembering a token, and a reply whose content happens to contain "[-1]" (an
850
- * array literal, a temperature) cannot move the room either. No prompt teaches
851
- * a marker, so this stripping is defense against a model that emits one
852
- * regardless: an unspoken, unpersisted "[-1]" is the correct handling of a
853
- * token that now means nothing.
854
- */
855
- function createControlMarkerHoldback(
856
- turn: ActiveAssistantTurn,
857
- emit: (chunk: string) => void,
858
- ): (raw: string, opts?: { force?: boolean }) => void {
859
- let emitted = 0;
860
- return (raw, opts) => {
861
- let safeEnd = raw.length;
862
- if (opts?.force !== true) {
863
- for (
864
- let i = raw.indexOf("[", emitted);
865
- i !== -1;
866
- i = raw.indexOf("[", i + 1)
867
- ) {
868
- if (isIncompleteControlMarkerTail(raw.slice(i))) {
869
- safeEnd = i;
870
- break;
871
- }
872
- }
873
- }
874
- if (safeEnd > emitted) {
875
- emit(stripInternalSpeechMarkers(raw.slice(emitted, safeEnd)));
876
- emitted = safeEnd;
877
- }
878
- };
879
- }
880
-
881
753
  // Base control prompt for every live-voice turn. Opens with the spoken-reply
882
754
  // rules shared with the phone path (spoken-reply-rules.ts): this is the only
883
755
  // place the model learns that its text is spoken, since the system prompt has
@@ -1289,22 +1161,17 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1289
1161
  // assistant turn is audibly speaking: above-gate speech-chunk duration
1290
1162
  // accumulates until it reaches bargeInMinSpeechMs, then the deferred
1291
1163
  // speech_started + barge-in fire (at most once per onset). Brief sub-threshold
1292
- // gaps are tolerated (see BARGE_IN_GAP_TOLERANCE_MS); the run resets on a
1164
+ // gaps are tolerated (see barge-in-guard.ts); the run resets on a
1293
1165
  // single longer continuous silence, or once cumulative tolerated silence
1294
- // exceeds the duty-cycle ceiling (see BARGE_IN_MAX_TOLERATED_SILENCE_RATIO).
1166
+ // exceeds the duty-cycle ceiling (see barge-in-guard.ts).
1295
1167
  // The detector's utterance end discards the guard.
1296
1168
  private pendingBargeIn: {
1297
1169
  // Null when guarding only the post-tts_done drain window (the turn is
1298
1170
  // already finalized but the client is still playing its tail).
1299
1171
  turn: ActiveAssistantTurn | null;
1300
- speechMs: number;
1301
- // Consecutive sub-threshold (non-speech) time since the last speech chunk;
1302
- // resets speechMs once it exceeds BARGE_IN_GAP_TOLERANCE_MS.
1303
- silenceMs: number;
1304
- // Cumulative sub-threshold time over the whole run (not reset by speech
1305
- // chunks); resets speechMs once it exceeds the duty-cycle ceiling so sparse
1306
- // periodic blips cannot sum into a barge-in.
1307
- toleratedSilenceMs: number;
1172
+ // The shared sustained-speech accounting (gap tolerance, duty-cycle
1173
+ // ceiling, threshold).
1174
+ guard: BargeInGuard;
1308
1175
  } | null = null;
1309
1176
  // Estimated wall-clock ms until the client finishes draining the
1310
1177
  // assistant audio sent so far. The server clears the turn right after
@@ -1400,9 +1267,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1400
1267
  // never emits an answer.
1401
1268
  private readonly progressNarrator: VoiceProgressNarrator | null;
1402
1269
  // Complete front-model tunables: the constructor schema-parses the partial
1403
- // option once, so every field carries its `liveVoice.frontModel` schema
1270
+ // option once, so every field carries its `voice.frontModel` schema
1404
1271
  // default when unset.
1405
- private readonly frontModelConfig: LiveVoiceFrontModelConfig;
1272
+ private readonly frontModelConfig: VoiceFrontModelConfig;
1406
1273
  // Complete Flux tunables (the constructor schema-parses the partial option
1407
1274
  // once, so every field carries its `liveVoice.flux` schema default).
1408
1275
  private readonly fluxConfig: LiveVoiceFluxConfig;
@@ -1502,7 +1369,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1502
1369
  options.echoDrainSlackMs ?? DEFAULT_ECHO_DRAIN_SLACK_MS;
1503
1370
  this.finalizeGraceMs = options.finalizeGraceMs ?? FINALIZE_GRACE_MS;
1504
1371
  this.progressNarrator = options.progressNarrator ?? null;
1505
- this.frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
1372
+ this.frontModelConfig = VoiceFrontModelConfigSchema.parse(
1506
1373
  options.frontModelConfig ?? {},
1507
1374
  );
1508
1375
  this.fluxConfig = LiveVoiceFluxConfigSchema.parse(options.fluxConfig ?? {});
@@ -2488,7 +2355,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2488
2355
 
2489
2356
  if (this.echoWindowTotalAudioMs === 0) {
2490
2357
  this.echoWindowGuardCarryover =
2491
- this.pendingBargeIn !== null && this.pendingBargeIn.speechMs > 0;
2358
+ this.pendingBargeIn !== null && this.pendingBargeIn.guard.speechMs > 0;
2492
2359
  } else if (this.pendingBargeIn === null) {
2493
2360
  this.echoWindowGuardCarryover = false;
2494
2361
  }
@@ -2533,7 +2400,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2533
2400
  );
2534
2401
  if (meanAmplitude > speechThreshold) {
2535
2402
  const guardHasSpeech =
2536
- this.pendingBargeIn !== null && this.pendingBargeIn.speechMs > 0;
2403
+ this.pendingBargeIn !== null && this.pendingBargeIn.guard.speechMs > 0;
2537
2404
  if (!guardHasSpeech && this.echoMatchesAssistant(chunk)) {
2538
2405
  this.updateEchoEnergy(meanAmplitude, chunkMs);
2539
2406
  return [{ chunk, classification: "echo" }];
@@ -2831,9 +2698,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2831
2698
  // accumulates (trackBargeInGuard), so no speech is lost either way.
2832
2699
  this.pendingBargeIn = {
2833
2700
  turn: bargeableTurn,
2834
- speechMs: 0,
2835
- silenceMs: 0,
2836
- toleratedSilenceMs: 0,
2701
+ guard: createBargeInGuard(this.bargeInMinSpeechMs),
2837
2702
  };
2838
2703
  return;
2839
2704
  }
@@ -2857,71 +2722,42 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2857
2722
 
2858
2723
  // Advance the sustained-speech barge-in guard by one server-VAD chunk.
2859
2724
  // Speech accumulates toward bargeInMinSpeechMs, short true-silence gaps are
2860
- // tolerated, and classified playback echo resets the run immediately.
2861
- // Longer or mostly silent runs reset through the existing gap limits.
2725
+ // tolerated, and classified playback echo resets the run immediately (the
2726
+ // shared guard owns that accounting). A reset also drops the echo state a
2727
+ // run carried over, so the classifier re-learns the playback level.
2862
2728
  private trackBargeInGuard(
2863
2729
  classification: VadEnergyClassification,
2864
2730
  chunk: Buffer,
2865
2731
  ): void {
2866
- const guard = this.pendingBargeIn;
2867
- if (!guard) {
2732
+ const pending = this.pendingBargeIn;
2733
+ if (!pending) {
2868
2734
  return;
2869
2735
  }
2870
2736
  const chunkMs = pcm16DurationMs(
2871
2737
  chunk.byteLength,
2872
2738
  this.context.startFrame.audio.sampleRate,
2873
2739
  );
2874
- if (classification === "echo") {
2875
- this.resetBargeInGuardRun();
2876
- return;
2877
- }
2878
- if (classification === "silence") {
2879
- guard.silenceMs += chunkMs;
2880
- guard.toleratedSilenceMs += chunkMs;
2881
- // Strictly greater on the per-gap check: a gap of exactly
2882
- // BARGE_IN_GAP_TOLERANCE_MS is still tolerated. The web client batches PCM
2883
- // into 50 ms frames, so a run of ducked frames lands on the boundary
2884
- // exactly (e.g. four frames = 200 ms). The run also resets once its total
2885
- // tolerated silence outweighs the speech by the duty-cycle ceiling, so
2886
- // sparse periodic blips can never sum to the guard.
2887
- if (
2888
- guard.silenceMs > BARGE_IN_GAP_TOLERANCE_MS ||
2889
- guard.toleratedSilenceMs >
2890
- this.bargeInMinSpeechMs * BARGE_IN_MAX_TOLERATED_SILENCE_RATIO
2891
- ) {
2892
- this.resetBargeInGuardRun();
2740
+ const step = pending.guard.track(classification, chunkMs);
2741
+ if (step === "reset") {
2742
+ if (this.echoWindowGuardCarryover) {
2743
+ this.echoWindowGuardCarryover = false;
2744
+ this.echoEnergyEma = 0;
2745
+ this.echoProbeChunks = [];
2893
2746
  }
2894
2747
  return;
2895
2748
  }
2896
- guard.silenceMs = 0;
2897
- guard.speechMs += chunkMs;
2898
- if (guard.speechMs < this.bargeInMinSpeechMs) {
2749
+ if (step !== "fired") {
2899
2750
  return;
2900
2751
  }
2901
2752
  this.pendingBargeIn = null;
2902
2753
  this.assistantPlaybackTailUntilMs = 0;
2903
2754
  this.sendSpeechStarted();
2904
- const { turn } = guard;
2755
+ const { turn } = pending;
2905
2756
  if (turn && turn === this.activeAssistantTurn && !turn.finalized) {
2906
2757
  this.bargeIn(turn);
2907
2758
  }
2908
2759
  }
2909
2760
 
2910
- private resetBargeInGuardRun(): void {
2911
- const guard = this.pendingBargeIn;
2912
- if (!guard) {
2913
- return;
2914
- }
2915
- guard.speechMs = 0;
2916
- guard.silenceMs = 0;
2917
- guard.toleratedSilenceMs = 0;
2918
- if (this.echoWindowGuardCarryover) {
2919
- this.echoWindowGuardCarryover = false;
2920
- this.echoEnergyEma = 0;
2921
- this.echoProbeChunks = [];
2922
- }
2923
- }
2924
-
2925
2761
  private bargeIn(turn: ActiveAssistantTurn): void {
2926
2762
  // Abort synchronously so no tts_audio frame can follow turn_cancelled,
2927
2763
  // and settle the cancelled turn's metrics so the next utterance's marks
@@ -3561,11 +3397,15 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3561
3397
  // down. One line, not narration: the turn is not working, it is waiting,
3562
3398
  // and it says which, in the turn's spoken language, like every other
3563
3399
  // filler phrase.
3564
- this.enqueueFillerPhrase(
3565
- turn,
3566
- approvalPendingPhraseFor(turn.language),
3567
- this.fixedPhraseLanguage(turn, APPROVAL_PENDING_PHRASE_BY_LANGUAGE),
3568
- );
3400
+ if (
3401
+ this.enqueueFillerPhrase(
3402
+ turn,
3403
+ approvalPendingPhraseFor(turn.language),
3404
+ this.fixedPhraseLanguage(turn, APPROVAL_PENDING_PHRASE_BY_LANGUAGE),
3405
+ )
3406
+ ) {
3407
+ turn.progress.noteFloorHolder();
3408
+ }
3569
3409
  }
3570
3410
 
3571
3411
  /** Clear the wait once a decision lands, so the turn narrates normally again. */
@@ -4172,13 +4012,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4172
4012
  if (!alreadyReleased) {
4173
4013
  void this.releaseUtterance();
4174
4014
  }
4175
- if (
4176
- this.frontModelConfig.progress.enabled &&
4177
- this.streamTtsAudio &&
4178
- this.progressNarrator
4179
- ) {
4180
- this.armProgressIdleTimer(turn);
4181
- }
4015
+ turn.progress.arm();
4182
4016
  return true;
4183
4017
  }
4184
4018
 
@@ -5169,20 +5003,39 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5169
5003
  abortController,
5170
5004
  handle: null,
5171
5005
  launchedAtMs: Date.now(),
5172
- progress: {
5173
- ops: [],
5174
- opsSinceNarration: 0,
5175
- // Equal epochs at launch: a turn that has done nothing observable yet
5176
- // has nothing to narrate, so the idle trigger waits for tool activity
5177
- // or the maxSilenceMs heartbeat.
5178
- stateEpoch: 0,
5179
- narratedEpoch: 0,
5180
- updatesSpoken: 0,
5181
- lastFloorHolderAtMs: null,
5182
- lastAudibleAtMs: Date.now(),
5183
- idleTimer: null,
5184
- narrationInFlight: false,
5185
- },
5006
+ progress: createProgressCadence({
5007
+ config: this.frontModelConfig.progress,
5008
+ // Without TTS there is nothing to speak (the idle trigger's static
5009
+ // fallback still needs a generation attempt to fall back from).
5010
+ narrator: this.streamTtsAudio ? this.progressNarrator : null,
5011
+ nextFallbackPhraseIndex: () => this.progressPhraseCounter++,
5012
+ host: {
5013
+ turnId,
5014
+ launchedAtMs: Date.now(),
5015
+ signal: abortController.signal,
5016
+ // Narration may speak whenever the live turn is audibly silent and
5017
+ // has not completed. Escalated turns qualify too: the bridge phrase
5018
+ // holds the floor only while its audio plays (covered by the
5019
+ // audible-idle check), and the strong leg's tool loops are the
5020
+ // longest dead air in the system. Nothing is in flight while a
5021
+ // decision is pending, so every phrase narration has would be a lie
5022
+ // about who the call is waiting on: the turn says so once, when it
5023
+ // starts waiting, and is quiet after that.
5024
+ canNarrate: () =>
5025
+ this.isActiveAssistantTurn(token) &&
5026
+ !activeTurn.assistantCompleted &&
5027
+ activeTurn.pendingApproval === null,
5028
+ isAudioIdle: () => this.turnAudioIdle(activeTurn),
5029
+ playbackTailUntilMs: () => this.assistantPlaybackTailUntilMs,
5030
+ transcriptSoFar: () =>
5031
+ activeTurn.utterance.finalTranscriptSegments.join(" ").trim(),
5032
+ language: () => activeTurn.language,
5033
+ deltaEpoch: () => activeTurn.deltaEpoch,
5034
+ speak: (phrase, language) =>
5035
+ this.enqueueFillerPhrase(activeTurn, phrase, language),
5036
+ onNarrationSpoken: () => this.markProgressSpoken(activeTurn),
5037
+ },
5038
+ }),
5186
5039
  assistantCompleted: false,
5187
5040
  ttsDone: false,
5188
5041
  minimizeRequested: false,
@@ -5210,7 +5063,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5210
5063
  continuationDelivery: opts?.continuationDelivery ?? null,
5211
5064
  hiddenPrompt: opts?.hiddenPrompt === true,
5212
5065
  deltaEpoch: 0,
5213
- escalationHandedOff: false,
5066
+ frontDoor: null,
5214
5067
  ttsBuffer: "",
5215
5068
  spokenSegments: 0,
5216
5069
  spokenChars: 0,
@@ -5268,13 +5121,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5268
5121
  // cadence, wherever in the turn it occurs; without TTS or a narrator there
5269
5122
  // is nothing to speak (the idle trigger's static fallback still needs a
5270
5123
  // generation attempt to fall back from).
5271
- if (
5272
- this.frontModelConfig.progress.enabled &&
5273
- this.streamTtsAudio &&
5274
- this.progressNarrator &&
5275
- !opts?.speculative
5276
- ) {
5277
- this.armProgressIdleTimer(activeTurn);
5124
+ if (!opts?.speculative) {
5125
+ activeTurn.progress.arm();
5278
5126
  }
5279
5127
 
5280
5128
  // Front-door leg: a fast model fronts every turn (the `voiceFrontDoor`
@@ -5337,17 +5185,15 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5337
5185
  }
5338
5186
  const { token, utterance, turnId } = activeTurn;
5339
5187
 
5340
- // `rawText` accumulates this leg's full stream. A front-door leg starts
5341
- // in `deciding` until its leading tokens classify as hold / escalate /
5342
- // answer: an answer flushes through the shared marker holdback, while an
5343
- // escalation buffers the post-verdict stream into `bridgeRaw` until the
5344
- // bridge is complete, then hands off. A default/escalated leg flushes
5345
- // every delta through the same holdback, so a stray control marker from
5346
- // the main model is stripped instead of spoken.
5188
+ // `rawText` accumulates this leg's spoken stream. A front-door leg runs
5189
+ // through the shared coordinator, which reads its stream through the
5190
+ // verdict machine: released answer text flushes through the shared
5191
+ // marker holdback, while an escalation buffers the post-verdict stream
5192
+ // until the capped bridge is complete, then hands off. A default or
5193
+ // escalated leg flushes every delta through the same holdback, so a
5194
+ // stray control marker from the main model is stripped instead of
5195
+ // spoken.
5347
5196
  let rawText = "";
5348
- let frontDoorStage: "deciding" | "answer" | "bridging" | "handedOff" =
5349
- "deciding";
5350
- let bridgeRaw = "";
5351
5197
 
5352
5198
  const emitLegText = (chunk: string): void => {
5353
5199
  if (chunk.length === 0) {
@@ -5370,20 +5216,54 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5370
5216
  this.bufferAssistantTextForTts(token, chunk);
5371
5217
  };
5372
5218
 
5373
- const flushLegText = createControlMarkerHoldback(activeTurn, emitLegText);
5374
-
5375
- // Hand off once enough of the post-verdict stream has arrived to cap
5376
- // the bridge (sentence terminator or hard cap). Until then nothing is
5377
- // spoken — the bridge goes out in one piece at hand-off, so the audio,
5378
- // the persisted row, and the phrase quoted to the escalated leg are all
5379
- // the same capped text.
5380
- const maybeHandOffBridge = (): void => {
5381
- if (!isEscalationBridgeComplete(bridgeRaw)) {
5382
- return;
5383
- }
5384
- frontDoorStage = "handedOff";
5385
- this.escalateTurn(activeTurn, capEscalationBridge(bridgeRaw));
5386
- };
5219
+ const flushLegText = createControlMarkerHoldback(emitLegText);
5220
+
5221
+ const coordinator = leg.frontDoor
5222
+ ? createFrontDoorLegCoordinator({
5223
+ // The hold branch is only classifiable while the leg is
5224
+ // speculative: its decision rule is the only one that teaches the
5225
+ // hold token.
5226
+ holdEnabled:
5227
+ activeTurn.speculativePending && activeTurn.speculativeHoldAllowed,
5228
+ host: {
5229
+ isLive: () => !activeTurn.finalized,
5230
+ language: () => activeTurn.language,
5231
+ progress: {
5232
+ // The verdict deadline goes with the narration timer: the
5233
+ // hand-off settles the verdict.
5234
+ clear: () => this.clearFillerTimers(activeTurn),
5235
+ noteFloorHolder: () => activeTurn.progress.noteFloorHolder(),
5236
+ arm: () => activeTurn.progress.arm(),
5237
+ },
5238
+ // Hold discards a speculative turn (mid-thought pause, keep
5239
+ // listening); escalate and answer both commit it: utterance
5240
+ // release, thinking frame, and timers all happen inside
5241
+ // commitSpeculativeTurn.
5242
+ onHold: () => {
5243
+ void this.holdSpeculativeTurn(activeTurn);
5244
+ },
5245
+ commit: () =>
5246
+ !activeTurn.speculativePending ||
5247
+ this.commitSpeculativeTurn(activeTurn),
5248
+ onAnswerText: (text) => {
5249
+ rawText += text;
5250
+ flushLegText(rawText);
5251
+ },
5252
+ abortLeg: () => {
5253
+ activeTurn.handle?.abort();
5254
+ activeTurn.handle = null;
5255
+ },
5256
+ speakBridge: (bridge) =>
5257
+ this.speakEscalationBridge(activeTurn, bridge),
5258
+ startEscalatedLeg: (escalated) => {
5259
+ void this.startAssistantLeg(activeTurn, escalated);
5260
+ },
5261
+ },
5262
+ })
5263
+ : null;
5264
+ if (coordinator !== null) {
5265
+ activeTurn.frontDoor = coordinator;
5266
+ }
5387
5267
 
5388
5268
  try {
5389
5269
  // Latched before the await, not after: this flag only decides whether the
@@ -5470,53 +5350,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5470
5350
  if (!this.isForwardingAssistantText(token)) {
5471
5351
  return;
5472
5352
  }
5473
- if (leg.frontDoor) {
5474
- rawText += msg.text;
5475
- if (frontDoorStage === "handedOff") {
5476
- return;
5477
- }
5478
- if (frontDoorStage === "bridging") {
5479
- bridgeRaw += msg.text;
5480
- maybeHandOffBridge();
5481
- return;
5482
- }
5353
+ if (coordinator !== null) {
5483
5354
  // Verdict-first: the leg's leading tokens decide the turn's
5484
- // fate. Hold discards a speculative turn (mid-thought pause,
5485
- // keep listening); escalate and answer both commit it —
5486
- // utterance release, thinking frame, and timers all happen
5487
- // inside commitSpeculativeTurn. The hold branch is only
5488
- // classifiable while the leg is speculative (its decision
5489
- // rule is the only one that teaches the hold token).
5490
- if (frontDoorStage === "deciding") {
5491
- const verdict = classifyFrontDoorLeading(
5492
- rawText.trimStart(),
5493
- activeTurn.speculativePending &&
5494
- activeTurn.speculativeHoldAllowed,
5495
- );
5496
- if (verdict === "pending") {
5497
- return;
5498
- }
5499
- if (verdict === "hold") {
5500
- void this.holdSpeculativeTurn(activeTurn);
5501
- return;
5502
- }
5503
- if (
5504
- activeTurn.speculativePending &&
5505
- !this.commitSpeculativeTurn(activeTurn)
5506
- ) {
5507
- return;
5508
- }
5509
- if (verdict === "escalate") {
5510
- frontDoorStage = "bridging";
5511
- bridgeRaw = rawText
5512
- .trimStart()
5513
- .slice(ESCALATE_VERDICT_TOKEN.length);
5514
- maybeHandOffBridge();
5515
- return;
5516
- }
5517
- frontDoorStage = "answer";
5518
- }
5519
- flushLegText(rawText);
5355
+ // fate, and the coordinator acts on them. The bridge hands
5356
+ // off in one piece once the machine caps it, so the audio,
5357
+ // the persisted row, and the phrase quoted to the escalated
5358
+ // leg are all the same text.
5359
+ coordinator.push(msg.text);
5520
5360
  return;
5521
5361
  }
5522
5362
  // Defensive: speculative legs are always front-door today, but a
@@ -5552,27 +5392,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5552
5392
  if (current.speculativePending) {
5553
5393
  this.commitSpeculativeTurn(current);
5554
5394
  }
5555
- // A front-door leg that stopped mid-bridge (a bare escalate
5556
- // verdict, or a holding phrase with no sentence terminator)
5557
- // hands off now with whatever arrived; the canned fallback
5558
- // covers an empty bridge. A cancellation mid-bridge falls
5559
- // through to normal cancelled finalization instead — a dead
5560
- // turn must not spawn an escalated leg.
5561
- if (
5562
- leg.frontDoor &&
5563
- frontDoorStage === "bridging" &&
5564
- msg.type === "message_complete" &&
5565
- !current.escalationHandedOff
5566
- ) {
5567
- frontDoorStage = "handedOff";
5568
- this.escalateTurn(current, capEscalationBridge(bridgeRaw));
5569
- return;
5570
- }
5571
- // A front-door leg that handed off is finished; the escalated leg
5572
- // drives completion. The front-door leg's own trailing completion
5573
- // (including the generation_cancelled from its abort) is a no-op.
5574
- if (leg.frontDoor && current.escalationHandedOff) {
5575
- return;
5395
+ // A front-door leg that stopped mid-bridge hands off now with
5396
+ // whatever arrived. A cancellation mid-bridge falls through to
5397
+ // normal cancelled finalization instead: a dead turn must not
5398
+ // spawn an escalated leg. A front-door leg that handed off is
5399
+ // finished; the escalated leg drives completion, so this leg's
5400
+ // own trailing completion (including the generation_cancelled
5401
+ // from its abort) is a no-op.
5402
+ if (coordinator !== null) {
5403
+ if (msg.type === "message_complete") {
5404
+ coordinator.complete();
5405
+ }
5406
+ if (coordinator.handedOff) {
5407
+ return;
5408
+ }
5576
5409
  }
5577
5410
  // A held "[…"-tail that never completed a marker is real text —
5578
5411
  // force-flush it before assistantCompleted closes the TTS buffer
@@ -5649,57 +5482,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5649
5482
  toolName,
5650
5483
  });
5651
5484
  }
5652
- // The op counts toward the narration threshold on start (not
5653
- // completion) so a burst of slow tools still trips the ops
5654
- // trigger while they run.
5655
- current.progress.ops.push({
5656
- toolName,
5657
- ...(detail?.toolUseId !== undefined
5658
- ? { toolUseId: detail.toolUseId }
5659
- : {}),
5660
- startedAtMs: Date.now(),
5661
- });
5662
- current.progress.opsSinceNarration += 1;
5663
- current.progress.stateEpoch += 1;
5485
+ current.progress.toolStarted(toolName, detail?.toolUseId);
5664
5486
  this.refreshActivity(current);
5665
5487
  log.debug({ turnId, toolName }, "Live voice turn started tool use");
5666
- this.maybeNarrateProgress(current, "ops");
5667
5488
  },
5668
5489
  tool_result: (event) => {
5669
5490
  const current = this.activeAssistantTurn;
5670
5491
  if (current?.token !== token) {
5671
5492
  return;
5672
5493
  }
5673
- // Match by toolUseId when present; otherwise the last-started
5674
- // incomplete op with the same name (parallel same-name ops
5675
- // resolve newest-first).
5676
- const op =
5677
- (event.toolUseId !== undefined
5678
- ? current.progress.ops.find(
5679
- (o) => o.toolUseId === event.toolUseId,
5680
- )
5681
- : undefined) ??
5682
- findLastIncompleteOp(current.progress.ops, event.toolName);
5683
- // A long-running op finishing is the beat the user has been
5684
- // waiting through: it narrates immediately rather than waiting for
5685
- // `opsThreshold` more ops, which on a one-slow-tool turn never
5686
- // arrive. Short ops stay on the ops trigger — narrating every
5687
- // quick lookup is the chatter this cadence exists to avoid.
5688
- let trigger: "ops" | "op_complete" = "ops";
5689
- if (op) {
5690
- op.completedAtMs = Date.now();
5691
- if (event.isError !== undefined) {
5692
- op.isError = event.isError;
5693
- }
5694
- op.resultPreview = event.resultPreview;
5695
- if (
5696
- op.completedAtMs - op.startedAtMs >=
5697
- this.frontModelConfig.progress.longOpMs
5698
- ) {
5699
- trigger = "op_complete";
5700
- }
5701
- }
5702
- current.progress.stateEpoch += 1;
5494
+ current.progress.toolFinished(event);
5703
5495
  // Showing a surface implies revealing it: the room is a
5704
5496
  // full-screen overlay, so a surface rendered behind it is a
5705
5497
  // surface nobody sees.
@@ -5722,7 +5514,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5722
5514
  }
5723
5515
  }
5724
5516
  this.refreshActivity(current);
5725
- this.maybeNarrateProgress(current, trigger);
5726
5517
  },
5727
5518
  },
5728
5519
  onError: (message) => {
@@ -5781,7 +5572,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5781
5572
  }
5782
5573
  // The front-door leg may have handed off before its handle resolved;
5783
5574
  // abort it rather than exposing it as the turn's live handle.
5784
- if (leg.frontDoor && current.escalationHandedOff) {
5575
+ if (coordinator?.handedOff === true) {
5785
5576
  handle.abort();
5786
5577
  return true;
5787
5578
  }
@@ -5809,37 +5600,17 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5809
5600
  }
5810
5601
 
5811
5602
  /**
5812
- * Hand the turn from the front-door leg to the strong "escalated" leg after
5813
- * the escalate verdict. Speaks the capped bridge (the leg's own post-verdict
5814
- * holding phrase, or the canned fallback when that was too short) in one
5815
- * piece and force-flushes it so it plays during the strong-model call, then
5816
- * starts the escalated leg on the same ActiveAssistantTurn. Idempotent.
5603
+ * Speak the escalation bridge so the strong-model call has no dead air.
5604
+ * The model's own bridge is real assistant speech (captions + TTS); the
5605
+ * canned fallback stays audio-only, matching the persisted-row hygiene (a
5606
+ * deleted row for a bridge the model never produced).
5817
5607
  */
5818
- private escalateTurn(
5608
+ private speakEscalationBridge(
5819
5609
  activeTurn: ActiveAssistantTurn,
5820
- cappedBridge: string,
5610
+ bridge: SpokenEscalationBridge,
5821
5611
  ): void {
5822
- if (activeTurn.escalationHandedOff || activeTurn.finalized) {
5823
- return;
5824
- }
5825
- activeTurn.escalationHandedOff = true;
5826
- // The escalation bridge below holds the floor, so pending progress
5827
- // narration would only stack a second filler on top of it.
5828
- this.clearFillerTimers(activeTurn);
5829
- // Abort the front-door leg so a model that keeps generating past the
5830
- // bridge cap adds no latency before the escalated leg starts.
5831
- activeTurn.handle?.abort();
5832
- activeTurn.handle = null;
5833
-
5834
- // Speak the bridge so the strong-model call has no dead air. The model's
5835
- // own bridge is real assistant speech (captions + TTS); the canned
5836
- // fallback stays audio-only, matching the persisted-row hygiene (a
5837
- // deleted row for a bridge the model never produced).
5838
- const usesFallbackBridge = cappedBridge.length < MIN_SPOKEN_BRIDGE_CHARS;
5839
- const spokenBridge = usesFallbackBridge
5840
- ? fallbackEscalationBridgeFor(activeTurn.language)
5841
- : cappedBridge;
5842
- if (!usesFallbackBridge) {
5612
+ const { spokenBridge, usesFallback, language } = bridge;
5613
+ if (!usesFallback) {
5843
5614
  this.markFirstAssistantDelta(activeTurn.utterance, activeTurn.turnId);
5844
5615
  this.markAssistantDelta(activeTurn);
5845
5616
  void this.sendFrame(
@@ -5851,46 +5622,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5851
5622
  // otherwise sit buffered until a sentence boundary and leave the
5852
5623
  // caller in silence during the escalated model's call.
5853
5624
  this.flushTtsBuffer(activeTurn.token, true);
5854
- } else {
5855
- // The canned bridge is a fixed localized-table phrase, enqueued
5856
- // directly (it is already one complete sentence) so the segment can
5857
- // carry the "en" override when the table lacks the turn's language.
5858
- const speakable = sanitizeForTts(spokenBridge).trim();
5859
- if (speakable.length > 0) {
5860
- this.enqueueTtsSegment(activeTurn.token, speakable, {
5861
- language: this.fixedPhraseLanguage(
5862
- activeTurn,
5863
- FALLBACK_ESCALATION_BRIDGE_BY_LANGUAGE,
5864
- ),
5865
- });
5866
- }
5625
+ return;
5867
5626
  }
5868
-
5869
- // No overrideProfile here: the bridge pins the escalated leg to the
5870
- // conversation's own profile, the model the caller's typed turns already
5871
- // run on (see voice-triage-escalate.ts). The bridge phrase the caller
5872
- // just heard is handed along so the escalated continuation rule can
5873
- // quote it and ban a re-announcing echo ("Let me check…" twice in a
5874
- // row).
5875
- void this.startAssistantLeg(activeTurn, {
5876
- content: ESCALATION_CONTINUATION_CONTENT,
5877
- routingLeg: "escalated",
5878
- spokenEscalationBridge: spokenBridge,
5879
- });
5880
-
5881
- // Escalated legs run the slowest work in the system (strong-model
5882
- // thinking + tool loops), and the bridge only covers the first couple of
5883
- // seconds of it — exactly the dead air progress narration exists for.
5884
- // Re-arm the idle narration timer (cleared above with the ack): its
5885
- // audible-silence gating means nothing speaks until the bridge audio has
5886
- // fully drained plus a whole idle interval. Acks stay suppressed
5887
- // post-handoff — the bridge already served that role.
5888
- if (
5889
- this.frontModelConfig.progress.enabled &&
5890
- this.streamTtsAudio &&
5891
- this.progressNarrator
5892
- ) {
5893
- this.armProgressIdleTimer(activeTurn);
5627
+ // The canned bridge is a fixed localized-table phrase, enqueued
5628
+ // directly (it is already one complete sentence) so the segment can
5629
+ // carry the "en" override when the table lacks the turn's language.
5630
+ const speakable = sanitizeForTts(spokenBridge).trim();
5631
+ if (speakable.length > 0) {
5632
+ this.enqueueTtsSegment(activeTurn.token, speakable, { language });
5894
5633
  }
5895
5634
  }
5896
5635
 
@@ -5967,61 +5706,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5967
5706
  turn.deltaEpoch += 1;
5968
5707
  }
5969
5708
 
5970
- // Arms (or re-arms) the dead-air narration timer. The countdown measures
5971
- // audible silence — time since the turn's audio last (estimatedly) reached
5972
- // the user's ears — not time since launch, so it covers mid-turn silences
5973
- // for the whole turn. On expiry with audio still pending, or with the
5974
- // silence not yet a full interval old, it re-arms for the remainder; only a
5975
- // full interval of audible silence reaches the narration gatekeeper. The
5976
- // interval is a polling cadence, not a speaking cadence: most ticks find
5977
- // nothing new to report and stay quiet, so what the user hears follows the
5978
- // turn's tool activity (with `maxSilenceMs` as the heartbeat ceiling).
5979
- private armProgressIdleTimer(
5980
- turn: ActiveAssistantTurn,
5981
- delayMs?: number,
5982
- ): void {
5983
- const { token } = turn;
5984
- this.clearProgressIdleTimer(turn);
5985
- turn.progress.idleTimer = setTimeout(() => {
5986
- turn.progress.idleTimer = null;
5987
- if (!this.isActiveAssistantTurn(token) || turn.assistantCompleted) {
5988
- return;
5989
- }
5990
- if (!this.turnAudioIdle(turn)) {
5991
- // Audio is buffered, queued, or still playing: a fresh silence can
5992
- // only be a full interval old one interval from now.
5993
- this.armProgressIdleTimer(turn);
5994
- return;
5995
- }
5996
- const remaining = this.progressIdleDeadlineMs(turn) - Date.now();
5997
- if (remaining > 0) {
5998
- this.armProgressIdleTimer(turn, remaining);
5999
- return;
6000
- }
6001
- this.maybeNarrateProgress(turn, "idle");
6002
- this.armProgressIdleTimer(turn);
6003
- }, delayMs ?? this.frontModelConfig.progress.idleIntervalMs);
6004
- }
6005
-
6006
- // Wall-clock instant the current audible silence turns a full interval old.
6007
- private progressIdleDeadlineMs(turn: ActiveAssistantTurn): number {
6008
- return (
6009
- this.progressSilenceSinceMs(turn) +
6010
- this.frontModelConfig.progress.idleIntervalMs
6011
- );
6012
- }
6013
-
6014
- // When the turn's current audible silence began: the latest of the last
6015
- // emitted segment, the estimated client playback end, and the last enqueued
6016
- // filler.
6017
- private progressSilenceSinceMs(turn: ActiveAssistantTurn): number {
6018
- return Math.max(
6019
- turn.progress.lastAudibleAtMs,
6020
- this.assistantPlaybackTailUntilMs,
6021
- turn.progress.lastFloorHolderAtMs ?? 0,
6022
- );
6023
- }
6024
-
6025
5709
  // No assistant audio is pending or (estimatedly) still playing: nothing
6026
5710
  // buffered toward the next sentence, every queued TTS segment fully
6027
5711
  // emitted, and the client-side playback-tail estimate expired.
@@ -6033,177 +5717,18 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
6033
5717
  );
6034
5718
  }
6035
5719
 
6036
- private clearProgressIdleTimer(turn: ActiveAssistantTurn): void {
6037
- if (turn.progress.idleTimer !== null) {
6038
- clearTimeout(turn.progress.idleTimer);
6039
- turn.progress.idleTimer = null;
6040
- }
6041
- }
6042
-
6043
5720
  // Clears progress narration and verdict timers for events that end the
6044
5721
  // current filler lifecycle.
6045
5722
  private clearFillerTimers(turn: ActiveAssistantTurn): void {
6046
- this.clearProgressIdleTimer(turn);
5723
+ turn.progress.clear();
6047
5724
  if (turn.verdictDeadlineTimer !== null) {
6048
5725
  clearTimeout(turn.verdictDeadlineTimer);
6049
5726
  turn.verdictDeadlineTimer = null;
6050
5727
  }
6051
5728
  }
6052
5729
 
6053
- // Narration may speak whenever the live turn is audibly silent and has
6054
- // not completed. Escalated turns qualify too: the bridge phrase holds the
6055
- // floor only while its audio plays (covered by the audible-idle check),
6056
- // and the strong leg's tool loops are the longest dead air in the system.
6057
- private turnCanNarrateProgress(turn: ActiveAssistantTurn): boolean {
6058
- return (
6059
- this.isActiveAssistantTurn(turn.token) &&
6060
- !turn.assistantCompleted &&
6061
- // Nothing is in flight while a decision is pending, so every phrase
6062
- // narration has would be a lie about who the call is waiting on. The
6063
- // turn says so once, when it starts waiting, and is quiet after that.
6064
- turn.pendingApproval === null &&
6065
- this.turnAudioIdle(turn)
6066
- );
6067
- }
6068
-
6069
- // The idle tick has something worth saying when the turn's tool activity has
6070
- // moved since the last narration described it, or when the silence has run
6071
- // past `maxSilenceMs` — the heartbeat ceiling that proves the assistant is
6072
- // still alive on a turn with no observable activity at all. Every other tick
6073
- // stays quiet, so the cadence follows the work rather than the clock.
6074
- private progressIdleHasSomethingToSay(turn: ActiveAssistantTurn): boolean {
6075
- const { progress } = turn;
6076
- if (progress.stateEpoch !== progress.narratedEpoch) {
6077
- return true;
6078
- }
6079
- const silentForMs = Date.now() - this.progressSilenceSinceMs(turn);
6080
- if (silentForMs >= this.frontModelConfig.progress.maxSilenceMs) {
6081
- return true;
6082
- }
6083
- log.debug(
6084
- { turnId: turn.turnId, silentForMs },
6085
- "Live voice progress narration held — nothing new since the last update",
6086
- );
6087
- return false;
6088
- }
6089
-
6090
- // Gatekeeper for progress narration. It speaks only while the turn is
6091
- // audibly silent, with one generation at a time and the configured spacing.
6092
- private maybeNarrateProgress(
6093
- turn: ActiveAssistantTurn,
6094
- trigger: "ops" | "idle" | "op_complete",
6095
- ): void {
6096
- const cfg = this.frontModelConfig.progress;
6097
- const { progress } = turn;
6098
- const progressNarrator = this.progressNarrator;
6099
- if (
6100
- !cfg.enabled ||
6101
- !this.streamTtsAudio ||
6102
- !progressNarrator ||
6103
- !this.turnCanNarrateProgress(turn) ||
6104
- progress.narrationInFlight ||
6105
- (progress.lastFloorHolderAtMs !== null &&
6106
- Date.now() - progress.lastFloorHolderAtMs < cfg.minGapMs) ||
6107
- (trigger === "ops" && progress.opsSinceNarration < cfg.opsThreshold) ||
6108
- (trigger === "idle" && !this.progressIdleHasSomethingToSay(turn))
6109
- ) {
6110
- return;
6111
- }
6112
- void this.speakProgressUpdate(turn, progressNarrator, trigger);
6113
- }
6114
-
6115
- // Generate and speak one audio-only progress narration. On a null result,
6116
- // idle narration falls back to a static phrase while activity-triggered
6117
- // narration stays silent.
6118
- private async speakProgressUpdate(
6119
- turn: ActiveAssistantTurn,
6120
- progressNarrator: VoiceProgressNarrator,
6121
- trigger: "ops" | "idle" | "op_complete",
6122
- ): Promise<void> {
6123
- const { progress } = turn;
6124
- progress.narrationInFlight = true;
6125
- // Any delta that lands while generation is in flight makes the text stale.
6126
- const deltaEpochAtLaunch = turn.deltaEpoch;
6127
- // The activity this update describes. Tool events that land mid-generation
6128
- // are news the generated text cannot carry, so they must leave the idle
6129
- // trigger armed rather than count as already narrated.
6130
- const stateEpochAtLaunch = progress.stateEpoch;
6131
- try {
6132
- const now = Date.now();
6133
- const currentOp = findLastIncompleteOp(progress.ops);
6134
- const input: VoiceProgressTextInput = {
6135
- transcriptSoFar: turn.utterance.finalTranscriptSegments
6136
- .join(" ")
6137
- .trim(),
6138
- completedOps: progress.ops
6139
- .filter(
6140
- (op): op is TurnProgressOp & { completedAtMs: number } =>
6141
- op.completedAtMs !== undefined,
6142
- )
6143
- // `ops` is in start order; the narrator wants completion
6144
- // order, which differs when parallel tools finish out of order.
6145
- .sort((a, b) => a.completedAtMs - b.completedAtMs)
6146
- .map((op) => ({
6147
- toolName: op.toolName,
6148
- ...(op.isError !== undefined ? { isError: op.isError } : {}),
6149
- ...(op.resultPreview !== undefined
6150
- ? { resultPreview: op.resultPreview }
6151
- : {}),
6152
- })),
6153
- currentOp: currentOp
6154
- ? {
6155
- toolName: currentOp.toolName,
6156
- elapsedMs: now - currentOp.startedAtMs,
6157
- }
6158
- : null,
6159
- turnElapsedMs: now - turn.launchedAtMs,
6160
- updateIndex: progress.updatesSpoken + 1,
6161
- ...(turn.language !== undefined ? { languageHint: turn.language } : {}),
6162
- };
6163
- const generated = await progressNarrator
6164
- .generateProgressText(input, turn.abortController.signal)
6165
- // The narrator contract never rejects. Keep test stubs fail-soft too.
6166
- .catch(() => null);
6167
- // Re-check liveness and staleness after the provider call.
6168
- if (
6169
- !this.turnCanNarrateProgress(turn) ||
6170
- turn.deltaEpoch !== deltaEpochAtLaunch ||
6171
- (progress.lastFloorHolderAtMs !== null &&
6172
- Date.now() - progress.lastFloorHolderAtMs <
6173
- this.frontModelConfig.progress.minGapMs)
6174
- ) {
6175
- return;
6176
- }
6177
- let raw = generated;
6178
- // Generated text is in the turn's language; only the static
6179
- // fallback comes from a localized table and may need the "en" override.
6180
- let fillerLanguage: string | undefined;
6181
- if (raw === null) {
6182
- if (trigger !== "idle") {
6183
- return;
6184
- }
6185
- raw = pickProgressPhrase(this.progressPhraseCounter++, turn.language);
6186
- fillerLanguage = this.fixedPhraseLanguage(
6187
- turn,
6188
- PROGRESS_FALLBACK_PHRASES_BY_LANGUAGE,
6189
- );
6190
- }
6191
- if (!this.enqueueFillerPhrase(turn, raw, fillerLanguage)) {
6192
- return;
6193
- }
6194
- progress.opsSinceNarration = 0;
6195
- progress.narratedEpoch = stateEpochAtLaunch;
6196
- progress.updatesSpoken += 1;
6197
- // Record only when narration audio actually enqueued.
6198
- this.markProgressSpoken(turn);
6199
- // Restart the dead-air countdown from this narration.
6200
- this.armProgressIdleTimer(turn);
6201
- } finally {
6202
- progress.narrationInFlight = false;
6203
- }
6204
- }
6205
-
6206
- // Sanitize and enqueue one progress sentence on the ordered TTS queue.
5730
+ // Sanitize and enqueue one filler sentence (narration, the approval line)
5731
+ // on the ordered TTS queue, audio-only.
6207
5732
  private enqueueFillerPhrase(
6208
5733
  turn: ActiveAssistantTurn,
6209
5734
  raw: string,
@@ -6217,8 +5742,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
6217
5742
  countsAsFirstSegment: false,
6218
5743
  ...(language !== undefined ? { language } : {}),
6219
5744
  });
6220
- // A spoken filler holds the floor, so narration's minGapMs spaces from it.
6221
- turn.progress.lastFloorHolderAtMs = Date.now();
6222
5745
  return true;
6223
5746
  }
6224
5747
 
@@ -6231,10 +5754,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
6231
5754
  turn: ActiveAssistantTurn,
6232
5755
  table: Readonly<Record<string, unknown>>,
6233
5756
  ): string | undefined {
6234
- return turn.language !== undefined &&
6235
- !hasLocalizedEntry(table, turn.language)
6236
- ? "en"
6237
- : undefined;
5757
+ return fixedPhraseLanguage(table, turn.language);
6238
5758
  }
6239
5759
 
6240
5760
  private bufferAssistantTextForTts(token: symbol, text: string): void {
@@ -6277,7 +5797,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
6277
5797
  log.info(
6278
5798
  {
6279
5799
  turnId: currentTurn.turnId,
6280
- escalated: currentTurn.escalationHandedOff,
5800
+ escalated: currentTurn.frontDoor?.handedOff ?? false,
6281
5801
  spokenSegments: currentTurn.spokenSegments,
6282
5802
  spokenChars: currentTurn.spokenChars,
6283
5803
  },
@@ -6518,7 +6038,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
6518
6038
  if (settledTurn?.token === token) {
6519
6039
  // Anchor the dead-air countdown to the end of emission; the playback
6520
6040
  // -tail estimate covers any client-side buffer still draining.
6521
- settledTurn.progress.lastAudibleAtMs = Date.now();
6041
+ settledTurn.progress.noteAudioSettled();
6522
6042
  }
6523
6043
  this.pumpTtsSynthesis(token);
6524
6044
  }
@@ -7105,8 +6625,8 @@ export function createLiveVoiceSession(
7105
6625
  const vadConfig = liveVoiceConfig?.vad;
7106
6626
  // Parsed once here into a complete config, shared by endpointing and the
7107
6627
  // session (the constructor's own parse is then a no-op re-validation).
7108
- const frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
7109
- options.frontModelConfig ?? liveVoiceConfig?.frontModel ?? {},
6628
+ const frontModelConfig = VoiceFrontModelConfigSchema.parse(
6629
+ options.frontModelConfig ?? getConfig().voice?.frontModel ?? {},
7110
6630
  );
7111
6631
  return new LiveVoiceSession(context, {
7112
6632
  ...options,
@@ -7271,10 +6791,10 @@ export async function defaultSpawnBackgroundContinuation(args: {
7271
6791
  // spoken by a session turn, on the session's terms; it was never meant to
7272
6792
  // mean invisible.
7273
6793
  //
7274
- // NOT the conversation's own sender: the voice bridge resets that to a
7275
- // no-op at turn teardown (see voice-session-bridge's clientCallbackInstalled
7276
- // reset), and the detach deliberately waits for that teardown before
7277
- // spawning — so a sender-based route is guaranteed to be dead by the time
6794
+ // NOT a per-turn sender: the conversation's event sink is fixed at
6795
+ // construction (see "Conversation event delivery" in assistant/AGENTS.md),
6796
+ // and the detach deliberately waits for the interrupted turn's bridge
6797
+ // teardown before spawning, so nothing turn-scoped is alive by the time
7278
6798
  // these events fire. `broadcastMessage` is the same path the bridge itself
7279
6799
  // uses to reach an attached web client. The subagent events carry
7280
6800
  // `parentConversationId`, not `conversationId`, so scope explicitly.