@agent-native/core 0.98.0 → 0.98.1

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 (243) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +2 -2
  4. package/corpus/core/src/action-change-marker.ts +8 -1
  5. package/corpus/core/src/changelog/parse.ts +8 -3
  6. package/corpus/core/src/client/AgentPanel.tsx +2 -3
  7. package/corpus/core/src/client/AssistantChat.tsx +83 -39
  8. package/corpus/core/src/client/analytics.ts +29 -0
  9. package/corpus/core/src/client/blocks/library/wireframe-kit.tsx +16 -2
  10. package/corpus/core/src/client/chat-model-groups.ts +28 -0
  11. package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +442 -85
  12. package/corpus/core/src/client/composer/TiptapComposer.tsx +46 -29
  13. package/corpus/core/src/client/composer/VoiceButton.tsx +2 -1
  14. package/corpus/core/src/client/composer/index.ts +5 -0
  15. package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +1206 -67
  16. package/corpus/core/src/client/index.ts +5 -0
  17. package/corpus/core/src/client/progress/RunsTray.tsx +13 -1
  18. package/corpus/core/src/client/settings/SecretsSection.tsx +387 -266
  19. package/corpus/core/src/client/settings/SettingsPanel.tsx +117 -38
  20. package/corpus/core/src/client/use-action.ts +12 -0
  21. package/corpus/core/src/client/use-agent-chat-home-handoff.ts +8 -1
  22. package/corpus/core/src/client/use-agent-engine-configured.ts +17 -3
  23. package/corpus/core/src/client/use-db-sync.ts +84 -34
  24. package/corpus/core/src/client/use-pausing-interval.ts +14 -4
  25. package/corpus/core/src/integrations/scope-store.ts +1 -1
  26. package/corpus/core/src/integrations/usage-budget-store.ts +1 -1
  27. package/corpus/core/src/localization/default-messages.ts +44 -1
  28. package/corpus/core/src/mcp-client/workspace-servers.ts +4 -4
  29. package/corpus/core/src/secrets/crypto.ts +28 -8
  30. package/corpus/core/src/server/action-change.ts +5 -1
  31. package/corpus/core/src/server/action-routes.ts +8 -0
  32. package/corpus/core/src/server/poll.ts +1 -0
  33. package/corpus/core/src/server/realtime-voice.ts +299 -13
  34. package/corpus/core/src/styles/agent-native.css +2 -2
  35. package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  36. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  37. package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  38. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
  39. package/corpus/core/src/vite/client.ts +9 -1
  40. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +2 -2
  41. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +10 -5
  42. package/corpus/templates/analytics/AGENTS.md +9 -13
  43. package/corpus/templates/analytics/actions/navigate.ts +59 -5
  44. package/corpus/templates/analytics/actions/view-screen.ts +3 -6
  45. package/corpus/templates/analytics/app/components/ProviderCorpusJobNotifier.tsx +20 -3
  46. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +2 -1
  47. package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -1
  48. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -6
  49. package/corpus/templates/analytics/app/global.css +19 -20
  50. package/corpus/templates/analytics/app/lib/provider-corpus-job-sync.ts +26 -0
  51. package/corpus/templates/analytics/app/pages/Settings.tsx +35 -341
  52. package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +38 -7
  53. package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +1 -1
  54. package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +3 -0
  55. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +333 -272
  56. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +72 -17
  57. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +297 -108
  58. package/corpus/templates/analytics/app/root.tsx +28 -8
  59. package/corpus/templates/analytics/changelog/2026-06-24-first-party-signup-and-pageview-panels-now-honor-dashboard-filters.md +2 -1
  60. package/corpus/templates/analytics/changelog/2026-06-25-referrer-dashboard-panels-show-external-domains.md +2 -1
  61. package/corpus/templates/analytics/changelog/2026-07-06-mobile-chat-header-is-more-compact-and-the-menu-button-opens-navigation.md +1 -0
  62. package/corpus/templates/analytics/changelog/2026-07-08-dashboard-history-and-chat-context.md +1 -0
  63. package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +1 -0
  64. package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +1 -0
  65. package/corpus/templates/analytics/changelog/2026-07-11-analytics-no-longer-repeatedly-restarts-background-requests-.md +6 -0
  66. package/corpus/templates/analytics/changelog/2026-07-11-analytics-only-asks-for-replay-storage-when-you-enable-sessi.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-07-11-charts-now-show-their-loading-skeleton-while-refreshing.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-11-realtime-voice-now-shows-a-clear-connection-indicator-comple.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-11-replay-event-counts-stay-hidden-until-recordings-finish-load.md +6 -0
  70. package/corpus/templates/analytics/changelog/2026-07-11-replay-storage-key-mismatches-show-actionable-setup-guidance.md +6 -0
  71. package/corpus/templates/analytics/changelog/2026-07-11-replay-timelines-group-continuous-scrolling-and-dev-tools-co.md +6 -0
  72. package/corpus/templates/analytics/changelog/2026-07-11-session-filters-now-stay-compact-in-a-settings-menu-at-mediu.md +6 -0
  73. package/corpus/templates/analytics/changelog/2026-07-11-session-replay-console-errors-now-link-to-matching-issues-or.md +6 -0
  74. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-load-large-recordings-faster-and-surface-fai.md +6 -0
  75. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-preserve-recorded-styles-and-viewport-layout.md +6 -0
  76. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-stay-centered-instead-of-clipping-wide-recor.md +6 -0
  77. package/corpus/templates/analytics/package.json +1 -1
  78. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +1 -303
  79. package/corpus/templates/analytics/server/handlers/session-replay.ts +57 -0
  80. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +1 -1
  81. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +16 -3
  82. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +12 -0
  83. package/corpus/templates/analytics/server/lib/session-replay.ts +208 -2
  84. package/corpus/templates/analytics/server/plugins/onboarding.ts +7 -79
  85. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/chunks.get.ts +1 -0
  86. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +2 -2
  87. package/corpus/templates/assets/app/global.css +4 -4
  88. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +0 -1
  89. package/corpus/templates/assets/app/routes/library.tsx +0 -1
  90. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +2 -2
  91. package/corpus/templates/brain/app/global.css +5 -5
  92. package/corpus/templates/brain/app/root.tsx +0 -3
  93. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +2 -2
  94. package/corpus/templates/calendar/app/root.tsx +1 -0
  95. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +2 -2
  96. package/corpus/templates/clips/app/hooks/use-auto-title.ts +74 -8
  97. package/corpus/templates/clips/app/hooks/use-library.ts +12 -25
  98. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +20 -22
  99. package/corpus/templates/clips/changelog/2026-07-11-generated-titles-no-longer-cause-continuous-background-refreshes.md +6 -0
  100. package/corpus/templates/clips/changelog/2026-07-11-s3-storage-setup-stays-collapsed-until-you-choose-it-as-the-.md +6 -0
  101. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +2 -2
  102. package/corpus/templates/content/app/hooks/use-comments.ts +0 -1
  103. package/corpus/templates/content/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
  104. package/corpus/templates/design/app/components/layout/Header.tsx +1 -1
  105. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +2 -2
  106. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +1 -2
  107. package/corpus/templates/macros/app/root.tsx +0 -3
  108. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +2 -2
  109. package/corpus/templates/mail/app/hooks/use-compose-state.ts +1 -0
  110. package/corpus/templates/mail/app/root.tsx +1 -12
  111. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +2 -2
  112. package/corpus/templates/plan/app/hooks/use-navigation-state.ts +5 -2
  113. package/corpus/templates/plan/app/hooks/use-plans.ts +6 -13
  114. package/corpus/templates/plan/app/pages/PlansPage.tsx +5 -27
  115. package/corpus/templates/plan/changelog/2026-07-11-plan-refreshes-only-when-content-changes.md +6 -0
  116. package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +4 -1
  117. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +1 -1
  118. package/corpus/templates/slides/app/components/layout/Header.tsx +1 -1
  119. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -0
  120. package/corpus/templates/slides/app/hooks/use-slide-comments.ts +7 -31
  121. package/corpus/templates/slides/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
  122. package/dist/action-change-marker.d.ts +1 -0
  123. package/dist/action-change-marker.d.ts.map +1 -1
  124. package/dist/action-change-marker.js +7 -1
  125. package/dist/action-change-marker.js.map +1 -1
  126. package/dist/changelog/parse.d.ts +4 -2
  127. package/dist/changelog/parse.d.ts.map +1 -1
  128. package/dist/changelog/parse.js +5 -3
  129. package/dist/changelog/parse.js.map +1 -1
  130. package/dist/client/AgentPanel.d.ts.map +1 -1
  131. package/dist/client/AgentPanel.js +2 -3
  132. package/dist/client/AgentPanel.js.map +1 -1
  133. package/dist/client/AssistantChat.d.ts +7 -0
  134. package/dist/client/AssistantChat.d.ts.map +1 -1
  135. package/dist/client/AssistantChat.js +62 -40
  136. package/dist/client/AssistantChat.js.map +1 -1
  137. package/dist/client/analytics.d.ts.map +1 -1
  138. package/dist/client/analytics.js +26 -0
  139. package/dist/client/analytics.js.map +1 -1
  140. package/dist/client/blocks/library/wireframe-kit.d.ts +2 -0
  141. package/dist/client/blocks/library/wireframe-kit.d.ts.map +1 -1
  142. package/dist/client/blocks/library/wireframe-kit.js +9 -2
  143. package/dist/client/blocks/library/wireframe-kit.js.map +1 -1
  144. package/dist/client/chat-model-groups.d.ts.map +1 -1
  145. package/dist/client/chat-model-groups.js +24 -0
  146. package/dist/client/chat-model-groups.js.map +1 -1
  147. package/dist/client/composer/RealtimeVoiceMode.d.ts +40 -4
  148. package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
  149. package/dist/client/composer/RealtimeVoiceMode.js +172 -42
  150. package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
  151. package/dist/client/composer/TiptapComposer.d.ts +4 -0
  152. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  153. package/dist/client/composer/TiptapComposer.js +47 -29
  154. package/dist/client/composer/TiptapComposer.js.map +1 -1
  155. package/dist/client/composer/VoiceButton.d.ts.map +1 -1
  156. package/dist/client/composer/VoiceButton.js +2 -2
  157. package/dist/client/composer/VoiceButton.js.map +1 -1
  158. package/dist/client/composer/index.d.ts +2 -2
  159. package/dist/client/composer/index.d.ts.map +1 -1
  160. package/dist/client/composer/index.js +1 -1
  161. package/dist/client/composer/index.js.map +1 -1
  162. package/dist/client/composer/useRealtimeVoiceMode.d.ts +100 -3
  163. package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  164. package/dist/client/composer/useRealtimeVoiceMode.js +952 -51
  165. package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
  166. package/dist/client/index.d.ts +1 -1
  167. package/dist/client/index.d.ts.map +1 -1
  168. package/dist/client/index.js +1 -1
  169. package/dist/client/index.js.map +1 -1
  170. package/dist/client/progress/RunsTray.d.ts.map +1 -1
  171. package/dist/client/progress/RunsTray.js +10 -1
  172. package/dist/client/progress/RunsTray.js.map +1 -1
  173. package/dist/client/settings/SecretsSection.d.ts +2 -3
  174. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  175. package/dist/client/settings/SecretsSection.js +46 -22
  176. package/dist/client/settings/SecretsSection.js.map +1 -1
  177. package/dist/client/settings/SettingsPanel.d.ts +9 -0
  178. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  179. package/dist/client/settings/SettingsPanel.js +46 -12
  180. package/dist/client/settings/SettingsPanel.js.map +1 -1
  181. package/dist/client/use-action.d.ts +2 -0
  182. package/dist/client/use-action.d.ts.map +1 -1
  183. package/dist/client/use-action.js +10 -0
  184. package/dist/client/use-action.js.map +1 -1
  185. package/dist/client/use-agent-chat-home-handoff.d.ts +2 -0
  186. package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
  187. package/dist/client/use-agent-chat-home-handoff.js +7 -1
  188. package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
  189. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  190. package/dist/client/use-agent-engine-configured.js +17 -3
  191. package/dist/client/use-agent-engine-configured.js.map +1 -1
  192. package/dist/client/use-db-sync.d.ts +8 -2
  193. package/dist/client/use-db-sync.d.ts.map +1 -1
  194. package/dist/client/use-db-sync.js +57 -32
  195. package/dist/client/use-db-sync.js.map +1 -1
  196. package/dist/client/use-pausing-interval.d.ts.map +1 -1
  197. package/dist/client/use-pausing-interval.js +16 -4
  198. package/dist/client/use-pausing-interval.js.map +1 -1
  199. package/dist/integrations/scope-store.js +1 -1
  200. package/dist/integrations/scope-store.js.map +1 -1
  201. package/dist/integrations/usage-budget-store.js +1 -1
  202. package/dist/integrations/usage-budget-store.js.map +1 -1
  203. package/dist/localization/default-messages.d.ts +41 -1
  204. package/dist/localization/default-messages.d.ts.map +1 -1
  205. package/dist/localization/default-messages.js +41 -1
  206. package/dist/localization/default-messages.js.map +1 -1
  207. package/dist/mcp-client/workspace-servers.js +4 -4
  208. package/dist/mcp-client/workspace-servers.js.map +1 -1
  209. package/dist/observability/routes.d.ts +5 -5
  210. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  211. package/dist/secrets/crypto.d.ts +7 -6
  212. package/dist/secrets/crypto.d.ts.map +1 -1
  213. package/dist/secrets/crypto.js +30 -11
  214. package/dist/secrets/crypto.js.map +1 -1
  215. package/dist/secrets/routes.d.ts +9 -9
  216. package/dist/server/action-change.d.ts +1 -0
  217. package/dist/server/action-change.d.ts.map +1 -1
  218. package/dist/server/action-change.js +4 -1
  219. package/dist/server/action-change.js.map +1 -1
  220. package/dist/server/action-routes.d.ts.map +1 -1
  221. package/dist/server/action-routes.js +5 -0
  222. package/dist/server/action-routes.js.map +1 -1
  223. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  224. package/dist/server/poll.d.ts.map +1 -1
  225. package/dist/server/poll.js +1 -0
  226. package/dist/server/poll.js.map +1 -1
  227. package/dist/server/realtime-voice.d.ts +7 -0
  228. package/dist/server/realtime-voice.d.ts.map +1 -1
  229. package/dist/server/realtime-voice.js +222 -12
  230. package/dist/server/realtime-voice.js.map +1 -1
  231. package/dist/styles/agent-native.css +2 -2
  232. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  233. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  234. package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  235. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
  236. package/dist/vite/client.d.ts.map +1 -1
  237. package/dist/vite/client.js +6 -1
  238. package/dist/vite/client.js.map +1 -1
  239. package/package.json +2 -2
  240. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  241. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  242. package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  243. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
@@ -15,7 +15,7 @@ import {
15
15
  SIDEBAR_STATE_CHANGE_EVENT,
16
16
  type AgentSidebarStateChangeDetail,
17
17
  } from "../agent-sidebar-state.js";
18
- import { agentNativePath, appPath } from "../api-path.js";
18
+ import { agentNativePath } from "../api-path.js";
19
19
  import { readClientAppState, setClientAppState } from "../application-state.js";
20
20
  import { getBrowserTabId } from "../browser-tab-id.js";
21
21
  import { useT } from "../i18n.js";
@@ -33,10 +33,73 @@ import {
33
33
  } from "./RealtimeVoiceMode.js";
34
34
 
35
35
  const REALTIME_VOICE_STATE_KEY = "realtime-voice-session";
36
+ const REALTIME_VOICE_PREFERENCES_KEY = "realtime-voice-prefs";
36
37
  const REALTIME_VOICE_REQUEST_SOURCE = "realtime-voice";
37
38
  const REALTIME_VOICE_SESSION_PATH = "/_agent-native/realtime-voice/session";
38
39
  const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
40
+ const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
39
41
  const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
42
+ const REALTIME_VOICE_MAX_TOOLS = 32;
43
+ const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
44
+ const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
45
+ const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
46
+ const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
47
+ "navigate",
48
+ "set-url-path",
49
+ "set-search-params",
50
+ "view-screen",
51
+ "tool-search",
52
+ ] as const;
53
+ const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
54
+ const REALTIME_VOICE_MICROPHONE_STORAGE_KEY =
55
+ "agent-native:realtime-voice-microphone";
56
+
57
+ export const REALTIME_VOICE_LANGUAGES = [
58
+ "auto",
59
+ "en",
60
+ "es",
61
+ "fr",
62
+ "de",
63
+ "it",
64
+ "pt",
65
+ "ja",
66
+ "ko",
67
+ "zh",
68
+ ] as const;
69
+ export const REALTIME_VOICE_INTELLIGENCE_LEVELS = [
70
+ "instant",
71
+ "balanced",
72
+ "deep",
73
+ ] as const;
74
+ export const REALTIME_VOICES = [
75
+ "marin",
76
+ "cedar",
77
+ "coral",
78
+ "sage",
79
+ "verse",
80
+ "alloy",
81
+ "ash",
82
+ "ballad",
83
+ "echo",
84
+ "shimmer",
85
+ ] as const;
86
+
87
+ export type RealtimeVoiceLanguage = (typeof REALTIME_VOICE_LANGUAGES)[number];
88
+ export type RealtimeVoiceIntelligence =
89
+ (typeof REALTIME_VOICE_INTELLIGENCE_LEVELS)[number];
90
+ export type RealtimeVoice = (typeof REALTIME_VOICES)[number];
91
+
92
+ export interface RealtimeVoicePreferences {
93
+ language: RealtimeVoiceLanguage;
94
+ intelligence: RealtimeVoiceIntelligence;
95
+ voice: RealtimeVoice;
96
+ }
97
+
98
+ export const DEFAULT_REALTIME_VOICE_PREFERENCES: RealtimeVoicePreferences = {
99
+ language: "auto",
100
+ intelligence: "instant",
101
+ voice: "marin",
102
+ };
40
103
 
41
104
  export const REALTIME_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
42
105
  // Prefer the browser/OS-selected input instead of whichever physical device
@@ -55,6 +118,19 @@ export interface RealtimeVoiceToolResult {
55
118
  status: "completed" | "failed" | "approval_required";
56
119
  output: string;
57
120
  approvalKey?: string;
121
+ expandedTools?: RealtimeVoiceFunctionTool[];
122
+ }
123
+
124
+ export interface RealtimeVoiceSessionAnswer {
125
+ sdp: string;
126
+ capability?: string;
127
+ }
128
+
129
+ export interface RealtimeVoiceFunctionTool {
130
+ type: "function";
131
+ name: string;
132
+ description?: string;
133
+ parameters?: Record<string, unknown>;
58
134
  }
59
135
 
60
136
  export interface RealtimeVoiceModeApi {
@@ -63,17 +139,359 @@ export interface RealtimeVoiceModeApi {
63
139
  errorMessage: string | null;
64
140
  chatVisible: boolean;
65
141
  audioLevels: RealtimeVoiceAudioLevelStore;
142
+ preferences: RealtimeVoicePreferences;
143
+ microphones: readonly RealtimeVoiceMicrophone[];
144
+ microphoneDeviceId: string;
145
+ microphoneSwitching: boolean;
146
+ microphoneError: string | null;
147
+ voiceChangePending: boolean;
148
+ setLanguage: (language: RealtimeVoiceLanguage) => void;
149
+ setIntelligence: (intelligence: RealtimeVoiceIntelligence) => void;
150
+ setVoice: (voice: RealtimeVoice) => void;
151
+ setMicrophone: (deviceId: string) => Promise<void>;
66
152
  start: () => Promise<void>;
67
153
  end: () => void;
68
154
  toggleChat: () => void;
69
155
  }
70
156
 
157
+ export interface RealtimeVoiceMicrophone {
158
+ deviceId: string;
159
+ label: string;
160
+ }
161
+
162
+ export function createRealtimeVoiceAudioConstraints(
163
+ deviceId = "default",
164
+ exact = false,
165
+ ): MediaTrackConstraints {
166
+ return {
167
+ ...REALTIME_VOICE_AUDIO_CONSTRAINTS,
168
+ deviceId:
169
+ deviceId === "default"
170
+ ? { ideal: "default" }
171
+ : exact
172
+ ? { exact: deviceId }
173
+ : { ideal: deviceId },
174
+ };
175
+ }
176
+
177
+ export async function replaceRealtimeVoiceMicrophone(options: {
178
+ mediaDevices: Pick<MediaDevices, "getUserMedia">;
179
+ peer: Pick<RTCPeerConnection, "getSenders">;
180
+ currentStream: MediaStream | null;
181
+ deviceId: string;
182
+ }): Promise<MediaStream> {
183
+ const replacementStream = await options.mediaDevices.getUserMedia({
184
+ audio: createRealtimeVoiceAudioConstraints(options.deviceId, true),
185
+ });
186
+ try {
187
+ const replacementTrack = replacementStream.getAudioTracks()[0];
188
+ const sender = options.peer
189
+ .getSenders()
190
+ .find((candidate) => candidate.track?.kind === "audio");
191
+ if (!replacementTrack || !sender) {
192
+ throw new Error("No active microphone track is available.");
193
+ }
194
+
195
+ await sender.replaceTrack(replacementTrack);
196
+ for (const track of options.currentStream?.getTracks() ?? []) {
197
+ track.onended = null;
198
+ track.stop();
199
+ }
200
+ return replacementStream;
201
+ } catch (error) {
202
+ for (const track of replacementStream.getTracks()) track.stop();
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ function readRealtimeVoiceMicrophoneId(): string {
208
+ if (typeof window === "undefined") return "default";
209
+ try {
210
+ return (
211
+ window.localStorage.getItem(REALTIME_VOICE_MICROPHONE_STORAGE_KEY) ||
212
+ "default"
213
+ );
214
+ } catch {
215
+ return "default";
216
+ }
217
+ }
218
+
219
+ function writeRealtimeVoiceMicrophoneId(deviceId: string): void {
220
+ try {
221
+ window.localStorage.setItem(
222
+ REALTIME_VOICE_MICROPHONE_STORAGE_KEY,
223
+ deviceId,
224
+ );
225
+ } catch {
226
+ // A remembered local device is optional and must never block voice mode.
227
+ }
228
+ }
229
+
230
+ function isOneOf<T extends readonly string[]>(
231
+ values: T,
232
+ value: unknown,
233
+ ): value is T[number] {
234
+ return typeof value === "string" && values.includes(value);
235
+ }
236
+
237
+ export function normalizeRealtimeVoicePreferences(
238
+ value: unknown,
239
+ ): RealtimeVoicePreferences {
240
+ const record =
241
+ value && typeof value === "object" && !Array.isArray(value)
242
+ ? (value as Record<string, unknown>)
243
+ : {};
244
+ return {
245
+ language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
246
+ ? record.language
247
+ : DEFAULT_REALTIME_VOICE_PREFERENCES.language,
248
+ intelligence: isOneOf(
249
+ REALTIME_VOICE_INTELLIGENCE_LEVELS,
250
+ record.intelligence,
251
+ )
252
+ ? record.intelligence
253
+ : DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
254
+ voice: isOneOf(REALTIME_VOICES, record.voice)
255
+ ? record.voice
256
+ : DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
257
+ };
258
+ }
259
+
260
+ export function resolveRealtimeVoiceLanguage(
261
+ language: RealtimeVoiceLanguage,
262
+ browserLanguages: readonly string[] = [],
263
+ ): Exclude<RealtimeVoiceLanguage, "auto"> {
264
+ if (language !== "auto") return language;
265
+ for (const locale of browserLanguages) {
266
+ const primary = locale.trim().split("-")[0]?.toLowerCase();
267
+ if (isOneOf(REALTIME_VOICE_LANGUAGES, primary) && primary !== "auto") {
268
+ return primary;
269
+ }
270
+ }
271
+ return "en";
272
+ }
273
+
274
+ export function realtimeVoiceReasoningEffort(
275
+ intelligence: RealtimeVoiceIntelligence,
276
+ ): "minimal" | "low" | "medium" {
277
+ if (intelligence === "balanced") return "low";
278
+ if (intelligence === "deep") return "medium";
279
+ return "minimal";
280
+ }
281
+
282
+ export function createRealtimeVoicePreferenceUpdate(
283
+ preferences: RealtimeVoicePreferences,
284
+ options: {
285
+ browserLanguages?: readonly string[];
286
+ includeVoice?: boolean;
287
+ } = {},
288
+ ): Record<string, unknown> {
289
+ return {
290
+ type: "session.update",
291
+ session: {
292
+ type: "realtime",
293
+ reasoning: {
294
+ effort: realtimeVoiceReasoningEffort(preferences.intelligence),
295
+ },
296
+ audio: {
297
+ input: {
298
+ transcription: {
299
+ model: "gpt-4o-mini-transcribe",
300
+ language: resolveRealtimeVoiceLanguage(
301
+ preferences.language,
302
+ options.browserLanguages,
303
+ ),
304
+ },
305
+ },
306
+ ...(options.includeVoice
307
+ ? { output: { voice: preferences.voice } }
308
+ : {}),
309
+ },
310
+ },
311
+ };
312
+ }
313
+
71
314
  export interface CompletedRealtimeVoiceTranscript {
72
315
  role: "user" | "assistant";
73
316
  text: string;
74
317
  providerId?: string;
75
318
  }
76
319
 
320
+ export interface RealtimeVoiceTranscriptSequencer {
321
+ handle: (event: RealtimeServerEvent) => void;
322
+ reset: () => void;
323
+ }
324
+
325
+ interface SequencedRealtimeVoiceTranscript {
326
+ status: "pending" | "completed" | "skipped";
327
+ role?: CompletedRealtimeVoiceTranscript["role"];
328
+ transcript?: CompletedRealtimeVoiceTranscript;
329
+ }
330
+
331
+ /**
332
+ * Input transcription is produced by a separate ASR model and can finish after
333
+ * the assistant response. Reserve each message's position when OpenAI adds it
334
+ * to the conversation, then publish only the contiguous completed prefix.
335
+ */
336
+ export function createRealtimeVoiceTranscriptSequencer(
337
+ publish: (transcript: CompletedRealtimeVoiceTranscript) => void,
338
+ ): RealtimeVoiceTranscriptSequencer {
339
+ const order: string[] = [];
340
+ const items = new Map<string, SequencedRealtimeVoiceTranscript>();
341
+ const settledItemIds = new Set<string>();
342
+ const receivedTranscriptIds = new Set<string>();
343
+
344
+ const reserve = (
345
+ id: string,
346
+ role?: CompletedRealtimeVoiceTranscript["role"],
347
+ ) => {
348
+ if (settledItemIds.has(id)) return;
349
+ const existing = items.get(id);
350
+ if (existing) {
351
+ existing.role ??= role;
352
+ return;
353
+ }
354
+ order.push(id);
355
+ items.set(id, { status: "pending", role });
356
+ };
357
+
358
+ const drain = () => {
359
+ while (order.length > 0) {
360
+ const id = order[0]!;
361
+ const entry = items.get(id);
362
+ if (!entry || entry.status === "pending") return;
363
+ order.shift();
364
+ items.delete(id);
365
+ settledItemIds.add(id);
366
+ if (entry.status === "completed" && entry.transcript) {
367
+ publish(entry.transcript);
368
+ }
369
+ }
370
+ };
371
+
372
+ const skip = (id: unknown) => {
373
+ if (typeof id !== "string" || !id) return;
374
+ reserve(id);
375
+ const entry = items.get(id)!;
376
+ if (entry.status === "pending") entry.status = "skipped";
377
+ };
378
+
379
+ return {
380
+ handle(event) {
381
+ if (
382
+ event.type === "conversation.item.added" ||
383
+ event.type === "conversation.item.created"
384
+ ) {
385
+ const item = event.item;
386
+ if (item && typeof item === "object") {
387
+ const record = item as Record<string, unknown>;
388
+ if (
389
+ record.type === "message" &&
390
+ (record.role === "user" || record.role === "assistant") &&
391
+ typeof record.id === "string"
392
+ ) {
393
+ reserve(record.id, record.role);
394
+ }
395
+ }
396
+ }
397
+
398
+ const transcript = extractCompletedRealtimeVoiceTranscript(event);
399
+ if (transcript) {
400
+ const transcriptId = transcript.providerId
401
+ ? `${transcript.role}:${transcript.providerId}`
402
+ : undefined;
403
+ if (transcriptId && receivedTranscriptIds.has(transcriptId)) return;
404
+ if (transcriptId) receivedTranscriptIds.add(transcriptId);
405
+ const itemId =
406
+ typeof event.item_id === "string" ? event.item_id : undefined;
407
+ if (!itemId) {
408
+ // Older providers may omit item_id. Match the first reserved slot for
409
+ // that role so one incomplete legacy event cannot strand the queue.
410
+ const reservedId = order.find((id) => {
411
+ const entry = items.get(id);
412
+ return (
413
+ entry?.status === "pending" && entry.role === transcript.role
414
+ );
415
+ });
416
+ if (reservedId) {
417
+ items.set(reservedId, {
418
+ status: "completed",
419
+ role: transcript.role,
420
+ transcript,
421
+ });
422
+ } else {
423
+ publish(transcript);
424
+ }
425
+ } else {
426
+ if (settledItemIds.has(itemId)) return;
427
+ reserve(itemId, transcript.role);
428
+ items.set(itemId, {
429
+ status: "completed",
430
+ role: transcript.role,
431
+ transcript,
432
+ });
433
+ }
434
+ } else if (
435
+ event.type === "conversation.item.input_audio_transcription.failed" ||
436
+ event.type === "conversation.item.deleted"
437
+ ) {
438
+ skip(event.item_id);
439
+ } else if (event.type === "response.done") {
440
+ const response = event.response;
441
+ const output =
442
+ response && typeof response === "object"
443
+ ? (response as { output?: unknown }).output
444
+ : undefined;
445
+ if (Array.isArray(output)) {
446
+ for (const item of output) {
447
+ if (!item || typeof item !== "object") continue;
448
+ const record = item as Record<string, unknown>;
449
+ if (record.type === "message") skip(record.id);
450
+ }
451
+ }
452
+ }
453
+ drain();
454
+ },
455
+ reset() {
456
+ order.length = 0;
457
+ items.clear();
458
+ settledItemIds.clear();
459
+ receivedTranscriptIds.clear();
460
+ },
461
+ };
462
+ }
463
+
464
+ export function createRealtimeVoiceGreetingEvent(): Record<string, unknown> {
465
+ return {
466
+ type: "response.create",
467
+ response: {
468
+ output_modalities: ["audio"],
469
+ instructions:
470
+ 'Say exactly: "How can I help you?" Do not add anything else.',
471
+ // Do not set max_output_tokens here. Realtime counts assistant audio at
472
+ // roughly one token per 50 ms, so a text-sized cap can cut speech off.
473
+ // The exact-response instruction already keeps this greeting bounded.
474
+ },
475
+ };
476
+ }
477
+
478
+ export function createRealtimeVoiceGreetingStarter(
479
+ send: (event: Record<string, unknown>) => void,
480
+ ): { start: () => boolean; reset: () => void } {
481
+ let started = false;
482
+ return {
483
+ start() {
484
+ if (started) return false;
485
+ started = true;
486
+ send(createRealtimeVoiceGreetingEvent());
487
+ return true;
488
+ },
489
+ reset() {
490
+ started = false;
491
+ },
492
+ };
493
+ }
494
+
77
495
  /**
78
496
  * Voice mode owns the chat only temporarily. Restore the captured transcript
79
497
  * when it is still the user's active thread (or the chat has no active thread)
@@ -127,6 +545,14 @@ export function isRealtimeVoiceAbortError(error: unknown): boolean {
127
545
  );
128
546
  }
129
547
 
548
+ export function isRealtimeVoiceSetupRequiredError(error: unknown): boolean {
549
+ return Boolean(
550
+ error &&
551
+ typeof error === "object" &&
552
+ (error as { status?: unknown }).status === 409,
553
+ );
554
+ }
555
+
130
556
  export function createRealtimeVoiceConnectionTimeout(
131
557
  onTimeout: () => void,
132
558
  timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS,
@@ -135,6 +561,24 @@ export function createRealtimeVoiceConnectionTimeout(
135
561
  return () => window.clearTimeout(timeout);
136
562
  }
137
563
 
564
+ export function createRealtimeVoiceConnectionGate(
565
+ onTimeout: () => void,
566
+ timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS,
567
+ ): {
568
+ markTransportReady: () => void;
569
+ markSessionCreated: () => void;
570
+ cancel: () => void;
571
+ } {
572
+ const cancel = createRealtimeVoiceConnectionTimeout(onTimeout, timeoutMs);
573
+ return {
574
+ // RTC connectivity alone is not enough: the session may never finish its
575
+ // Realtime handshake. Keep the deadline armed until session.created.
576
+ markTransportReady() {},
577
+ markSessionCreated: cancel,
578
+ cancel,
579
+ };
580
+ }
581
+
138
582
  async function readErrorResponse(response: Response): Promise<string> {
139
583
  const raw = await response.text().catch(() => "");
140
584
  if (!raw) return response.statusText || `HTTP ${response.status}`;
@@ -146,10 +590,18 @@ async function readErrorResponse(response: Response): Promise<string> {
146
590
  }
147
591
  }
148
592
 
149
- export async function createRealtimeVoiceSession(
593
+ interface RealtimeVoiceSessionOptions {
594
+ browserTabId?: string;
595
+ signal?: AbortSignal;
596
+ preferences?: RealtimeVoicePreferences;
597
+ browserLanguages?: readonly string[];
598
+ }
599
+
600
+ export async function createRealtimeVoiceSessionWithCapability(
150
601
  offerSdp: string,
151
- options: { browserTabId?: string; signal?: AbortSignal } = {},
152
- ): Promise<string> {
602
+ options: RealtimeVoiceSessionOptions = {},
603
+ ): Promise<RealtimeVoiceSessionAnswer> {
604
+ const preferences = options.preferences;
153
605
  const response = await fetch(agentNativePath(REALTIME_VOICE_SESSION_PATH), {
154
606
  method: "POST",
155
607
  credentials: "same-origin",
@@ -158,6 +610,16 @@ export async function createRealtimeVoiceSession(
158
610
  ...(options.browserTabId
159
611
  ? { "X-Agent-Native-Browser-Tab": options.browserTabId }
160
612
  : {}),
613
+ ...(preferences
614
+ ? {
615
+ "X-Agent-Native-Realtime-Language": resolveRealtimeVoiceLanguage(
616
+ preferences.language,
617
+ options.browserLanguages,
618
+ ),
619
+ "X-Agent-Native-Realtime-Intelligence": preferences.intelligence,
620
+ "X-Agent-Native-Realtime-Voice": preferences.voice,
621
+ }
622
+ : {}),
161
623
  },
162
624
  body: offerSdp,
163
625
  signal: options.signal,
@@ -168,7 +630,21 @@ export async function createRealtimeVoiceSession(
168
630
  (error as { status?: number }).status = response.status;
169
631
  throw error;
170
632
  }
171
- return response.text();
633
+ const sdp = await response.text();
634
+ const capability = response.headers.get(REALTIME_VOICE_CAPABILITY_HEADER);
635
+ return {
636
+ sdp,
637
+ ...(capability ? { capability } : {}),
638
+ };
639
+ }
640
+
641
+ /** Backwards-compatible SDP-only session helper for existing client callers. */
642
+ export async function createRealtimeVoiceSession(
643
+ offerSdp: string,
644
+ options: RealtimeVoiceSessionOptions = {},
645
+ ): Promise<string> {
646
+ return (await createRealtimeVoiceSessionWithCapability(offerSdp, options))
647
+ .sdp;
172
648
  }
173
649
 
174
650
  export async function executeRealtimeVoiceTool(input: {
@@ -177,6 +653,7 @@ export async function executeRealtimeVoiceTool(input: {
177
653
  callId: string;
178
654
  sessionId?: string;
179
655
  browserTabId?: string;
656
+ capability?: string;
180
657
  signal?: AbortSignal;
181
658
  }): Promise<RealtimeVoiceToolResult> {
182
659
  const response = await fetch(agentNativePath(REALTIME_VOICE_TOOL_PATH), {
@@ -187,6 +664,9 @@ export async function executeRealtimeVoiceTool(input: {
187
664
  ...(input.browserTabId
188
665
  ? { "X-Agent-Native-Browser-Tab": input.browserTabId }
189
666
  : {}),
667
+ ...(input.capability
668
+ ? { [REALTIME_VOICE_CAPABILITY_HEADER]: input.capability }
669
+ : {}),
190
670
  },
191
671
  body: JSON.stringify({
192
672
  name: input.name,
@@ -203,6 +683,264 @@ export async function executeRealtimeVoiceTool(input: {
203
683
  return (await response.json()) as RealtimeVoiceToolResult;
204
684
  }
205
685
 
686
+ function jsonByteLength(value: unknown): number {
687
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
688
+ }
689
+
690
+ function normalizeRealtimeVoiceFunctionTool(
691
+ value: unknown,
692
+ ): RealtimeVoiceFunctionTool | null {
693
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
694
+ const record = value as Record<string, unknown>;
695
+ if (
696
+ record.type !== "function" ||
697
+ typeof record.name !== "string" ||
698
+ !REALTIME_VOICE_TOOL_NAME.test(record.name)
699
+ ) {
700
+ return null;
701
+ }
702
+ if (
703
+ record.description !== undefined &&
704
+ typeof record.description !== "string"
705
+ ) {
706
+ return null;
707
+ }
708
+ if (
709
+ record.parameters !== undefined &&
710
+ (!record.parameters ||
711
+ typeof record.parameters !== "object" ||
712
+ Array.isArray(record.parameters))
713
+ ) {
714
+ return null;
715
+ }
716
+ try {
717
+ if (jsonByteLength(record) > REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES) {
718
+ return null;
719
+ }
720
+ return JSON.parse(JSON.stringify(record)) as RealtimeVoiceFunctionTool;
721
+ } catch {
722
+ return null;
723
+ }
724
+ }
725
+
726
+ export function extractRealtimeVoiceSessionTools(
727
+ event: RealtimeServerEvent,
728
+ ): RealtimeVoiceFunctionTool[] | null {
729
+ if (event.type !== "session.created" && event.type !== "session.updated") {
730
+ return null;
731
+ }
732
+ const session = event.session;
733
+ if (!session || typeof session !== "object") return null;
734
+ const tools = (session as { tools?: unknown }).tools;
735
+ if (!Array.isArray(tools)) return null;
736
+ return tools
737
+ .map(normalizeRealtimeVoiceFunctionTool)
738
+ .filter((tool): tool is RealtimeVoiceFunctionTool => Boolean(tool));
739
+ }
740
+
741
+ function createRealtimeVoiceToolManifestUpdate(
742
+ tools: RealtimeVoiceFunctionTool[],
743
+ eventId?: string,
744
+ ): Record<string, unknown> {
745
+ return {
746
+ type: "session.update",
747
+ ...(eventId ? { event_id: eventId } : {}),
748
+ session: {
749
+ type: "realtime",
750
+ tools,
751
+ tool_choice: "auto",
752
+ },
753
+ };
754
+ }
755
+
756
+ /**
757
+ * Merge search-discovered schemas into the live manifest. Pinned navigation
758
+ * and discovery tools stay available, then the newest discoveries take the
759
+ * remaining slots ahead of lower-priority tools from the original manifest.
760
+ */
761
+ export function mergeRealtimeVoiceToolManifest(
762
+ currentTools: readonly RealtimeVoiceFunctionTool[],
763
+ expandedTools: readonly RealtimeVoiceFunctionTool[],
764
+ ): RealtimeVoiceFunctionTool[] {
765
+ const current = new Map<string, RealtimeVoiceFunctionTool>();
766
+ for (const value of currentTools) {
767
+ const tool = normalizeRealtimeVoiceFunctionTool(value);
768
+ if (tool) current.set(tool.name, tool);
769
+ }
770
+ const expanded = new Map<string, RealtimeVoiceFunctionTool>();
771
+ for (const value of expandedTools) {
772
+ const tool = normalizeRealtimeVoiceFunctionTool(value);
773
+ if (tool) expanded.set(tool.name, tool);
774
+ }
775
+
776
+ const candidates: RealtimeVoiceFunctionTool[] = [];
777
+ const seen = new Set<string>();
778
+ const add = (tool: RealtimeVoiceFunctionTool | undefined) => {
779
+ if (!tool || seen.has(tool.name)) return;
780
+ seen.add(tool.name);
781
+ candidates.push(tool);
782
+ };
783
+ for (const name of REALTIME_VOICE_PRIORITY_TOOL_NAMES) {
784
+ add(expanded.get(name) ?? current.get(name));
785
+ }
786
+ for (const tool of expanded.values()) add(tool);
787
+ for (const tool of current.values()) add(tool);
788
+
789
+ const packed: RealtimeVoiceFunctionTool[] = [];
790
+ for (const tool of candidates) {
791
+ if (packed.length >= REALTIME_VOICE_MAX_TOOLS) break;
792
+ const candidate = [...packed, tool];
793
+ if (
794
+ jsonByteLength(
795
+ createRealtimeVoiceToolManifestUpdate(
796
+ candidate,
797
+ "realtime_tool_manifest_999999999",
798
+ ),
799
+ ) <= REALTIME_VOICE_MAX_SESSION_BYTES
800
+ ) {
801
+ packed.push(tool);
802
+ }
803
+ }
804
+ return packed;
805
+ }
806
+
807
+ function createRealtimeVoiceToolResultEvent(
808
+ result: RealtimeVoiceToolResult,
809
+ ): Record<string, unknown> {
810
+ const modelResult = {
811
+ callId: result.callId,
812
+ status: result.status,
813
+ output: result.output,
814
+ ...(result.approvalKey ? { approvalKey: result.approvalKey } : {}),
815
+ };
816
+ return {
817
+ type: "conversation.item.create",
818
+ item: {
819
+ type: "function_call_output",
820
+ call_id: result.callId,
821
+ output: JSON.stringify(modelResult),
822
+ },
823
+ };
824
+ }
825
+
826
+ export interface RealtimeVoiceToolManifestCoordinator {
827
+ enqueue: (result: RealtimeVoiceToolResult) => void;
828
+ setSessionTools: (tools: readonly RealtimeVoiceFunctionTool[]) => void;
829
+ handleError: (eventId: string | undefined, message?: string) => boolean;
830
+ reset: () => void;
831
+ getTools: () => readonly RealtimeVoiceFunctionTool[];
832
+ }
833
+
834
+ /**
835
+ * Realtime treats `session.update.tools` as a full replacement. Serialize
836
+ * discovery updates and wait for a confirming `session.updated` manifest
837
+ * before returning the search result and allowing the next model response.
838
+ */
839
+ export function createRealtimeVoiceToolManifestCoordinator(
840
+ send: (event: Record<string, unknown>) => void,
841
+ timeoutMs = REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS,
842
+ ): RealtimeVoiceToolManifestCoordinator {
843
+ let currentTools: RealtimeVoiceFunctionTool[] = [];
844
+ const queue: RealtimeVoiceToolResult[] = [];
845
+ let updateSequence = 0;
846
+ let active:
847
+ | {
848
+ result: RealtimeVoiceToolResult;
849
+ expectedNames: string[];
850
+ eventId: string;
851
+ timer: ReturnType<typeof setTimeout>;
852
+ }
853
+ | undefined;
854
+
855
+ const finish = (failureMessage?: string) => {
856
+ if (!active) return;
857
+ const { result, timer } = active;
858
+ clearTimeout(timer);
859
+ active = undefined;
860
+ send(
861
+ createRealtimeVoiceToolResultEvent(
862
+ failureMessage
863
+ ? {
864
+ callId: result.callId,
865
+ status: "failed",
866
+ output: `${failureMessage} The discovered tools were not added to this voice session. Original tool result: ${result.output}`,
867
+ }
868
+ : result,
869
+ ),
870
+ );
871
+ send({ type: "response.create" });
872
+ drain();
873
+ };
874
+
875
+ const drain = () => {
876
+ if (active) return;
877
+ const result = queue.shift();
878
+ if (!result) return;
879
+ const expanded = Array.isArray(result.expandedTools)
880
+ ? result.expandedTools
881
+ .map(normalizeRealtimeVoiceFunctionTool)
882
+ .filter((tool): tool is RealtimeVoiceFunctionTool => Boolean(tool))
883
+ : [];
884
+ if (result.status !== "completed" || expanded.length === 0) {
885
+ send(createRealtimeVoiceToolResultEvent(result));
886
+ send({ type: "response.create" });
887
+ drain();
888
+ return;
889
+ }
890
+
891
+ const merged = mergeRealtimeVoiceToolManifest(currentTools, expanded);
892
+ const mergedNames = new Set(merged.map((tool) => tool.name));
893
+ const expectedNames = expanded
894
+ .map((tool) => tool.name)
895
+ .filter((name) => mergedNames.has(name));
896
+ if (expectedNames.length === 0) {
897
+ send(createRealtimeVoiceToolResultEvent(result));
898
+ send({ type: "response.create" });
899
+ drain();
900
+ return;
901
+ }
902
+
903
+ const eventId = `realtime_tool_manifest_${++updateSequence}`;
904
+ active = {
905
+ result,
906
+ expectedNames,
907
+ eventId,
908
+ timer: setTimeout(
909
+ () => finish("Timed out while installing the discovered tools."),
910
+ timeoutMs,
911
+ ),
912
+ };
913
+ send(createRealtimeVoiceToolManifestUpdate(merged, eventId));
914
+ };
915
+
916
+ return {
917
+ enqueue(result) {
918
+ queue.push(result);
919
+ drain();
920
+ },
921
+ setSessionTools(tools) {
922
+ currentTools = mergeRealtimeVoiceToolManifest([], tools);
923
+ if (!active) return;
924
+ const names = new Set(currentTools.map((tool) => tool.name));
925
+ if (active.expectedNames.every((name) => names.has(name))) finish();
926
+ },
927
+ handleError(eventId, message) {
928
+ if (!active || !eventId || active.eventId !== eventId) return false;
929
+ finish(message || "The provider rejected the discovered tool update.");
930
+ return true;
931
+ },
932
+ reset() {
933
+ if (active) clearTimeout(active.timer);
934
+ active = undefined;
935
+ queue.length = 0;
936
+ currentTools = [];
937
+ },
938
+ getTools() {
939
+ return currentTools;
940
+ },
941
+ };
942
+ }
943
+
206
944
  export function extractRealtimeVoiceFunctionCalls(
207
945
  event: RealtimeServerEvent,
208
946
  ): Array<{ name: string; callId: string; argumentsText: string }> {
@@ -269,19 +1007,6 @@ function openOpenAiKeySettings(): void {
269
1007
  );
270
1008
  }
271
1009
 
272
- function openMicrophoneSettings(): void {
273
- if (typeof window === "undefined") return;
274
- window.location.assign(`${appPath("/settings")}#voice`);
275
- }
276
-
277
- export function endRealtimeVoiceBeforeNavigation(
278
- end: () => void,
279
- navigate: () => void,
280
- ): void {
281
- end();
282
- window.setTimeout(navigate, 0);
283
- }
284
-
285
1010
  export function listenForRealtimeVoicePageHide(
286
1011
  cleanup: () => void,
287
1012
  ): () => void {
@@ -304,7 +1029,49 @@ function voiceCopy(t: ReturnType<typeof useT>): RealtimeVoiceModeCopy {
304
1029
  showChat: t("agentPanel.voiceMode.showChat"),
305
1030
  hideChat: t("agentPanel.voiceMode.hideChat"),
306
1031
  endVoiceMode: t("agentPanel.voiceMode.end"),
307
- microphoneSettings: t("agentPanel.voiceMode.microphoneSettings"),
1032
+ voiceSettings: t("agentPanel.voiceMode.voiceSettings"),
1033
+ settings: {
1034
+ language: t("agentPanel.voiceMode.settings.language"),
1035
+ autoLanguage: t("agentPanel.voiceMode.settings.autoLanguage"),
1036
+ languages: {
1037
+ en: t("agentPanel.voiceMode.settings.languages.en"),
1038
+ es: t("agentPanel.voiceMode.settings.languages.es"),
1039
+ fr: t("agentPanel.voiceMode.settings.languages.fr"),
1040
+ de: t("agentPanel.voiceMode.settings.languages.de"),
1041
+ it: t("agentPanel.voiceMode.settings.languages.it"),
1042
+ pt: t("agentPanel.voiceMode.settings.languages.pt"),
1043
+ ja: t("agentPanel.voiceMode.settings.languages.ja"),
1044
+ ko: t("agentPanel.voiceMode.settings.languages.ko"),
1045
+ zh: t("agentPanel.voiceMode.settings.languages.zh"),
1046
+ },
1047
+ intelligence: t("agentPanel.voiceMode.settings.intelligence"),
1048
+ intelligenceLevels: {
1049
+ instant: t("agentPanel.voiceMode.settings.intelligenceLevels.instant"),
1050
+ balanced: t(
1051
+ "agentPanel.voiceMode.settings.intelligenceLevels.balanced",
1052
+ ),
1053
+ deep: t("agentPanel.voiceMode.settings.intelligenceLevels.deep"),
1054
+ },
1055
+ voiceStyle: t("agentPanel.voiceMode.settings.voiceStyle"),
1056
+ microphone: t("agentPanel.voiceMode.settings.microphone"),
1057
+ defaultMicrophone: t("agentPanel.voiceMode.settings.defaultMicrophone"),
1058
+ microphoneSwitchFailed: t(
1059
+ "agentPanel.voiceMode.settings.microphoneSwitchFailed",
1060
+ ),
1061
+ voiceChangePending: t("agentPanel.voiceMode.settings.voiceChangePending"),
1062
+ voiceDescriptions: {
1063
+ marin: t("agentPanel.voiceMode.settings.voiceDescriptions.marin"),
1064
+ cedar: t("agentPanel.voiceMode.settings.voiceDescriptions.cedar"),
1065
+ coral: t("agentPanel.voiceMode.settings.voiceDescriptions.coral"),
1066
+ sage: t("agentPanel.voiceMode.settings.voiceDescriptions.sage"),
1067
+ verse: t("agentPanel.voiceMode.settings.voiceDescriptions.verse"),
1068
+ alloy: t("agentPanel.voiceMode.settings.voiceDescriptions.alloy"),
1069
+ ash: t("agentPanel.voiceMode.settings.voiceDescriptions.ash"),
1070
+ ballad: t("agentPanel.voiceMode.settings.voiceDescriptions.ballad"),
1071
+ echo: t("agentPanel.voiceMode.settings.voiceDescriptions.echo"),
1072
+ shimmer: t("agentPanel.voiceMode.settings.voiceDescriptions.shimmer"),
1073
+ },
1074
+ },
308
1075
  status: {
309
1076
  connecting: t("agentPanel.voiceMode.status.connecting"),
310
1077
  listening: t("agentPanel.voiceMode.status.listening"),
@@ -318,6 +1085,7 @@ function voiceCopy(t: ReturnType<typeof useT>): RealtimeVoiceModeCopy {
318
1085
  responseFailed: t("agentPanel.voiceMode.errors.responseFailed"),
319
1086
  sessionFailed: t("agentPanel.voiceMode.errors.sessionFailed"),
320
1087
  channelDisconnected: t("agentPanel.voiceMode.errors.channelDisconnected"),
1088
+ connectionTimedOut: t("agentPanel.voiceMode.errors.connectionTimedOut"),
321
1089
  connectionFailed: t("agentPanel.voiceMode.errors.connectionFailed"),
322
1090
  offerFailed: t("agentPanel.voiceMode.errors.offerFailed"),
323
1091
  },
@@ -336,8 +1104,20 @@ function useRealtimeVoiceModeController(
336
1104
  const [state, setState] = useState<"idle" | RealtimeVoiceModeState>("idle");
337
1105
  const [error, setError] = useState<string | null>(null);
338
1106
  const [chatVisible, setChatVisible] = useState(false);
1107
+ const [preferences, setPreferences] = useState(
1108
+ DEFAULT_REALTIME_VOICE_PREFERENCES,
1109
+ );
1110
+ const [voiceChangePending, setVoiceChangePending] = useState(false);
1111
+ const [microphones, setMicrophones] = useState<RealtimeVoiceMicrophone[]>([]);
1112
+ const [microphoneDeviceId, setMicrophoneDeviceId] = useState(
1113
+ readRealtimeVoiceMicrophoneId,
1114
+ );
1115
+ const [microphoneSwitching, setMicrophoneSwitching] = useState(false);
1116
+ const [microphoneError, setMicrophoneError] = useState<string | null>(null);
339
1117
  const [audioLevels] = useState(createRealtimeVoiceAudioLevelStore);
340
1118
  const stateRef = useRef(state);
1119
+ const preferencesRef = useRef(preferences);
1120
+ const hasOutputAudioRef = useRef(false);
341
1121
  const peerRef = useRef<RTCPeerConnection | null>(null);
342
1122
  const channelRef = useRef<RTCDataChannel | null>(null);
343
1123
  const streamRef = useRef<MediaStream | null>(null);
@@ -352,19 +1132,56 @@ function useRealtimeVoiceModeController(
352
1132
  const meterFrameRef = useRef<number | null>(null);
353
1133
  const lastMeterSampleRef = useRef(0);
354
1134
  const abortRef = useRef<AbortController | null>(null);
355
- const cancelConnectionTimeoutRef = useRef<(() => void) | null>(null);
1135
+ const connectionGateRef = useRef<ReturnType<
1136
+ typeof createRealtimeVoiceConnectionGate
1137
+ > | null>(null);
356
1138
  const handledCallsRef = useRef(new Set<string>());
357
1139
  const sessionIdRef = useRef<string | undefined>(undefined);
1140
+ const capabilityRef = useRef<string | undefined>(undefined);
1141
+ const transportGenerationRef = useRef(0);
358
1142
  const startedAtRef = useRef<string | undefined>(undefined);
359
1143
  const lastUserTextRef = useRef("");
360
1144
  const lastAssistantTextRef = useRef("");
361
1145
  const transcriptThreadIdRef = useRef<string | undefined>(undefined);
362
1146
  const transcriptSequenceRef = useRef(0);
1147
+ const preferencesHydratedRef = useRef(false);
1148
+ const preferencesEditedRef = useRef(false);
1149
+ const preferenceWriteChainRef = useRef<Promise<void>>(Promise.resolve());
1150
+ const toolManifestCoordinator = useMemo(
1151
+ () =>
1152
+ createRealtimeVoiceToolManifestCoordinator((event) => {
1153
+ sendDataChannelEvent(channelRef.current, event);
1154
+ }),
1155
+ [],
1156
+ );
1157
+ const switchMicrophoneRef = useRef<
1158
+ (deviceId: string, force?: boolean) => Promise<void>
1159
+ >(async () => undefined);
363
1160
 
364
1161
  useEffect(() => {
365
1162
  stateRef.current = state;
366
1163
  }, [state]);
367
1164
 
1165
+ useEffect(() => {
1166
+ preferencesRef.current = preferences;
1167
+ }, [preferences]);
1168
+
1169
+ const hydratePreferences = useCallback(async () => {
1170
+ if (preferencesHydratedRef.current) return;
1171
+ try {
1172
+ const stored = await readClientAppState(REALTIME_VOICE_PREFERENCES_KEY);
1173
+ if (!preferencesEditedRef.current && stored != null) {
1174
+ const next = normalizeRealtimeVoicePreferences(stored);
1175
+ preferencesRef.current = next;
1176
+ setPreferences(next);
1177
+ }
1178
+ } catch {
1179
+ // Preferences are optional; keep safe defaults when storage is unavailable.
1180
+ } finally {
1181
+ preferencesHydratedRef.current = true;
1182
+ }
1183
+ }, []);
1184
+
368
1185
  const syncAppState = useCallback(
369
1186
  (nextState: "idle" | RealtimeVoiceModeState) => {
370
1187
  const value =
@@ -377,6 +1194,7 @@ function useRealtimeVoiceModeController(
377
1194
  startedAt: startedAtRef.current,
378
1195
  sessionId: sessionIdRef.current,
379
1196
  browserTabId,
1197
+ preferences: preferencesRef.current,
380
1198
  lastUserText: lastUserTextRef.current || undefined,
381
1199
  lastAssistantText: lastAssistantTextRef.current || undefined,
382
1200
  };
@@ -396,6 +1214,95 @@ function useRealtimeVoiceModeController(
396
1214
  [syncAppState],
397
1215
  );
398
1216
 
1217
+ const savePreferences = useCallback((next: RealtimeVoicePreferences) => {
1218
+ preferencesEditedRef.current = true;
1219
+ preferencesHydratedRef.current = true;
1220
+ preferencesRef.current = next;
1221
+ setPreferences(next);
1222
+ preferenceWriteChainRef.current = preferenceWriteChainRef.current
1223
+ .catch(() => undefined)
1224
+ .then(async () => {
1225
+ await setClientAppState(REALTIME_VOICE_PREFERENCES_KEY, next, {
1226
+ requestSource: REALTIME_VOICE_REQUEST_SOURCE,
1227
+ });
1228
+ })
1229
+ .catch(() => undefined);
1230
+ }, []);
1231
+
1232
+ const updateLivePreferences = useCallback(
1233
+ (next: RealtimeVoicePreferences, includeVoice = false) => {
1234
+ sendDataChannelEvent(
1235
+ channelRef.current,
1236
+ createRealtimeVoicePreferenceUpdate(next, {
1237
+ browserLanguages:
1238
+ typeof navigator === "undefined" ? [] : navigator.languages,
1239
+ includeVoice,
1240
+ }),
1241
+ );
1242
+ },
1243
+ [],
1244
+ );
1245
+
1246
+ const setLanguage = useCallback(
1247
+ (language: RealtimeVoiceLanguage) => {
1248
+ const next = { ...preferencesRef.current, language };
1249
+ savePreferences(next);
1250
+ updateLivePreferences(next);
1251
+ },
1252
+ [savePreferences, updateLivePreferences],
1253
+ );
1254
+
1255
+ const setIntelligence = useCallback(
1256
+ (intelligence: RealtimeVoiceIntelligence) => {
1257
+ const next = { ...preferencesRef.current, intelligence };
1258
+ savePreferences(next);
1259
+ updateLivePreferences(next);
1260
+ },
1261
+ [savePreferences, updateLivePreferences],
1262
+ );
1263
+
1264
+ const setVoice = useCallback(
1265
+ (voice: RealtimeVoice) => {
1266
+ const next = { ...preferencesRef.current, voice };
1267
+ savePreferences(next);
1268
+ if (hasOutputAudioRef.current) {
1269
+ setVoiceChangePending(true);
1270
+ updateLivePreferences(next);
1271
+ return;
1272
+ }
1273
+ setVoiceChangePending(false);
1274
+ updateLivePreferences(next, true);
1275
+ },
1276
+ [savePreferences, updateLivePreferences],
1277
+ );
1278
+
1279
+ const refreshMicrophones = useCallback(async (): Promise<
1280
+ RealtimeVoiceMicrophone[] | null
1281
+ > => {
1282
+ const mediaDevices = navigator.mediaDevices;
1283
+ if (!mediaDevices?.enumerateDevices) return null;
1284
+ try {
1285
+ const devices = await mediaDevices.enumerateDevices();
1286
+ const inputs = devices
1287
+ .filter(
1288
+ (device) =>
1289
+ device.kind === "audioinput" &&
1290
+ device.deviceId &&
1291
+ device.deviceId !== "default" &&
1292
+ device.deviceId !== "communications",
1293
+ )
1294
+ .map((device, index) => ({
1295
+ deviceId: device.deviceId,
1296
+ label: device.label || `Microphone ${index + 1}`,
1297
+ }));
1298
+ setMicrophones(inputs);
1299
+ return inputs;
1300
+ } catch {
1301
+ // Enumeration is progressive enhancement; system default remains usable.
1302
+ return null;
1303
+ }
1304
+ }, []);
1305
+
399
1306
  const startMeterLoop = useCallback(() => {
400
1307
  if (meterFrameRef.current !== null) return;
401
1308
  const sample = (timestamp: number) => {
@@ -467,16 +1374,69 @@ function useRealtimeVoiceModeController(
467
1374
  [startMeterLoop],
468
1375
  );
469
1376
 
1377
+ const setMicrophone = useCallback(
1378
+ async (deviceId: string, force = false) => {
1379
+ if ((!force && deviceId === microphoneDeviceId) || microphoneSwitching) {
1380
+ return;
1381
+ }
1382
+ const mediaDevices = navigator.mediaDevices;
1383
+ const peer = peerRef.current;
1384
+ if (!mediaDevices?.getUserMedia || !peer) return;
1385
+
1386
+ setMicrophoneSwitching(true);
1387
+ setMicrophoneError(null);
1388
+ try {
1389
+ const replacementStream = await replaceRealtimeVoiceMicrophone({
1390
+ mediaDevices,
1391
+ peer,
1392
+ currentStream: streamRef.current,
1393
+ deviceId,
1394
+ });
1395
+ const replacementTrack = replacementStream.getAudioTracks()[0];
1396
+ streamRef.current = replacementStream;
1397
+ if (replacementTrack) {
1398
+ replacementTrack.onended = () => {
1399
+ void switchMicrophoneRef.current("default", true);
1400
+ };
1401
+ }
1402
+ attachAudioMeter(streamRef.current, "input");
1403
+ writeRealtimeVoiceMicrophoneId(deviceId);
1404
+ setMicrophoneDeviceId(deviceId);
1405
+ await refreshMicrophones();
1406
+ } catch {
1407
+ setMicrophoneError(
1408
+ copy?.settings.microphoneSwitchFailed ??
1409
+ "Could not switch microphones. Your current microphone is still active.",
1410
+ );
1411
+ } finally {
1412
+ setMicrophoneSwitching(false);
1413
+ }
1414
+ },
1415
+ [
1416
+ attachAudioMeter,
1417
+ copy,
1418
+ microphoneDeviceId,
1419
+ microphoneSwitching,
1420
+ refreshMicrophones,
1421
+ ],
1422
+ );
1423
+ switchMicrophoneRef.current = setMicrophone;
1424
+
470
1425
  const cleanupTransport = useCallback(() => {
471
- cancelConnectionTimeoutRef.current?.();
472
- cancelConnectionTimeoutRef.current = null;
1426
+ connectionGateRef.current?.cancel();
1427
+ connectionGateRef.current = null;
1428
+ transportGenerationRef.current += 1;
1429
+ capabilityRef.current = undefined;
473
1430
  abortRef.current?.abort();
474
1431
  abortRef.current = null;
475
1432
  channelRef.current?.close();
476
1433
  channelRef.current = null;
477
1434
  peerRef.current?.close();
478
1435
  peerRef.current = null;
479
- for (const track of streamRef.current?.getTracks() ?? []) track.stop();
1436
+ for (const track of streamRef.current?.getTracks() ?? []) {
1437
+ track.onended = null;
1438
+ track.stop();
1439
+ }
480
1440
  streamRef.current = null;
481
1441
  if (audioRef.current) {
482
1442
  audioRef.current.pause();
@@ -501,7 +1461,8 @@ function useRealtimeVoiceModeController(
501
1461
  if (audioContext) void audioContext.close().catch(() => undefined);
502
1462
  audioLevels.reset();
503
1463
  handledCallsRef.current.clear();
504
- }, [audioLevels]);
1464
+ toolManifestCoordinator.reset();
1465
+ }, [audioLevels, toolManifestCoordinator]);
505
1466
 
506
1467
  const fail = useCallback(
507
1468
  (message: string, options?: { openKeySettings?: boolean }) => {
@@ -517,6 +1478,8 @@ function useRealtimeVoiceModeController(
517
1478
  async (call: { name: string; callId: string; argumentsText: string }) => {
518
1479
  if (handledCallsRef.current.has(call.callId)) return;
519
1480
  handledCallsRef.current.add(call.callId);
1481
+ const transportGeneration = transportGenerationRef.current;
1482
+ const transportChannel = channelRef.current;
520
1483
  transition("working");
521
1484
  let result: RealtimeVoiceToolResult;
522
1485
  try {
@@ -527,6 +1490,7 @@ function useRealtimeVoiceModeController(
527
1490
  callId: call.callId,
528
1491
  sessionId: sessionIdRef.current,
529
1492
  browserTabId,
1493
+ capability: capabilityRef.current,
530
1494
  signal: abortRef.current?.signal,
531
1495
  });
532
1496
  } catch (toolError) {
@@ -536,24 +1500,21 @@ function useRealtimeVoiceModeController(
536
1500
  output: errorMessage(toolError),
537
1501
  };
538
1502
  }
539
- sendDataChannelEvent(channelRef.current, {
540
- type: "conversation.item.create",
541
- item: {
542
- type: "function_call_output",
543
- call_id: call.callId,
544
- output: JSON.stringify(result),
545
- },
546
- });
547
- sendDataChannelEvent(channelRef.current, { type: "response.create" });
1503
+ if (
1504
+ transportGeneration !== transportGenerationRef.current ||
1505
+ channelRef.current !== transportChannel
1506
+ ) {
1507
+ return;
1508
+ }
1509
+ toolManifestCoordinator.enqueue(result);
548
1510
  },
549
- [browserTabId, transition],
1511
+ [browserTabId, toolManifestCoordinator, transition],
550
1512
  );
551
1513
 
552
- const persistCompletedTranscript = useCallback(
553
- (event: RealtimeServerEvent) => {
554
- const transcript = extractCompletedRealtimeVoiceTranscript(event);
1514
+ const publishCompletedTranscript = useCallback(
1515
+ (transcript: CompletedRealtimeVoiceTranscript) => {
555
1516
  const threadId = transcriptThreadIdRef.current;
556
- if (!transcript || !threadId) return;
1517
+ if (!threadId) return;
557
1518
  const sessionIdentity =
558
1519
  sessionIdRef.current ?? startedAtRef.current ?? "pending";
559
1520
  const providerIdentity =
@@ -569,23 +1530,46 @@ function useRealtimeVoiceModeController(
569
1530
  [],
570
1531
  );
571
1532
 
1533
+ const transcriptSequencer = useMemo(
1534
+ () => createRealtimeVoiceTranscriptSequencer(publishCompletedTranscript),
1535
+ [publishCompletedTranscript],
1536
+ );
1537
+ const greetingStarter = useMemo(
1538
+ () =>
1539
+ createRealtimeVoiceGreetingStarter((event) => {
1540
+ sendDataChannelEvent(channelRef.current, event);
1541
+ }),
1542
+ [],
1543
+ );
1544
+
572
1545
  const handleServerEvent = useCallback(
573
1546
  (event: RealtimeServerEvent) => {
1547
+ transcriptSequencer.handle(event);
1548
+ const sessionTools = extractRealtimeVoiceSessionTools(event);
1549
+ if (sessionTools) toolManifestCoordinator.setSessionTools(sessionTools);
574
1550
  if (event.type === "session.created") {
1551
+ connectionGateRef.current?.markSessionCreated();
1552
+ connectionGateRef.current = null;
575
1553
  const session = event.session;
576
1554
  if (session && typeof session === "object") {
577
1555
  const id = (session as { id?: unknown }).id;
578
1556
  if (typeof id === "string") sessionIdRef.current = id;
579
1557
  }
580
- transition("listening");
1558
+ updateLivePreferences(preferencesRef.current, true);
1559
+ greetingStarter.start();
1560
+ transition("working");
581
1561
  } else if (event.type === "input_audio_buffer.speech_started") {
582
1562
  transition("listening");
583
1563
  } else if (event.type === "input_audio_buffer.speech_stopped") {
584
1564
  transition("working");
585
1565
  } else if (event.type === "response.created") {
1566
+ // From this point onward the response is committed to audio output, so
1567
+ // changing voices risks violating Realtime's per-session voice lock.
1568
+ hasOutputAudioRef.current = true;
586
1569
  lastAssistantTextRef.current = "";
587
1570
  transition("working");
588
1571
  } else if (event.type === "response.output_audio_transcript.delta") {
1572
+ hasOutputAudioRef.current = true;
589
1573
  if (typeof event.delta === "string") {
590
1574
  lastAssistantTextRef.current += event.delta;
591
1575
  }
@@ -594,7 +1578,6 @@ function useRealtimeVoiceModeController(
594
1578
  if (typeof event.transcript === "string") {
595
1579
  lastAssistantTextRef.current = event.transcript;
596
1580
  }
597
- persistCompletedTranscript(event);
598
1581
  syncAppState("speaking");
599
1582
  } else if (
600
1583
  event.type === "conversation.item.input_audio_transcription.completed"
@@ -602,7 +1585,6 @@ function useRealtimeVoiceModeController(
602
1585
  if (typeof event.transcript === "string") {
603
1586
  lastUserTextRef.current = event.transcript;
604
1587
  }
605
- persistCompletedTranscript(event);
606
1588
  syncAppState("working");
607
1589
  } else if (event.type === "response.done") {
608
1590
  const response = event.response;
@@ -625,6 +1607,18 @@ function useRealtimeVoiceModeController(
625
1607
  : typeof detail === "string"
626
1608
  ? detail
627
1609
  : "";
1610
+ const expansionEventId =
1611
+ detail && typeof detail === "object"
1612
+ ? ((detail as { event_id?: unknown }).event_id ?? event.event_id)
1613
+ : event.event_id;
1614
+ if (
1615
+ toolManifestCoordinator.handleError(
1616
+ typeof expansionEventId === "string" ? expansionEventId : undefined,
1617
+ message,
1618
+ )
1619
+ ) {
1620
+ return;
1621
+ }
628
1622
  fail(
629
1623
  message ||
630
1624
  copy?.errors.sessionFailed ||
@@ -642,10 +1636,13 @@ function useRealtimeVoiceModeController(
642
1636
  [
643
1637
  copy,
644
1638
  fail,
1639
+ greetingStarter,
645
1640
  handleFunctionCall,
646
- persistCompletedTranscript,
647
1641
  syncAppState,
1642
+ transcriptSequencer,
648
1643
  transition,
1644
+ toolManifestCoordinator,
1645
+ updateLivePreferences,
649
1646
  ],
650
1647
  );
651
1648
 
@@ -666,42 +1663,62 @@ function useRealtimeVoiceModeController(
666
1663
  lastUserTextRef.current = "";
667
1664
  lastAssistantTextRef.current = "";
668
1665
  sessionIdRef.current = undefined;
1666
+ hasOutputAudioRef.current = false;
1667
+ greetingStarter.reset();
1668
+ setVoiceChangePending(false);
669
1669
  transcriptThreadIdRef.current =
670
1670
  realtimeVoiceTranscriptRegistry.activeThreadId();
671
1671
  transcriptSequenceRef.current = 0;
1672
+ transcriptSequencer.reset();
672
1673
  transition("connecting");
673
1674
  setChatVisible(false);
674
1675
  window.dispatchEvent(new Event("agent-panel:close"));
675
1676
 
1677
+ await hydratePreferences();
1678
+ if ((stateRef.current as string) !== "connecting") return;
1679
+
676
1680
  const abortController = new AbortController();
677
1681
  abortRef.current = abortController;
678
1682
  const isCurrentAttempt = () =>
679
1683
  abortRef.current === abortController && !abortController.signal.aborted;
680
- const markConnected = () => {
1684
+ const connectionGate = createRealtimeVoiceConnectionGate(() => {
681
1685
  if (!isCurrentAttempt()) return;
682
- cancelConnectionTimeoutRef.current?.();
683
- cancelConnectionTimeoutRef.current = null;
684
- transition("listening");
685
- };
686
- cancelConnectionTimeoutRef.current = createRealtimeVoiceConnectionTimeout(
687
- () => {
688
- if (!isCurrentAttempt()) return;
689
- fail(
690
- copy?.errors.connectionFailed ??
691
- "The realtime voice connection timed out.",
692
- );
693
- },
694
- );
1686
+ fail(
1687
+ copy?.errors.connectionTimedOut ??
1688
+ "The realtime voice connection timed out.",
1689
+ );
1690
+ });
1691
+ connectionGateRef.current = connectionGate;
695
1692
  try {
696
1693
  const stream = await navigator.mediaDevices.getUserMedia({
697
- audio: REALTIME_VOICE_AUDIO_CONSTRAINTS,
1694
+ audio: createRealtimeVoiceAudioConstraints(
1695
+ readRealtimeVoiceMicrophoneId(),
1696
+ ),
698
1697
  });
699
1698
  if (!isCurrentAttempt()) {
700
1699
  for (const track of stream.getTracks()) track.stop();
701
1700
  return;
702
1701
  }
703
1702
  streamRef.current = stream;
1703
+ for (const track of stream.getAudioTracks()) {
1704
+ track.onended = () => {
1705
+ void switchMicrophoneRef.current("default", true);
1706
+ };
1707
+ }
704
1708
  attachAudioMeter(stream, "input");
1709
+ void refreshMicrophones().then((inputs) => {
1710
+ const selected = readRealtimeVoiceMicrophoneId();
1711
+ if (
1712
+ inputs &&
1713
+ selected !== "default" &&
1714
+ !inputs.some((device) => device.deviceId === selected)
1715
+ ) {
1716
+ // The persisted device was only an ideal preference, so getUserMedia
1717
+ // already selected a usable fallback for this new session.
1718
+ writeRealtimeVoiceMicrophoneId("default");
1719
+ setMicrophoneDeviceId("default");
1720
+ }
1721
+ });
705
1722
 
706
1723
  const peer = new RTCPeerConnection();
707
1724
  peerRef.current = peer;
@@ -720,7 +1737,7 @@ function useRealtimeVoiceModeController(
720
1737
 
721
1738
  const channel = peer.createDataChannel("oai-events");
722
1739
  channelRef.current = channel;
723
- channel.onopen = markConnected;
1740
+ channel.onopen = connectionGate.markTransportReady;
724
1741
  channel.onmessage = (messageEvent) => {
725
1742
  if (!isCurrentAttempt()) return;
726
1743
  try {
@@ -738,7 +1755,9 @@ function useRealtimeVoiceModeController(
738
1755
  };
739
1756
  peer.onconnectionstatechange = () => {
740
1757
  if (!isCurrentAttempt()) return;
741
- if (peer.connectionState === "connected") markConnected();
1758
+ if (peer.connectionState === "connected") {
1759
+ connectionGate.markTransportReady();
1760
+ }
742
1761
  if (peer.connectionState === "failed") {
743
1762
  fail(
744
1763
  copy?.errors.connectionFailed ??
@@ -757,12 +1776,15 @@ function useRealtimeVoiceModeController(
757
1776
  "The browser did not create an audio offer.",
758
1777
  );
759
1778
  }
760
- const answerSdp = await createRealtimeVoiceSession(offer.sdp, {
1779
+ const answer = await createRealtimeVoiceSessionWithCapability(offer.sdp, {
761
1780
  browserTabId,
762
1781
  signal: abortController.signal,
1782
+ preferences: preferencesRef.current,
1783
+ browserLanguages: navigator.languages,
763
1784
  });
764
1785
  if (!isCurrentAttempt()) return;
765
- await peer.setRemoteDescription({ type: "answer", sdp: answerSdp });
1786
+ capabilityRef.current = answer.capability;
1787
+ await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
766
1788
  } catch (startError) {
767
1789
  // Ending a connection aborts the SDP request by design. A superseded
768
1790
  // attempt must never resurrect the dock or surface that cancellation as
@@ -774,15 +1796,36 @@ function useRealtimeVoiceModeController(
774
1796
  ) {
775
1797
  return;
776
1798
  }
1799
+ if (isRealtimeVoiceSetupRequiredError(startError)) {
1800
+ // Credentials can disappear after the entry-point status check. Treat
1801
+ // the authoritative setup response as a gate, not as an active voice
1802
+ // session failure: clean up the mic/RTC attempt, reopen chat, and
1803
+ // refresh both the chat and voice provider setup surfaces.
1804
+ cleanupTransport();
1805
+ setError(null);
1806
+ startedAtRef.current = undefined;
1807
+ sessionIdRef.current = undefined;
1808
+ transcriptThreadIdRef.current = undefined;
1809
+ transition("idle");
1810
+ setChatVisible(true);
1811
+ window.dispatchEvent(new Event("agent-panel:open"));
1812
+ window.dispatchEvent(new Event("agent-engine:configured-changed"));
1813
+ return;
1814
+ }
777
1815
  const status = (startError as { status?: unknown })?.status;
778
1816
  fail(errorMessage(startError), { openKeySettings: status === 400 });
779
1817
  }
780
1818
  }, [
781
1819
  attachAudioMeter,
782
1820
  browserTabId,
1821
+ cleanupTransport,
783
1822
  copy,
784
1823
  fail,
1824
+ greetingStarter,
785
1825
  handleServerEvent,
1826
+ hydratePreferences,
1827
+ refreshMicrophones,
1828
+ transcriptSequencer,
786
1829
  transition,
787
1830
  ]);
788
1831
 
@@ -821,6 +1864,26 @@ function useRealtimeVoiceModeController(
821
1864
  window.dispatchEvent(new Event("agent-panel:toggle"));
822
1865
  }, []);
823
1866
 
1867
+ useEffect(() => {
1868
+ const mediaDevices = navigator.mediaDevices;
1869
+ if (!mediaDevices?.addEventListener) return;
1870
+ const handleDeviceChange = () => {
1871
+ const selected = readRealtimeVoiceMicrophoneId();
1872
+ void refreshMicrophones().then((inputs) => {
1873
+ if (
1874
+ inputs &&
1875
+ selected !== "default" &&
1876
+ !inputs.some((device) => device.deviceId === selected)
1877
+ ) {
1878
+ void setMicrophone("default");
1879
+ }
1880
+ });
1881
+ };
1882
+ mediaDevices.addEventListener("devicechange", handleDeviceChange);
1883
+ return () =>
1884
+ mediaDevices.removeEventListener("devicechange", handleDeviceChange);
1885
+ }, [refreshMicrophones, setMicrophone]);
1886
+
824
1887
  useEffect(() => {
825
1888
  const onSidebarState = (event: Event) => {
826
1889
  const detail = (event as CustomEvent<AgentSidebarStateChangeDetail>)
@@ -848,6 +1911,16 @@ function useRealtimeVoiceModeController(
848
1911
  errorMessage: error,
849
1912
  chatVisible,
850
1913
  audioLevels,
1914
+ preferences,
1915
+ microphones,
1916
+ microphoneDeviceId,
1917
+ microphoneSwitching,
1918
+ microphoneError,
1919
+ voiceChangePending,
1920
+ setLanguage,
1921
+ setIntelligence,
1922
+ setVoice,
1923
+ setMicrophone,
851
1924
  start,
852
1925
  end,
853
1926
  toggleChat,
@@ -868,6 +1941,77 @@ export function RealtimeVoiceModeProvider({
868
1941
  );
869
1942
  const copy = useRealtimeVoiceModeCopy();
870
1943
  const voice = useRealtimeVoiceModeController(resolvedBrowserTabId, copy);
1944
+ const inlineSettings = useMemo(
1945
+ () => ({
1946
+ dialogLabel: copy.voiceSettings,
1947
+ ...(voice.voiceChangePending
1948
+ ? { appliesNextConversationNote: copy.settings.voiceChangePending }
1949
+ : {}),
1950
+ ...(voice.microphoneError
1951
+ ? { microphoneError: voice.microphoneError }
1952
+ : {}),
1953
+ microphone: {
1954
+ label: copy.settings.microphone,
1955
+ value: voice.microphoneDeviceId,
1956
+ disabled: voice.microphoneSwitching,
1957
+ options: [
1958
+ {
1959
+ value: "default",
1960
+ label: copy.settings.defaultMicrophone,
1961
+ },
1962
+ ...voice.microphones.map((microphone) => ({
1963
+ value: microphone.deviceId,
1964
+ label: microphone.label,
1965
+ })),
1966
+ ],
1967
+ onValueChange: (value: string) => {
1968
+ void voice.setMicrophone(value);
1969
+ },
1970
+ },
1971
+ language: {
1972
+ label: copy.settings.language,
1973
+ value: voice.preferences.language,
1974
+ options: REALTIME_VOICE_LANGUAGES.map((language) => ({
1975
+ value: language,
1976
+ label:
1977
+ language === "auto"
1978
+ ? copy.settings.autoLanguage
1979
+ : copy.settings.languages[language],
1980
+ })),
1981
+ onValueChange: (value: string) => {
1982
+ if (isOneOf(REALTIME_VOICE_LANGUAGES, value)) {
1983
+ voice.setLanguage(value);
1984
+ }
1985
+ },
1986
+ },
1987
+ intelligence: {
1988
+ label: copy.settings.intelligence,
1989
+ value: voice.preferences.intelligence,
1990
+ options: REALTIME_VOICE_INTELLIGENCE_LEVELS.map((intelligence) => ({
1991
+ value: intelligence,
1992
+ label: copy.settings.intelligenceLevels[intelligence],
1993
+ })),
1994
+ onValueChange: (value: string) => {
1995
+ if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
1996
+ voice.setIntelligence(value);
1997
+ }
1998
+ },
1999
+ },
2000
+ voiceStyle: {
2001
+ label: copy.settings.voiceStyle,
2002
+ value: voice.preferences.voice,
2003
+ options: REALTIME_VOICES.map((voiceName) => ({
2004
+ value: voiceName,
2005
+ label: `${voiceName[0]!.toUpperCase()}${voiceName.slice(1)}`,
2006
+ description: copy.settings.voiceDescriptions[voiceName],
2007
+ })),
2008
+ onValueChange: (value: string) => {
2009
+ if (isOneOf(REALTIME_VOICES, value)) voice.setVoice(value);
2010
+ },
2011
+ },
2012
+ }),
2013
+ [copy, voice],
2014
+ );
871
2015
 
872
2016
  return (
873
2017
  <RealtimeVoiceModeContext.Provider value={voice}>
@@ -881,12 +2025,7 @@ export function RealtimeVoiceModeProvider({
881
2025
  audioLevels={voice.audioLevels}
882
2026
  onToggleChat={voice.toggleChat}
883
2027
  onEndVoiceMode={voice.end}
884
- onOpenMicrophoneSettings={() =>
885
- endRealtimeVoiceBeforeNavigation(
886
- voice.end,
887
- openMicrophoneSettings,
888
- )
889
- }
2028
+ settings={inlineSettings}
890
2029
  errorMessage={voice.errorMessage}
891
2030
  />,
892
2031
  document.body,