@agent-native/toolkit 0.221.0-same-host-mcp-20261027-20261027081850 → 0.221.0

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 (259) hide show
  1. package/agent-native.eject.json +16 -1
  2. package/dist/app/agentkit/react/components.d.ts +5 -3
  3. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  4. package/dist/app/agentkit/react/components.js +103 -42
  5. package/dist/app/agentkit/react/components.js.map +1 -1
  6. package/dist/app/agentkit/react/styles.css +93 -65
  7. package/dist/app/agentkit/react/voice-transcripts.d.ts +22 -1
  8. package/dist/app/agentkit/react/voice-transcripts.d.ts.map +1 -1
  9. package/dist/app/agentkit/react/voice-transcripts.js +209 -8
  10. package/dist/app/agentkit/react/voice-transcripts.js.map +1 -1
  11. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  12. package/dist/app/chat/AgentKitAssistantChat.js +55 -40
  13. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  14. package/dist/app/chat/agentkit-chat/guided-questions.d.ts +1 -0
  15. package/dist/app/chat/agentkit-chat/guided-questions.d.ts.map +1 -1
  16. package/dist/app/chat/agentkit-chat/guided-questions.js +18 -6
  17. package/dist/app/chat/agentkit-chat/guided-questions.js.map +1 -1
  18. package/dist/app/chat/agentkit-chat/index.d.ts +1 -1
  19. package/dist/app/chat/agentkit-chat/index.d.ts.map +1 -1
  20. package/dist/app/chat/agentkit-chat/index.js +1 -1
  21. package/dist/app/chat/agentkit-chat/index.js.map +1 -1
  22. package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
  23. package/dist/app/chat/chat/run-recovery.js +5 -1
  24. package/dist/app/chat/chat/run-recovery.js.map +1 -1
  25. package/dist/app/chat/composer/runtime-adapters.d.ts.map +1 -1
  26. package/dist/app/chat/composer/runtime-adapters.js +2 -0
  27. package/dist/app/chat/composer/runtime-adapters.js.map +1 -1
  28. package/dist/app/chat/reply-connection-suggestion.d.ts +29 -0
  29. package/dist/app/chat/reply-connection-suggestion.d.ts.map +1 -0
  30. package/dist/app/chat/reply-connection-suggestion.js +42 -0
  31. package/dist/app/chat/reply-connection-suggestion.js.map +1 -0
  32. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  33. package/dist/app/i18n/catalogs/ar-SA.js +7 -0
  34. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  35. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/de-DE.js +7 -0
  37. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  38. package/dist/app/i18n/catalogs/en-US.d.ts +7 -0
  39. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  40. package/dist/app/i18n/catalogs/en-US.js +7 -0
  41. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  42. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  43. package/dist/app/i18n/catalogs/es-ES.js +7 -0
  44. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  45. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  46. package/dist/app/i18n/catalogs/fr-FR.js +7 -0
  47. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  48. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  49. package/dist/app/i18n/catalogs/hi-IN.js +7 -0
  50. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  51. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  52. package/dist/app/i18n/catalogs/ja-JP.js +7 -0
  53. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  54. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  55. package/dist/app/i18n/catalogs/ko-KR.js +7 -0
  56. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  57. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  58. package/dist/app/i18n/catalogs/pt-BR.js +7 -0
  59. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  60. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  61. package/dist/app/i18n/catalogs/zh-CN.js +7 -0
  62. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  63. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  64. package/dist/app/i18n/catalogs/zh-TW.js +7 -0
  65. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  66. package/dist/app/mcp-approval/McpApprovalReview.d.ts +25 -0
  67. package/dist/app/mcp-approval/McpApprovalReview.d.ts.map +1 -0
  68. package/dist/app/mcp-approval/McpApprovalReview.js +342 -0
  69. package/dist/app/mcp-approval/McpApprovalReview.js.map +1 -0
  70. package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
  71. package/dist/app/onboarding/FirstRunOnboarding.js +299 -71
  72. package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
  73. package/dist/app/onboarding/GettingStartedCard.js +1 -1
  74. package/dist/app/onboarding/GettingStartedCard.js.map +1 -1
  75. package/dist/app/onboarding/first-run-builder-credits-flag.d.ts +2 -0
  76. package/dist/app/onboarding/first-run-builder-credits-flag.d.ts.map +1 -0
  77. package/dist/app/onboarding/first-run-builder-credits-flag.js +8 -0
  78. package/dist/app/onboarding/first-run-builder-credits-flag.js.map +1 -0
  79. package/dist/app/providers/AppProviders.d.ts.map +1 -1
  80. package/dist/app/providers/AppProviders.js +14 -1
  81. package/dist/app/providers/AppProviders.js.map +1 -1
  82. package/dist/app/resources/McpConnectionSuggestion.d.ts +2 -4
  83. package/dist/app/resources/McpConnectionSuggestion.d.ts.map +1 -1
  84. package/dist/app/resources/McpConnectionSuggestion.js +59 -36
  85. package/dist/app/resources/McpConnectionSuggestion.js.map +1 -1
  86. package/dist/app/settings/account/PreferencesSettings.d.ts.map +1 -1
  87. package/dist/app/settings/account/PreferencesSettings.js +14 -1
  88. package/dist/app/settings/account/PreferencesSettings.js.map +1 -1
  89. package/dist/app/settings/account/ThemeFamilySettings.js +2 -2
  90. package/dist/app/settings/account/ThemeFamilySettings.js.map +1 -1
  91. package/dist/app/settings/account/search-entries.d.ts +1 -0
  92. package/dist/app/settings/account/search-entries.d.ts.map +1 -1
  93. package/dist/app/settings/account/search-entries.js +5 -0
  94. package/dist/app/settings/account/search-entries.js.map +1 -1
  95. package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
  96. package/dist/app/settings/shell/SettingsShell.js +26 -2
  97. package/dist/app/settings/shell/SettingsShell.js.map +1 -1
  98. package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
  99. package/dist/app/shared/AppShellSkeleton.js +4 -10
  100. package/dist/app/shared/AppShellSkeleton.js.map +1 -1
  101. package/dist/app/shared/EnvironmentBadge.d.ts +2 -1
  102. package/dist/app/shared/EnvironmentBadge.d.ts.map +1 -1
  103. package/dist/app/shared/EnvironmentBadge.js +2 -3
  104. package/dist/app/shared/EnvironmentBadge.js.map +1 -1
  105. package/dist/app/shared/is-builder-io-employee.d.ts +8 -0
  106. package/dist/app/shared/is-builder-io-employee.d.ts.map +1 -0
  107. package/dist/app/shared/is-builder-io-employee.js +10 -0
  108. package/dist/app/shared/is-builder-io-employee.js.map +1 -0
  109. package/dist/app/styles/agent-conversation.css +30 -17
  110. package/dist/app/styles/theme-families.css +22 -5
  111. package/dist/app/styles/tokens/agent-kit.css +3 -3
  112. package/dist/composer/PromptComposer.d.ts +15 -3
  113. package/dist/composer/PromptComposer.d.ts.map +1 -1
  114. package/dist/composer/PromptComposer.js +74 -16
  115. package/dist/composer/PromptComposer.js.map +1 -1
  116. package/dist/composer/RealtimeVoiceMode.d.ts +7 -0
  117. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
  118. package/dist/composer/RealtimeVoiceMode.js +9 -2
  119. package/dist/composer/RealtimeVoiceMode.js.map +1 -1
  120. package/dist/composer/TiptapComposer.d.ts +7 -0
  121. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  122. package/dist/composer/TiptapComposer.js +12 -4
  123. package/dist/composer/TiptapComposer.js.map +1 -1
  124. package/dist/composer/VoiceButton.d.ts +2 -0
  125. package/dist/composer/VoiceButton.d.ts.map +1 -1
  126. package/dist/composer/VoiceButton.js +5 -4
  127. package/dist/composer/VoiceButton.js.map +1 -1
  128. package/dist/composer/index.d.ts +2 -2
  129. package/dist/composer/index.d.ts.map +1 -1
  130. package/dist/composer/index.js +1 -1
  131. package/dist/composer/index.js.map +1 -1
  132. package/dist/composer/prompt-attachments.d.ts +20 -0
  133. package/dist/composer/prompt-attachments.d.ts.map +1 -1
  134. package/dist/composer/prompt-attachments.js +188 -11
  135. package/dist/composer/prompt-attachments.js.map +1 -1
  136. package/dist/composer/realtime-voice-transcript.d.ts +27 -0
  137. package/dist/composer/realtime-voice-transcript.d.ts.map +1 -1
  138. package/dist/composer/realtime-voice-transcript.js +34 -1
  139. package/dist/composer/realtime-voice-transcript.js.map +1 -1
  140. package/dist/composer/realtime-voice-turn-metrics.d.ts +43 -0
  141. package/dist/composer/realtime-voice-turn-metrics.d.ts.map +1 -0
  142. package/dist/composer/realtime-voice-turn-metrics.js +132 -0
  143. package/dist/composer/realtime-voice-turn-metrics.js.map +1 -0
  144. package/dist/composer/runtime-adapters.d.ts +4 -1
  145. package/dist/composer/runtime-adapters.d.ts.map +1 -1
  146. package/dist/composer/runtime-adapters.js +2 -1
  147. package/dist/composer/runtime-adapters.js.map +1 -1
  148. package/dist/composer/useRealtimeVoiceMode.d.ts +61 -9
  149. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  150. package/dist/composer/useRealtimeVoiceMode.js +536 -99
  151. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  152. package/dist/composer/voice-chat-icon.d.ts +7 -0
  153. package/dist/composer/voice-chat-icon.d.ts.map +1 -0
  154. package/dist/composer/voice-chat-icon.js +14 -0
  155. package/dist/composer/voice-chat-icon.js.map +1 -0
  156. package/dist/editor/DragHandle.d.ts +12 -1
  157. package/dist/editor/DragHandle.d.ts.map +1 -1
  158. package/dist/editor/DragHandle.js +165 -41
  159. package/dist/editor/DragHandle.js.map +1 -1
  160. package/dist/editor/RegistryBlockNode.d.ts +20 -0
  161. package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
  162. package/dist/editor/RegistryBlockNode.js +58 -13
  163. package/dist/editor/RegistryBlockNode.js.map +1 -1
  164. package/dist/editor/index.d.ts +2 -2
  165. package/dist/editor/index.d.ts.map +1 -1
  166. package/dist/editor/index.js +1 -1
  167. package/dist/editor/index.js.map +1 -1
  168. package/dist/styles.css +2 -0
  169. package/dist/ui/dropdown-menu.d.ts.map +1 -1
  170. package/dist/ui/dropdown-menu.js +5 -1
  171. package/dist/ui/dropdown-menu.js.map +1 -1
  172. package/package.json +14 -4
  173. package/src/app/agentkit/react/components.regenerate-readiness.spec.tsx +3 -1
  174. package/src/app/agentkit/react/components.spec.tsx +24 -4
  175. package/src/app/agentkit/react/components.tsx +174 -44
  176. package/src/app/agentkit/react/composer-context.spec.tsx +3 -0
  177. package/src/app/agentkit/react/disclosures.spec.tsx +124 -0
  178. package/src/app/agentkit/react/lifecycle.spec.tsx +8 -2
  179. package/src/app/agentkit/react/message-actions.spec.tsx +161 -5
  180. package/src/app/agentkit/react/saved-reply-reload.spec.tsx +206 -0
  181. package/src/app/agentkit/react/styles.css +93 -65
  182. package/src/app/agentkit/react/voice-transcripts.spec.tsx +239 -4
  183. package/src/app/agentkit/react/voice-transcripts.ts +245 -8
  184. package/src/app/chat/AgentKitAssistantChat.spec.tsx +59 -11
  185. package/src/app/chat/AgentKitAssistantChat.tsx +74 -62
  186. package/src/app/chat/agentkit-chat/guided-answers-context.spec.ts +9 -0
  187. package/src/app/chat/agentkit-chat/guided-questions.tsx +17 -7
  188. package/src/app/chat/agentkit-chat/index.ts +1 -0
  189. package/src/app/chat/chat/run-recovery.spec.tsx +45 -0
  190. package/src/app/chat/chat/run-recovery.tsx +6 -1
  191. package/src/app/chat/composer/runtime-adapters.tsx +2 -0
  192. package/src/app/chat/reply-connection-suggestion.spec.ts +121 -0
  193. package/src/app/chat/reply-connection-suggestion.ts +76 -0
  194. package/src/app/i18n/catalogs/ar-SA.ts +10 -0
  195. package/src/app/i18n/catalogs/de-DE.ts +10 -0
  196. package/src/app/i18n/catalogs/en-US.ts +10 -0
  197. package/src/app/i18n/catalogs/es-ES.ts +10 -0
  198. package/src/app/i18n/catalogs/fr-FR.ts +10 -0
  199. package/src/app/i18n/catalogs/hi-IN.ts +10 -0
  200. package/src/app/i18n/catalogs/ja-JP.ts +10 -0
  201. package/src/app/i18n/catalogs/ko-KR.ts +10 -0
  202. package/src/app/i18n/catalogs/pt-BR.ts +10 -0
  203. package/src/app/i18n/catalogs/zh-CN.ts +10 -0
  204. package/src/app/i18n/catalogs/zh-TW.ts +10 -0
  205. package/src/app/mcp-approval/McpApprovalReview.spec.tsx +687 -0
  206. package/src/app/mcp-approval/McpApprovalReview.tsx +913 -0
  207. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +736 -26
  208. package/src/app/onboarding/FirstRunOnboarding.tsx +473 -145
  209. package/src/app/onboarding/GettingStartedCard.tsx +1 -1
  210. package/src/app/onboarding/first-run-builder-credits-flag.spec.ts +13 -0
  211. package/src/app/onboarding/first-run-builder-credits-flag.ts +9 -0
  212. package/src/app/providers/AppProviders.spec.tsx +109 -0
  213. package/src/app/providers/AppProviders.tsx +53 -1
  214. package/src/app/resources/McpConnectionSuggestion.spec.ts +77 -12
  215. package/src/app/resources/McpConnectionSuggestion.tsx +69 -51
  216. package/src/app/settings/VoiceChatSettings.spec.tsx +6 -1
  217. package/src/app/settings/account/PreferencesSettings.spec.tsx +73 -0
  218. package/src/app/settings/account/PreferencesSettings.tsx +24 -7
  219. package/src/app/settings/account/ThemeFamilySettings.tsx +2 -2
  220. package/src/app/settings/account/search-entries.spec.ts +14 -0
  221. package/src/app/settings/account/search-entries.ts +9 -0
  222. package/src/app/settings/shell/SettingsShell.tsx +29 -2
  223. package/src/app/shared/AppShellSkeleton.spec.tsx +2 -2
  224. package/src/app/shared/AppShellSkeleton.tsx +3 -16
  225. package/src/app/shared/EnvironmentBadge.tsx +3 -4
  226. package/src/app/shared/is-builder-io-employee.ts +17 -0
  227. package/src/app/styles/agent-conversation.css +30 -17
  228. package/src/app/styles/theme-families.css +22 -5
  229. package/src/app/styles/theme-families.spec.ts +73 -0
  230. package/src/app/styles/tokens/agent-kit.css +3 -3
  231. package/src/composer/AgentSuggestionBar.overflow.spec.tsx +1 -1
  232. package/src/composer/AttachmentChip.spec.tsx +87 -0
  233. package/src/composer/PromptComposer.context.spec.tsx +8 -4
  234. package/src/composer/PromptComposer.setup-card.spec.tsx +170 -0
  235. package/src/composer/PromptComposer.tsx +125 -14
  236. package/src/composer/RealtimeVoiceMode.spec.tsx +48 -0
  237. package/src/composer/RealtimeVoiceMode.tsx +45 -2
  238. package/src/composer/TiptapComposer.tsx +25 -0
  239. package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +4 -1
  240. package/src/composer/VoiceButton.tsx +8 -3
  241. package/src/composer/index.ts +4 -0
  242. package/src/composer/prompt-attachments.spec.ts +160 -8
  243. package/src/composer/prompt-attachments.ts +221 -12
  244. package/src/composer/realtime-voice-transcript.spec.ts +36 -0
  245. package/src/composer/realtime-voice-transcript.ts +56 -1
  246. package/src/composer/realtime-voice-turn-metrics.spec.ts +175 -0
  247. package/src/composer/realtime-voice-turn-metrics.ts +192 -0
  248. package/src/composer/runtime-adapters.tsx +5 -1
  249. package/src/composer/useRealtimeVoiceMode.session.spec.tsx +487 -2
  250. package/src/composer/useRealtimeVoiceMode.spec.ts +267 -65
  251. package/src/composer/useRealtimeVoiceMode.tsx +716 -139
  252. package/src/composer/voice-chat-icon.tsx +19 -0
  253. package/src/editor/DragHandle.spec.ts +217 -0
  254. package/src/editor/DragHandle.ts +214 -55
  255. package/src/editor/RegistryBlockNode.spec.ts +172 -2
  256. package/src/editor/RegistryBlockNode.tsx +91 -13
  257. package/src/editor/index.ts +4 -0
  258. package/src/styles.css +2 -0
  259. package/src/ui/dropdown-menu.tsx +4 -0
@@ -1,3 +1,4 @@
1
+ import { TranscriptGrouper, type TranscriptSegment } from "openai/helpers/live";
1
2
  import {
2
3
  createContext,
3
4
  useCallback,
@@ -15,7 +16,11 @@ import {
15
16
  smoothRealtimeVoiceLevel,
16
17
  type RealtimeVoiceAudioLevelStore,
17
18
  } from "./realtime-voice-audio-level.js";
18
- import { realtimeVoiceTranscriptRegistry } from "./realtime-voice-transcript.js";
19
+ import {
20
+ realtimeVoiceTranscriptRegistry,
21
+ type RealtimeVoiceWorkCall,
22
+ } from "./realtime-voice-transcript.js";
23
+ import { createRealtimeVoiceTurnMetrics } from "./realtime-voice-turn-metrics.js";
19
24
  import type {
20
25
  RealtimeVoiceModeCopy,
21
26
  RealtimeVoiceModeInlineSettings,
@@ -38,19 +43,25 @@ const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
38
43
  const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
39
44
  const REALTIME_VOICE_PROTOCOL_HEADER = "X-Agent-Native-Realtime-Protocol";
40
45
  const REALTIME_VOICE_MODEL_HEADER = "X-Agent-Native-Realtime-Model";
46
+ const REALTIME_VOICE_INTELLIGENCE_HEADER =
47
+ "X-Agent-Native-Realtime-Intelligence";
48
+ const REALTIME_VOICE_PRIORITY_TOOLS_HEADER =
49
+ "X-Agent-Native-Realtime-Priority-Tools";
41
50
  const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
42
51
  const REALTIME_VOICE_MAX_TOOLS = 32;
43
52
  const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
44
53
  const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
45
54
  const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
46
55
  const REALTIME_VOICE_LIVE_CLOSE_DRAIN_TIMEOUT_MS = 2_000;
47
- const REALTIME_VOICE_LIVE_TRANSCRIPT_GAP_MS = 750;
48
56
  const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
49
57
  "navigate",
50
58
  "set-url-path",
51
59
  "set-search-params",
52
60
  "view-screen",
61
+ "chat-history",
53
62
  "tool-search",
63
+ "web-search",
64
+ "web-request",
54
65
  ] as const;
55
66
  const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
56
67
  const REALTIME_VOICE_MICROPHONE_STORAGE_KEY =
@@ -96,12 +107,18 @@ export interface RealtimeVoicePreferences {
96
107
  language: RealtimeVoiceLanguage;
97
108
  intelligence: RealtimeVoiceIntelligence;
98
109
  voice: RealtimeVoice;
110
+ /**
111
+ * False until the user picks a level; until then the app's default applies.
112
+ * Omitted means `intelligence` was chosen.
113
+ */
114
+ intelligenceChosen?: boolean;
99
115
  }
100
116
 
101
117
  export const DEFAULT_REALTIME_VOICE_PREFERENCES: RealtimeVoicePreferences = {
102
118
  language: "auto",
103
119
  intelligence: "instant",
104
120
  voice: "marin",
121
+ intelligenceChosen: false,
105
122
  };
106
123
 
107
124
  export const REALTIME_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
@@ -130,8 +147,10 @@ export interface RealtimeVoiceToolResult {
130
147
  status: "completed" | "failed" | "approval_required";
131
148
  output: string;
132
149
  approvalKey?: string;
150
+ approvalId?: string;
133
151
  expandedTools?: RealtimeVoiceFunctionTool[];
134
152
  capability?: string;
153
+ readOnly?: boolean;
135
154
  }
136
155
 
137
156
  export interface RealtimeVoiceSessionAnswer {
@@ -139,6 +158,10 @@ export interface RealtimeVoiceSessionAnswer {
139
158
  capability?: string;
140
159
  protocol?: RealtimeVoiceProtocol;
141
160
  model?: string;
161
+ /** The level the session runs at, after the app's default. */
162
+ intelligence?: RealtimeVoiceIntelligence;
163
+ /** App edit tools the session loaded, kept through tool expansions. */
164
+ priorityToolNames?: string[];
142
165
  }
143
166
 
144
167
  export interface RealtimeVoiceFunctionTool {
@@ -169,10 +192,31 @@ export interface RealtimeVoiceModeApi {
169
192
  setMicrophone: (deviceId: string) => Promise<void>;
170
193
  setInputMuted: (muted: boolean) => void;
171
194
  setOutputMuted: (muted: boolean) => void;
172
- start: () => Promise<void>;
195
+ start: (options?: RealtimeVoiceStartOptions) => Promise<void>;
173
196
  end: () => void;
174
197
  /** Clears a failed session so voice chat can start again. */
175
198
  dismissError: () => void;
199
+ /** The tool running now, latest first started; null while none runs. */
200
+ currentToolName: string | null;
201
+ /** Tool calls waiting for the user to approve them, oldest first. */
202
+ approvals: readonly RealtimeVoiceApproval[];
203
+ /** Runs an approved call and tells the assistant how it went. */
204
+ resolveApproval: (callId: string, approved: boolean) => Promise<void>;
205
+ }
206
+
207
+ export interface RealtimeVoiceApproval {
208
+ /** The provider's id for the call that asked. */
209
+ callId: string;
210
+ toolName: string;
211
+ }
212
+
213
+ export interface RealtimeVoiceStartOptions {
214
+ /**
215
+ * The thread that receives the session's transcripts. Defaults to the
216
+ * thread on screen at start; pass one when the session starts before its
217
+ * thread is shown, such as from a page that hands off to the chat.
218
+ */
219
+ threadId?: string;
176
220
  }
177
221
 
178
222
  export interface RealtimeVoiceMicrophone {
@@ -294,19 +338,37 @@ export function normalizeRealtimeVoicePreferences(
294
338
  value && typeof value === "object" && !Array.isArray(value)
295
339
  ? (value as Record<string, unknown>)
296
340
  : {};
341
+ const storedIntelligence = isOneOf(
342
+ REALTIME_VOICE_INTELLIGENCE_LEVELS,
343
+ record.intelligence,
344
+ )
345
+ ? record.intelligence
346
+ : undefined;
347
+ // Saves from before `intelligenceChosen` stored "instant" for everyone, so
348
+ // only a non-default level among them shows a pick.
349
+ const chosenIntelligence =
350
+ typeof record.intelligenceChosen === "boolean"
351
+ ? record.intelligenceChosen
352
+ ? storedIntelligence
353
+ : undefined
354
+ : storedIntelligence !== DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence
355
+ ? storedIntelligence
356
+ : undefined;
357
+ // A level saved as not chosen is the app default a session last reported.
358
+ const appDefaultIntelligence =
359
+ record.intelligenceChosen === false ? storedIntelligence : undefined;
297
360
  return {
298
361
  language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
299
362
  ? record.language
300
363
  : DEFAULT_REALTIME_VOICE_PREFERENCES.language,
301
- intelligence: isOneOf(
302
- REALTIME_VOICE_INTELLIGENCE_LEVELS,
303
- record.intelligence,
304
- )
305
- ? record.intelligence
306
- : DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
364
+ intelligence:
365
+ chosenIntelligence ??
366
+ appDefaultIntelligence ??
367
+ DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
307
368
  voice: isOneOf(REALTIME_VOICES, record.voice)
308
369
  ? record.voice
309
370
  : DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
371
+ intelligenceChosen: chosenIntelligence !== undefined,
310
372
  };
311
373
  }
312
374
 
@@ -327,10 +389,16 @@ export function resolveRealtimeVoiceLanguage(
327
389
  export function realtimeVoiceReasoningEffort(
328
390
  intelligence: RealtimeVoiceIntelligence,
329
391
  protocol?: RealtimeVoiceProtocol,
330
- ): "minimal" | "low" | "medium" {
392
+ ): "minimal" | "low" | "medium" | "high" {
393
+ // The delegated Live model rejects "minimal", so its levels start at "low".
394
+ if (protocol === "live") {
395
+ if (intelligence === "balanced") return "medium";
396
+ if (intelligence === "deep") return "high";
397
+ return "low";
398
+ }
331
399
  if (intelligence === "balanced") return "low";
332
400
  if (intelligence === "deep") return "medium";
333
- return protocol === "live" ? "low" : "minimal";
401
+ return "minimal";
334
402
  }
335
403
 
336
404
  export function createRealtimeVoicePreferenceUpdate(
@@ -398,6 +466,111 @@ export function createRealtimeVoiceLanguageUpdate(
398
466
  };
399
467
  }
400
468
 
469
+ interface RealtimeVoiceWorkSegment {
470
+ id: string;
471
+ threadId: string;
472
+ /** The GPT-Live delegation the calls ran under; one delegation is one request. */
473
+ delegationId: string | undefined;
474
+ calls: RealtimeVoiceWorkCall[];
475
+ /** The reply is over; the segment ends once its last call does. */
476
+ closing: boolean;
477
+ }
478
+
479
+ function publishRealtimeVoiceWorkSegment(
480
+ segment: RealtimeVoiceWorkSegment,
481
+ completedAt?: string,
482
+ ): void {
483
+ if (segment.calls.length === 0) return;
484
+ realtimeVoiceTranscriptRegistry.publishWork({
485
+ id: segment.id,
486
+ threadId: segment.threadId,
487
+ calls: segment.calls.map((call) => ({ ...call })),
488
+ ...(completedAt ? { completedAt } : {}),
489
+ });
490
+ }
491
+
492
+ /**
493
+ * The reply's open work a new call joins, or new work; none without a thread.
494
+ * A call from a different delegation is a new request.
495
+ */
496
+ function openRealtimeVoiceWorkSegment(
497
+ ref: { current: RealtimeVoiceWorkSegment | null },
498
+ threadId: string | undefined,
499
+ callId: string,
500
+ delegationId: string | undefined,
501
+ ): RealtimeVoiceWorkSegment | null {
502
+ if (!threadId) return null;
503
+ const current = ref.current;
504
+ if (
505
+ current?.threadId === threadId &&
506
+ (!current.delegationId ||
507
+ !delegationId ||
508
+ current.delegationId === delegationId)
509
+ ) {
510
+ current.delegationId ??= delegationId;
511
+ return current;
512
+ }
513
+ if (current) closeRealtimeVoiceWorkSegment(ref, current);
514
+ const segment = {
515
+ id: callId,
516
+ threadId,
517
+ delegationId,
518
+ calls: [],
519
+ closing: false,
520
+ };
521
+ ref.current = segment;
522
+ return segment;
523
+ }
524
+
525
+ /**
526
+ * Ends a reply's work. It ends at its last call's completion, so the reply
527
+ * that follows the work reads below it.
528
+ */
529
+ interface PendingApproval {
530
+ callId: string;
531
+ name: string;
532
+ args: Record<string, unknown>;
533
+ /** The server's approval record; a decision names exactly this one. */
534
+ approvalId: string;
535
+ workCall: RealtimeVoiceWorkCall;
536
+ workSegment: RealtimeVoiceWorkSegment | null;
537
+ }
538
+
539
+ /** Ends a call that waited for approval: `result` is null when it never ran. */
540
+ function finishPendingApproval(
541
+ pending: PendingApproval,
542
+ result: RealtimeVoiceToolResult | null,
543
+ ): void {
544
+ const { workCall, workSegment } = pending;
545
+ workCall.status =
546
+ !result || result.status === "approval_required"
547
+ ? "cancelled"
548
+ : result.status;
549
+ if (result) workCall.output = result.output;
550
+ workCall.completedAt = new Date().toISOString();
551
+ if (!workSegment) return;
552
+ if (workSegment.closing) {
553
+ closeRealtimeVoiceWorkSegment({ current: null }, workSegment);
554
+ } else {
555
+ publishRealtimeVoiceWorkSegment(workSegment);
556
+ }
557
+ }
558
+
559
+ function closeRealtimeVoiceWorkSegment(
560
+ ref: { current: RealtimeVoiceWorkSegment | null },
561
+ segment: RealtimeVoiceWorkSegment | null = ref.current,
562
+ ): void {
563
+ if (!segment) return;
564
+ if (ref.current === segment) ref.current = null;
565
+ segment.closing = true;
566
+ if (segment.calls.some((call) => call.status === "running")) return;
567
+ const completedAt = segment.calls
568
+ .map((call) => call.completedAt ?? call.startedAt)
569
+ .sort()
570
+ .at(-1);
571
+ publishRealtimeVoiceWorkSegment(segment, completedAt);
572
+ }
573
+
401
574
  export interface CompletedRealtimeVoiceTranscript {
402
575
  role: "user" | "assistant";
403
576
  text: string;
@@ -410,56 +583,78 @@ export interface RealtimeVoiceTranscriptSequencer {
410
583
  reset: (options?: { preserveQueuedTranscripts?: boolean }) => void;
411
584
  }
412
585
 
586
+ /**
587
+ * A GPT-Live turn as the transcript grouper sees it. Declared here so the
588
+ * toolkit's public types never pull in the OpenAI SDK's Node globals.
589
+ */
590
+ export interface RealtimeVoiceLiveTurn {
591
+ id: string;
592
+ speaker: CompletedRealtimeVoiceTranscript["role"];
593
+ text: string;
594
+ startMs: number;
595
+ endMs: number;
596
+ }
597
+
413
598
  interface SequencedRealtimeVoiceTranscript {
414
599
  status: "pending" | "completed" | "skipped";
415
600
  role?: CompletedRealtimeVoiceTranscript["role"];
416
601
  transcript?: CompletedRealtimeVoiceTranscript;
417
602
  }
418
603
 
419
- interface LiveRealtimeVoiceTranscriptBuffer {
420
- role: CompletedRealtimeVoiceTranscript["role"];
421
- text: string;
422
- lastEndMs?: number;
423
- sequence: number;
424
- }
425
-
426
604
  /**
427
605
  * Orders provider transcripts into conversation order. `publish` receives each
428
606
  * final transcript; `stream`, when given, receives the growing text of a turn
429
- * under the same `providerId` its final transcript will carry.
607
+ * under the same `providerId` its final transcript will carry. GPT-Live turns
608
+ * come from the SDK's transcript grouper, which splits on speaker changes and
609
+ * two-second silences and drops short backchannels heard over the reply.
430
610
  */
431
611
  export function createRealtimeVoiceTranscriptSequencer(
432
612
  publish: (transcript: CompletedRealtimeVoiceTranscript) => void,
433
613
  stream?: (transcript: CompletedRealtimeVoiceTranscript) => void,
614
+ /** Each GPT-Live turn as it grows, with its place on the session clock. */
615
+ onLiveTurn?: (turn: RealtimeVoiceLiveTurn) => void,
434
616
  ): RealtimeVoiceTranscriptSequencer {
435
617
  const order: string[] = [];
436
618
  const items = new Map<string, SequencedRealtimeVoiceTranscript>();
437
619
  const settledItemIds = new Set<string>();
438
620
  const receivedTranscriptIds = new Set<string>();
439
- const liveBuffers = new Map<
440
- CompletedRealtimeVoiceTranscript["role"],
441
- LiveRealtimeVoiceTranscriptBuffer
442
- >();
443
621
  const streamingText = new Map<string, string>();
444
622
  const streamedText = new Map<string, string>();
445
- let liveSequence = 0;
623
+ let liveGrouper: TranscriptGrouper | undefined;
624
+
625
+ const liveTranscript = (segment: TranscriptSegment) => {
626
+ const text = segment.text.trim();
627
+ if (!text) return undefined;
628
+ return stream
629
+ ? { role: segment.speaker, text, providerId: segment.id }
630
+ : { role: segment.speaker, text };
631
+ };
446
632
 
447
- const liveProviderId = (buffer: LiveRealtimeVoiceTranscriptBuffer) =>
448
- `live-${buffer.sequence}`;
633
+ // Listeners check that their grouper is still current, so a discarded one
634
+ // never publishes what it closes on the way out.
635
+ const openLiveGrouper = () => {
636
+ if (liveGrouper) return liveGrouper;
637
+ const grouper = new TranscriptGrouper();
638
+ grouper.on("segment.updated", (segment) => {
639
+ if (grouper !== liveGrouper) return;
640
+ onLiveTurn?.(segment);
641
+ const transcript = liveTranscript(segment);
642
+ if (stream && transcript) stream(transcript);
643
+ });
644
+ grouper.on("segment.closed", ({ segment }) => {
645
+ const transcript = liveTranscript(segment);
646
+ if (grouper === liveGrouper && transcript) publish(transcript);
647
+ });
648
+ liveGrouper = grouper;
649
+ return grouper;
650
+ };
449
651
 
450
- const flushLiveTranscript = (
451
- role: CompletedRealtimeVoiceTranscript["role"],
452
- ) => {
453
- const buffer = liveBuffers.get(role);
454
- if (!buffer) return;
455
- liveBuffers.delete(role);
456
- const text = buffer.text.trim();
457
- if (!text) return;
458
- publish(
459
- stream
460
- ? { role, text, providerId: liveProviderId(buffer) }
461
- : { role, text },
462
- );
652
+ const closeLiveGrouper = (keepText: boolean) => {
653
+ const grouper = liveGrouper;
654
+ if (!grouper) return;
655
+ if (!keepText) liveGrouper = undefined;
656
+ grouper.close();
657
+ liveGrouper = undefined;
463
658
  };
464
659
 
465
660
  // A turn appears once every earlier turn has appeared, so a fast reply
@@ -485,14 +680,6 @@ export function createRealtimeVoiceTranscriptSequencer(
485
680
  }
486
681
  };
487
682
 
488
- const flushLiveTranscripts = () => {
489
- for (const buffer of [...liveBuffers.values()].sort(
490
- (left, right) => left.sequence - right.sequence,
491
- )) {
492
- flushLiveTranscript(buffer.role);
493
- }
494
- };
495
-
496
683
  const reserve = (
497
684
  id: string,
498
685
  role?: CompletedRealtimeVoiceTranscript["role"],
@@ -536,37 +723,22 @@ export function createRealtimeVoiceTranscriptSequencer(
536
723
  event.type === "session.input_transcript.delta" ||
537
724
  event.type === "session.output_transcript.delta"
538
725
  ) {
539
- const role =
726
+ const type =
540
727
  event.type === "session.input_transcript.delta"
541
- ? "user"
542
- : "assistant";
543
- const startMs =
544
- typeof event.start_ms === "number" ? event.start_ms : undefined;
545
- const endMs = typeof event.end_ms === "number" ? event.end_ms : startMs;
546
- let buffer = liveBuffers.get(role);
728
+ ? "session.input_transcript.delta"
729
+ : "session.output_transcript.delta";
730
+ const { event_id, delta, start_ms, end_ms } = event;
547
731
  if (
548
- buffer &&
549
- startMs !== undefined &&
550
- buffer.lastEndMs !== undefined &&
551
- startMs - buffer.lastEndMs > REALTIME_VOICE_LIVE_TRANSCRIPT_GAP_MS
732
+ typeof event_id !== "string" ||
733
+ typeof delta !== "string" ||
734
+ typeof start_ms !== "number" ||
735
+ typeof end_ms !== "number"
552
736
  ) {
553
- flushLiveTranscript(role);
554
- buffer = undefined;
555
- }
556
- if (!buffer) {
557
- buffer = {
558
- role,
559
- text: "",
560
- sequence: ++liveSequence,
561
- };
562
- liveBuffers.set(role, buffer);
563
- }
564
- if (typeof event.delta === "string") buffer.text += event.delta;
565
- if (endMs !== undefined) buffer.lastEndMs = endMs;
566
- const text = buffer.text.trim();
567
- if (stream && text) {
568
- stream({ role, text, providerId: liveProviderId(buffer) });
737
+ throw new Error(
738
+ "GPT-Live sent a transcript delta without an id, text, or timing.",
739
+ );
569
740
  }
741
+ openLiveGrouper().push({ type, event_id, delta, start_ms, end_ms });
570
742
  return;
571
743
  }
572
744
  if (
@@ -591,7 +763,7 @@ export function createRealtimeVoiceTranscriptSequencer(
591
763
  emitStreams();
592
764
  return;
593
765
  }
594
- if (event.type === "session.closed") flushLiveTranscripts();
766
+ if (event.type === "session.closed") closeLiveGrouper(true);
595
767
  if (
596
768
  event.type === "conversation.item.added" ||
597
769
  event.type === "conversation.item.created"
@@ -669,7 +841,7 @@ export function createRealtimeVoiceTranscriptSequencer(
669
841
  emitStreams();
670
842
  },
671
843
  flush() {
672
- flushLiveTranscripts();
844
+ closeLiveGrouper(true);
673
845
  if (!stream) return;
674
846
  // The session is over: a turn that streamed but never got its final
675
847
  // transcript keeps the text that was heard.
@@ -696,9 +868,10 @@ export function createRealtimeVoiceTranscriptSequencer(
696
868
  }
697
869
  },
698
870
  reset(options) {
699
- liveBuffers.clear();
700
- liveSequence = 0;
871
+ // A failed response leaves what was already heard; a new session
872
+ // starts clean.
701
873
  if (options?.preserveQueuedTranscripts) return;
874
+ closeLiveGrouper(false);
702
875
  order.length = 0;
703
876
  items.clear();
704
877
  settledItemIds.clear();
@@ -985,7 +1158,12 @@ export async function createRealtimeVoiceSessionWithCapability(
985
1158
  preferences.language,
986
1159
  options.browserLanguages,
987
1160
  ),
988
- "X-Agent-Native-Realtime-Intelligence": preferences.intelligence,
1161
+ ...(preferences.intelligenceChosen !== false
1162
+ ? {
1163
+ [REALTIME_VOICE_INTELLIGENCE_HEADER]:
1164
+ preferences.intelligence,
1165
+ }
1166
+ : {}),
989
1167
  "X-Agent-Native-Realtime-Voice": preferences.voice,
990
1168
  }
991
1169
  : {}),
@@ -1008,11 +1186,29 @@ export async function createRealtimeVoiceSessionWithCapability(
1008
1186
  : undefined;
1009
1187
  const modelHeader = response.headers.get(REALTIME_VOICE_MODEL_HEADER);
1010
1188
  const model = modelHeader?.trim() || undefined;
1189
+ const intelligenceHeader = response.headers
1190
+ .get(REALTIME_VOICE_INTELLIGENCE_HEADER)
1191
+ ?.trim();
1192
+ const intelligence = isOneOf(
1193
+ REALTIME_VOICE_INTELLIGENCE_LEVELS,
1194
+ intelligenceHeader,
1195
+ )
1196
+ ? intelligenceHeader
1197
+ : undefined;
1198
+ const priorityToolNames = (
1199
+ response.headers.get(REALTIME_VOICE_PRIORITY_TOOLS_HEADER) ?? ""
1200
+ )
1201
+ .split(",")
1202
+ .map((name) => name.trim())
1203
+ .filter((name) => REALTIME_VOICE_TOOL_NAME.test(name))
1204
+ .slice(0, REALTIME_VOICE_MAX_TOOLS);
1011
1205
  return {
1012
1206
  sdp,
1013
1207
  ...(capability ? { capability } : {}),
1014
1208
  ...(protocol ? { protocol } : {}),
1015
1209
  ...(model ? { model } : {}),
1210
+ ...(intelligence ? { intelligence } : {}),
1211
+ ...(priorityToolNames.length > 0 ? { priorityToolNames } : {}),
1016
1212
  };
1017
1213
  }
1018
1214
 
@@ -1032,10 +1228,14 @@ export async function executeRealtimeVoiceTool(input: {
1032
1228
  name: string;
1033
1229
  args: Record<string, unknown>;
1034
1230
  callId: string;
1035
- sessionId?: string;
1036
- browserTabId?: string;
1037
- capability?: string;
1038
- signal?: AbortSignal;
1231
+ sessionId?: string | undefined;
1232
+ browserTabId?: string | undefined;
1233
+ capability?: string | undefined;
1234
+ signal?: AbortSignal | undefined;
1235
+ /** Runs the call this approval record was asked for, once. */
1236
+ approvalId?: string | undefined;
1237
+ /** Declines that approval record instead of running the call. */
1238
+ declinedApprovalId?: string | undefined;
1039
1239
  }): Promise<RealtimeVoiceToolResult> {
1040
1240
  const response = await fetch(REALTIME_VOICE_TOOL_PATH, {
1041
1241
  method: "POST",
@@ -1055,6 +1255,10 @@ export async function executeRealtimeVoiceTool(input: {
1055
1255
  callId: input.callId,
1056
1256
  sessionId: input.sessionId,
1057
1257
  browserTabId: input.browserTabId,
1258
+ ...(input.approvalId ? { approvalId: input.approvalId } : {}),
1259
+ ...(input.declinedApprovalId
1260
+ ? { declinedApprovalId: input.declinedApprovalId }
1261
+ : {}),
1058
1262
  }),
1059
1263
  signal: input.signal,
1060
1264
  });
@@ -1146,7 +1350,11 @@ function createRealtimeVoiceToolManifestUpdate(
1146
1350
  ? {
1147
1351
  delegation: {
1148
1352
  type: "responses",
1149
- responses: { tools, tool_choice: "auto" },
1353
+ // Strict schemas make every optional parameter required.
1354
+ responses: {
1355
+ tools: tools.map((tool) => ({ ...tool, strict: false })),
1356
+ tool_choice: "auto",
1357
+ },
1150
1358
  },
1151
1359
  }
1152
1360
  : {
@@ -1161,6 +1369,7 @@ export function mergeRealtimeVoiceToolManifest(
1161
1369
  currentTools: readonly RealtimeVoiceFunctionTool[],
1162
1370
  expandedTools: readonly RealtimeVoiceFunctionTool[],
1163
1371
  protocol: RealtimeVoiceProtocol = "realtime",
1372
+ appPriorityToolNames: readonly string[] = [],
1164
1373
  ): RealtimeVoiceFunctionTool[] {
1165
1374
  const current = new Map<string, RealtimeVoiceFunctionTool>();
1166
1375
  for (const value of currentTools) {
@@ -1180,7 +1389,10 @@ export function mergeRealtimeVoiceToolManifest(
1180
1389
  seen.add(tool.name);
1181
1390
  candidates.push(tool);
1182
1391
  };
1183
- for (const name of REALTIME_VOICE_PRIORITY_TOOL_NAMES) {
1392
+ for (const name of [
1393
+ ...REALTIME_VOICE_PRIORITY_TOOL_NAMES,
1394
+ ...appPriorityToolNames,
1395
+ ]) {
1184
1396
  add(expanded.get(name) ?? current.get(name));
1185
1397
  }
1186
1398
  for (const tool of expanded.values()) add(tool);
@@ -1205,6 +1417,31 @@ export function mergeRealtimeVoiceToolManifest(
1205
1417
  return packed;
1206
1418
  }
1207
1419
 
1420
+ /** Tells the assistant what the user decided about a call that asked. */
1421
+ export function createRealtimeVoiceApprovalOutcomeEvent(
1422
+ toolName: string,
1423
+ result: RealtimeVoiceToolResult | null,
1424
+ protocol: RealtimeVoiceProtocol = "realtime",
1425
+ ): Record<string, unknown> {
1426
+ const text = !result
1427
+ ? `The user declined ${toolName}, so it did not run.`
1428
+ : result.status === "approval_required"
1429
+ ? `${toolName} asked for approval again, so it has not run. The user can approve it on screen.`
1430
+ : `The user approved ${toolName}. ${JSON.stringify({
1431
+ status: result.status,
1432
+ output: result.output,
1433
+ })}`;
1434
+ return {
1435
+ type:
1436
+ protocol === "live" ? "response.item.create" : "conversation.item.create",
1437
+ item: {
1438
+ type: "message",
1439
+ role: "system",
1440
+ content: [{ type: "input_text", text }],
1441
+ },
1442
+ };
1443
+ }
1444
+
1208
1445
  function createRealtimeVoiceToolResultEvent(
1209
1446
  result: RealtimeVoiceToolResult,
1210
1447
  protocol: RealtimeVoiceProtocol = "realtime",
@@ -1229,6 +1466,7 @@ function createRealtimeVoiceToolResultEvent(
1229
1466
  export interface RealtimeVoiceToolManifestCoordinator {
1230
1467
  enqueue: (result: RealtimeVoiceToolResult) => void;
1231
1468
  setProtocol: (protocol: RealtimeVoiceProtocol) => void;
1469
+ setPriorityToolNames: (names: readonly string[]) => void;
1232
1470
  setSessionTools: (tools: readonly RealtimeVoiceFunctionTool[]) => void;
1233
1471
  handleError: (eventId: string | undefined, message?: string) => boolean;
1234
1472
  reset: (options?: { preserveSessionTools?: boolean }) => void;
@@ -1241,6 +1479,7 @@ export function createRealtimeVoiceToolManifestCoordinator(
1241
1479
  ): RealtimeVoiceToolManifestCoordinator {
1242
1480
  let currentTools: RealtimeVoiceFunctionTool[] = [];
1243
1481
  let protocol: RealtimeVoiceProtocol = "realtime";
1482
+ let priorityToolNames: readonly string[] = [];
1244
1483
  const queue: RealtimeVoiceToolResult[] = [];
1245
1484
  let updateSequence = 0;
1246
1485
  let active:
@@ -1293,6 +1532,7 @@ export function createRealtimeVoiceToolManifestCoordinator(
1293
1532
  currentTools,
1294
1533
  expanded,
1295
1534
  protocol,
1535
+ priorityToolNames,
1296
1536
  );
1297
1537
  const mergedNames = new Set(merged.map((tool) => tool.name));
1298
1538
  const expectedNames = expanded
@@ -1326,8 +1566,16 @@ export function createRealtimeVoiceToolManifestCoordinator(
1326
1566
  setProtocol(nextProtocol) {
1327
1567
  protocol = nextProtocol;
1328
1568
  },
1569
+ setPriorityToolNames(names) {
1570
+ priorityToolNames = [...names];
1571
+ },
1329
1572
  setSessionTools(tools) {
1330
- currentTools = mergeRealtimeVoiceToolManifest([], tools, protocol);
1573
+ currentTools = mergeRealtimeVoiceToolManifest(
1574
+ [],
1575
+ tools,
1576
+ protocol,
1577
+ priorityToolNames,
1578
+ );
1331
1579
  if (!active) return;
1332
1580
  const names = new Set(currentTools.map((tool) => tool.name));
1333
1581
  if (active.expectedNames.every((name) => names.has(name))) finish();
@@ -1341,7 +1589,10 @@ export function createRealtimeVoiceToolManifestCoordinator(
1341
1589
  if (active) clearTimeout(active.timer);
1342
1590
  active = undefined;
1343
1591
  queue.length = 0;
1344
- if (!options?.preserveSessionTools) currentTools = [];
1592
+ if (!options?.preserveSessionTools) {
1593
+ currentTools = [];
1594
+ priorityToolNames = [];
1595
+ }
1345
1596
  protocol = "realtime";
1346
1597
  },
1347
1598
  getTools() {
@@ -1482,6 +1733,16 @@ export function createRealtimeVoiceModeCopy(
1482
1733
  voiceSettings: t("agentChat.voiceMode.voiceSettings", {
1483
1734
  defaultValue: "Voice settings",
1484
1735
  }),
1736
+ thinking: t("agentChat.status.thinking", { defaultValue: "Thinking" }),
1737
+ approval: {
1738
+ question: (toolLabel) =>
1739
+ t("agentChat.approval.question", {
1740
+ tool: toolLabel,
1741
+ defaultValue: "Approve to run {{tool}}?",
1742
+ }),
1743
+ approve: t("agentChat.approval.approve", { defaultValue: "Approve" }),
1744
+ deny: t("agentChat.approval.deny", { defaultValue: "Deny" }),
1745
+ },
1485
1746
  settings: {
1486
1747
  language: t("agentChat.voiceMode.settings.language", {
1487
1748
  defaultValue: "Language",
@@ -1693,10 +1954,37 @@ function useRealtimeVoiceModeController(
1693
1954
  const lastUserTextRef = useRef("");
1694
1955
  const lastAssistantTextRef = useRef("");
1695
1956
  const transcriptThreadIdRef = useRef<string | undefined>(undefined);
1957
+ const requestedTranscriptThreadIdRef = useRef<string | undefined>(undefined);
1696
1958
  const liveCloseDrainTimerRef = useRef<number | null>(null);
1697
1959
  const liveCloseDrainFinishRef = useRef<(() => void) | null>(null);
1698
1960
  const toolAbortControllersRef = useRef(new Set<AbortController>());
1961
+ const pendingApprovalsRef = useRef(new Map<string, PendingApproval>());
1962
+ const runningToolsRef = useRef(new Map<string, string>());
1963
+ const [currentToolName, setCurrentToolName] = useState<string | null>(null);
1964
+ const trackRunningTool = useCallback(
1965
+ (callId: string, name: string | null) => {
1966
+ const running = runningToolsRef.current;
1967
+ if (name) running.set(callId, name);
1968
+ else running.delete(callId);
1969
+ setCurrentToolName([...running.values()].at(-1) ?? null);
1970
+ },
1971
+ [],
1972
+ );
1973
+ const [approvals, setApprovals] = useState<readonly RealtimeVoiceApproval[]>(
1974
+ [],
1975
+ );
1976
+ const syncApprovals = useCallback(() => {
1977
+ setApprovals(
1978
+ [...pendingApprovalsRef.current.values()].map(({ callId, name }) => ({
1979
+ callId,
1980
+ toolName: name,
1981
+ })),
1982
+ );
1983
+ }, []);
1699
1984
  const transcriptSequenceRef = useRef(0);
1985
+ const transcriptCreatedAtRef = useRef(new Map<string, string>());
1986
+ const workSegmentRef = useRef<RealtimeVoiceWorkSegment | null>(null);
1987
+ const responseCallCountsRef = useRef(new Map<string, number>());
1700
1988
  const preferencesHydratedRef = useRef(false);
1701
1989
  const preferencesEditedRef = useRef(false);
1702
1990
  const preferenceWriteChainRef = useRef<Promise<void>>(Promise.resolve());
@@ -1707,6 +1995,17 @@ function useRealtimeVoiceModeController(
1707
1995
  }),
1708
1996
  [],
1709
1997
  );
1998
+ // The metrics outlive adapter changes such as a locale switch: the
1999
+ // transport cleanup depends on them, and a new instance would end the call.
2000
+ const trackEventRef = useRef(adapters.voice.trackEvent);
2001
+ trackEventRef.current = adapters.voice.trackEvent;
2002
+ const turnMetrics = useMemo(
2003
+ () =>
2004
+ createRealtimeVoiceTurnMetrics((name, properties) =>
2005
+ trackEventRef.current(name, properties),
2006
+ ),
2007
+ [],
2008
+ );
1710
2009
  const toolManifestCoordinator = useMemo(
1711
2010
  () =>
1712
2011
  createRealtimeVoiceToolManifestCoordinator((event) => {
@@ -1809,6 +2108,11 @@ function useRealtimeVoiceModeController(
1809
2108
 
1810
2109
  const updateLivePreferences = useCallback(
1811
2110
  (next: RealtimeVoicePreferences, includeVoice = false) => {
2111
+ turnMetrics.setContext({
2112
+ protocol: protocolRef.current,
2113
+ intelligence: next.intelligence,
2114
+ intelligenceSource: next.intelligenceChosen ? "user" : "default",
2115
+ });
1812
2116
  sendDataChannelEvent(
1813
2117
  channelRef.current,
1814
2118
  createRealtimeVoicePreferenceUpdate(next, {
@@ -1819,7 +2123,7 @@ function useRealtimeVoiceModeController(
1819
2123
  }),
1820
2124
  );
1821
2125
  },
1822
- [],
2126
+ [turnMetrics],
1823
2127
  );
1824
2128
 
1825
2129
  const setLanguage = useCallback(
@@ -1839,7 +2143,11 @@ function useRealtimeVoiceModeController(
1839
2143
 
1840
2144
  const setIntelligence = useCallback(
1841
2145
  (intelligence: RealtimeVoiceIntelligence) => {
1842
- const next = { ...preferencesRef.current, intelligence };
2146
+ const next = {
2147
+ ...preferencesRef.current,
2148
+ intelligence,
2149
+ intelligenceChosen: true,
2150
+ };
1843
2151
  savePreferences(next);
1844
2152
  updateLivePreferences(next);
1845
2153
  },
@@ -1994,6 +2302,19 @@ function useRealtimeVoiceModeController(
1994
2302
  switchMicrophoneRef.current = setMicrophone;
1995
2303
 
1996
2304
  const cleanupTransport = useCallback(() => {
2305
+ // Teardown keeps what was heard now, so no grouper timer publishes into
2306
+ // the chat after the session is gone.
2307
+ flushTranscriptsRef.current?.();
2308
+ turnMetrics.sessionEnded();
2309
+ for (const pending of pendingApprovalsRef.current.values()) {
2310
+ finishPendingApproval(pending, null);
2311
+ }
2312
+ pendingApprovalsRef.current.clear();
2313
+ syncApprovals();
2314
+ runningToolsRef.current.clear();
2315
+ setCurrentToolName(null);
2316
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
2317
+ responseCallCountsRef.current.clear();
1997
2318
  if (liveCloseDrainTimerRef.current !== null) {
1998
2319
  window.clearTimeout(liveCloseDrainTimerRef.current);
1999
2320
  liveCloseDrainTimerRef.current = null;
@@ -2044,7 +2365,9 @@ function useRealtimeVoiceModeController(
2044
2365
  abortActiveToolCalls,
2045
2366
  audioLevels,
2046
2367
  responseCoordinator,
2368
+ syncApprovals,
2047
2369
  toolManifestCoordinator,
2370
+ turnMetrics,
2048
2371
  ]);
2049
2372
 
2050
2373
  const fail = useCallback(
@@ -2058,8 +2381,68 @@ function useRealtimeVoiceModeController(
2058
2381
  [cleanupTransport, transition],
2059
2382
  );
2060
2383
 
2384
+ const publishTranscript = useCallback(
2385
+ (
2386
+ transcript: CompletedRealtimeVoiceTranscript,
2387
+ status: "streaming" | "complete",
2388
+ ) => {
2389
+ const threadId = transcriptThreadIdRef.current;
2390
+ if (!threadId) return;
2391
+ // The session id arrives after the first events; the start time keeps
2392
+ // every update of one turn under the same message id.
2393
+ const sessionIdentity = startedAtRef.current ?? "pending";
2394
+ const providerIdentity =
2395
+ transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
2396
+ const id = `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`;
2397
+ // Work rows sit before the first message created after their call, so a
2398
+ // turn keeps the time it started however late its final text arrives.
2399
+ let createdAt = transcriptCreatedAtRef.current.get(id);
2400
+ if (!createdAt) {
2401
+ createdAt = new Date().toISOString();
2402
+ transcriptCreatedAtRef.current.set(id, createdAt);
2403
+ }
2404
+ realtimeVoiceTranscriptRegistry.publish({
2405
+ id,
2406
+ threadId,
2407
+ role: transcript.role,
2408
+ text: transcript.text,
2409
+ createdAt,
2410
+ status,
2411
+ });
2412
+ },
2413
+ [],
2414
+ );
2415
+
2416
+ const transcriptSequencer = useMemo(
2417
+ () =>
2418
+ createRealtimeVoiceTranscriptSequencer(
2419
+ (transcript) => publishTranscript(transcript, "complete"),
2420
+ (transcript) => publishTranscript(transcript, "streaming"),
2421
+ (turn) => {
2422
+ if (turn.speaker !== "user") return;
2423
+ turnMetrics.userTurnUpdated(performance.now(), turn.id, turn.endMs);
2424
+ },
2425
+ ),
2426
+ [publishTranscript, turnMetrics],
2427
+ );
2428
+ flushTranscriptsRef.current = transcriptSequencer.flush;
2429
+ const greetingStarter = useMemo(
2430
+ () =>
2431
+ createRealtimeVoiceGreetingStarter((event) => {
2432
+ if (event.type === "response.create") {
2433
+ responseCoordinator.request(event);
2434
+ } else {
2435
+ sendDataChannelEvent(channelRef.current, event);
2436
+ }
2437
+ }),
2438
+ [responseCoordinator],
2439
+ );
2440
+
2061
2441
  const handleFunctionCall = useCallback(
2062
- async (call: { name: string; callId: string; argumentsText: string }) => {
2442
+ async (
2443
+ call: { name: string; callId: string; argumentsText: string },
2444
+ delegationId: string | undefined,
2445
+ ) => {
2063
2446
  if (handledCallsRef.current.has(call.callId)) return;
2064
2447
  handledCallsRef.current.add(call.callId);
2065
2448
  const transportGeneration = transportGenerationRef.current;
@@ -2067,9 +2450,28 @@ function useRealtimeVoiceModeController(
2067
2450
  const toolAbortController = new AbortController();
2068
2451
  toolAbortControllersRef.current.add(toolAbortController);
2069
2452
  transition("working");
2453
+ trackRunningTool(call.callId, call.name);
2454
+ const finishTurnToolCall = turnMetrics.toolCallStarted();
2455
+ const workCall: RealtimeVoiceWorkCall = {
2456
+ id: call.callId,
2457
+ name: call.name,
2458
+ status: "running",
2459
+ startedAt: new Date().toISOString(),
2460
+ };
2461
+ const workSegment = openRealtimeVoiceWorkSegment(
2462
+ workSegmentRef,
2463
+ transcriptThreadIdRef.current,
2464
+ call.callId,
2465
+ delegationId,
2466
+ );
2070
2467
  let result: RealtimeVoiceToolResult;
2071
2468
  try {
2072
2469
  const args = parseFunctionArguments(call.argumentsText);
2470
+ workCall.input = args;
2471
+ if (workSegment) {
2472
+ workSegment.calls.push(workCall);
2473
+ publishRealtimeVoiceWorkSegment(workSegment);
2474
+ }
2073
2475
  result = await executeRealtimeVoiceTool({
2074
2476
  name: call.name,
2075
2477
  args,
@@ -2087,7 +2489,41 @@ function useRealtimeVoiceModeController(
2087
2489
  };
2088
2490
  } finally {
2089
2491
  toolAbortControllersRef.current.delete(toolAbortController);
2492
+ trackRunningTool(call.callId, null);
2493
+ }
2494
+ // Session teardown aborted the call, so it did not fail. A call that
2495
+ // asks for approval stays running until the user decides.
2496
+ const approvalId =
2497
+ !toolAbortController.signal.aborted &&
2498
+ result.status === "approval_required" &&
2499
+ workCall.input &&
2500
+ transportGeneration === transportGenerationRef.current
2501
+ ? result.approvalId
2502
+ : undefined;
2503
+ const awaitingApproval = Boolean(approvalId);
2504
+ if (!awaitingApproval) {
2505
+ workCall.status =
2506
+ toolAbortController.signal.aborted ||
2507
+ result.status === "approval_required"
2508
+ ? "cancelled"
2509
+ : result.status;
2510
+ workCall.output = result.output;
2511
+ workCall.completedAt = new Date().toISOString();
2512
+ }
2513
+ if (workSegment) {
2514
+ if (!workSegment.calls.includes(workCall)) {
2515
+ workSegment.calls.push(workCall);
2516
+ }
2517
+ if (workSegment.closing) {
2518
+ closeRealtimeVoiceWorkSegment(workSegmentRef, workSegment);
2519
+ } else {
2520
+ publishRealtimeVoiceWorkSegment(workSegment);
2521
+ }
2090
2522
  }
2523
+ finishTurnToolCall(
2524
+ performance.now(),
2525
+ toolAbortController.signal.aborted ? { status: "cancelled" } : result,
2526
+ );
2091
2527
  if (
2092
2528
  transportGeneration !== transportGenerationRef.current ||
2093
2529
  channelRef.current !== transportChannel
@@ -2095,54 +2531,115 @@ function useRealtimeVoiceModeController(
2095
2531
  return;
2096
2532
  }
2097
2533
  if (result.capability) capabilityRef.current = result.capability;
2534
+ if (approvalId && workCall.input) {
2535
+ pendingApprovalsRef.current.set(call.callId, {
2536
+ callId: call.callId,
2537
+ name: call.name,
2538
+ args: workCall.input,
2539
+ approvalId,
2540
+ workCall,
2541
+ workSegment,
2542
+ });
2543
+ syncApprovals();
2544
+ }
2098
2545
  toolManifestCoordinator.enqueue(result);
2099
2546
  },
2100
- [browserTabId, toolManifestCoordinator, transition],
2101
- );
2102
-
2103
- const publishTranscript = useCallback(
2104
- (
2105
- transcript: CompletedRealtimeVoiceTranscript,
2106
- status: "streaming" | "complete",
2107
- ) => {
2108
- const threadId = transcriptThreadIdRef.current;
2109
- if (!threadId) return;
2110
- // The session id arrives after the first events; the start time keeps
2111
- // every update of one turn under the same message id.
2112
- const sessionIdentity = startedAtRef.current ?? "pending";
2113
- const providerIdentity =
2114
- transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
2115
- realtimeVoiceTranscriptRegistry.publish({
2116
- id: `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`,
2117
- threadId,
2118
- role: transcript.role,
2119
- text: transcript.text,
2120
- createdAt: new Date().toISOString(),
2121
- status,
2122
- });
2123
- },
2124
- [],
2547
+ [
2548
+ browserTabId,
2549
+ syncApprovals,
2550
+ toolManifestCoordinator,
2551
+ trackRunningTool,
2552
+ transition,
2553
+ turnMetrics,
2554
+ ],
2125
2555
  );
2126
2556
 
2127
- const transcriptSequencer = useMemo(
2128
- () =>
2129
- createRealtimeVoiceTranscriptSequencer(
2130
- (transcript) => publishTranscript(transcript, "complete"),
2131
- (transcript) => publishTranscript(transcript, "streaming"),
2132
- ),
2133
- [publishTranscript],
2134
- );
2135
- flushTranscriptsRef.current = transcriptSequencer.flush;
2136
- const greetingStarter = useMemo(
2137
- () =>
2138
- createRealtimeVoiceGreetingStarter((event) => {
2139
- if (event.type === "response.create") {
2140
- responseCoordinator.request(event);
2141
- } else {
2142
- sendDataChannelEvent(channelRef.current, event);
2557
+ const resolveApproval = useCallback(
2558
+ async (callId: string, approved: boolean) => {
2559
+ const pending = pendingApprovalsRef.current.get(callId);
2560
+ if (!pending) return;
2561
+ pendingApprovalsRef.current.delete(callId);
2562
+ syncApprovals();
2563
+ const transportGeneration = transportGenerationRef.current;
2564
+ const transportChannel = channelRef.current;
2565
+ let result: RealtimeVoiceToolResult | null = null;
2566
+ let aborted = false;
2567
+ if (approved) {
2568
+ transition("working");
2569
+ trackRunningTool(callId, pending.name);
2570
+ const toolAbortController = new AbortController();
2571
+ toolAbortControllersRef.current.add(toolAbortController);
2572
+ try {
2573
+ result = await executeRealtimeVoiceTool({
2574
+ name: pending.name,
2575
+ args: pending.args,
2576
+ callId: `${callId}_approved`,
2577
+ sessionId: sessionIdRef.current,
2578
+ browserTabId,
2579
+ capability: capabilityRef.current,
2580
+ signal: toolAbortController.signal,
2581
+ approvalId: pending.approvalId,
2582
+ });
2583
+ } catch (toolError) {
2584
+ result = {
2585
+ callId,
2586
+ status: "failed",
2587
+ output: errorMessage(toolError),
2588
+ };
2589
+ } finally {
2590
+ toolAbortControllersRef.current.delete(toolAbortController);
2591
+ trackRunningTool(callId, null);
2143
2592
  }
2144
- }),
2145
- [responseCoordinator],
2593
+ aborted = toolAbortController.signal.aborted;
2594
+ } else {
2595
+ // A declined record left pending would make a later identical
2596
+ // request's approval ambiguous on the server.
2597
+ await executeRealtimeVoiceTool({
2598
+ name: pending.name,
2599
+ args: pending.args,
2600
+ callId: `${callId}_declined`,
2601
+ sessionId: sessionIdRef.current,
2602
+ browserTabId,
2603
+ capability: capabilityRef.current,
2604
+ declinedApprovalId: pending.approvalId,
2605
+ }).catch((declineError: unknown) => {
2606
+ console.warn(
2607
+ "Could not record the declined voice approval.",
2608
+ declineError,
2609
+ );
2610
+ });
2611
+ }
2612
+ const transportChanged =
2613
+ transportGeneration !== transportGenerationRef.current ||
2614
+ channelRef.current !== transportChannel;
2615
+ // An approval that expired or no longer applies asks again; the call
2616
+ // stays running and the user sees the new request.
2617
+ const renewedApprovalId =
2618
+ !aborted && !transportChanged && result?.status === "approval_required"
2619
+ ? result.approvalId
2620
+ : undefined;
2621
+ if (renewedApprovalId) {
2622
+ pendingApprovalsRef.current.set(callId, {
2623
+ ...pending,
2624
+ approvalId: renewedApprovalId,
2625
+ });
2626
+ syncApprovals();
2627
+ } else {
2628
+ finishPendingApproval(pending, aborted || !result ? null : result);
2629
+ }
2630
+ if (aborted || transportChanged) return;
2631
+ if (result?.capability) capabilityRef.current = result.capability;
2632
+ sendDataChannelEvent(
2633
+ channelRef.current,
2634
+ createRealtimeVoiceApprovalOutcomeEvent(
2635
+ pending.name,
2636
+ result,
2637
+ protocolRef.current,
2638
+ ),
2639
+ );
2640
+ sendDataChannelEvent(channelRef.current, { type: "response.create" });
2641
+ },
2642
+ [browserTabId, syncApprovals, trackRunningTool, transition],
2146
2643
  );
2147
2644
 
2148
2645
  const handleServerEvent = useCallback(
@@ -2184,15 +2681,21 @@ function useRealtimeVoiceModeController(
2184
2681
  greetingStarter.start();
2185
2682
  transition("working");
2186
2683
  } else if (event.type === "input_audio_buffer.speech_started") {
2684
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
2685
+ turnMetrics.userSpeechStarted(performance.now());
2187
2686
  transition("listening");
2188
2687
  } else if (event.type === "input_audio_buffer.speech_stopped") {
2688
+ turnMetrics.userSpeechEnded(performance.now());
2189
2689
  transition("working");
2190
2690
  if (protocolRef.current === "realtime") responseCoordinator.request();
2191
2691
  } else if (event.type === "response.created") {
2192
2692
  hasOutputAudioRef.current = true;
2193
2693
  lastAssistantTextRef.current = "";
2194
2694
  transition("working");
2695
+ } else if (event.type === "output_audio_buffer.started") {
2696
+ turnMetrics.assistantAudioStarted(performance.now());
2195
2697
  } else if (event.type === "response.output_audio_transcript.delta") {
2698
+ turnMetrics.assistantResponded();
2196
2699
  hasOutputAudioRef.current = true;
2197
2700
  if (typeof event.delta === "string") {
2198
2701
  lastAssistantTextRef.current += event.delta;
@@ -2216,6 +2719,10 @@ function useRealtimeVoiceModeController(
2216
2719
  }
2217
2720
  transition("listening");
2218
2721
  } else if (event.type === "session.output_transcript.delta") {
2722
+ turnMetrics.assistantAudioStarted(
2723
+ performance.now(),
2724
+ typeof event.start_ms === "number" ? event.start_ms : undefined,
2725
+ );
2219
2726
  hasOutputAudioRef.current = true;
2220
2727
  if (typeof event.delta === "string") {
2221
2728
  lastAssistantTextRef.current += event.delta;
@@ -2234,6 +2741,7 @@ function useRealtimeVoiceModeController(
2234
2741
  ? (response as { status?: unknown }).status
2235
2742
  : undefined;
2236
2743
  if (responseEvent.type === "response.failed" || status === "failed") {
2744
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
2237
2745
  transportGenerationRef.current += 1;
2238
2746
  handledCallsRef.current.clear();
2239
2747
  abortActiveToolCalls();
@@ -2285,11 +2793,33 @@ function useRealtimeVoiceModeController(
2285
2793
  return;
2286
2794
  }
2287
2795
 
2796
+ const delegationId =
2797
+ typeof event.delegation_id === "string" ? event.delegation_id : "";
2288
2798
  const calls = extractRealtimeVoiceFunctionCalls(event);
2289
- for (const call of calls) void handleFunctionCall(call);
2290
- if (responseBoundary && calls.length === 0) {
2799
+ for (const call of calls) {
2800
+ void handleFunctionCall(call, delegationId || undefined);
2801
+ }
2802
+ // GPT-Live's response snapshots carry no output, so whether a response
2803
+ // called a tool is counted from its streamed items, per delegation.
2804
+ const responseCalls = responseCallCountsRef.current;
2805
+ if (responseEvent.type === "response.created") {
2806
+ responseCalls.set(delegationId, 0);
2807
+ }
2808
+ if (calls.length > 0) {
2809
+ responseCalls.set(
2810
+ delegationId,
2811
+ (responseCalls.get(delegationId) ?? 0) + calls.length,
2812
+ );
2813
+ }
2814
+ if (responseBoundary && !responseCalls.get(delegationId)) {
2815
+ // Only the segment's own delegation ends it; an uncorrelated
2816
+ // boundary would split one request's work into several rows.
2817
+ if ((workSegmentRef.current?.delegationId ?? "") === delegationId) {
2818
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
2819
+ }
2291
2820
  transition("listening");
2292
2821
  }
2822
+ if (responseBoundary) responseCalls.delete(delegationId);
2293
2823
  if (responseBoundary) {
2294
2824
  lastUserTextRef.current = "";
2295
2825
  lastAssistantTextRef.current = "";
@@ -2307,6 +2837,7 @@ function useRealtimeVoiceModeController(
2307
2837
  transition,
2308
2838
  responseCoordinator,
2309
2839
  toolManifestCoordinator,
2840
+ turnMetrics,
2310
2841
  updateLivePreferences,
2311
2842
  ],
2312
2843
  );
@@ -2337,8 +2868,10 @@ function useRealtimeVoiceModeController(
2337
2868
  greetingStarter.reset();
2338
2869
  setVoiceChangePending(false);
2339
2870
  transcriptThreadIdRef.current =
2871
+ requestedTranscriptThreadIdRef.current ??
2340
2872
  realtimeVoiceTranscriptRegistry.activeThreadId();
2341
2873
  transcriptSequenceRef.current = 0;
2874
+ transcriptCreatedAtRef.current.clear();
2342
2875
  transcriptSequencer.reset();
2343
2876
  inputMutedRef.current = false;
2344
2877
  outputMutedRef.current = false;
@@ -2422,8 +2955,13 @@ function useRealtimeVoiceModeController(
2422
2955
  JSON.parse(String(messageEvent.data)),
2423
2956
  stateRef.current === "ending",
2424
2957
  );
2425
- } catch {
2426
- // Ignore malformed provider events without ending a healthy call.
2958
+ } catch (eventError) {
2959
+ // A malformed provider event must not end a healthy call, but the
2960
+ // text it carried is lost, so say so.
2961
+ console.warn(
2962
+ "Ignored a realtime voice event that could not be handled.",
2963
+ eventError,
2964
+ );
2427
2965
  }
2428
2966
  };
2429
2967
  channel.onerror = () => {
@@ -2475,7 +3013,29 @@ function useRealtimeVoiceModeController(
2475
3013
  answer.model ??
2476
3014
  (protocolRef.current === "live" ? "gpt-live-1" : "gpt-realtime-2.1");
2477
3015
  toolManifestCoordinator.setProtocol(protocolRef.current);
3016
+ toolManifestCoordinator.setPriorityToolNames(
3017
+ answer.priorityToolNames ?? [],
3018
+ );
2478
3019
  greetingStarter.setProtocol(protocolRef.current);
3020
+ const chosen = preferencesRef.current.intelligenceChosen === true;
3021
+ if (
3022
+ !chosen &&
3023
+ answer.intelligence &&
3024
+ answer.intelligence !== preferencesRef.current.intelligence
3025
+ ) {
3026
+ // Saved as not chosen, so Settings shows the app's default after a
3027
+ // reload and the app can still change it.
3028
+ savePreferences({
3029
+ ...preferencesRef.current,
3030
+ intelligence: answer.intelligence,
3031
+ intelligenceChosen: false,
3032
+ });
3033
+ }
3034
+ turnMetrics.setContext({
3035
+ protocol: protocolRef.current,
3036
+ intelligence: preferencesRef.current.intelligence,
3037
+ intelligenceSource: chosen ? "user" : "default",
3038
+ });
2479
3039
  await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
2480
3040
  } catch (startError) {
2481
3041
  if (
@@ -2514,11 +3074,21 @@ function useRealtimeVoiceModeController(
2514
3074
  handleServerEvent,
2515
3075
  hydratePreferences,
2516
3076
  refreshMicrophones,
3077
+ savePreferences,
2517
3078
  t,
2518
3079
  transcriptSequencer,
2519
3080
  transition,
3081
+ turnMetrics,
2520
3082
  ]);
2521
3083
 
3084
+ const startForThread = useCallback(
3085
+ (options?: RealtimeVoiceStartOptions) => {
3086
+ requestedTranscriptThreadIdRef.current = options?.threadId;
3087
+ return start();
3088
+ },
3089
+ [start],
3090
+ );
3091
+
2522
3092
  const end = useCallback(() => {
2523
3093
  if (stateRef.current === "idle" || stateRef.current === "ending") return;
2524
3094
  const transcriptThreadId = transcriptThreadIdRef.current;
@@ -2644,9 +3214,12 @@ function useRealtimeVoiceModeController(
2644
3214
  setMicrophone,
2645
3215
  setInputMuted,
2646
3216
  setOutputMuted,
2647
- start,
3217
+ start: startForThread,
2648
3218
  end,
2649
3219
  dismissError,
3220
+ currentToolName,
3221
+ approvals,
3222
+ resolveApproval,
2650
3223
  };
2651
3224
  }
2652
3225
 
@@ -2744,7 +3317,11 @@ export function buildRealtimeVoiceSettings({
2744
3317
  })),
2745
3318
  onValueChange: (value: string) => {
2746
3319
  if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
2747
- onPreferencesChange({ ...preferences, intelligence: value });
3320
+ onPreferencesChange({
3321
+ ...preferences,
3322
+ intelligence: value,
3323
+ intelligenceChosen: true,
3324
+ });
2748
3325
  }
2749
3326
  },
2750
3327
  },