@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
@@ -41,6 +41,8 @@ function FirstRunOnboarding({
41
41
 
42
42
  const mocks = vi.hoisted(() => ({
43
43
  completeFirstRun: vi.fn(),
44
+ recordCreditsV2Exposure: vi.fn(),
45
+ useActionMutation: vi.fn(),
44
46
  useBuilderConnectFlow: vi.fn(),
45
47
  routePathname: "/",
46
48
  useActualRouter: false,
@@ -52,6 +54,11 @@ const mocks = vi.hoisted(() => ({
52
54
  useOnboardingPreviewStep: vi.fn(),
53
55
  setOnboardingExperiment: vi.fn(),
54
56
  setupVariant: "control" as "control" | "credits" | null,
57
+ creditsV2FlagState: { status: "ready", enabled: false } as {
58
+ status: "ready" | "loading" | "unavailable";
59
+ enabled: boolean;
60
+ },
61
+ recordCreditsV2Exposure: vi.fn(),
55
62
  }));
56
63
 
57
64
  vi.mock("react-router", async (importOriginal) => {
@@ -69,7 +76,23 @@ vi.mock("react-router", async (importOriginal) => {
69
76
  });
70
77
 
71
78
  vi.mock("@agent-native/core/client/feature-flags/use-feature-flag", () => ({
72
- useFeatureFlagState: () => ({ status: "ready", enabled: true }),
79
+ useFeatureFlagState: (key: string) =>
80
+ key === "clips-first-run-credits-v2"
81
+ ? mocks.creditsV2FlagState
82
+ : { status: "ready", enabled: true },
83
+ }));
84
+
85
+ vi.mock("@agent-native/core/client/use-action", () => ({
86
+ useActionMutation: () => ({
87
+ mutateAsync: (...args: unknown[]) => mocks.recordCreditsV2Exposure(...args),
88
+ }),
89
+ }));
90
+
91
+ vi.mock("@agent-native/core/client/use-action", async (importOriginal) => ({
92
+ ...(await importOriginal<
93
+ typeof import("@agent-native/core/client/use-action")
94
+ >()),
95
+ useActionMutation: mocks.useActionMutation,
73
96
  }));
74
97
 
75
98
  vi.mock("@agent-native/core/client/onboarding/use-onboarding", () => ({
@@ -87,10 +110,6 @@ vi.mock("@agent-native/core/client/onboarding/use-preview-mode", () => ({
87
110
  useOnboardingPreviewStep: mocks.useOnboardingPreviewStep,
88
111
  }));
89
112
 
90
- vi.mock("@agent-native/core/client/feature-flags/use-feature-flag", () => ({
91
- useFeatureFlagState: () => ({ status: "ready", enabled: true }),
92
- }));
93
-
94
113
  vi.mock("./first-run-experiment.js", async (importOriginal) => ({
95
114
  ...(await importOriginal<typeof import("./first-run-experiment.js")>()),
96
115
  assignFirstRunSetupVariant: () => mocks.setupVariant,
@@ -107,6 +126,12 @@ describe("FirstRunOnboarding", () => {
107
126
  beforeEach(() => {
108
127
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
109
128
  mocks.completeFirstRun.mockReset();
129
+ mocks.recordCreditsV2Exposure.mockReset().mockResolvedValue({
130
+ assignment: null,
131
+ });
132
+ mocks.useActionMutation.mockReset().mockReturnValue({
133
+ mutateAsync: mocks.recordCreditsV2Exposure,
134
+ });
110
135
  mocks.routePathname = "/";
111
136
  mocks.useActualRouter = false;
112
137
  mocks.navigate.mockReset();
@@ -120,6 +145,9 @@ describe("FirstRunOnboarding", () => {
120
145
  mocks.useOnboarding.mockReset();
121
146
  mocks.useOnboardingPreviewMode.mockReset();
122
147
  mocks.useOnboardingPreviewStep.mockReset();
148
+ mocks.creditsV2FlagState = { status: "ready", enabled: false };
149
+ mocks.recordCreditsV2Exposure.mockReset();
150
+ mocks.recordCreditsV2Exposure.mockResolvedValue({ assignment: null });
123
151
  mocks.useOnboardingPreviewMode.mockReturnValue(false);
124
152
  mocks.useOnboardingPreviewStep.mockReturnValue(null);
125
153
  mocks.useBuilderConnectFlow.mockReturnValue({
@@ -978,6 +1006,7 @@ describe("FirstRunOnboarding", () => {
978
1006
  ])(
979
1007
  "restarts the prior $mode mode after a $failure",
980
1008
  ({
1009
+ mode,
981
1010
  choiceId,
982
1011
  provisionAccount,
983
1012
  statusUnavailable,
@@ -1018,12 +1047,25 @@ describe("FirstRunOnboarding", () => {
1018
1047
  });
1019
1048
 
1020
1049
  expect(start).toHaveBeenCalledOnce();
1021
- expect(document.body.textContent).toContain(error);
1022
- act(() => {
1023
- [...document.body.querySelectorAll("button")]
1024
- .find((button) => button.textContent === "Try again")
1025
- ?.click();
1026
- });
1050
+ if (mode === "provision" && terminalError) {
1051
+ expect(document.body.textContent).toContain(
1052
+ "Builder.io setup didn't finish. Try again, or use your own keys.",
1053
+ );
1054
+ act(() => {
1055
+ document.body
1056
+ .querySelector<HTMLButtonElement>(
1057
+ '[data-testid="first-run-retry-builder"]',
1058
+ )
1059
+ ?.click();
1060
+ });
1061
+ } else {
1062
+ expect(document.body.textContent).toContain(error);
1063
+ act(() => {
1064
+ [...document.body.querySelectorAll("button")]
1065
+ .find((button) => button.textContent === "Try again")
1066
+ ?.click();
1067
+ });
1068
+ }
1027
1069
 
1028
1070
  expect(start).toHaveBeenCalledTimes(2);
1029
1071
  expect(start).toHaveBeenLastCalledWith(
@@ -1563,7 +1605,8 @@ describe("FirstRunOnboarding", () => {
1563
1605
  ).toBeTruthy();
1564
1606
  });
1565
1607
 
1566
- it("returns to setup choices after Builder provisioning fails", () => {
1608
+ it("offers retry, own-key, and skip actions after Builder provisioning fails", () => {
1609
+ const start = vi.fn();
1567
1610
  const flow = {
1568
1611
  hasFetchedStatus: true,
1569
1612
  statusResolved: true,
@@ -1572,7 +1615,7 @@ describe("FirstRunOnboarding", () => {
1572
1615
  connecting: false,
1573
1616
  terminalError: "Builder account provisioning failed (503).",
1574
1617
  error: "Builder account provisioning failed (503).",
1575
- start: vi.fn(),
1618
+ start,
1576
1619
  retry: vi.fn(),
1577
1620
  };
1578
1621
  mocks.useBuilderConnectFlow.mockReturnValue(flow);
@@ -1597,26 +1640,138 @@ describe("FirstRunOnboarding", () => {
1597
1640
  ?.click();
1598
1641
  });
1599
1642
 
1643
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
1644
+ "onboarding_step_viewed",
1645
+ expect.objectContaining({ step_id: "connecting", optional: true }),
1646
+ );
1647
+
1600
1648
  expect(
1601
- document.body.querySelector('[data-testid="first-run-back-to-choice"]'),
1649
+ document.body.querySelector('[data-testid="first-run-retry-builder"]'),
1602
1650
  ).toBeTruthy();
1651
+ expect(
1652
+ document.body.querySelector(
1653
+ '[data-testid="first-run-open-key-settings-after-failure"]',
1654
+ ),
1655
+ ).toBeTruthy();
1656
+ expect(
1657
+ document.body.querySelector(
1658
+ '[data-testid="first-run-skip-builder-after-failure"]',
1659
+ ),
1660
+ ).toBeTruthy();
1661
+ act(() => {
1662
+ document.body
1663
+ .querySelector<HTMLButtonElement>(
1664
+ '[data-testid="first-run-retry-builder"]',
1665
+ )
1666
+ ?.click();
1667
+ });
1668
+ expect(start).toHaveBeenCalledTimes(2);
1669
+ expect(start).toHaveBeenLastCalledWith(
1670
+ expect.objectContaining({ provisionAccount: true }),
1671
+ );
1672
+ });
1673
+
1674
+ it("opens AI key settings directly from a failed Builder connection", async () => {
1675
+ mocks.useBuilderConnectFlow.mockReturnValue({
1676
+ hasFetchedStatus: true,
1677
+ statusResolved: true,
1678
+ configured: false,
1679
+ agentNativeProvisioningEnabled: true,
1680
+ connecting: false,
1681
+ terminalError: "Builder account provisioning failed (503).",
1682
+ error: "Builder account provisioning failed (503).",
1683
+ start: vi.fn(),
1684
+ retry: vi.fn(),
1685
+ });
1603
1686
 
1687
+ await act(async () => {
1688
+ root.render(
1689
+ <TooltipProvider>
1690
+ <FirstRunOnboarding />
1691
+ </TooltipProvider>,
1692
+ );
1693
+ });
1694
+ act(() => {
1695
+ document.body
1696
+ .querySelector<HTMLButtonElement>('[data-testid="first-run-role-skip"]')
1697
+ ?.click();
1698
+ });
1604
1699
  act(() => {
1605
1700
  document.body
1606
1701
  .querySelector<HTMLButtonElement>(
1607
- '[data-testid="first-run-back-to-choice"]',
1702
+ '[data-testid="first-run-builder-create-account"]',
1608
1703
  )
1609
1704
  ?.click();
1610
1705
  });
1611
1706
 
1612
- expect(
1613
- document.body.querySelector('[data-onboarding-screen="choice"]'),
1614
- ).toBeTruthy();
1615
- expect(
1616
- document.body.querySelector(
1617
- '[data-testid="first-run-open-key-settings"]',
1618
- ),
1619
- ).toBeTruthy();
1707
+ await act(async () => {
1708
+ document.body
1709
+ .querySelector<HTMLButtonElement>(
1710
+ '[data-testid="first-run-open-key-settings-after-failure"]',
1711
+ )
1712
+ ?.click();
1713
+ await Promise.resolve();
1714
+ await Promise.resolve();
1715
+ });
1716
+
1717
+ expect(mocks.navigate).toHaveBeenCalledWith(
1718
+ manualSetupSettingsRoute({ redesign: true }),
1719
+ );
1720
+ });
1721
+
1722
+ it("can skip onboarding setup after a failed Builder connection", async () => {
1723
+ mocks.useBuilderConnectFlow.mockReturnValue({
1724
+ hasFetchedStatus: true,
1725
+ statusResolved: true,
1726
+ configured: false,
1727
+ agentNativeProvisioningEnabled: true,
1728
+ connecting: false,
1729
+ terminalError: "Builder account provisioning failed (503).",
1730
+ error: "Builder account provisioning failed (503).",
1731
+ start: vi.fn(),
1732
+ retry: vi.fn(),
1733
+ });
1734
+
1735
+ await act(async () => {
1736
+ root.render(
1737
+ <TooltipProvider>
1738
+ <FirstRunOnboarding />
1739
+ </TooltipProvider>,
1740
+ );
1741
+ });
1742
+ act(() => {
1743
+ document.body
1744
+ .querySelector<HTMLButtonElement>('[data-testid="first-run-role-skip"]')
1745
+ ?.click();
1746
+ });
1747
+ act(() => {
1748
+ document.body
1749
+ .querySelector<HTMLButtonElement>(
1750
+ '[data-testid="first-run-builder-create-account"]',
1751
+ )
1752
+ ?.click();
1753
+ });
1754
+
1755
+ await act(async () => {
1756
+ document.body
1757
+ .querySelector<HTMLButtonElement>(
1758
+ '[data-testid="first-run-skip-builder-after-failure"]',
1759
+ )
1760
+ ?.click();
1761
+ await Promise.resolve();
1762
+ await Promise.resolve();
1763
+ });
1764
+
1765
+ expect(mocks.completeFirstRun).toHaveBeenCalledOnce();
1766
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
1767
+ "onboarding_step_skipped",
1768
+ expect.objectContaining({
1769
+ flow: "first_run",
1770
+ step_id: "connecting",
1771
+ optional: true,
1772
+ reason: "builder_provision_failed",
1773
+ }),
1774
+ );
1620
1775
  });
1621
1776
 
1622
1777
  it("shows the role step first", () => {
@@ -1639,6 +1794,13 @@ describe("FirstRunOnboarding", () => {
1639
1794
  });
1640
1795
 
1641
1796
  it("labels Clips setup as Builder, custom keys, or an optional skip", async () => {
1797
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
1798
+ mocks.recordCreditsV2Exposure.mockResolvedValue({
1799
+ assignment: {
1800
+ experiment_id: "first_run_builder_credits_v2",
1801
+ experiment_variant: "control",
1802
+ },
1803
+ });
1642
1804
  mocks.useOnboarding.mockReturnValue({
1643
1805
  firstRun: true,
1644
1806
  loading: false,
@@ -1684,8 +1846,137 @@ describe("FirstRunOnboarding", () => {
1684
1846
  customKeysAction: "Open AI key settings",
1685
1847
  skip: "Skip for now",
1686
1848
  });
1849
+ expect(mocks.useActionMutation).toHaveBeenCalledWith(
1850
+ "record-first-run-builder-credits-v2-exposure",
1851
+ { uiOnly: true, skipActionQueryInvalidation: true },
1852
+ );
1853
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledTimes(2);
1854
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenNthCalledWith(1, {});
1855
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenNthCalledWith(2, {
1856
+ confirmedVariant: "control",
1857
+ });
1858
+ expect(mocks.setOnboardingExperiment).toHaveBeenCalledWith({
1859
+ id: "first_run_builder_credits_v2",
1860
+ variant: "control",
1861
+ });
1862
+ expect(mocks.setOnboardingExperiment).not.toHaveBeenCalledWith({
1863
+ id: "first_run_builder_credits_v2",
1864
+ variant: "credits",
1865
+ });
1866
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
1867
+ "onboarding_experiment_exposed",
1868
+ {
1869
+ flow: "first_run",
1870
+ step_id: "choice",
1871
+ experiment_id: "first_run_builder_credits_v2",
1872
+ experiment_variant: "control",
1873
+ },
1874
+ );
1875
+ expect(
1876
+ document.body.querySelector("[data-testid='first-run-credits-offer']"),
1877
+ ).toBeNull();
1878
+ });
1879
+
1880
+ it("does not hold another app's first-run screen while the v2 flag loads", async () => {
1881
+ mocks.creditsV2FlagState = { status: "loading", enabled: false };
1882
+ mocks.useOnboarding.mockReturnValue({
1883
+ firstRun: true,
1884
+ loading: false,
1885
+ error: null,
1886
+ profile: { appId: "forms", appName: "Forms", capabilities: [] },
1887
+ completeFirstRun: mocks.completeFirstRun,
1888
+ completeFirstRunError: null,
1889
+ });
1890
+
1891
+ await act(async () => {
1892
+ root.render(
1893
+ <TooltipProvider>
1894
+ <FirstRunOnboarding />
1895
+ </TooltipProvider>,
1896
+ );
1897
+ });
1898
+ await act(async () => {
1899
+ document.body
1900
+ .querySelector<HTMLButtonElement>("[data-testid='first-run-role-skip']")
1901
+ ?.click();
1902
+ });
1903
+
1904
+ expect(
1905
+ document.body.querySelector("[data-onboarding-loading='true']"),
1906
+ ).toBeNull();
1907
+ expect(
1908
+ document.body.querySelector('[data-onboarding-screen="choice"]'),
1909
+ ).not.toBeNull();
1910
+ expect(mocks.recordCreditsV2Exposure).not.toHaveBeenCalled();
1687
1911
  });
1688
1912
 
1913
+ it.each(["design", "slides", "clips"])(
1914
+ "shows the v2 credits actions in %s",
1915
+ async (appId) => {
1916
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
1917
+ mocks.recordCreditsV2Exposure.mockResolvedValue({
1918
+ assignment: {
1919
+ experiment_id: "first_run_builder_credits_v2",
1920
+ experiment_variant: "credits",
1921
+ },
1922
+ });
1923
+ mocks.useOnboarding.mockReturnValue({
1924
+ firstRun: true,
1925
+ loading: false,
1926
+ error: null,
1927
+ profile: { appId, appName: appId, capabilities: [] },
1928
+ completeFirstRun: mocks.completeFirstRun,
1929
+ completeFirstRunError: null,
1930
+ });
1931
+
1932
+ await act(async () => {
1933
+ root.render(
1934
+ <TooltipProvider>
1935
+ <FirstRunOnboarding />
1936
+ </TooltipProvider>,
1937
+ );
1938
+ });
1939
+ await act(async () => {
1940
+ document.body
1941
+ .querySelector<HTMLButtonElement>(
1942
+ "[data-testid='first-run-role-skip']",
1943
+ )
1944
+ ?.click();
1945
+ });
1946
+
1947
+ const primary = document.body.querySelector<HTMLButtonElement>(
1948
+ "[data-testid='first-run-credits-activate']",
1949
+ );
1950
+ const existingAccount = document.body.querySelector<HTMLButtonElement>(
1951
+ "[data-testid='first-run-credits-connect-existing']",
1952
+ );
1953
+ const ownKeys = document.body.querySelector<HTMLButtonElement>(
1954
+ "[data-testid='first-run-credits-own-api-keys']",
1955
+ );
1956
+ expect(primary?.textContent).toBe("Activate free credits");
1957
+ expect(existingAccount?.textContent).toBe("I have a Builder.io account");
1958
+ expect(ownKeys?.textContent).toBe("Use my own API keys");
1959
+ expect(
1960
+ document.body.querySelector(
1961
+ "[data-testid='first-run-credits-skip-confirm']",
1962
+ ),
1963
+ ).toBeNull();
1964
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
1965
+ "onboarding_experiment_exposed",
1966
+ expect.objectContaining({
1967
+ experiment_id: "first_run_builder_credits_v2",
1968
+ experiment_variant: "credits",
1969
+ }),
1970
+ );
1971
+
1972
+ await act(async () => {
1973
+ ownKeys?.click();
1974
+ await Promise.resolve();
1975
+ });
1976
+ expect(mocks.navigate).toHaveBeenCalled();
1977
+ },
1978
+ );
1979
+
1689
1980
  it("opens the app directly after a configured Builder connection", () => {
1690
1981
  mocks.useBuilderConnectFlow.mockReturnValue({
1691
1982
  hasFetchedStatus: true,
@@ -2865,7 +3156,12 @@ describe("FirstRunOnboarding", () => {
2865
3156
  });
2866
3157
  expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
2867
3158
  "onboarding_experiment_exposed",
2868
- { flow: "first_run", step_id: "choice" },
3159
+ expect.objectContaining({
3160
+ flow: "first_run",
3161
+ step_id: "choice",
3162
+ experiment_id: "first_run_builder_credits",
3163
+ experiment_variant: "credits",
3164
+ }),
2869
3165
  );
2870
3166
  });
2871
3167
 
@@ -2932,8 +3228,13 @@ describe("FirstRunOnboarding", () => {
2932
3228
  expect(mocks.setOnboardingExperiment).not.toHaveBeenCalled();
2933
3229
  });
2934
3230
 
2935
- const openClipsChoice = (start = vi.fn()) => {
2936
- mocks.useBuilderConnectFlow.mockReturnValue(flow({ start }));
3231
+ const openClipsChoice = (
3232
+ start = vi.fn(),
3233
+ overrides: Record<string, unknown> = {},
3234
+ ) => {
3235
+ mocks.useBuilderConnectFlow.mockReturnValue(
3236
+ flow({ start, ...overrides }),
3237
+ );
2937
3238
  mocks.useOnboarding.mockReturnValue({
2938
3239
  ...mocks.useOnboarding(),
2939
3240
  profile: { ...mocks.useOnboarding().profile, appId: "clips" },
@@ -2942,6 +3243,176 @@ describe("FirstRunOnboarding", () => {
2942
3243
  return start;
2943
3244
  };
2944
3245
 
3246
+ it("does not expose the credits v2 experiment when connection status is unavailable", () => {
3247
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3248
+ openClipsChoice(vi.fn(), { statusUnavailable: true });
3249
+
3250
+ expect(mocks.recordCreditsV2Exposure).not.toHaveBeenCalled();
3251
+ expect(mocks.trackOnboardingEvent).not.toHaveBeenCalledWith(
3252
+ "onboarding_experiment_exposed",
3253
+ expect.objectContaining({
3254
+ experiment_id: "first_run_builder_credits_v2",
3255
+ }),
3256
+ );
3257
+ });
3258
+
3259
+ it("retries credits v2 exposure after a transient mutation failure", async () => {
3260
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3261
+ mocks.recordCreditsV2Exposure
3262
+ .mockRejectedValueOnce(new Error("temporary failure"))
3263
+ .mockResolvedValue({
3264
+ assignment: {
3265
+ experiment_id: "first_run_builder_credits_v2",
3266
+ experiment_variant: "credits",
3267
+ },
3268
+ });
3269
+
3270
+ openClipsChoice();
3271
+ await act(async () => {
3272
+ await Promise.resolve();
3273
+ await Promise.resolve();
3274
+ });
3275
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledOnce();
3276
+
3277
+ mocks.useBuilderConnectFlow.mockReturnValue(flow({ configured: true }));
3278
+ await act(async () => {
3279
+ root.render(
3280
+ <TooltipProvider>
3281
+ <FirstRunOnboarding />
3282
+ </TooltipProvider>,
3283
+ );
3284
+ });
3285
+ mocks.useBuilderConnectFlow.mockReturnValue(flow());
3286
+ await act(async () => {
3287
+ root.render(
3288
+ <TooltipProvider>
3289
+ <FirstRunOnboarding />
3290
+ </TooltipProvider>,
3291
+ );
3292
+ await Promise.resolve();
3293
+ await Promise.resolve();
3294
+ });
3295
+
3296
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledTimes(3);
3297
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3298
+ "onboarding_experiment_exposed",
3299
+ {
3300
+ flow: "first_run",
3301
+ step_id: "choice",
3302
+ experiment_id: "first_run_builder_credits_v2",
3303
+ experiment_variant: "credits",
3304
+ },
3305
+ );
3306
+ });
3307
+
3308
+ it("retries exposure persistence after tracking the rendered v2 arm", async () => {
3309
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3310
+ let confirmationAttempts = 0;
3311
+ mocks.recordCreditsV2Exposure.mockImplementation((...args: unknown[]) => {
3312
+ const input = args[0] as { confirmedVariant?: string } | undefined;
3313
+ if (input?.confirmedVariant) {
3314
+ confirmationAttempts += 1;
3315
+ if (confirmationAttempts === 1) {
3316
+ return Promise.reject(new Error("temporary confirmation failure"));
3317
+ }
3318
+ }
3319
+ return Promise.resolve({
3320
+ assignment: {
3321
+ experiment_id: "first_run_builder_credits_v2",
3322
+ experiment_variant: "credits",
3323
+ },
3324
+ });
3325
+ });
3326
+ mocks.trackOnboardingEvent.mockImplementation((name: string) => {
3327
+ if (name === "onboarding_experiment_exposed") {
3328
+ expect(q("first-run-credits-offer")).not.toBeNull();
3329
+ }
3330
+ });
3331
+
3332
+ openClipsChoice();
3333
+ await act(async () => {
3334
+ await Promise.resolve();
3335
+ await Promise.resolve();
3336
+ await Promise.resolve();
3337
+ });
3338
+
3339
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledTimes(3);
3340
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenNthCalledWith(1, {});
3341
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenNthCalledWith(2, {
3342
+ confirmedVariant: "credits",
3343
+ });
3344
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenNthCalledWith(3, {
3345
+ confirmedVariant: "credits",
3346
+ });
3347
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3348
+ "onboarding_experiment_exposed",
3349
+ {
3350
+ flow: "first_run",
3351
+ step_id: "choice",
3352
+ experiment_id: "first_run_builder_credits_v2",
3353
+ experiment_variant: "credits",
3354
+ },
3355
+ );
3356
+ });
3357
+
3358
+ it("reconciles failed exposure persistence without blocking the rendered v2 arm", async () => {
3359
+ vi.useFakeTimers();
3360
+ try {
3361
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3362
+ mocks.recordCreditsV2Exposure
3363
+ .mockResolvedValueOnce({
3364
+ assignment: {
3365
+ experiment_id: "first_run_builder_credits_v2",
3366
+ experiment_variant: "credits",
3367
+ },
3368
+ })
3369
+ .mockRejectedValueOnce(new Error("first confirmation unavailable"))
3370
+ .mockRejectedValueOnce(new Error("second confirmation unavailable"))
3371
+ .mockResolvedValueOnce({
3372
+ assignment: {
3373
+ experiment_id: "first_run_builder_credits_v2",
3374
+ experiment_variant: "credits",
3375
+ },
3376
+ });
3377
+
3378
+ openClipsChoice();
3379
+ await act(async () => {
3380
+ await Promise.resolve();
3381
+ await Promise.resolve();
3382
+ await Promise.resolve();
3383
+ });
3384
+
3385
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledTimes(3);
3386
+ expect(q("first-run-credits-offer")).not.toBeNull();
3387
+ expect(q("first-run-credits-own-api-keys")).not.toBeNull();
3388
+ expect(
3389
+ document.body.querySelector("[data-onboarding-loading='true']"),
3390
+ ).toBeNull();
3391
+ expect(mocks.setOnboardingExperiment).toHaveBeenCalledWith({
3392
+ id: "first_run_builder_credits_v2",
3393
+ variant: "credits",
3394
+ });
3395
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3396
+ "onboarding_experiment_exposed",
3397
+ expect.objectContaining({
3398
+ experiment_id: "first_run_builder_credits_v2",
3399
+ }),
3400
+ );
3401
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3402
+ "onboarding_step_viewed",
3403
+ expect.objectContaining({ step_id: "choice" }),
3404
+ );
3405
+
3406
+ await act(async () => {
3407
+ await vi.advanceTimersByTimeAsync(1_000);
3408
+ });
3409
+
3410
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledTimes(4);
3411
+ } finally {
3412
+ vi.useRealTimers();
3413
+ }
3414
+ });
3415
+
2945
3416
  it("shows one activate action instead of the two setup cards", () => {
2946
3417
  mocks.useBuilderConnectFlow.mockReturnValue(flow());
2947
3418
  openChoice();
@@ -3121,6 +3592,102 @@ describe("FirstRunOnboarding", () => {
3121
3592
  },
3122
3593
  );
3123
3594
 
3595
+ it("keeps a late Builder status response out of Clips v2 after timeout", () => {
3596
+ vi.useFakeTimers();
3597
+ try {
3598
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3599
+ openClipsChoice(vi.fn(), { hasFetchedStatus: false });
3600
+
3601
+ expect(mocks.recordCreditsV2Exposure).not.toHaveBeenCalled();
3602
+ act(() => {
3603
+ vi.advanceTimersByTime(4000);
3604
+ });
3605
+ expect(q("first-run-open-key-settings")).not.toBeNull();
3606
+ expect(q("first-run-credits-offer")).toBeNull();
3607
+
3608
+ mocks.useBuilderConnectFlow.mockReturnValue(flow());
3609
+ act(() => {
3610
+ root.render(
3611
+ <TooltipProvider>
3612
+ <FirstRunOnboarding />
3613
+ </TooltipProvider>,
3614
+ );
3615
+ });
3616
+
3617
+ expect(q("first-run-open-key-settings")).not.toBeNull();
3618
+ expect(q("first-run-credits-offer")).toBeNull();
3619
+ expect(mocks.recordCreditsV2Exposure).not.toHaveBeenCalled();
3620
+ expect(mocks.setOnboardingExperiment).not.toHaveBeenCalled();
3621
+ expect(mocks.trackOnboardingEvent).not.toHaveBeenCalledWith(
3622
+ "onboarding_experiment_exposed",
3623
+ expect.objectContaining({
3624
+ experiment_id: "first_run_builder_credits_v2",
3625
+ }),
3626
+ );
3627
+ } finally {
3628
+ vi.useRealTimers();
3629
+ }
3630
+ });
3631
+
3632
+ it("falls back if the Clips v2 exposure mutation stalls and ignores a late assignment", async () => {
3633
+ vi.useFakeTimers();
3634
+ try {
3635
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3636
+ let resolveAssignment:
3637
+ | ((result: {
3638
+ assignment: {
3639
+ experiment_id: string;
3640
+ experiment_variant: string;
3641
+ };
3642
+ }) => void)
3643
+ | undefined;
3644
+ mocks.recordCreditsV2Exposure.mockImplementation(
3645
+ () =>
3646
+ new Promise((resolve) => {
3647
+ resolveAssignment = resolve;
3648
+ }),
3649
+ );
3650
+
3651
+ openClipsChoice();
3652
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledOnce();
3653
+ expect(
3654
+ document.body.querySelector("[data-onboarding-loading='true']"),
3655
+ ).not.toBeNull();
3656
+
3657
+ act(() => {
3658
+ vi.advanceTimersByTime(4000);
3659
+ });
3660
+ expect(
3661
+ document.body.querySelector("[data-onboarding-loading='true']"),
3662
+ ).toBeNull();
3663
+ expect(q("first-run-open-key-settings")).not.toBeNull();
3664
+ expect(q("first-run-credits-offer")).toBeNull();
3665
+
3666
+ await act(async () => {
3667
+ resolveAssignment?.({
3668
+ assignment: {
3669
+ experiment_id: "first_run_builder_credits_v2",
3670
+ experiment_variant: "credits",
3671
+ },
3672
+ });
3673
+ });
3674
+
3675
+ expect(q("first-run-open-key-settings")).not.toBeNull();
3676
+ expect(q("first-run-credits-offer")).toBeNull();
3677
+ expect(mocks.setOnboardingExperiment).not.toHaveBeenCalled();
3678
+ expect(mocks.trackOnboardingEvent).not.toHaveBeenCalledWith(
3679
+ "onboarding_experiment_exposed",
3680
+ expect.objectContaining({
3681
+ experiment_id: "first_run_builder_credits_v2",
3682
+ }),
3683
+ );
3684
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledOnce();
3685
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledWith({});
3686
+ } finally {
3687
+ vi.useRealTimers();
3688
+ }
3689
+ });
3690
+
3124
3691
  it("falls back to the full setup where activation is unavailable", () => {
3125
3692
  mocks.useBuilderConnectFlow.mockReturnValue(
3126
3693
  flow({ agentNativeProvisioningEnabled: false }),
@@ -3130,5 +3697,148 @@ describe("FirstRunOnboarding", () => {
3130
3697
  expect(q("first-run-open-key-settings")).not.toBeNull();
3131
3698
  expect(q("first-run-credits-more-info")).toBeNull();
3132
3699
  });
3700
+
3701
+ it("renders and attributes the v2 choice while exposure persistence is pending", async () => {
3702
+ mocks.creditsV2FlagState = { status: "ready", enabled: true };
3703
+ mocks.useBuilderConnectFlow.mockReturnValue(flow());
3704
+ mocks.useOnboarding.mockReturnValue({
3705
+ ...mocks.useOnboarding(),
3706
+ profile: { ...mocks.useOnboarding().profile, appId: "clips" },
3707
+ });
3708
+ let resolveAssignment:
3709
+ | ((result: {
3710
+ assignment: {
3711
+ experiment_id: string;
3712
+ experiment_variant: string;
3713
+ };
3714
+ }) => void)
3715
+ | undefined;
3716
+ let resolveConfirmation:
3717
+ | ((result: {
3718
+ assignment: {
3719
+ experiment_id: string;
3720
+ experiment_variant: string;
3721
+ };
3722
+ }) => void)
3723
+ | undefined;
3724
+ mocks.recordCreditsV2Exposure.mockImplementation((...args: unknown[]) => {
3725
+ const input = args[0] as { confirmedVariant?: string } | undefined;
3726
+ if (input?.confirmedVariant) {
3727
+ return new Promise((resolve) => {
3728
+ resolveConfirmation = resolve;
3729
+ });
3730
+ }
3731
+ return new Promise((resolve) => {
3732
+ resolveAssignment = resolve;
3733
+ });
3734
+ });
3735
+ mocks.trackOnboardingEvent.mockImplementation(
3736
+ (name: string, properties?: Record<string, unknown>) => {
3737
+ if (name === "onboarding_experiment_exposed") {
3738
+ expect(q("first-run-credits-offer")).not.toBeNull();
3739
+ }
3740
+ if (
3741
+ name === "onboarding_step_viewed" &&
3742
+ properties?.step_id === "choice"
3743
+ ) {
3744
+ expect(mocks.setOnboardingExperiment).toHaveBeenCalledWith({
3745
+ id: "first_run_builder_credits_v2",
3746
+ variant: "credits",
3747
+ });
3748
+ }
3749
+ },
3750
+ );
3751
+
3752
+ await act(async () => {
3753
+ root.render(
3754
+ <TooltipProvider>
3755
+ <FirstRunOnboarding />
3756
+ </TooltipProvider>,
3757
+ );
3758
+ });
3759
+ await act(async () => {
3760
+ document.body
3761
+ .querySelector<HTMLButtonElement>(
3762
+ "[data-testid='first-run-role-skip']",
3763
+ )
3764
+ ?.click();
3765
+ });
3766
+
3767
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledOnce();
3768
+ expect(
3769
+ document.body.querySelector("[data-onboarding-loading='true']"),
3770
+ ).not.toBeNull();
3771
+ expect(q("first-run-credits-offer")).toBeNull();
3772
+
3773
+ await act(async () => {
3774
+ resolveAssignment?.({
3775
+ assignment: {
3776
+ experiment_id: "first_run_builder_credits_v2",
3777
+ experiment_variant: "credits",
3778
+ },
3779
+ });
3780
+ });
3781
+
3782
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenNthCalledWith(2, {
3783
+ confirmedVariant: "credits",
3784
+ });
3785
+ expect(
3786
+ document.body.querySelector("[data-onboarding-loading='true']"),
3787
+ ).toBeNull();
3788
+ expect(q("first-run-credits-offer")).not.toBeNull();
3789
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3790
+ "onboarding_step_viewed",
3791
+ expect.objectContaining({ step_id: "choice" }),
3792
+ );
3793
+ expect(mocks.setOnboardingExperiment).toHaveBeenCalledWith({
3794
+ id: "first_run_builder_credits_v2",
3795
+ variant: "credits",
3796
+ });
3797
+
3798
+ await act(async () => {
3799
+ resolveConfirmation?.({
3800
+ assignment: {
3801
+ experiment_id: "first_run_builder_credits_v2",
3802
+ experiment_variant: "credits",
3803
+ },
3804
+ });
3805
+ });
3806
+
3807
+ expect(mocks.setOnboardingExperiment).toHaveBeenCalledWith({
3808
+ id: "first_run_builder_credits_v2",
3809
+ variant: "credits",
3810
+ });
3811
+ expect(q("first-run-credits-offer")).not.toBeNull();
3812
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3813
+ "onboarding_experiment_exposed",
3814
+ {
3815
+ flow: "first_run",
3816
+ step_id: "choice",
3817
+ experiment_id: "first_run_builder_credits_v2",
3818
+ experiment_variant: "credits",
3819
+ },
3820
+ );
3821
+ expect(q("first-run-credits-skip")).not.toBeNull();
3822
+ expect(q("first-run-credits-skip-confirm")).toBeNull();
3823
+ const exposureIndex = mocks.trackOnboardingEvent.mock.calls.findIndex(
3824
+ ([name]) => name === "onboarding_experiment_exposed",
3825
+ );
3826
+ const choiceViewIndex = mocks.trackOnboardingEvent.mock.calls.findIndex(
3827
+ ([name, properties]) =>
3828
+ name === "onboarding_step_viewed" &&
3829
+ (properties as Record<string, unknown> | undefined)?.step_id ===
3830
+ "choice",
3831
+ );
3832
+ expect(exposureIndex).toBeGreaterThanOrEqual(0);
3833
+ expect(choiceViewIndex).toBeGreaterThan(exposureIndex);
3834
+ expect(mocks.recordCreditsV2Exposure).toHaveBeenCalledTimes(2);
3835
+ expect(document.body.textContent).not.toContain("Are you sure?");
3836
+
3837
+ click("first-run-credits-skip");
3838
+ expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
3839
+ "onboarding_step_skipped",
3840
+ expect.objectContaining({ flow: "first_run", step_id: "choice" }),
3841
+ );
3842
+ });
3133
3843
  });
3134
3844
  });