@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
@@ -99,4 +99,40 @@ describe("realtime voice transcript registry", () => {
99
99
  visible = true;
100
100
  expect(registry.activeThreadId()).toBe("thread-1");
101
101
  });
102
+
103
+ it("queues a reply's work until its thread's sink takes it, finished over running", () => {
104
+ const registry = createRealtimeVoiceTranscriptRegistry();
105
+ const running = {
106
+ id: "call-1",
107
+ threadId: "thread-1",
108
+ calls: [
109
+ {
110
+ id: "call-1",
111
+ name: "web-search",
112
+ status: "running" as const,
113
+ startedAt: "2026-07-10T20:00:00.000Z",
114
+ },
115
+ ],
116
+ };
117
+ registry.publishWork(running);
118
+ registry.publishWork({
119
+ ...running,
120
+ completedAt: "2026-07-10T20:00:02.000Z",
121
+ });
122
+ registry.publishWork(running);
123
+ expect(registry.pendingCount()).toBe(1);
124
+
125
+ const appendWork = vi.fn(() => true);
126
+ registry.register({
127
+ threadId: "thread-1",
128
+ active: true,
129
+ append: vi.fn(() => true),
130
+ appendWork,
131
+ });
132
+ expect(appendWork).toHaveBeenCalledOnce();
133
+ expect(appendWork).toHaveBeenCalledWith(
134
+ expect.objectContaining({ completedAt: "2026-07-10T20:00:02.000Z" }),
135
+ );
136
+ expect(registry.pendingCount()).toBe(0);
137
+ });
102
138
  });
@@ -10,17 +10,50 @@ export interface RealtimeVoiceTranscriptMessage {
10
10
  status?: "streaming" | "complete";
11
11
  }
12
12
 
13
+ /** One tool call in a voice reply's work. */
14
+ /** A tool name as a short label, such as "Edit design" for `edit-design`. */
15
+ export function realtimeVoiceToolLabel(name: string): string {
16
+ const label = name.replace(/[_-]+/g, " ").trim();
17
+ return label ? label.charAt(0).toUpperCase() + label.slice(1) : name;
18
+ }
19
+
20
+ export interface RealtimeVoiceWorkCall {
21
+ /** The provider's call id. */
22
+ id: string;
23
+ name: string;
24
+ input?: Record<string, unknown>;
25
+ status: "running" | "completed" | "failed" | "cancelled";
26
+ output?: string;
27
+ startedAt: string;
28
+ completedAt?: string;
29
+ }
30
+
31
+ /**
32
+ * The tool calls one voice reply made, so the chat can show them as one work
33
+ * item. Each update is the whole reply so far under the same id.
34
+ */
35
+ export interface RealtimeVoiceWorkUpdate {
36
+ id: string;
37
+ threadId: string;
38
+ calls: RealtimeVoiceWorkCall[];
39
+ /** Set once the reply has finished and every call in it has ended. */
40
+ completedAt?: string;
41
+ }
42
+
13
43
  export interface RealtimeVoiceTranscriptSink {
14
44
  threadId: string;
15
45
  /** Read when a voice session starts, so a sink can report whether it is on screen. */
16
46
  active: boolean | (() => boolean);
17
47
  /** False keeps the message queued, newest text per id, until the sink re-registers. */
18
48
  append: (message: RealtimeVoiceTranscriptMessage) => boolean;
49
+ /** False keeps the update queued, newest per reply, until the sink re-registers. */
50
+ appendWork?: (update: RealtimeVoiceWorkUpdate) => boolean;
19
51
  }
20
52
 
21
53
  export interface RealtimeVoiceTranscriptRegistry {
22
54
  activeThreadId: () => string | undefined;
23
55
  publish: (message: RealtimeVoiceTranscriptMessage) => void;
56
+ publishWork: (update: RealtimeVoiceWorkUpdate) => void;
24
57
  register: (sink: RealtimeVoiceTranscriptSink) => () => void;
25
58
  pendingCount: () => number;
26
59
  }
@@ -31,10 +64,23 @@ export function createRealtimeVoiceTranscriptRegistry(): RealtimeVoiceTranscript
31
64
  RealtimeVoiceTranscriptSink & { order: number }
32
65
  >();
33
66
  const pending = new Map<string, RealtimeVoiceTranscriptMessage>();
67
+ const pendingWork = new Map<string, RealtimeVoiceWorkUpdate>();
34
68
  const delivered = new Set<string>();
35
69
  let order = 0;
36
70
 
37
71
  const flush = () => {
72
+ for (const [id, update] of pendingWork) {
73
+ let consumed = false;
74
+ for (const sink of sinks.values()) {
75
+ if (sink.threadId !== update.threadId || !sink.appendWork) continue;
76
+ try {
77
+ consumed = sink.appendWork(update) || consumed;
78
+ } catch {
79
+ // coercion-ok: a throwing sink leaves the update queued for the next flush.
80
+ }
81
+ }
82
+ if (consumed) pendingWork.delete(id);
83
+ }
38
84
  for (const [id, message] of pending) {
39
85
  let consumed = false;
40
86
  for (const sink of sinks.values()) {
@@ -86,13 +132,22 @@ export function createRealtimeVoiceTranscriptRegistry(): RealtimeVoiceTranscript
86
132
  });
87
133
  flush();
88
134
  },
135
+ publishWork(update) {
136
+ const id = update.id.trim();
137
+ const threadId = update.threadId.trim();
138
+ if (!id || !threadId) return;
139
+ // A finished reply outranks a late update from while it ran.
140
+ if (pendingWork.get(id)?.completedAt && !update.completedAt) return;
141
+ pendingWork.set(id, { ...update, id, threadId });
142
+ flush();
143
+ },
89
144
  register(sink) {
90
145
  const token = Symbol("realtime-voice-transcript-sink");
91
146
  sinks.set(token, { ...sink, order: ++order });
92
147
  flush();
93
148
  return () => sinks.delete(token);
94
149
  },
95
- pendingCount: () => pending.size,
150
+ pendingCount: () => pending.size + pendingWork.size,
96
151
  };
97
152
  }
98
153
 
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ createRealtimeVoiceTurnMetrics,
5
+ REALTIME_VOICE_TURN_EVENT,
6
+ } from "./realtime-voice-turn-metrics.js";
7
+
8
+ function metrics() {
9
+ const emit = vi.fn();
10
+ const tracker = createRealtimeVoiceTurnMetrics(emit);
11
+ tracker.setContext({
12
+ protocol: "live",
13
+ intelligence: "balanced",
14
+ intelligenceSource: "default",
15
+ });
16
+ return { emit, tracker };
17
+ }
18
+
19
+ describe("realtime voice turn metrics", () => {
20
+ it("times speech end to first audio on the provider's session clock", () => {
21
+ const { emit, tracker } = metrics();
22
+ tracker.userTurnUpdated(1_000, "user-turn-1", 40_000);
23
+ tracker.userTurnUpdated(1_100, "user-turn-1", 40_200);
24
+ tracker.assistantAudioStarted(1_500, 41_100);
25
+ tracker.assistantAudioStarted(1_600, 41_400);
26
+ tracker.sessionEnded();
27
+
28
+ expect(emit).toHaveBeenCalledWith(REALTIME_VOICE_TURN_EVENT, {
29
+ protocol: "live",
30
+ intelligence: "balanced",
31
+ intelligence_source: "default",
32
+ turn_index: 0,
33
+ speech_to_audio_ms: 900,
34
+ latency_clock: "session",
35
+ tool_calls: 0,
36
+ write_tool_calls: 0,
37
+ failed_tool_calls: 0,
38
+ spoke_again_after_edit: null,
39
+ ms_after_edit: null,
40
+ });
41
+ });
42
+
43
+ it("flags speech within the window after an edit as a likely correction", () => {
44
+ const { emit, tracker } = metrics();
45
+ tracker.userSpeechStarted(0);
46
+ tracker.userSpeechEnded(1_000);
47
+ tracker.assistantAudioStarted(1_400);
48
+ tracker.toolCallStarted()(2_000, { status: "completed", readOnly: true });
49
+ tracker.toolCallStarted()(3_000, { status: "completed", readOnly: false });
50
+ tracker.toolCallStarted()(3_500, { status: "completed" });
51
+ tracker.userSpeechStarted(7_000);
52
+
53
+ expect(emit.mock.calls[0]![1]).toMatchObject({
54
+ speech_to_audio_ms: 400,
55
+ latency_clock: "browser",
56
+ tool_calls: 3,
57
+ write_tool_calls: 1,
58
+ spoke_again_after_edit: true,
59
+ ms_after_edit: 4_000,
60
+ });
61
+ });
62
+
63
+ it("does not count a reply long after the edit, or a failed edit", () => {
64
+ const { emit, tracker } = metrics();
65
+ tracker.userSpeechEnded(0);
66
+ tracker.toolCallStarted()(500, { status: "completed", readOnly: false });
67
+ tracker.userSpeechStarted(20_000);
68
+ tracker.userSpeechEnded(21_000);
69
+ tracker.toolCallStarted()(21_500, { status: "failed" });
70
+ tracker.userSpeechStarted(22_000);
71
+
72
+ expect(emit.mock.calls.map(([, properties]) => properties)).toMatchObject([
73
+ { turn_index: 0, spoke_again_after_edit: false, ms_after_edit: 19_500 },
74
+ {
75
+ turn_index: 1,
76
+ failed_tool_calls: 1,
77
+ write_tool_calls: 0,
78
+ spoke_again_after_edit: null,
79
+ },
80
+ ]);
81
+ });
82
+
83
+ it("counts neither an unclassified nor a cancelled call as an edit or a failure", () => {
84
+ const { emit, tracker } = metrics();
85
+ tracker.userSpeechEnded(0);
86
+ tracker.toolCallStarted()(500, { status: "completed" });
87
+ tracker.toolCallStarted()(600, { status: "cancelled" });
88
+ tracker.userSpeechStarted(1_000);
89
+
90
+ expect(emit.mock.calls[0]![1]).toMatchObject({
91
+ tool_calls: 2,
92
+ write_tool_calls: 0,
93
+ failed_tool_calls: 0,
94
+ spoke_again_after_edit: null,
95
+ });
96
+ });
97
+
98
+ it("keeps one turn while the user pauses with no reply yet", () => {
99
+ const { emit, tracker } = metrics();
100
+ tracker.userSpeechStarted(0);
101
+ tracker.userSpeechEnded(1_000);
102
+ tracker.userSpeechStarted(1_200);
103
+ tracker.userSpeechEnded(2_000);
104
+ tracker.assistantAudioStarted(2_300);
105
+ tracker.sessionEnded();
106
+
107
+ expect(emit).toHaveBeenCalledTimes(1);
108
+ expect(emit.mock.calls[0]![1]).toMatchObject({ speech_to_audio_ms: 300 });
109
+ });
110
+
111
+ it("ignores late text from a GPT-Live turn the assistant is already answering", () => {
112
+ const { emit, tracker } = metrics();
113
+ tracker.userTurnUpdated(1_000, "user-turn-1", 40_000);
114
+ tracker.assistantAudioStarted(1_400, 40_600);
115
+ tracker.userTurnUpdated(1_500, "user-turn-1", 40_100);
116
+ tracker.userTurnUpdated(9_000, "user-turn-2", 48_000);
117
+
118
+ expect(emit).toHaveBeenCalledTimes(1);
119
+ expect(emit.mock.calls[0]![1]).toMatchObject({
120
+ turn_index: 0,
121
+ speech_to_audio_ms: 600,
122
+ latency_clock: "session",
123
+ });
124
+ });
125
+
126
+ it("counts a tool result in the turn it started in, after an interruption", () => {
127
+ const { emit, tracker } = metrics();
128
+ tracker.userSpeechEnded(0);
129
+ const finishEdit = tracker.toolCallStarted();
130
+ // The user speaks again while the edit is still running.
131
+ tracker.userSpeechStarted(1_000);
132
+ tracker.userSpeechEnded(1_500);
133
+ expect(emit).not.toHaveBeenCalled();
134
+
135
+ finishEdit(2_000, { status: "completed", readOnly: false });
136
+ expect(emit).toHaveBeenCalledTimes(1);
137
+ // Speaking before the edit finished is not a reaction to it.
138
+ expect(emit.mock.calls[0]![1]).toMatchObject({
139
+ turn_index: 0,
140
+ tool_calls: 1,
141
+ write_tool_calls: 1,
142
+ spoke_again_after_edit: false,
143
+ ms_after_edit: null,
144
+ });
145
+
146
+ tracker.assistantResponded();
147
+ tracker.sessionEnded();
148
+ expect(emit.mock.calls[1]![1]).toMatchObject({
149
+ turn_index: 1,
150
+ tool_calls: 0,
151
+ write_tool_calls: 0,
152
+ });
153
+ });
154
+
155
+ it("measures a text-only reply without a first-audio time", () => {
156
+ const { emit, tracker } = metrics();
157
+ tracker.userSpeechEnded(0);
158
+ tracker.assistantResponded();
159
+ tracker.userSpeechStarted(5_000);
160
+
161
+ expect(emit.mock.calls[0]![1]).toMatchObject({
162
+ speech_to_audio_ms: null,
163
+ latency_clock: null,
164
+ });
165
+ });
166
+
167
+ it("skips a greeting nobody asked for and an empty session", () => {
168
+ const { emit, tracker } = metrics();
169
+ tracker.assistantAudioStarted(100);
170
+ tracker.sessionEnded();
171
+ tracker.sessionEnded();
172
+
173
+ expect(emit).not.toHaveBeenCalled();
174
+ });
175
+ });
@@ -0,0 +1,192 @@
1
+ export const REALTIME_VOICE_TURN_EVENT = "realtime_voice_turn";
2
+ export const REALTIME_VOICE_CORRECTION_WINDOW_MS = 10_000;
3
+
4
+ export interface RealtimeVoiceTurnMetricsContext {
5
+ protocol: "live" | "realtime";
6
+ intelligence: string;
7
+ intelligenceSource: "user" | "default";
8
+ }
9
+
10
+ /**
11
+ * Records how a tool call that started in a turn ended. Only a call classified
12
+ * `readOnly: false` counts as an edit; an unclassified call is neither.
13
+ */
14
+ export type RealtimeVoiceTurnToolCallFinish = (
15
+ at: number,
16
+ result: { status: string; readOnly?: boolean | undefined },
17
+ ) => void;
18
+
19
+ export interface RealtimeVoiceTurnMetrics {
20
+ setContext: (context: RealtimeVoiceTurnMetricsContext) => void;
21
+ /** Realtime: the provider heard the user start speaking. */
22
+ userSpeechStarted: (at: number) => void;
23
+ /** Realtime: the provider heard the user stop speaking. */
24
+ userSpeechEnded: (at: number) => void;
25
+ /**
26
+ * GPT-Live: a user turn's transcript grew. `turnId` names the transcript
27
+ * turn and `endMs` is its end on the session clock, so late text from a turn
28
+ * already answered never starts another.
29
+ */
30
+ userTurnUpdated: (at: number, turnId: string, endMs: number) => void;
31
+ /** The assistant replied in text, which carries no audio timing. */
32
+ assistantResponded: () => void;
33
+ /** `timelineMs` is the provider's session clock, when the event has one. */
34
+ assistantAudioStarted: (at: number, timelineMs?: number) => void;
35
+ /** The returned function records the call's outcome against this turn. */
36
+ toolCallStarted: () => RealtimeVoiceTurnToolCallFinish;
37
+ /** Reports the open turn; nobody can speak again after the session ends. */
38
+ sessionEnded: () => void;
39
+ }
40
+
41
+ interface Turn {
42
+ userTurnId?: string | undefined;
43
+ speechEndedAt?: number | undefined;
44
+ speechEndedTimelineMs?: number | undefined;
45
+ firstAudioAt?: number | undefined;
46
+ firstAudioTimelineMs?: number | undefined;
47
+ responding: boolean;
48
+ toolCalls: number;
49
+ pendingToolCalls: number;
50
+ writeToolCalls: number;
51
+ failedToolCalls: number;
52
+ lastWriteCompletedAt?: number | undefined;
53
+ /** Reported while a call still ran; it is sent once the last call ends. */
54
+ heldReport?: { index: number; nextSpeechAt: number | undefined } | undefined;
55
+ }
56
+
57
+ function emptyTurn(): Turn {
58
+ return {
59
+ responding: false,
60
+ toolCalls: 0,
61
+ pendingToolCalls: 0,
62
+ writeToolCalls: 0,
63
+ failedToolCalls: 0,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * One event per voice turn: from the end of the user's speech to the next time
69
+ * they speak. It carries timings and counts only, never transcript text or
70
+ * tool arguments.
71
+ */
72
+ export function createRealtimeVoiceTurnMetrics(
73
+ emit: (name: string, properties: Record<string, unknown>) => void,
74
+ correctionWindowMs = REALTIME_VOICE_CORRECTION_WINDOW_MS,
75
+ ): RealtimeVoiceTurnMetrics {
76
+ let context: RealtimeVoiceTurnMetricsContext | undefined;
77
+ let turn = emptyTurn();
78
+ let turnIndex = 0;
79
+
80
+ const send = (
81
+ current: Turn,
82
+ index: number,
83
+ nextSpeechAt: number | undefined,
84
+ ) => {
85
+ const { speechEndedTimelineMs, firstAudioTimelineMs } = current;
86
+ const sessionClock =
87
+ speechEndedTimelineMs !== undefined && firstAudioTimelineMs !== undefined;
88
+ const speechToAudioMs = sessionClock
89
+ ? firstAudioTimelineMs - speechEndedTimelineMs
90
+ : current.speechEndedAt !== undefined &&
91
+ current.firstAudioAt !== undefined
92
+ ? current.firstAudioAt - current.speechEndedAt
93
+ : null;
94
+ // Speech that began while the edit still ran did not answer the edit.
95
+ const msAfterEdit =
96
+ current.lastWriteCompletedAt !== undefined &&
97
+ nextSpeechAt !== undefined &&
98
+ nextSpeechAt >= current.lastWriteCompletedAt
99
+ ? Math.round(nextSpeechAt - current.lastWriteCompletedAt)
100
+ : null;
101
+ emit(REALTIME_VOICE_TURN_EVENT, {
102
+ protocol: context?.protocol ?? null,
103
+ intelligence: context?.intelligence ?? null,
104
+ intelligence_source: context?.intelligenceSource ?? null,
105
+ turn_index: index,
106
+ speech_to_audio_ms:
107
+ speechToAudioMs === null
108
+ ? null
109
+ : Math.max(0, Math.round(speechToAudioMs)),
110
+ latency_clock:
111
+ speechToAudioMs === null ? null : sessionClock ? "session" : "browser",
112
+ tool_calls: current.toolCalls,
113
+ write_tool_calls: current.writeToolCalls,
114
+ failed_tool_calls: current.failedToolCalls,
115
+ spoke_again_after_edit:
116
+ current.lastWriteCompletedAt === undefined
117
+ ? null
118
+ : msAfterEdit !== null && msAfterEdit <= correctionWindowMs,
119
+ ms_after_edit: msAfterEdit,
120
+ });
121
+ };
122
+
123
+ const report = (nextSpeechAt?: number) => {
124
+ const current = turn;
125
+ turn = emptyTurn();
126
+ if (current.speechEndedAt === undefined && current.toolCalls === 0) return;
127
+ const index = turnIndex++;
128
+ if (current.pendingToolCalls > 0) {
129
+ current.heldReport = { index, nextSpeechAt };
130
+ return;
131
+ }
132
+ send(current, index, nextSpeechAt);
133
+ };
134
+
135
+ return {
136
+ setContext(next) {
137
+ context = next;
138
+ },
139
+ userSpeechStarted(at) {
140
+ if (turn.responding) report(at);
141
+ },
142
+ userSpeechEnded(at) {
143
+ if (turn.responding) report(at);
144
+ turn.speechEndedAt = at;
145
+ },
146
+ userTurnUpdated(at, turnId, endMs) {
147
+ if (turn.userTurnId === turnId) {
148
+ if (turn.responding) return;
149
+ } else if (turn.responding) {
150
+ report(at);
151
+ }
152
+ turn.userTurnId = turnId;
153
+ turn.speechEndedAt = at;
154
+ turn.speechEndedTimelineMs = endMs;
155
+ },
156
+ assistantResponded() {
157
+ turn.responding = true;
158
+ },
159
+ assistantAudioStarted(at, timelineMs) {
160
+ turn.responding = true;
161
+ if (turn.firstAudioAt !== undefined) return;
162
+ turn.firstAudioAt = at;
163
+ turn.firstAudioTimelineMs = timelineMs;
164
+ },
165
+ toolCallStarted() {
166
+ const owner = turn;
167
+ owner.responding = true;
168
+ owner.toolCalls += 1;
169
+ owner.pendingToolCalls += 1;
170
+ let finished = false;
171
+ return (at, result) => {
172
+ if (finished) return;
173
+ finished = true;
174
+ owner.pendingToolCalls -= 1;
175
+ if (result.status === "failed") {
176
+ owner.failedToolCalls += 1;
177
+ } else if (result.status === "completed" && result.readOnly === false) {
178
+ owner.writeToolCalls += 1;
179
+ owner.lastWriteCompletedAt = at;
180
+ }
181
+ const held = owner.heldReport;
182
+ if (owner.pendingToolCalls > 0 || !held) return;
183
+ owner.heldReport = undefined;
184
+ send(owner, held.index, held.nextSpeechAt);
185
+ };
186
+ },
187
+ sessionEnded() {
188
+ report();
189
+ turnIndex = 0;
190
+ },
191
+ };
192
+ }
@@ -267,11 +267,13 @@ export interface ComposerRuntimeAdapters {
267
267
  text: string,
268
268
  context: VoiceContextPack | undefined,
269
269
  ) => string;
270
+ /** Sends a voice analytics event; properties never carry transcript text. */
271
+ trackEvent?: (name: string, properties: Record<string, unknown>) => void;
270
272
  };
271
273
  }
272
274
 
273
275
  const identityPath = (path: string) => path;
274
- const fallbackTranslate: ComposerTranslate = (key, options) => {
276
+ export const fallbackTranslate: ComposerTranslate = (key, options) => {
275
277
  const template =
276
278
  typeof options?.defaultValue === "string" ? options.defaultValue : key;
277
279
 
@@ -352,6 +354,7 @@ type ResolvedComposerRuntimeAdapters = ComposerRuntimeAdapters &
352
354
  | "setAppState"
353
355
  | "subscribeSidebarState"
354
356
  | "applyContextReplacements"
357
+ | "trackEvent"
355
358
  >
356
359
  >;
357
360
  };
@@ -394,6 +397,7 @@ const fallbackAdapters: ResolvedComposerRuntimeAdapters = {
394
397
  setAppState: () => {},
395
398
  subscribeSidebarState: () => () => {},
396
399
  applyContextReplacements: applyVoiceContextReplacements,
400
+ trackEvent: () => {},
397
401
  },
398
402
  };
399
403