@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,7 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { TranscriptGrouper } from "openai/helpers/live";
2
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
4
  import { createRealtimeVoiceAudioLevelStore, normalizeRealtimeVoiceRms, smoothRealtimeVoiceLevel, } from "./realtime-voice-audio-level.js";
4
- import { realtimeVoiceTranscriptRegistry } from "./realtime-voice-transcript.js";
5
+ import { realtimeVoiceTranscriptRegistry, } from "./realtime-voice-transcript.js";
6
+ import { createRealtimeVoiceTurnMetrics } from "./realtime-voice-turn-metrics.js";
5
7
  import { useComposerRuntimeAdapters, } from "./runtime-adapters.js";
6
8
  import { isMicrophoneAccessError, voiceDictationStartErrorMessage, } from "./useVoiceDictation.js";
7
9
  const REALTIME_VOICE_STATE_KEY = "realtime-voice-session";
@@ -11,19 +13,23 @@ const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
11
13
  const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
12
14
  const REALTIME_VOICE_PROTOCOL_HEADER = "X-Agent-Native-Realtime-Protocol";
13
15
  const REALTIME_VOICE_MODEL_HEADER = "X-Agent-Native-Realtime-Model";
16
+ const REALTIME_VOICE_INTELLIGENCE_HEADER = "X-Agent-Native-Realtime-Intelligence";
17
+ const REALTIME_VOICE_PRIORITY_TOOLS_HEADER = "X-Agent-Native-Realtime-Priority-Tools";
14
18
  const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
15
19
  const REALTIME_VOICE_MAX_TOOLS = 32;
16
20
  const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
17
21
  const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
18
22
  const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
19
23
  const REALTIME_VOICE_LIVE_CLOSE_DRAIN_TIMEOUT_MS = 2_000;
20
- const REALTIME_VOICE_LIVE_TRANSCRIPT_GAP_MS = 750;
21
24
  const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
22
25
  "navigate",
23
26
  "set-url-path",
24
27
  "set-search-params",
25
28
  "view-screen",
29
+ "chat-history",
26
30
  "tool-search",
31
+ "web-search",
32
+ "web-request",
27
33
  ];
28
34
  const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
29
35
  const REALTIME_VOICE_MICROPHONE_STORAGE_KEY = "agent-native:realtime-voice-microphone";
@@ -60,6 +66,7 @@ export const DEFAULT_REALTIME_VOICE_PREFERENCES = {
60
66
  language: "auto",
61
67
  intelligence: "instant",
62
68
  voice: "marin",
69
+ intelligenceChosen: false,
63
70
  };
64
71
  export const REALTIME_VOICE_AUDIO_CONSTRAINTS = {
65
72
  deviceId: { ideal: "default" },
@@ -163,16 +170,31 @@ export function normalizeRealtimeVoicePreferences(value) {
163
170
  const record = value && typeof value === "object" && !Array.isArray(value)
164
171
  ? value
165
172
  : {};
173
+ const storedIntelligence = isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, record.intelligence)
174
+ ? record.intelligence
175
+ : undefined;
176
+ // Saves from before `intelligenceChosen` stored "instant" for everyone, so
177
+ // only a non-default level among them shows a pick.
178
+ const chosenIntelligence = typeof record.intelligenceChosen === "boolean"
179
+ ? record.intelligenceChosen
180
+ ? storedIntelligence
181
+ : undefined
182
+ : storedIntelligence !== DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence
183
+ ? storedIntelligence
184
+ : undefined;
185
+ // A level saved as not chosen is the app default a session last reported.
186
+ const appDefaultIntelligence = record.intelligenceChosen === false ? storedIntelligence : undefined;
166
187
  return {
167
188
  language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
168
189
  ? record.language
169
190
  : DEFAULT_REALTIME_VOICE_PREFERENCES.language,
170
- intelligence: isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, record.intelligence)
171
- ? record.intelligence
172
- : DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
191
+ intelligence: chosenIntelligence ??
192
+ appDefaultIntelligence ??
193
+ DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
173
194
  voice: isOneOf(REALTIME_VOICES, record.voice)
174
195
  ? record.voice
175
196
  : DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
197
+ intelligenceChosen: chosenIntelligence !== undefined,
176
198
  };
177
199
  }
178
200
  export function resolveRealtimeVoiceLanguage(language, browserLanguages = []) {
@@ -187,11 +209,19 @@ export function resolveRealtimeVoiceLanguage(language, browserLanguages = []) {
187
209
  return "en";
188
210
  }
189
211
  export function realtimeVoiceReasoningEffort(intelligence, protocol) {
212
+ // The delegated Live model rejects "minimal", so its levels start at "low".
213
+ if (protocol === "live") {
214
+ if (intelligence === "balanced")
215
+ return "medium";
216
+ if (intelligence === "deep")
217
+ return "high";
218
+ return "low";
219
+ }
190
220
  if (intelligence === "balanced")
191
221
  return "low";
192
222
  if (intelligence === "deep")
193
223
  return "medium";
194
- return protocol === "live" ? "low" : "minimal";
224
+ return "minimal";
195
225
  }
196
226
  export function createRealtimeVoicePreferenceUpdate(preferences, options = {}) {
197
227
  if (options.protocol === "live") {
@@ -237,32 +267,131 @@ export function createRealtimeVoiceLanguageUpdate(language, browserLanguages = [
237
267
  content: `Speak in ${resolvedLanguage} unless the user asks to switch languages.`,
238
268
  };
239
269
  }
270
+ function publishRealtimeVoiceWorkSegment(segment, completedAt) {
271
+ if (segment.calls.length === 0)
272
+ return;
273
+ realtimeVoiceTranscriptRegistry.publishWork({
274
+ id: segment.id,
275
+ threadId: segment.threadId,
276
+ calls: segment.calls.map((call) => ({ ...call })),
277
+ ...(completedAt ? { completedAt } : {}),
278
+ });
279
+ }
280
+ /**
281
+ * The reply's open work a new call joins, or new work; none without a thread.
282
+ * A call from a different delegation is a new request.
283
+ */
284
+ function openRealtimeVoiceWorkSegment(ref, threadId, callId, delegationId) {
285
+ if (!threadId)
286
+ return null;
287
+ const current = ref.current;
288
+ if (current?.threadId === threadId &&
289
+ (!current.delegationId ||
290
+ !delegationId ||
291
+ current.delegationId === delegationId)) {
292
+ current.delegationId ??= delegationId;
293
+ return current;
294
+ }
295
+ if (current)
296
+ closeRealtimeVoiceWorkSegment(ref, current);
297
+ const segment = {
298
+ id: callId,
299
+ threadId,
300
+ delegationId,
301
+ calls: [],
302
+ closing: false,
303
+ };
304
+ ref.current = segment;
305
+ return segment;
306
+ }
307
+ /** Ends a call that waited for approval: `result` is null when it never ran. */
308
+ function finishPendingApproval(pending, result) {
309
+ const { workCall, workSegment } = pending;
310
+ workCall.status =
311
+ !result || result.status === "approval_required"
312
+ ? "cancelled"
313
+ : result.status;
314
+ if (result)
315
+ workCall.output = result.output;
316
+ workCall.completedAt = new Date().toISOString();
317
+ if (!workSegment)
318
+ return;
319
+ if (workSegment.closing) {
320
+ closeRealtimeVoiceWorkSegment({ current: null }, workSegment);
321
+ }
322
+ else {
323
+ publishRealtimeVoiceWorkSegment(workSegment);
324
+ }
325
+ }
326
+ function closeRealtimeVoiceWorkSegment(ref, segment = ref.current) {
327
+ if (!segment)
328
+ return;
329
+ if (ref.current === segment)
330
+ ref.current = null;
331
+ segment.closing = true;
332
+ if (segment.calls.some((call) => call.status === "running"))
333
+ return;
334
+ const completedAt = segment.calls
335
+ .map((call) => call.completedAt ?? call.startedAt)
336
+ .sort()
337
+ .at(-1);
338
+ publishRealtimeVoiceWorkSegment(segment, completedAt);
339
+ }
240
340
  /**
241
341
  * Orders provider transcripts into conversation order. `publish` receives each
242
342
  * final transcript; `stream`, when given, receives the growing text of a turn
243
- * under the same `providerId` its final transcript will carry.
343
+ * under the same `providerId` its final transcript will carry. GPT-Live turns
344
+ * come from the SDK's transcript grouper, which splits on speaker changes and
345
+ * two-second silences and drops short backchannels heard over the reply.
244
346
  */
245
- export function createRealtimeVoiceTranscriptSequencer(publish, stream) {
347
+ export function createRealtimeVoiceTranscriptSequencer(publish, stream,
348
+ /** Each GPT-Live turn as it grows, with its place on the session clock. */
349
+ onLiveTurn) {
246
350
  const order = [];
247
351
  const items = new Map();
248
352
  const settledItemIds = new Set();
249
353
  const receivedTranscriptIds = new Set();
250
- const liveBuffers = new Map();
251
354
  const streamingText = new Map();
252
355
  const streamedText = new Map();
253
- let liveSequence = 0;
254
- const liveProviderId = (buffer) => `live-${buffer.sequence}`;
255
- const flushLiveTranscript = (role) => {
256
- const buffer = liveBuffers.get(role);
257
- if (!buffer)
258
- return;
259
- liveBuffers.delete(role);
260
- const text = buffer.text.trim();
356
+ let liveGrouper;
357
+ const liveTranscript = (segment) => {
358
+ const text = segment.text.trim();
261
359
  if (!text)
360
+ return undefined;
361
+ return stream
362
+ ? { role: segment.speaker, text, providerId: segment.id }
363
+ : { role: segment.speaker, text };
364
+ };
365
+ // Listeners check that their grouper is still current, so a discarded one
366
+ // never publishes what it closes on the way out.
367
+ const openLiveGrouper = () => {
368
+ if (liveGrouper)
369
+ return liveGrouper;
370
+ const grouper = new TranscriptGrouper();
371
+ grouper.on("segment.updated", (segment) => {
372
+ if (grouper !== liveGrouper)
373
+ return;
374
+ onLiveTurn?.(segment);
375
+ const transcript = liveTranscript(segment);
376
+ if (stream && transcript)
377
+ stream(transcript);
378
+ });
379
+ grouper.on("segment.closed", ({ segment }) => {
380
+ const transcript = liveTranscript(segment);
381
+ if (grouper === liveGrouper && transcript)
382
+ publish(transcript);
383
+ });
384
+ liveGrouper = grouper;
385
+ return grouper;
386
+ };
387
+ const closeLiveGrouper = (keepText) => {
388
+ const grouper = liveGrouper;
389
+ if (!grouper)
262
390
  return;
263
- publish(stream
264
- ? { role, text, providerId: liveProviderId(buffer) }
265
- : { role, text });
391
+ if (!keepText)
392
+ liveGrouper = undefined;
393
+ grouper.close();
394
+ liveGrouper = undefined;
266
395
  };
267
396
  // A turn appears once every earlier turn has appeared, so a fast reply
268
397
  // never streams in above the question it answers.
@@ -289,11 +418,6 @@ export function createRealtimeVoiceTranscriptSequencer(publish, stream) {
289
418
  });
290
419
  }
291
420
  };
292
- const flushLiveTranscripts = () => {
293
- for (const buffer of [...liveBuffers.values()].sort((left, right) => left.sequence - right.sequence)) {
294
- flushLiveTranscript(buffer.role);
295
- }
296
- };
297
421
  const reserve = (id, role) => {
298
422
  if (settledItemIds.has(id))
299
423
  return;
@@ -333,35 +457,17 @@ export function createRealtimeVoiceTranscriptSequencer(publish, stream) {
333
457
  handle(event) {
334
458
  if (event.type === "session.input_transcript.delta" ||
335
459
  event.type === "session.output_transcript.delta") {
336
- const role = event.type === "session.input_transcript.delta"
337
- ? "user"
338
- : "assistant";
339
- const startMs = typeof event.start_ms === "number" ? event.start_ms : undefined;
340
- const endMs = typeof event.end_ms === "number" ? event.end_ms : startMs;
341
- let buffer = liveBuffers.get(role);
342
- if (buffer &&
343
- startMs !== undefined &&
344
- buffer.lastEndMs !== undefined &&
345
- startMs - buffer.lastEndMs > REALTIME_VOICE_LIVE_TRANSCRIPT_GAP_MS) {
346
- flushLiveTranscript(role);
347
- buffer = undefined;
348
- }
349
- if (!buffer) {
350
- buffer = {
351
- role,
352
- text: "",
353
- sequence: ++liveSequence,
354
- };
355
- liveBuffers.set(role, buffer);
356
- }
357
- if (typeof event.delta === "string")
358
- buffer.text += event.delta;
359
- if (endMs !== undefined)
360
- buffer.lastEndMs = endMs;
361
- const text = buffer.text.trim();
362
- if (stream && text) {
363
- stream({ role, text, providerId: liveProviderId(buffer) });
460
+ const type = event.type === "session.input_transcript.delta"
461
+ ? "session.input_transcript.delta"
462
+ : "session.output_transcript.delta";
463
+ const { event_id, delta, start_ms, end_ms } = event;
464
+ if (typeof event_id !== "string" ||
465
+ typeof delta !== "string" ||
466
+ typeof start_ms !== "number" ||
467
+ typeof end_ms !== "number") {
468
+ throw new Error("GPT-Live sent a transcript delta without an id, text, or timing.");
364
469
  }
470
+ openLiveGrouper().push({ type, event_id, delta, start_ms, end_ms });
365
471
  return;
366
472
  }
367
473
  if (event.type === "conversation.item.input_audio_transcription.delta" ||
@@ -379,7 +485,7 @@ export function createRealtimeVoiceTranscriptSequencer(publish, stream) {
379
485
  return;
380
486
  }
381
487
  if (event.type === "session.closed")
382
- flushLiveTranscripts();
488
+ closeLiveGrouper(true);
383
489
  if (event.type === "conversation.item.added" ||
384
490
  event.type === "conversation.item.created") {
385
491
  const item = event.item;
@@ -455,7 +561,7 @@ export function createRealtimeVoiceTranscriptSequencer(publish, stream) {
455
561
  emitStreams();
456
562
  },
457
563
  flush() {
458
- flushLiveTranscripts();
564
+ closeLiveGrouper(true);
459
565
  if (!stream)
460
566
  return;
461
567
  // The session is over: a turn that streamed but never got its final
@@ -482,10 +588,11 @@ export function createRealtimeVoiceTranscriptSequencer(publish, stream) {
482
588
  }
483
589
  },
484
590
  reset(options) {
485
- liveBuffers.clear();
486
- liveSequence = 0;
591
+ // A failed response leaves what was already heard; a new session
592
+ // starts clean.
487
593
  if (options?.preserveQueuedTranscripts)
488
594
  return;
595
+ closeLiveGrouper(false);
489
596
  order.length = 0;
490
597
  items.clear();
491
598
  settledItemIds.clear();
@@ -688,7 +795,11 @@ export async function createRealtimeVoiceSessionWithCapability(offerSdp, options
688
795
  ...(preferences
689
796
  ? {
690
797
  "X-Agent-Native-Realtime-Language": resolveRealtimeVoiceLanguage(preferences.language, options.browserLanguages),
691
- "X-Agent-Native-Realtime-Intelligence": preferences.intelligence,
798
+ ...(preferences.intelligenceChosen !== false
799
+ ? {
800
+ [REALTIME_VOICE_INTELLIGENCE_HEADER]: preferences.intelligence,
801
+ }
802
+ : {}),
692
803
  "X-Agent-Native-Realtime-Voice": preferences.voice,
693
804
  }
694
805
  : {}),
@@ -710,11 +821,24 @@ export async function createRealtimeVoiceSessionWithCapability(offerSdp, options
710
821
  : undefined;
711
822
  const modelHeader = response.headers.get(REALTIME_VOICE_MODEL_HEADER);
712
823
  const model = modelHeader?.trim() || undefined;
824
+ const intelligenceHeader = response.headers
825
+ .get(REALTIME_VOICE_INTELLIGENCE_HEADER)
826
+ ?.trim();
827
+ const intelligence = isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, intelligenceHeader)
828
+ ? intelligenceHeader
829
+ : undefined;
830
+ const priorityToolNames = (response.headers.get(REALTIME_VOICE_PRIORITY_TOOLS_HEADER) ?? "")
831
+ .split(",")
832
+ .map((name) => name.trim())
833
+ .filter((name) => REALTIME_VOICE_TOOL_NAME.test(name))
834
+ .slice(0, REALTIME_VOICE_MAX_TOOLS);
713
835
  return {
714
836
  sdp,
715
837
  ...(capability ? { capability } : {}),
716
838
  ...(protocol ? { protocol } : {}),
717
839
  ...(model ? { model } : {}),
840
+ ...(intelligence ? { intelligence } : {}),
841
+ ...(priorityToolNames.length > 0 ? { priorityToolNames } : {}),
718
842
  };
719
843
  }
720
844
  export async function createRealtimeVoiceSession(offerSdp, options = {}) {
@@ -742,6 +866,10 @@ export async function executeRealtimeVoiceTool(input) {
742
866
  callId: input.callId,
743
867
  sessionId: input.sessionId,
744
868
  browserTabId: input.browserTabId,
869
+ ...(input.approvalId ? { approvalId: input.approvalId } : {}),
870
+ ...(input.declinedApprovalId
871
+ ? { declinedApprovalId: input.declinedApprovalId }
872
+ : {}),
745
873
  }),
746
874
  signal: input.signal,
747
875
  });
@@ -814,7 +942,11 @@ function createRealtimeVoiceToolManifestUpdate(tools, eventId, protocol = "realt
814
942
  ? {
815
943
  delegation: {
816
944
  type: "responses",
817
- responses: { tools, tool_choice: "auto" },
945
+ // Strict schemas make every optional parameter required.
946
+ responses: {
947
+ tools: tools.map((tool) => ({ ...tool, strict: false })),
948
+ tool_choice: "auto",
949
+ },
818
950
  },
819
951
  }
820
952
  : {
@@ -824,7 +956,7 @@ function createRealtimeVoiceToolManifestUpdate(tools, eventId, protocol = "realt
824
956
  },
825
957
  };
826
958
  }
827
- export function mergeRealtimeVoiceToolManifest(currentTools, expandedTools, protocol = "realtime") {
959
+ export function mergeRealtimeVoiceToolManifest(currentTools, expandedTools, protocol = "realtime", appPriorityToolNames = []) {
828
960
  const current = new Map();
829
961
  for (const value of currentTools) {
830
962
  const tool = normalizeRealtimeVoiceFunctionTool(value);
@@ -845,7 +977,10 @@ export function mergeRealtimeVoiceToolManifest(currentTools, expandedTools, prot
845
977
  seen.add(tool.name);
846
978
  candidates.push(tool);
847
979
  };
848
- for (const name of REALTIME_VOICE_PRIORITY_TOOL_NAMES) {
980
+ for (const name of [
981
+ ...REALTIME_VOICE_PRIORITY_TOOL_NAMES,
982
+ ...appPriorityToolNames,
983
+ ]) {
849
984
  add(expanded.get(name) ?? current.get(name));
850
985
  }
851
986
  for (const tool of expanded.values())
@@ -863,6 +998,25 @@ export function mergeRealtimeVoiceToolManifest(currentTools, expandedTools, prot
863
998
  }
864
999
  return packed;
865
1000
  }
1001
+ /** Tells the assistant what the user decided about a call that asked. */
1002
+ export function createRealtimeVoiceApprovalOutcomeEvent(toolName, result, protocol = "realtime") {
1003
+ const text = !result
1004
+ ? `The user declined ${toolName}, so it did not run.`
1005
+ : result.status === "approval_required"
1006
+ ? `${toolName} asked for approval again, so it has not run. The user can approve it on screen.`
1007
+ : `The user approved ${toolName}. ${JSON.stringify({
1008
+ status: result.status,
1009
+ output: result.output,
1010
+ })}`;
1011
+ return {
1012
+ type: protocol === "live" ? "response.item.create" : "conversation.item.create",
1013
+ item: {
1014
+ type: "message",
1015
+ role: "system",
1016
+ content: [{ type: "input_text", text }],
1017
+ },
1018
+ };
1019
+ }
866
1020
  function createRealtimeVoiceToolResultEvent(result, protocol = "realtime") {
867
1021
  const modelResult = {
868
1022
  callId: result.callId,
@@ -882,6 +1036,7 @@ function createRealtimeVoiceToolResultEvent(result, protocol = "realtime") {
882
1036
  export function createRealtimeVoiceToolManifestCoordinator(send, timeoutMs = REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS) {
883
1037
  let currentTools = [];
884
1038
  let protocol = "realtime";
1039
+ let priorityToolNames = [];
885
1040
  const queue = [];
886
1041
  let updateSequence = 0;
887
1042
  let active;
@@ -918,7 +1073,7 @@ export function createRealtimeVoiceToolManifestCoordinator(send, timeoutMs = REA
918
1073
  drain();
919
1074
  return;
920
1075
  }
921
- const merged = mergeRealtimeVoiceToolManifest(currentTools, expanded, protocol);
1076
+ const merged = mergeRealtimeVoiceToolManifest(currentTools, expanded, protocol, priorityToolNames);
922
1077
  const mergedNames = new Set(merged.map((tool) => tool.name));
923
1078
  const expectedNames = expanded
924
1079
  .map((tool) => tool.name)
@@ -946,8 +1101,11 @@ export function createRealtimeVoiceToolManifestCoordinator(send, timeoutMs = REA
946
1101
  setProtocol(nextProtocol) {
947
1102
  protocol = nextProtocol;
948
1103
  },
1104
+ setPriorityToolNames(names) {
1105
+ priorityToolNames = [...names];
1106
+ },
949
1107
  setSessionTools(tools) {
950
- currentTools = mergeRealtimeVoiceToolManifest([], tools, protocol);
1108
+ currentTools = mergeRealtimeVoiceToolManifest([], tools, protocol, priorityToolNames);
951
1109
  if (!active)
952
1110
  return;
953
1111
  const names = new Set(currentTools.map((tool) => tool.name));
@@ -965,8 +1123,10 @@ export function createRealtimeVoiceToolManifestCoordinator(send, timeoutMs = REA
965
1123
  clearTimeout(active.timer);
966
1124
  active = undefined;
967
1125
  queue.length = 0;
968
- if (!options?.preserveSessionTools)
1126
+ if (!options?.preserveSessionTools) {
969
1127
  currentTools = [];
1128
+ priorityToolNames = [];
1129
+ }
970
1130
  protocol = "realtime";
971
1131
  },
972
1132
  getTools() {
@@ -1099,6 +1259,15 @@ export function createRealtimeVoiceModeCopy(t) {
1099
1259
  voiceSettings: t("agentChat.voiceMode.voiceSettings", {
1100
1260
  defaultValue: "Voice settings",
1101
1261
  }),
1262
+ thinking: t("agentChat.status.thinking", { defaultValue: "Thinking" }),
1263
+ approval: {
1264
+ question: (toolLabel) => t("agentChat.approval.question", {
1265
+ tool: toolLabel,
1266
+ defaultValue: "Approve to run {{tool}}?",
1267
+ }),
1268
+ approve: t("agentChat.approval.approve", { defaultValue: "Approve" }),
1269
+ deny: t("agentChat.approval.deny", { defaultValue: "Deny" }),
1270
+ },
1102
1271
  settings: {
1103
1272
  language: t("agentChat.voiceMode.settings.language", {
1104
1273
  defaultValue: "Language",
@@ -1289,16 +1458,43 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1289
1458
  const lastUserTextRef = useRef("");
1290
1459
  const lastAssistantTextRef = useRef("");
1291
1460
  const transcriptThreadIdRef = useRef(undefined);
1461
+ const requestedTranscriptThreadIdRef = useRef(undefined);
1292
1462
  const liveCloseDrainTimerRef = useRef(null);
1293
1463
  const liveCloseDrainFinishRef = useRef(null);
1294
1464
  const toolAbortControllersRef = useRef(new Set());
1465
+ const pendingApprovalsRef = useRef(new Map());
1466
+ const runningToolsRef = useRef(new Map());
1467
+ const [currentToolName, setCurrentToolName] = useState(null);
1468
+ const trackRunningTool = useCallback((callId, name) => {
1469
+ const running = runningToolsRef.current;
1470
+ if (name)
1471
+ running.set(callId, name);
1472
+ else
1473
+ running.delete(callId);
1474
+ setCurrentToolName([...running.values()].at(-1) ?? null);
1475
+ }, []);
1476
+ const [approvals, setApprovals] = useState([]);
1477
+ const syncApprovals = useCallback(() => {
1478
+ setApprovals([...pendingApprovalsRef.current.values()].map(({ callId, name }) => ({
1479
+ callId,
1480
+ toolName: name,
1481
+ })));
1482
+ }, []);
1295
1483
  const transcriptSequenceRef = useRef(0);
1484
+ const transcriptCreatedAtRef = useRef(new Map());
1485
+ const workSegmentRef = useRef(null);
1486
+ const responseCallCountsRef = useRef(new Map());
1296
1487
  const preferencesHydratedRef = useRef(false);
1297
1488
  const preferencesEditedRef = useRef(false);
1298
1489
  const preferenceWriteChainRef = useRef(Promise.resolve());
1299
1490
  const responseCoordinator = useMemo(() => createRealtimeVoiceResponseCoordinator((event) => {
1300
1491
  sendDataChannelEvent(channelRef.current, event);
1301
1492
  }), []);
1493
+ // The metrics outlive adapter changes such as a locale switch: the
1494
+ // transport cleanup depends on them, and a new instance would end the call.
1495
+ const trackEventRef = useRef(adapters.voice.trackEvent);
1496
+ trackEventRef.current = adapters.voice.trackEvent;
1497
+ const turnMetrics = useMemo(() => createRealtimeVoiceTurnMetrics((name, properties) => trackEventRef.current(name, properties)), []);
1302
1498
  const toolManifestCoordinator = useMemo(() => createRealtimeVoiceToolManifestCoordinator((event) => {
1303
1499
  if (event.type === "response.create") {
1304
1500
  responseCoordinator.request(event);
@@ -1373,12 +1569,17 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1373
1569
  .catch(() => undefined);
1374
1570
  }, [adapters]);
1375
1571
  const updateLivePreferences = useCallback((next, includeVoice = false) => {
1572
+ turnMetrics.setContext({
1573
+ protocol: protocolRef.current,
1574
+ intelligence: next.intelligence,
1575
+ intelligenceSource: next.intelligenceChosen ? "user" : "default",
1576
+ });
1376
1577
  sendDataChannelEvent(channelRef.current, createRealtimeVoicePreferenceUpdate(next, {
1377
1578
  browserLanguages: typeof navigator === "undefined" ? [] : navigator.languages,
1378
1579
  includeVoice,
1379
1580
  protocol: protocolRef.current,
1380
1581
  }));
1381
- }, []);
1582
+ }, [turnMetrics]);
1382
1583
  const setLanguage = useCallback((language) => {
1383
1584
  const next = { ...preferencesRef.current, language };
1384
1585
  savePreferences(next);
@@ -1388,7 +1589,11 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1388
1589
  updateLivePreferences(next);
1389
1590
  }, [savePreferences, updateLivePreferences]);
1390
1591
  const setIntelligence = useCallback((intelligence) => {
1391
- const next = { ...preferencesRef.current, intelligence };
1592
+ const next = {
1593
+ ...preferencesRef.current,
1594
+ intelligence,
1595
+ intelligenceChosen: true,
1596
+ };
1392
1597
  savePreferences(next);
1393
1598
  updateLivePreferences(next);
1394
1599
  }, [savePreferences, updateLivePreferences]);
@@ -1519,6 +1724,19 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1519
1724
  ]);
1520
1725
  switchMicrophoneRef.current = setMicrophone;
1521
1726
  const cleanupTransport = useCallback(() => {
1727
+ // Teardown keeps what was heard now, so no grouper timer publishes into
1728
+ // the chat after the session is gone.
1729
+ flushTranscriptsRef.current?.();
1730
+ turnMetrics.sessionEnded();
1731
+ for (const pending of pendingApprovalsRef.current.values()) {
1732
+ finishPendingApproval(pending, null);
1733
+ }
1734
+ pendingApprovalsRef.current.clear();
1735
+ syncApprovals();
1736
+ runningToolsRef.current.clear();
1737
+ setCurrentToolName(null);
1738
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
1739
+ responseCallCountsRef.current.clear();
1522
1740
  if (liveCloseDrainTimerRef.current !== null) {
1523
1741
  window.clearTimeout(liveCloseDrainTimerRef.current);
1524
1742
  liveCloseDrainTimerRef.current = null;
@@ -1570,7 +1788,9 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1570
1788
  abortActiveToolCalls,
1571
1789
  audioLevels,
1572
1790
  responseCoordinator,
1791
+ syncApprovals,
1573
1792
  toolManifestCoordinator,
1793
+ turnMetrics,
1574
1794
  ]);
1575
1795
  const fail = useCallback((message, options) => {
1576
1796
  flushTranscriptsRef.current?.();
@@ -1580,7 +1800,46 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1580
1800
  if (options?.openKeySettings)
1581
1801
  openOpenAiKeySettings();
1582
1802
  }, [cleanupTransport, transition]);
1583
- const handleFunctionCall = useCallback(async (call) => {
1803
+ const publishTranscript = useCallback((transcript, status) => {
1804
+ const threadId = transcriptThreadIdRef.current;
1805
+ if (!threadId)
1806
+ return;
1807
+ // The session id arrives after the first events; the start time keeps
1808
+ // every update of one turn under the same message id.
1809
+ const sessionIdentity = startedAtRef.current ?? "pending";
1810
+ const providerIdentity = transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
1811
+ const id = `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`;
1812
+ // Work rows sit before the first message created after their call, so a
1813
+ // turn keeps the time it started however late its final text arrives.
1814
+ let createdAt = transcriptCreatedAtRef.current.get(id);
1815
+ if (!createdAt) {
1816
+ createdAt = new Date().toISOString();
1817
+ transcriptCreatedAtRef.current.set(id, createdAt);
1818
+ }
1819
+ realtimeVoiceTranscriptRegistry.publish({
1820
+ id,
1821
+ threadId,
1822
+ role: transcript.role,
1823
+ text: transcript.text,
1824
+ createdAt,
1825
+ status,
1826
+ });
1827
+ }, []);
1828
+ const transcriptSequencer = useMemo(() => createRealtimeVoiceTranscriptSequencer((transcript) => publishTranscript(transcript, "complete"), (transcript) => publishTranscript(transcript, "streaming"), (turn) => {
1829
+ if (turn.speaker !== "user")
1830
+ return;
1831
+ turnMetrics.userTurnUpdated(performance.now(), turn.id, turn.endMs);
1832
+ }), [publishTranscript, turnMetrics]);
1833
+ flushTranscriptsRef.current = transcriptSequencer.flush;
1834
+ const greetingStarter = useMemo(() => createRealtimeVoiceGreetingStarter((event) => {
1835
+ if (event.type === "response.create") {
1836
+ responseCoordinator.request(event);
1837
+ }
1838
+ else {
1839
+ sendDataChannelEvent(channelRef.current, event);
1840
+ }
1841
+ }), [responseCoordinator]);
1842
+ const handleFunctionCall = useCallback(async (call, delegationId) => {
1584
1843
  if (handledCallsRef.current.has(call.callId))
1585
1844
  return;
1586
1845
  handledCallsRef.current.add(call.callId);
@@ -1589,9 +1848,23 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1589
1848
  const toolAbortController = new AbortController();
1590
1849
  toolAbortControllersRef.current.add(toolAbortController);
1591
1850
  transition("working");
1851
+ trackRunningTool(call.callId, call.name);
1852
+ const finishTurnToolCall = turnMetrics.toolCallStarted();
1853
+ const workCall = {
1854
+ id: call.callId,
1855
+ name: call.name,
1856
+ status: "running",
1857
+ startedAt: new Date().toISOString(),
1858
+ };
1859
+ const workSegment = openRealtimeVoiceWorkSegment(workSegmentRef, transcriptThreadIdRef.current, call.callId, delegationId);
1592
1860
  let result;
1593
1861
  try {
1594
1862
  const args = parseFunctionArguments(call.argumentsText);
1863
+ workCall.input = args;
1864
+ if (workSegment) {
1865
+ workSegment.calls.push(workCall);
1866
+ publishRealtimeVoiceWorkSegment(workSegment);
1867
+ }
1595
1868
  result = await executeRealtimeVoiceTool({
1596
1869
  name: call.name,
1597
1870
  args,
@@ -1611,42 +1884,143 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1611
1884
  }
1612
1885
  finally {
1613
1886
  toolAbortControllersRef.current.delete(toolAbortController);
1887
+ trackRunningTool(call.callId, null);
1614
1888
  }
1889
+ // Session teardown aborted the call, so it did not fail. A call that
1890
+ // asks for approval stays running until the user decides.
1891
+ const approvalId = !toolAbortController.signal.aborted &&
1892
+ result.status === "approval_required" &&
1893
+ workCall.input &&
1894
+ transportGeneration === transportGenerationRef.current
1895
+ ? result.approvalId
1896
+ : undefined;
1897
+ const awaitingApproval = Boolean(approvalId);
1898
+ if (!awaitingApproval) {
1899
+ workCall.status =
1900
+ toolAbortController.signal.aborted ||
1901
+ result.status === "approval_required"
1902
+ ? "cancelled"
1903
+ : result.status;
1904
+ workCall.output = result.output;
1905
+ workCall.completedAt = new Date().toISOString();
1906
+ }
1907
+ if (workSegment) {
1908
+ if (!workSegment.calls.includes(workCall)) {
1909
+ workSegment.calls.push(workCall);
1910
+ }
1911
+ if (workSegment.closing) {
1912
+ closeRealtimeVoiceWorkSegment(workSegmentRef, workSegment);
1913
+ }
1914
+ else {
1915
+ publishRealtimeVoiceWorkSegment(workSegment);
1916
+ }
1917
+ }
1918
+ finishTurnToolCall(performance.now(), toolAbortController.signal.aborted ? { status: "cancelled" } : result);
1615
1919
  if (transportGeneration !== transportGenerationRef.current ||
1616
1920
  channelRef.current !== transportChannel) {
1617
1921
  return;
1618
1922
  }
1619
1923
  if (result.capability)
1620
1924
  capabilityRef.current = result.capability;
1925
+ if (approvalId && workCall.input) {
1926
+ pendingApprovalsRef.current.set(call.callId, {
1927
+ callId: call.callId,
1928
+ name: call.name,
1929
+ args: workCall.input,
1930
+ approvalId,
1931
+ workCall,
1932
+ workSegment,
1933
+ });
1934
+ syncApprovals();
1935
+ }
1621
1936
  toolManifestCoordinator.enqueue(result);
1622
- }, [browserTabId, toolManifestCoordinator, transition]);
1623
- const publishTranscript = useCallback((transcript, status) => {
1624
- const threadId = transcriptThreadIdRef.current;
1625
- if (!threadId)
1937
+ }, [
1938
+ browserTabId,
1939
+ syncApprovals,
1940
+ toolManifestCoordinator,
1941
+ trackRunningTool,
1942
+ transition,
1943
+ turnMetrics,
1944
+ ]);
1945
+ const resolveApproval = useCallback(async (callId, approved) => {
1946
+ const pending = pendingApprovalsRef.current.get(callId);
1947
+ if (!pending)
1626
1948
  return;
1627
- // The session id arrives after the first events; the start time keeps
1628
- // every update of one turn under the same message id.
1629
- const sessionIdentity = startedAtRef.current ?? "pending";
1630
- const providerIdentity = transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
1631
- realtimeVoiceTranscriptRegistry.publish({
1632
- id: `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`,
1633
- threadId,
1634
- role: transcript.role,
1635
- text: transcript.text,
1636
- createdAt: new Date().toISOString(),
1637
- status,
1638
- });
1639
- }, []);
1640
- const transcriptSequencer = useMemo(() => createRealtimeVoiceTranscriptSequencer((transcript) => publishTranscript(transcript, "complete"), (transcript) => publishTranscript(transcript, "streaming")), [publishTranscript]);
1641
- flushTranscriptsRef.current = transcriptSequencer.flush;
1642
- const greetingStarter = useMemo(() => createRealtimeVoiceGreetingStarter((event) => {
1643
- if (event.type === "response.create") {
1644
- responseCoordinator.request(event);
1949
+ pendingApprovalsRef.current.delete(callId);
1950
+ syncApprovals();
1951
+ const transportGeneration = transportGenerationRef.current;
1952
+ const transportChannel = channelRef.current;
1953
+ let result = null;
1954
+ let aborted = false;
1955
+ if (approved) {
1956
+ transition("working");
1957
+ trackRunningTool(callId, pending.name);
1958
+ const toolAbortController = new AbortController();
1959
+ toolAbortControllersRef.current.add(toolAbortController);
1960
+ try {
1961
+ result = await executeRealtimeVoiceTool({
1962
+ name: pending.name,
1963
+ args: pending.args,
1964
+ callId: `${callId}_approved`,
1965
+ sessionId: sessionIdRef.current,
1966
+ browserTabId,
1967
+ capability: capabilityRef.current,
1968
+ signal: toolAbortController.signal,
1969
+ approvalId: pending.approvalId,
1970
+ });
1971
+ }
1972
+ catch (toolError) {
1973
+ result = {
1974
+ callId,
1975
+ status: "failed",
1976
+ output: errorMessage(toolError),
1977
+ };
1978
+ }
1979
+ finally {
1980
+ toolAbortControllersRef.current.delete(toolAbortController);
1981
+ trackRunningTool(callId, null);
1982
+ }
1983
+ aborted = toolAbortController.signal.aborted;
1645
1984
  }
1646
1985
  else {
1647
- sendDataChannelEvent(channelRef.current, event);
1986
+ // A declined record left pending would make a later identical
1987
+ // request's approval ambiguous on the server.
1988
+ await executeRealtimeVoiceTool({
1989
+ name: pending.name,
1990
+ args: pending.args,
1991
+ callId: `${callId}_declined`,
1992
+ sessionId: sessionIdRef.current,
1993
+ browserTabId,
1994
+ capability: capabilityRef.current,
1995
+ declinedApprovalId: pending.approvalId,
1996
+ }).catch((declineError) => {
1997
+ console.warn("Could not record the declined voice approval.", declineError);
1998
+ });
1648
1999
  }
1649
- }), [responseCoordinator]);
2000
+ const transportChanged = transportGeneration !== transportGenerationRef.current ||
2001
+ channelRef.current !== transportChannel;
2002
+ // An approval that expired or no longer applies asks again; the call
2003
+ // stays running and the user sees the new request.
2004
+ const renewedApprovalId = !aborted && !transportChanged && result?.status === "approval_required"
2005
+ ? result.approvalId
2006
+ : undefined;
2007
+ if (renewedApprovalId) {
2008
+ pendingApprovalsRef.current.set(callId, {
2009
+ ...pending,
2010
+ approvalId: renewedApprovalId,
2011
+ });
2012
+ syncApprovals();
2013
+ }
2014
+ else {
2015
+ finishPendingApproval(pending, aborted || !result ? null : result);
2016
+ }
2017
+ if (aborted || transportChanged)
2018
+ return;
2019
+ if (result?.capability)
2020
+ capabilityRef.current = result.capability;
2021
+ sendDataChannelEvent(channelRef.current, createRealtimeVoiceApprovalOutcomeEvent(pending.name, result, protocolRef.current));
2022
+ sendDataChannelEvent(channelRef.current, { type: "response.create" });
2023
+ }, [browserTabId, syncApprovals, trackRunningTool, transition]);
1650
2024
  const handleServerEvent = useCallback((event, draining = false) => {
1651
2025
  if (draining) {
1652
2026
  transcriptSequencer.handle(event);
@@ -1686,9 +2060,12 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1686
2060
  transition("working");
1687
2061
  }
1688
2062
  else if (event.type === "input_audio_buffer.speech_started") {
2063
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
2064
+ turnMetrics.userSpeechStarted(performance.now());
1689
2065
  transition("listening");
1690
2066
  }
1691
2067
  else if (event.type === "input_audio_buffer.speech_stopped") {
2068
+ turnMetrics.userSpeechEnded(performance.now());
1692
2069
  transition("working");
1693
2070
  if (protocolRef.current === "realtime")
1694
2071
  responseCoordinator.request();
@@ -1698,7 +2075,11 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1698
2075
  lastAssistantTextRef.current = "";
1699
2076
  transition("working");
1700
2077
  }
2078
+ else if (event.type === "output_audio_buffer.started") {
2079
+ turnMetrics.assistantAudioStarted(performance.now());
2080
+ }
1701
2081
  else if (event.type === "response.output_audio_transcript.delta") {
2082
+ turnMetrics.assistantResponded();
1702
2083
  hasOutputAudioRef.current = true;
1703
2084
  if (typeof event.delta === "string") {
1704
2085
  lastAssistantTextRef.current += event.delta;
@@ -1724,6 +2105,7 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1724
2105
  transition("listening");
1725
2106
  }
1726
2107
  else if (event.type === "session.output_transcript.delta") {
2108
+ turnMetrics.assistantAudioStarted(performance.now(), typeof event.start_ms === "number" ? event.start_ms : undefined);
1727
2109
  hasOutputAudioRef.current = true;
1728
2110
  if (typeof event.delta === "string") {
1729
2111
  lastAssistantTextRef.current += event.delta;
@@ -1741,6 +2123,7 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1741
2123
  ? response.status
1742
2124
  : undefined;
1743
2125
  if (responseEvent.type === "response.failed" || status === "failed") {
2126
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
1744
2127
  transportGenerationRef.current += 1;
1745
2128
  handledCallsRef.current.clear();
1746
2129
  abortActiveToolCalls();
@@ -1783,12 +2166,30 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1783
2166
  }));
1784
2167
  return;
1785
2168
  }
2169
+ const delegationId = typeof event.delegation_id === "string" ? event.delegation_id : "";
1786
2170
  const calls = extractRealtimeVoiceFunctionCalls(event);
1787
- for (const call of calls)
1788
- void handleFunctionCall(call);
1789
- if (responseBoundary && calls.length === 0) {
2171
+ for (const call of calls) {
2172
+ void handleFunctionCall(call, delegationId || undefined);
2173
+ }
2174
+ // GPT-Live's response snapshots carry no output, so whether a response
2175
+ // called a tool is counted from its streamed items, per delegation.
2176
+ const responseCalls = responseCallCountsRef.current;
2177
+ if (responseEvent.type === "response.created") {
2178
+ responseCalls.set(delegationId, 0);
2179
+ }
2180
+ if (calls.length > 0) {
2181
+ responseCalls.set(delegationId, (responseCalls.get(delegationId) ?? 0) + calls.length);
2182
+ }
2183
+ if (responseBoundary && !responseCalls.get(delegationId)) {
2184
+ // Only the segment's own delegation ends it; an uncorrelated
2185
+ // boundary would split one request's work into several rows.
2186
+ if ((workSegmentRef.current?.delegationId ?? "") === delegationId) {
2187
+ closeRealtimeVoiceWorkSegment(workSegmentRef);
2188
+ }
1790
2189
  transition("listening");
1791
2190
  }
2191
+ if (responseBoundary)
2192
+ responseCalls.delete(delegationId);
1792
2193
  if (responseBoundary) {
1793
2194
  lastUserTextRef.current = "";
1794
2195
  lastAssistantTextRef.current = "";
@@ -1805,6 +2206,7 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1805
2206
  transition,
1806
2207
  responseCoordinator,
1807
2208
  toolManifestCoordinator,
2209
+ turnMetrics,
1808
2210
  updateLivePreferences,
1809
2211
  ]);
1810
2212
  const start = useCallback(async () => {
@@ -1829,8 +2231,10 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1829
2231
  greetingStarter.reset();
1830
2232
  setVoiceChangePending(false);
1831
2233
  transcriptThreadIdRef.current =
1832
- realtimeVoiceTranscriptRegistry.activeThreadId();
2234
+ requestedTranscriptThreadIdRef.current ??
2235
+ realtimeVoiceTranscriptRegistry.activeThreadId();
1833
2236
  transcriptSequenceRef.current = 0;
2237
+ transcriptCreatedAtRef.current.clear();
1834
2238
  transcriptSequencer.reset();
1835
2239
  inputMutedRef.current = false;
1836
2240
  outputMutedRef.current = false;
@@ -1904,8 +2308,10 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1904
2308
  try {
1905
2309
  handleServerEvent(JSON.parse(String(messageEvent.data)), stateRef.current === "ending");
1906
2310
  }
1907
- catch {
1908
- // Ignore malformed provider events without ending a healthy call.
2311
+ catch (eventError) {
2312
+ // A malformed provider event must not end a healthy call, but the
2313
+ // text it carried is lost, so say so.
2314
+ console.warn("Ignored a realtime voice event that could not be handled.", eventError);
1909
2315
  }
1910
2316
  };
1911
2317
  channel.onerror = () => {
@@ -1955,7 +2361,25 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1955
2361
  answer.model ??
1956
2362
  (protocolRef.current === "live" ? "gpt-live-1" : "gpt-realtime-2.1");
1957
2363
  toolManifestCoordinator.setProtocol(protocolRef.current);
2364
+ toolManifestCoordinator.setPriorityToolNames(answer.priorityToolNames ?? []);
1958
2365
  greetingStarter.setProtocol(protocolRef.current);
2366
+ const chosen = preferencesRef.current.intelligenceChosen === true;
2367
+ if (!chosen &&
2368
+ answer.intelligence &&
2369
+ answer.intelligence !== preferencesRef.current.intelligence) {
2370
+ // Saved as not chosen, so Settings shows the app's default after a
2371
+ // reload and the app can still change it.
2372
+ savePreferences({
2373
+ ...preferencesRef.current,
2374
+ intelligence: answer.intelligence,
2375
+ intelligenceChosen: false,
2376
+ });
2377
+ }
2378
+ turnMetrics.setContext({
2379
+ protocol: protocolRef.current,
2380
+ intelligence: preferencesRef.current.intelligence,
2381
+ intelligenceSource: chosen ? "user" : "default",
2382
+ });
1959
2383
  await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
1960
2384
  }
1961
2385
  catch (startError) {
@@ -1990,10 +2414,16 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
1990
2414
  handleServerEvent,
1991
2415
  hydratePreferences,
1992
2416
  refreshMicrophones,
2417
+ savePreferences,
1993
2418
  t,
1994
2419
  transcriptSequencer,
1995
2420
  transition,
2421
+ turnMetrics,
1996
2422
  ]);
2423
+ const startForThread = useCallback((options) => {
2424
+ requestedTranscriptThreadIdRef.current = options?.threadId;
2425
+ return start();
2426
+ }, [start]);
1997
2427
  const end = useCallback(() => {
1998
2428
  if (stateRef.current === "idle" || stateRef.current === "ending")
1999
2429
  return;
@@ -2107,9 +2537,12 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
2107
2537
  setMicrophone,
2108
2538
  setInputMuted,
2109
2539
  setOutputMuted,
2110
- start,
2540
+ start: startForThread,
2111
2541
  end,
2112
2542
  dismissError,
2543
+ currentToolName,
2544
+ approvals,
2545
+ resolveApproval,
2113
2546
  };
2114
2547
  }
2115
2548
  const RealtimeVoiceModeContext = createContext(null);
@@ -2170,7 +2603,11 @@ export function buildRealtimeVoiceSettings({ copy, preferences, microphones, mic
2170
2603
  })),
2171
2604
  onValueChange: (value) => {
2172
2605
  if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
2173
- onPreferencesChange({ ...preferences, intelligence: value });
2606
+ onPreferencesChange({
2607
+ ...preferences,
2608
+ intelligence: value,
2609
+ intelligenceChosen: true,
2610
+ });
2174
2611
  }
2175
2612
  },
2176
2613
  },