@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
@@ -12,6 +12,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
12
12
  import {
13
13
  realtimeVoiceTranscriptRegistry,
14
14
  type RealtimeVoiceTranscriptMessage,
15
+ type RealtimeVoiceWorkUpdate,
15
16
  } from "../../../composer/realtime-voice-transcript.js";
16
17
  import { ComposerRuntimeAdaptersProvider } from "../../../composer/runtime-adapters.js";
17
18
  import { AgentKitChat } from "./components.js";
@@ -25,8 +26,16 @@ afterEach(async () => {
25
26
  vi.unstubAllGlobals();
26
27
  });
27
28
 
28
- async function mountChat(threadId: string) {
29
+ async function mountChat(
30
+ threadId: string,
31
+ stored: Partial<AgentThreadSnapshot> = {},
32
+ { loadBeforeMount = true } = {},
33
+ ) {
29
34
  const saved: AgentThreadSnapshot[] = [];
35
+ let releaseSnapshot = () => {};
36
+ const snapshotReleased = loadBeforeMount
37
+ ? Promise.resolve()
38
+ : new Promise<void>((resolve) => (releaseSnapshot = resolve));
30
39
  const transport: AgentTransport = {
31
40
  async startRun() {
32
41
  return { runId: "unused" };
@@ -34,7 +43,14 @@ async function mountChat(threadId: string) {
34
43
  async *subscribeToRun() {},
35
44
  async cancelRun() {},
36
45
  async getThreadSnapshot() {
37
- return { id: threadId, createdAt: AT, updatedAt: AT, messages: [] };
46
+ await snapshotReleased;
47
+ return {
48
+ id: threadId,
49
+ createdAt: AT,
50
+ updatedAt: AT,
51
+ messages: [],
52
+ ...stored,
53
+ };
38
54
  },
39
55
  async persistThreadSnapshot({ snapshot }) {
40
56
  saved.push(snapshot);
@@ -45,7 +61,8 @@ async function mountChat(threadId: string) {
45
61
  aiSetupReadiness: "not-applicable",
46
62
  reconnect: { attempts: 0, delayMs: 0 },
47
63
  });
48
- await client.loadThread(threadId);
64
+ const loaded = client.loadThread(threadId);
65
+ if (loadBeforeMount) await loaded;
49
66
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
50
67
  const container = document.createElement("div");
51
68
  document.body.appendChild(container);
@@ -65,7 +82,14 @@ async function mountChat(threadId: string) {
65
82
  await client.shutdown();
66
83
  container.remove();
67
84
  });
68
- return { container, client, saved };
85
+ const finishLoading = async () => {
86
+ await act(async () => {
87
+ releaseSnapshot();
88
+ await loaded;
89
+ await new Promise((resolve) => setTimeout(resolve, 0));
90
+ });
91
+ };
92
+ return { container, client, saved, finishLoading };
69
93
  }
70
94
 
71
95
  async function publish(
@@ -193,4 +217,215 @@ describe("voice chat transcripts in AgentKitChat", () => {
193
217
 
194
218
  expect(messages(container)).toEqual([]);
195
219
  });
220
+
221
+ it("waits for running work to finish before saving a final turn", async () => {
222
+ const threadId = "thread-voice-open-work";
223
+ const { saved } = await mountChat(threadId);
224
+ const call = {
225
+ id: "call-edit",
226
+ name: "edit-design",
227
+ startedAt: "2026-10-24T00:00:02.000Z",
228
+ };
229
+ const publishWork = async (update: RealtimeVoiceWorkUpdate) => {
230
+ await act(async () => {
231
+ realtimeVoiceTranscriptRegistry.publishWork(update);
232
+ await new Promise((resolve) => setTimeout(resolve, 0));
233
+ });
234
+ };
235
+
236
+ await publishWork({
237
+ id: "call-edit",
238
+ threadId,
239
+ calls: [{ ...call, status: "running" }],
240
+ });
241
+ await publish(threadId, {
242
+ id: `${threadId}:user-2`,
243
+ role: "user",
244
+ text: "Make it blue too.",
245
+ createdAt: "2026-10-24T00:00:03.000Z",
246
+ });
247
+ expect(saved).toEqual([]);
248
+
249
+ await publishWork({
250
+ id: "call-edit",
251
+ threadId,
252
+ calls: [
253
+ {
254
+ ...call,
255
+ status: "completed",
256
+ output: "{}",
257
+ completedAt: "2026-10-24T00:00:04.000Z",
258
+ },
259
+ ],
260
+ completedAt: "2026-10-24T00:00:04.000Z",
261
+ });
262
+ expect(saved.at(-1)?.runs).toEqual([
263
+ expect.objectContaining({ id: "voice:call-edit", status: "completed" }),
264
+ ]);
265
+ expect(saved.at(-1)?.messages.map((message) => message.id)).toContain(
266
+ `${threadId}:user-2`,
267
+ );
268
+ });
269
+
270
+ it.each([
271
+ ["before", true],
272
+ ["after", false],
273
+ ])(
274
+ "closes voice work a reload left running when the thread loads %s the chat mounts",
275
+ async (_when, loadBeforeMount) => {
276
+ const threadId = `thread-voice-orphan-${String(loadBeforeMount)}`;
277
+ const runId = "voice:call-edit";
278
+ const started = "2026-10-24T00:00:02.000Z";
279
+ const { client, saved, finishLoading } = await mountChat(
280
+ threadId,
281
+ {
282
+ events: [
283
+ {
284
+ id: `${runId}:call-edit:tool-started`,
285
+ threadId,
286
+ runId,
287
+ sequence: 1,
288
+ occurredAt: started,
289
+ type: "tool.started",
290
+ toolCall: {
291
+ id: "call-edit",
292
+ name: "edit-design",
293
+ status: "running",
294
+ runId,
295
+ },
296
+ },
297
+ ],
298
+ runs: [{ id: runId, threadId, status: "running", lastSequence: 1 }],
299
+ },
300
+ { loadBeforeMount },
301
+ );
302
+ await finishLoading();
303
+
304
+ expect(
305
+ client
306
+ .getThread(threadId)
307
+ .events.filter((event) => event.runId === runId)
308
+ .map((event) =>
309
+ event.type === "tool.updated"
310
+ ? `${event.type}:${event.toolCall.status}`
311
+ : event.type,
312
+ ),
313
+ ).toEqual([
314
+ "tool.started",
315
+ "activity.started",
316
+ "tool.updated:cancelled",
317
+ "activity.completed",
318
+ "run.completed",
319
+ ]);
320
+
321
+ await publish(threadId, {
322
+ id: `${threadId}:user-1`,
323
+ role: "user",
324
+ text: "Try again.",
325
+ });
326
+ expect(saved.at(-1)?.messages.map((message) => message.id)).toEqual([
327
+ `${threadId}:user-1`,
328
+ ]);
329
+ },
330
+ );
331
+
332
+ it("shows a reply's tool calls as one work item between the turns and saves it", async () => {
333
+ const threadId = "thread-voice-work";
334
+ const { container, client, saved } = await mountChat(threadId);
335
+ const search = {
336
+ id: "call-search",
337
+ name: "web-search",
338
+ input: { query: "cats" },
339
+ startedAt: "2026-10-24T00:00:02.000Z",
340
+ };
341
+ const read = {
342
+ id: "call-read",
343
+ name: "web-request",
344
+ input: { url: "https://example.com/cats" },
345
+ startedAt: "2026-10-24T00:00:03.000Z",
346
+ };
347
+ const publishWork = async (update: RealtimeVoiceWorkUpdate) => {
348
+ await act(async () => {
349
+ realtimeVoiceTranscriptRegistry.publishWork(update);
350
+ await new Promise((resolve) => setTimeout(resolve, 0));
351
+ });
352
+ };
353
+ const reply = { id: "call-search", threadId };
354
+
355
+ await publish(threadId, {
356
+ id: `${threadId}:user-1`,
357
+ role: "user",
358
+ text: "Search the web for cats.",
359
+ });
360
+ await publish(threadId, {
361
+ id: `${threadId}:assistant-1`,
362
+ role: "assistant",
363
+ text: "On it, searching now.",
364
+ createdAt: "2026-10-24T00:00:01.000Z",
365
+ });
366
+ await publishWork({ ...reply, calls: [{ ...search, status: "running" }] });
367
+
368
+ const running = container.textContent ?? "";
369
+ expect(running.indexOf("Searched 1 query")).toBeGreaterThan(
370
+ running.indexOf("On it, searching now."),
371
+ );
372
+ // The local run never holds the composer the way a server run does.
373
+ expect(client.getThread(threadId).activeRunIds).toEqual([]);
374
+ // It still shows what it is doing now.
375
+ const liveStep = () =>
376
+ container.querySelector('[data-agentkit-current-activity="true"]')
377
+ ?.textContent;
378
+ expect(liveStep()).toBeTruthy();
379
+
380
+ const finishedSearch = {
381
+ ...search,
382
+ status: "completed" as const,
383
+ output: "{}",
384
+ completedAt: "2026-10-24T00:00:02.500Z",
385
+ };
386
+ await publishWork({
387
+ ...reply,
388
+ calls: [finishedSearch, { ...read, status: "running" }],
389
+ });
390
+ await publishWork({
391
+ ...reply,
392
+ calls: [
393
+ finishedSearch,
394
+ {
395
+ ...read,
396
+ status: "completed",
397
+ output: "<html>",
398
+ completedAt: "2026-10-24T00:00:04.000Z",
399
+ },
400
+ ],
401
+ completedAt: "2026-10-24T00:00:04.000Z",
402
+ });
403
+ await publish(threadId, {
404
+ id: `${threadId}:assistant-2`,
405
+ role: "assistant",
406
+ text: "Cats are great.",
407
+ createdAt: "2026-10-24T00:00:05.000Z",
408
+ });
409
+
410
+ const done = container.textContent ?? "";
411
+ expect(liveStep()).toBeUndefined();
412
+ expect(done.match(/Worked/g)).toHaveLength(1);
413
+ const row = done.indexOf("Worked");
414
+ expect(row).toBeGreaterThan(done.indexOf("On it, searching now."));
415
+ expect(row).toBeLessThan(done.indexOf("Cats are great."));
416
+ expect(saved.at(-1)?.events?.map((event) => event.type)).toEqual([
417
+ "tool.started",
418
+ "activity.started",
419
+ "tool.updated",
420
+ "activity.completed",
421
+ "tool.started",
422
+ "activity.started",
423
+ "tool.updated",
424
+ "activity.completed",
425
+ "run.completed",
426
+ ]);
427
+ expect(saved.at(-1)?.runs).toEqual([
428
+ expect.objectContaining({ id: "voice:call-search", status: "completed" }),
429
+ ]);
430
+ });
196
431
  });
@@ -2,16 +2,26 @@ import {
2
2
  hasActiveAgentRuns,
3
3
  type AgentKitController,
4
4
  } from "@agent-native/agentkit/client";
5
- import type { AgentMessage } from "@agent-native/agentkit/protocol";
5
+ import {
6
+ inferAgentActivityKind,
7
+ type AgentActivity,
8
+ type AgentEvent,
9
+ type AgentMessage,
10
+ type AgentToolCall,
11
+ } from "@agent-native/agentkit/protocol";
6
12
  import { useEffect } from "react";
7
13
 
8
14
  import {
15
+ realtimeVoiceToolLabel,
9
16
  realtimeVoiceTranscriptRegistry,
10
17
  type RealtimeVoiceTranscriptMessage,
18
+ type RealtimeVoiceWorkCall,
19
+ type RealtimeVoiceWorkUpdate,
11
20
  } from "../../../composer/realtime-voice-transcript.js";
12
21
  import { isChatThreadViewed } from "./viewed-threads.js";
13
22
 
14
23
  const REALTIME_VOICE_MESSAGE_SOURCE = "realtime-voice";
24
+ const VOICE_RUN_PREFIX = "voice:";
15
25
 
16
26
  function realtimeVoiceTranscriptMessage(
17
27
  transcript: RealtimeVoiceTranscriptMessage,
@@ -29,6 +39,175 @@ function realtimeVoiceTranscriptMessage(
29
39
  };
30
40
  }
31
41
 
42
+ /**
43
+ * The events a voice reply's work still needs in its thread, as one local
44
+ * run. The run never starts on the server, so it never counts as an active
45
+ * run that would hold the composer or the transcript. `existingIds` and
46
+ * `lastSequence` describe the run as the thread already has it.
47
+ */
48
+ export function realtimeVoiceWorkEvents(
49
+ update: RealtimeVoiceWorkUpdate,
50
+ existingIds: ReadonlySet<string> = new Set(),
51
+ lastSequence = 0,
52
+ ): AgentEvent[] {
53
+ const runId = `${VOICE_RUN_PREFIX}${update.id}`;
54
+ const base = (id: string, occurredAt: string) => ({
55
+ id: `${runId}:${id}`,
56
+ threadId: update.threadId,
57
+ runId,
58
+ // Numbered below, once the events this run already has are left out.
59
+ sequence: 0,
60
+ occurredAt,
61
+ });
62
+ const steps: { at: string; event: AgentEvent }[] = [];
63
+ for (const call of update.calls) {
64
+ const toolCall: AgentToolCall = {
65
+ id: call.id,
66
+ name: call.name,
67
+ status: "running",
68
+ runId,
69
+ ...(call.input ? { input: call.input } : {}),
70
+ };
71
+ const activity: AgentActivity = {
72
+ id: call.id,
73
+ kind: inferAgentActivityKind(call.name),
74
+ label: realtimeVoiceToolLabel(call.name),
75
+ status: "running",
76
+ runId,
77
+ startedAt: call.startedAt,
78
+ };
79
+ steps.push(
80
+ {
81
+ at: call.startedAt,
82
+ event: {
83
+ ...base(`${call.id}:tool-started`, call.startedAt),
84
+ type: "tool.started",
85
+ toolCall,
86
+ },
87
+ },
88
+ {
89
+ at: call.startedAt,
90
+ event: {
91
+ ...base(`${call.id}:activity-started`, call.startedAt),
92
+ type: "activity.started",
93
+ activity,
94
+ },
95
+ },
96
+ );
97
+ const { completedAt } = call;
98
+ if (!completedAt || call.status === "running") continue;
99
+ steps.push(
100
+ {
101
+ at: completedAt,
102
+ event: {
103
+ ...base(`${call.id}:tool-ended`, completedAt),
104
+ type: "tool.updated",
105
+ toolCall: {
106
+ ...toolCall,
107
+ status: call.status,
108
+ ...(call.output !== undefined ? { output: call.output } : {}),
109
+ },
110
+ },
111
+ },
112
+ {
113
+ at: completedAt,
114
+ event: {
115
+ ...base(`${call.id}:activity-ended`, completedAt),
116
+ type: "activity.completed",
117
+ activity: { ...activity, status: call.status, completedAt },
118
+ },
119
+ },
120
+ );
121
+ }
122
+ // Stable: a call's start keeps its place before its end at the same time.
123
+ steps.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
124
+ if (update.completedAt) {
125
+ steps.push({
126
+ at: update.completedAt,
127
+ event: {
128
+ ...base("completed", update.completedAt),
129
+ type: "run.completed",
130
+ },
131
+ });
132
+ }
133
+ let sequence = lastSequence;
134
+ return steps
135
+ .filter(({ event }) => !existingIds.has(event.id))
136
+ .map(({ event }) => ({ ...event, sequence: ++sequence }));
137
+ }
138
+
139
+ /**
140
+ * Voice work this page is still running, by thread. Saved work that is not
141
+ * here can never finish, so it is closed instead of waited on.
142
+ */
143
+ const runningVoiceWork = new Map<string, Set<string>>();
144
+
145
+ interface VoiceRunThread {
146
+ events: readonly AgentEvent[];
147
+ runs: Readonly<Record<string, { lastSequence: number }>>;
148
+ }
149
+
150
+ function voiceRunPosition(thread: VoiceRunThread, runId: string) {
151
+ return {
152
+ existingIds: new Set(
153
+ thread.events
154
+ .filter((event) => event.runId === runId)
155
+ .map((event) => event.id),
156
+ ),
157
+ lastSequence:
158
+ new Map(Object.entries(thread.runs)).get(runId)?.lastSequence ?? 0,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * The events that end, as cancelled, every voice run in `thread` with no
164
+ * terminal event. A snapshot saved while a call ran keeps such a run after a
165
+ * reload, and nothing is left to finish it.
166
+ */
167
+ export function orphanedVoiceWorkEvents(
168
+ thread: VoiceRunThread,
169
+ threadId: string,
170
+ completedAt = new Date().toISOString(),
171
+ ): AgentEvent[] {
172
+ const runs = new Map<
173
+ string,
174
+ { calls: RealtimeVoiceWorkCall[]; completed: boolean }
175
+ >();
176
+ for (const event of thread.events) {
177
+ if (!event.runId.startsWith(VOICE_RUN_PREFIX)) continue;
178
+ const run = runs.get(event.runId) ?? { calls: [], completed: false };
179
+ runs.set(event.runId, run);
180
+ if (event.type === "run.completed") run.completed = true;
181
+ if (event.type !== "tool.started") continue;
182
+ const { input } = event.toolCall;
183
+ run.calls.push({
184
+ id: event.toolCall.id,
185
+ name: event.toolCall.name,
186
+ status: "cancelled",
187
+ startedAt: event.occurredAt,
188
+ completedAt,
189
+ ...(input && typeof input === "object" && !Array.isArray(input)
190
+ ? { input: input as Record<string, unknown> }
191
+ : {}),
192
+ });
193
+ }
194
+ return [...runs]
195
+ .filter(([, run]) => !run.completed)
196
+ .flatMap(([runId, run]) => {
197
+ const { existingIds, lastSequence } = voiceRunPosition(thread, runId);
198
+ return realtimeVoiceWorkEvents(
199
+ {
200
+ id: runId.slice(VOICE_RUN_PREFIX.length),
201
+ threadId,
202
+ calls: run.calls,
203
+ completedAt,
204
+ },
205
+ existingIds,
206
+ lastSequence,
207
+ );
208
+ });
209
+ }
210
+
32
211
  /** A voice chat turn still being spoken; hosts must not save it. */
33
212
  export function isStreamingVoiceMessage(message: AgentMessage): boolean {
34
213
  const custom = message.metadata?.custom as { source?: unknown } | undefined;
@@ -51,23 +230,81 @@ export function useRealtimeVoiceTranscripts(
51
230
  useEffect(() => {
52
231
  if (!threadId || !controller.upsertLocalMessages) return;
53
232
  const upsert = controller.upsertLocalMessages.bind(controller);
54
- return realtimeVoiceTranscriptRegistry.register({
233
+ const appendEvents = controller.appendLocalEvents?.bind(controller);
234
+ /** False when the thread could not be checked yet. */
235
+ const closeOrphanedWork = (): boolean => {
236
+ if (!appendEvents || runningVoiceWork.get(threadId)?.size) return false;
237
+ const thread = controller.getThread(threadId);
238
+ if (hasActiveAgentRuns(thread)) return false;
239
+ const events = orphanedVoiceWorkEvents(thread, threadId);
240
+ if (events.length > 0) appendEvents(threadId, events);
241
+ return true;
242
+ };
243
+ const persist = () => {
244
+ // The work's completion saves the thread, this turn included.
245
+ if (runningVoiceWork.get(threadId)?.size) return;
246
+ closeOrphanedWork();
247
+ const messages = controller
248
+ .getThread(threadId)
249
+ .messages.filter((message) => !isStreamingVoiceMessage(message));
250
+ // A failed save surfaces through the controller's connection error;
251
+ // a host without transport persistence saves the thread itself.
252
+ void controller.persistThreadSnapshotWithResult?.(threadId, messages);
253
+ };
254
+ // The thread's snapshot can land after this mounts, so check each time
255
+ // its events change rather than once.
256
+ let checkedEventCount = -1;
257
+ const closeOrphanedWorkOnChange = () => {
258
+ const { length } = controller.getThread(threadId).events;
259
+ if (length === checkedEventCount) return;
260
+ if (closeOrphanedWork()) checkedEventCount = length;
261
+ };
262
+ closeOrphanedWorkOnChange();
263
+ const unsubscribe = controller.subscribe(closeOrphanedWorkOnChange);
264
+ const unregister = realtimeVoiceTranscriptRegistry.register({
55
265
  threadId,
56
266
  active: () => isChatThreadViewed(threadId),
267
+ ...(appendEvents
268
+ ? {
269
+ appendWork: (update: RealtimeVoiceWorkUpdate) => {
270
+ if (hasActiveAgentRuns(controller.getThread(threadId))) {
271
+ return false;
272
+ }
273
+ const runId = `${VOICE_RUN_PREFIX}${update.id}`;
274
+ const { existingIds, lastSequence } = voiceRunPosition(
275
+ controller.getThread(threadId),
276
+ runId,
277
+ );
278
+ const running = runningVoiceWork.get(threadId) ?? new Set();
279
+ runningVoiceWork.set(threadId, running);
280
+ // Marked before the append notifies, so its own events never
281
+ // read as orphaned.
282
+ if (!update.completedAt) running.add(runId);
283
+ appendEvents(
284
+ threadId,
285
+ realtimeVoiceWorkEvents(update, existingIds, lastSequence),
286
+ );
287
+ if (update.completedAt) {
288
+ running.delete(runId);
289
+ persist();
290
+ }
291
+ return true;
292
+ },
293
+ }
294
+ : {}),
57
295
  append: (transcript) => {
58
296
  // A run owns the thread snapshot while it streams; the turn waits in
59
297
  // the registry and lands when this sink re-registers after the run.
60
298
  if (hasActiveAgentRuns(controller.getThread(threadId))) return false;
61
299
  upsert(threadId, [realtimeVoiceTranscriptMessage(transcript)]);
62
300
  if (transcript.status === "streaming") return true;
63
- const messages = controller
64
- .getThread(threadId)
65
- .messages.filter((message) => !isStreamingVoiceMessage(message));
66
- // A failed save surfaces through the controller's connection error;
67
- // a host without transport persistence saves the thread itself.
68
- void controller.persistThreadSnapshotWithResult?.(threadId, messages);
301
+ persist();
69
302
  return true;
70
303
  },
71
304
  });
305
+ return () => {
306
+ unsubscribe();
307
+ unregister();
308
+ };
72
309
  }, [controller, threadId, running]);
73
310
  }
@@ -76,6 +76,7 @@ const chatMocks = vi.hoisted(() => ({
76
76
  activeRunIds: [] as string[],
77
77
  runs: {} as Record<string, any>,
78
78
  approvals: {} as Record<string, any>,
79
+ connectionRequests: {} as Record<string, any>,
79
80
  suggestions: [] as any[],
80
81
  tools: {} as Record<string, unknown>,
81
82
  activities: {} as Record<string, unknown>,
@@ -1060,6 +1061,7 @@ beforeEach(() => {
1060
1061
  activeRunIds: [],
1061
1062
  runs: {},
1062
1063
  approvals: {},
1064
+ connectionRequests: {},
1063
1065
  suggestions: [],
1064
1066
  tools: {},
1065
1067
  activities: {},
@@ -3040,22 +3042,19 @@ describe("AgentKitAssistantChat host behavior", () => {
3040
3042
  }
3041
3043
  });
3042
3044
 
3043
- it("keeps the real editor editable while AI setup is missing", async () => {
3045
+ it("closes the real editor to typing while AI setup is missing", async () => {
3044
3046
  chatMocks.useRealChat = true;
3045
3047
  chatMocks.readiness = { canChat: false, missing: true, state: "missing" };
3046
3048
  await useRealComposer();
3047
3049
  await mount(baseProps({ showModelSelector: false }));
3048
3050
 
3049
- const editor = container.querySelector<HTMLElement>(
3050
- '[contenteditable="true"]',
3051
- );
3052
- expect(editor).not.toBeNull();
3053
- expect(chatMocks.composerProps.disabled).toBe(false);
3054
-
3055
- await act(async () =>
3056
- chatMocks.composerProps.composerRef.current.setText("Draft before setup"),
3057
- );
3058
- expect(editor?.textContent).toBe("Draft before setup");
3051
+ expect(container.querySelector('[contenteditable="false"]')).not.toBeNull();
3052
+ expect(container.querySelector('[contenteditable="true"]')).toBeNull();
3053
+ expect(
3054
+ container
3055
+ .querySelector('[role="textbox"]')
3056
+ ?.getAttribute("aria-disabled"),
3057
+ ).toBe("true");
3059
3058
  });
3060
3059
 
3061
3060
  it("keeps the real editor editable while a submission is in flight", async () => {
@@ -9365,6 +9364,55 @@ describe("AgentKitAssistantChat host behavior", () => {
9365
9364
  });
9366
9365
  });
9367
9366
 
9367
+ it("keeps a host-owned setup route open until the server confirms setup is missing", async () => {
9368
+ chatMocks.readiness = { canChat: false, missing: false, state: "unknown" };
9369
+ const props = baseProps({ setupCardOwner: "host" });
9370
+ await mount(props);
9371
+ await act(async () =>
9372
+ chatMocks.composerProps.onModelConnectionRequiredChange(true),
9373
+ );
9374
+ await flush();
9375
+
9376
+ expect(chatMocks.composerProps.setupCardVisible).toBe(false);
9377
+
9378
+ chatMocks.readiness = { canChat: false, missing: true, state: "missing" };
9379
+ await act(async () => root.render(<AgentKitAssistantChat {...props} />));
9380
+ await flush();
9381
+
9382
+ expect(chatMocks.composerProps.setupCardVisible).toBe(true);
9383
+ });
9384
+
9385
+ it("does not replay a refused prompt when only the model list guessed setup was missing", async () => {
9386
+ chatMocks.readiness = { canChat: false, missing: false, state: "unknown" };
9387
+ chatMocks.failureError = {
9388
+ code: "missing_credentials",
9389
+ message: "No LLM provider is connected.",
9390
+ };
9391
+ chatMocks.thread.messages = [
9392
+ {
9393
+ id: "user-refused",
9394
+ role: "user",
9395
+ parts: [{ type: "text", text: "Old prompt" }],
9396
+ },
9397
+ ];
9398
+ const props = baseProps({});
9399
+ await mount(props);
9400
+ await act(async () =>
9401
+ chatMocks.composerProps.onModelConnectionRequiredChange(true),
9402
+ );
9403
+ await flush();
9404
+
9405
+ chatMocks.readiness = {
9406
+ canChat: true,
9407
+ missing: false,
9408
+ state: "configured",
9409
+ };
9410
+ await act(async () => root.render(<AgentKitAssistantChat {...props} />));
9411
+ await flush();
9412
+
9413
+ expect(chatMocks.control.sendMessage).not.toHaveBeenCalled();
9414
+ });
9415
+
9368
9416
  it("does not replay a refused prompt when the thread reopens already connected", async () => {
9369
9417
  chatMocks.readiness = { canChat: false, missing: false, state: "unknown" };
9370
9418
  chatMocks.failureError = {