@shipfox/client-agent 0.2.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 (182) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +93 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/add-custom-provider-card.d.ts +4 -0
  10. package/dist/components/add-custom-provider-card.d.ts.map +1 -0
  11. package/dist/components/add-custom-provider-card.js +41 -0
  12. package/dist/components/add-custom-provider-card.js.map +1 -0
  13. package/dist/components/agent-workflow-example.d.ts +11 -0
  14. package/dist/components/agent-workflow-example.d.ts.map +1 -0
  15. package/dist/components/agent-workflow-example.js +38 -0
  16. package/dist/components/agent-workflow-example.js.map +1 -0
  17. package/dist/components/available-provider-card.d.ts +6 -0
  18. package/dist/components/available-provider-card.d.ts.map +1 -0
  19. package/dist/components/available-provider-card.js +41 -0
  20. package/dist/components/available-provider-card.js.map +1 -0
  21. package/dist/components/available-providers-grid.d.ts +10 -0
  22. package/dist/components/available-providers-grid.d.ts.map +1 -0
  23. package/dist/components/available-providers-grid.js +89 -0
  24. package/dist/components/available-providers-grid.js.map +1 -0
  25. package/dist/components/change-default-model-form.d.ts +9 -0
  26. package/dist/components/change-default-model-form.d.ts.map +1 -0
  27. package/dist/components/change-default-model-form.js +98 -0
  28. package/dist/components/change-default-model-form.js.map +1 -0
  29. package/dist/components/custom-model-provider-api-options.d.ts +7 -0
  30. package/dist/components/custom-model-provider-api-options.d.ts.map +1 -0
  31. package/dist/components/custom-model-provider-api-options.js +23 -0
  32. package/dist/components/custom-model-provider-api-options.js.map +1 -0
  33. package/dist/components/custom-model-provider-form.d.ts +8 -0
  34. package/dist/components/custom-model-provider-form.d.ts.map +1 -0
  35. package/dist/components/custom-model-provider-form.js +667 -0
  36. package/dist/components/custom-model-provider-form.js.map +1 -0
  37. package/dist/components/custom-model-provider-form.stories.d.ts +20 -0
  38. package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -0
  39. package/dist/components/custom-model-provider-form.stories.js +104 -0
  40. package/dist/components/custom-model-provider-form.stories.js.map +1 -0
  41. package/dist/components/custom-model-provider-payload.d.ts +39 -0
  42. package/dist/components/custom-model-provider-payload.d.ts.map +1 -0
  43. package/dist/components/custom-model-provider-payload.js +203 -0
  44. package/dist/components/custom-model-provider-payload.js.map +1 -0
  45. package/dist/components/custom-model-provider-slug.d.ts +3 -0
  46. package/dist/components/custom-model-provider-slug.d.ts.map +1 -0
  47. package/dist/components/custom-model-provider-slug.js +14 -0
  48. package/dist/components/custom-model-provider-slug.js.map +1 -0
  49. package/dist/components/default-model-field.d.ts +16 -0
  50. package/dist/components/default-model-field.d.ts.map +1 -0
  51. package/dist/components/default-model-field.js +60 -0
  52. package/dist/components/default-model-field.js.map +1 -0
  53. package/dist/components/form-errors.d.ts +11 -0
  54. package/dist/components/form-errors.d.ts.map +1 -0
  55. package/dist/components/form-errors.js +101 -0
  56. package/dist/components/form-errors.js.map +1 -0
  57. package/dist/components/harness-availability.d.ts +8 -0
  58. package/dist/components/harness-availability.d.ts.map +1 -0
  59. package/dist/components/harness-availability.js +16 -0
  60. package/dist/components/harness-availability.js.map +1 -0
  61. package/dist/components/harnesses-section.d.ts +4 -0
  62. package/dist/components/harnesses-section.d.ts.map +1 -0
  63. package/dist/components/harnesses-section.js +249 -0
  64. package/dist/components/harnesses-section.js.map +1 -0
  65. package/dist/components/harnesses-section.stories.d.ts +25 -0
  66. package/dist/components/harnesses-section.stories.d.ts.map +1 -0
  67. package/dist/components/harnesses-section.stories.js +138 -0
  68. package/dist/components/harnesses-section.stories.js.map +1 -0
  69. package/dist/components/model-provider-usage-modal.d.ts +11 -0
  70. package/dist/components/model-provider-usage-modal.d.ts.map +1 -0
  71. package/dist/components/model-provider-usage-modal.js +349 -0
  72. package/dist/components/model-provider-usage-modal.js.map +1 -0
  73. package/dist/components/model-provider-usage-modal.stories.d.ts +21 -0
  74. package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -0
  75. package/dist/components/model-provider-usage-modal.stories.js +115 -0
  76. package/dist/components/model-provider-usage-modal.stories.js.map +1 -0
  77. package/dist/components/model-provider-usage-target.d.ts +14 -0
  78. package/dist/components/model-provider-usage-target.d.ts.map +1 -0
  79. package/dist/components/model-provider-usage-target.js +20 -0
  80. package/dist/components/model-provider-usage-target.js.map +1 -0
  81. package/dist/components/model-providers-section.d.ts +4 -0
  82. package/dist/components/model-providers-section.d.ts.map +1 -0
  83. package/dist/components/model-providers-section.js +668 -0
  84. package/dist/components/model-providers-section.js.map +1 -0
  85. package/dist/components/model-providers-section.stories.d.ts +32 -0
  86. package/dist/components/model-providers-section.stories.d.ts.map +1 -0
  87. package/dist/components/model-providers-section.stories.js +442 -0
  88. package/dist/components/model-providers-section.stories.js.map +1 -0
  89. package/dist/components/provider-search.d.ts +4 -0
  90. package/dist/components/provider-search.d.ts.map +1 -0
  91. package/dist/components/provider-search.js +13 -0
  92. package/dist/components/provider-search.js.map +1 -0
  93. package/dist/components/supported-model-provider-catalog-entry.d.ts +8 -0
  94. package/dist/components/supported-model-provider-catalog-entry.d.ts.map +1 -0
  95. package/dist/components/supported-model-provider-catalog-entry.js +9 -0
  96. package/dist/components/supported-model-provider-catalog-entry.js.map +1 -0
  97. package/dist/components/test-and-save-form.d.ts +10 -0
  98. package/dist/components/test-and-save-form.d.ts.map +1 -0
  99. package/dist/components/test-and-save-form.js +138 -0
  100. package/dist/components/test-and-save-form.js.map +1 -0
  101. package/dist/feature.d.ts +9 -0
  102. package/dist/feature.d.ts.map +1 -0
  103. package/dist/feature.js +13 -0
  104. package/dist/feature.js.map +1 -0
  105. package/dist/hooks/api/model-providers.d.ts +334 -0
  106. package/dist/hooks/api/model-providers.d.ts.map +1 -0
  107. package/dist/hooks/api/model-providers.js +190 -0
  108. package/dist/hooks/api/model-providers.js.map +1 -0
  109. package/dist/index.d.ts +9 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +10 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/pages/model-provider-onboarding-page.d.ts +6 -0
  114. package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -0
  115. package/dist/pages/model-provider-onboarding-page.js +295 -0
  116. package/dist/pages/model-provider-onboarding-page.js.map +1 -0
  117. package/dist/pages/model-provider-onboarding-page.stories.d.ts +27 -0
  118. package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -0
  119. package/dist/pages/model-provider-onboarding-page.stories.js +368 -0
  120. package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -0
  121. package/dist/routes/model-provider.d.ts +5 -0
  122. package/dist/routes/model-provider.d.ts.map +1 -0
  123. package/dist/routes/model-provider.js +28 -0
  124. package/dist/routes/model-provider.js.map +1 -0
  125. package/dist/state/model-provider-onboarding.d.ts +3 -0
  126. package/dist/state/model-provider-onboarding.d.ts.map +1 -0
  127. package/dist/state/model-provider-onboarding.js +28 -0
  128. package/dist/state/model-provider-onboarding.js.map +1 -0
  129. package/dist/tsconfig.test.tsbuildinfo +1 -0
  130. package/package.json +114 -0
  131. package/src/components/add-custom-provider-card.tsx +32 -0
  132. package/src/components/agent-workflow-example.test.ts +74 -0
  133. package/src/components/agent-workflow-example.ts +55 -0
  134. package/src/components/available-provider-card.tsx +39 -0
  135. package/src/components/available-providers-grid.test.tsx +55 -0
  136. package/src/components/available-providers-grid.tsx +106 -0
  137. package/src/components/change-default-model-form.tsx +104 -0
  138. package/src/components/custom-model-provider-api-options.test.ts +18 -0
  139. package/src/components/custom-model-provider-api-options.ts +12 -0
  140. package/src/components/custom-model-provider-form.stories.tsx +91 -0
  141. package/src/components/custom-model-provider-form.tsx +710 -0
  142. package/src/components/custom-model-provider-payload.test.ts +170 -0
  143. package/src/components/custom-model-provider-payload.ts +264 -0
  144. package/src/components/custom-model-provider-slug.test.ts +40 -0
  145. package/src/components/custom-model-provider-slug.ts +23 -0
  146. package/src/components/default-model-field.tsx +85 -0
  147. package/src/components/form-errors.test.ts +159 -0
  148. package/src/components/form-errors.ts +102 -0
  149. package/src/components/harness-availability.test.ts +45 -0
  150. package/src/components/harness-availability.ts +35 -0
  151. package/src/components/harnesses-section.stories.tsx +129 -0
  152. package/src/components/harnesses-section.test.tsx +188 -0
  153. package/src/components/harnesses-section.tsx +245 -0
  154. package/src/components/model-provider-usage-modal.stories.tsx +106 -0
  155. package/src/components/model-provider-usage-modal.test.tsx +210 -0
  156. package/src/components/model-provider-usage-modal.tsx +326 -0
  157. package/src/components/model-provider-usage-target.ts +36 -0
  158. package/src/components/model-providers-section.stories.tsx +345 -0
  159. package/src/components/model-providers-section.test.tsx +831 -0
  160. package/src/components/model-providers-section.tsx +645 -0
  161. package/src/components/provider-search.test.ts +45 -0
  162. package/src/components/provider-search.ts +18 -0
  163. package/src/components/supported-model-provider-catalog-entry.ts +19 -0
  164. package/src/components/test-and-save-form.tsx +166 -0
  165. package/src/feature.ts +12 -0
  166. package/src/hooks/api/model-providers.test.ts +167 -0
  167. package/src/hooks/api/model-providers.ts +281 -0
  168. package/src/index.ts +8 -0
  169. package/src/pages/model-provider-onboarding-page.stories.tsx +284 -0
  170. package/src/pages/model-provider-onboarding-page.test.tsx +368 -0
  171. package/src/pages/model-provider-onboarding-page.tsx +290 -0
  172. package/src/routes/model-provider.tsx +20 -0
  173. package/src/state/model-provider-onboarding.test.ts +55 -0
  174. package/src/state/model-provider-onboarding.ts +34 -0
  175. package/test/fixtures/model-providers.ts +111 -0
  176. package/test/setup.ts +3 -0
  177. package/tsconfig.build.json +9 -0
  178. package/tsconfig.build.tsbuildinfo +1 -0
  179. package/tsconfig.json +3 -0
  180. package/tsconfig.test.json +8 -0
  181. package/vercel.json +8 -0
  182. package/vitest.config.ts +72 -0
@@ -0,0 +1,284 @@
1
+ import type {ModelProviderCatalogEntryDto, ModelProviderConfigDto} from '@shipfox/api-agent-dto';
2
+ import {configureApiClient} from '@shipfox/client-api';
3
+ import {Toaster} from '@shipfox/react-ui/toast';
4
+ import type {Meta, StoryObj} from '@storybook/react';
5
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
6
+ import {useMemo} from 'react';
7
+ import {screen, userEvent, within} from 'storybook/test';
8
+ import {ModelProviderOnboardingPage} from './model-provider-onboarding-page.js';
9
+
10
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
11
+
12
+ type Scenario = 'available' | 'loading' | 'catalog-error' | 'no-providers' | 'long-names';
13
+
14
+ interface ModelProviderOnboardingStoryProps {
15
+ scenario: Scenario;
16
+ }
17
+
18
+ const CATALOG: ModelProviderCatalogEntryDto[] = [
19
+ providerEntry({
20
+ id: 'anthropic',
21
+ label: 'Anthropic',
22
+ default_model: 'claude-opus-4-8',
23
+ models: [
24
+ {id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
25
+ {id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5'},
26
+ ],
27
+ }),
28
+ providerEntry({
29
+ id: 'openai',
30
+ label: 'OpenAI',
31
+ default_model: 'gpt-5.5-pro',
32
+ models: [
33
+ {id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'},
34
+ {id: 'gpt-5-mini', label: 'GPT-5 Mini'},
35
+ ],
36
+ }),
37
+ providerEntry({
38
+ id: 'xai',
39
+ label: 'xAI',
40
+ default_model: 'grok-code-fast-1',
41
+ models: [{id: 'grok-code-fast-1', label: 'Grok Code Fast 1'}],
42
+ }),
43
+ providerEntry({
44
+ id: 'openrouter',
45
+ label: 'OpenRouter',
46
+ default_model: 'openrouter/auto',
47
+ models: [{id: 'openrouter/auto', label: 'OpenRouter Auto'}],
48
+ }),
49
+ providerEntry({
50
+ id: 'google',
51
+ label: 'Google Gemini',
52
+ default_model: 'gemini-3-pro',
53
+ models: [{id: 'gemini-3-pro', label: 'Gemini 3 Pro'}],
54
+ }),
55
+ providerEntry({
56
+ id: 'mistral',
57
+ label: 'Mistral AI',
58
+ default_model: 'codestral-latest',
59
+ models: [{id: 'codestral-latest', label: 'Codestral Latest'}],
60
+ }),
61
+ providerEntry({
62
+ id: 'groq',
63
+ label: 'Groq',
64
+ default_model: 'llama-4-fast',
65
+ models: [{id: 'llama-4-fast', label: 'Llama 4 Fast'}],
66
+ }),
67
+ providerEntry({
68
+ id: 'deepseek',
69
+ label: 'DeepSeek',
70
+ default_model: 'deepseek-coder',
71
+ models: [{id: 'deepseek-coder', label: 'DeepSeek Coder'}],
72
+ }),
73
+ providerEntry({
74
+ id: 'moonshotai',
75
+ label: 'Moonshot AI',
76
+ default_model: 'kimi-k2',
77
+ models: [{id: 'kimi-k2', label: 'Kimi K2'}],
78
+ }),
79
+ providerEntry({
80
+ id: 'azure-openai-responses',
81
+ label: 'Azure OpenAI Responses',
82
+ default_model: 'gpt-5.5-pro',
83
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
84
+ }),
85
+ providerEntry({
86
+ id: 'amazon-bedrock',
87
+ label: 'Amazon Bedrock',
88
+ support_status: 'unsupported',
89
+ credential_fields: [],
90
+ unsupported_reason: 'AWS cloud credentials are not supported yet.',
91
+ models: [],
92
+ }),
93
+ ];
94
+
95
+ function ModelProviderOnboardingStory({scenario}: ModelProviderOnboardingStoryProps) {
96
+ configureApiClient({
97
+ baseUrl: 'https://api.example.test',
98
+ fetchImpl: fetchForScenario(scenario),
99
+ });
100
+
101
+ const queryClient = useMemo(
102
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
103
+ [],
104
+ );
105
+
106
+ return (
107
+ <QueryClientProvider client={queryClient}>
108
+ <div className="bg-background-neutral-background px-24 py-32">
109
+ <ModelProviderOnboardingPage
110
+ workspaceId={WORKSPACE_ID}
111
+ onSkip={() => undefined}
112
+ onConfigured={() => undefined}
113
+ />
114
+ </div>
115
+ <Toaster />
116
+ </QueryClientProvider>
117
+ );
118
+ }
119
+
120
+ const meta = {
121
+ title: 'Agent/ProviderOnboarding',
122
+ component: ModelProviderOnboardingStory,
123
+ parameters: {layout: 'fullscreen'},
124
+ args: {scenario: 'available'},
125
+ } satisfies Meta<typeof ModelProviderOnboardingStory>;
126
+
127
+ export default meta;
128
+ type Story = StoryObj<typeof meta>;
129
+
130
+ export const Playground: Story = {};
131
+
132
+ export const Loading: Story = {
133
+ args: {scenario: 'loading'},
134
+ play: async ({canvasElement}) => {
135
+ const canvas = within(canvasElement);
136
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
137
+ },
138
+ };
139
+
140
+ export const CatalogError: Story = {
141
+ args: {scenario: 'catalog-error'},
142
+ play: async ({canvasElement}) => {
143
+ const canvas = within(canvasElement);
144
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
145
+ },
146
+ };
147
+
148
+ export const NoProvidersAvailable: Story = {
149
+ args: {scenario: 'no-providers'},
150
+ play: async ({canvasElement}) => {
151
+ const canvas = within(canvasElement);
152
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
153
+ },
154
+ };
155
+
156
+ export const LongNames: Story = {
157
+ args: {scenario: 'long-names'},
158
+ play: async ({canvasElement}) => {
159
+ const canvas = within(canvasElement);
160
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
161
+ },
162
+ };
163
+
164
+ export const FilteredProviders: Story = {
165
+ args: {scenario: 'available'},
166
+ play: async ({canvasElement}) => {
167
+ const canvas = within(canvasElement);
168
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
169
+ await userEvent.type(
170
+ await canvas.findByRole('searchbox', {name: 'Search providers'}),
171
+ 'openrouter',
172
+ );
173
+ await canvas.findByRole('button', {name: 'Configure OpenRouter'});
174
+ },
175
+ };
176
+
177
+ export const NoMatchingProviders: Story = {
178
+ args: {scenario: 'available'},
179
+ play: async ({canvasElement}) => {
180
+ const canvas = within(canvasElement);
181
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
182
+ await userEvent.type(
183
+ await canvas.findByRole('searchbox', {name: 'Search providers'}),
184
+ 'not-a-provider',
185
+ );
186
+ await canvas.findByRole('button', {name: 'Clear search'});
187
+ },
188
+ };
189
+
190
+ export const ConfigureModalOpen: Story = {
191
+ args: {scenario: 'available'},
192
+ play: async ({canvasElement}) => {
193
+ const canvas = within(canvasElement);
194
+ await userEvent.click(canvas.getByRole('button', {name: 'Choose pi'}));
195
+ await userEvent.click(await canvas.findByRole('button', {name: 'Configure Anthropic'}));
196
+ await screen.findByLabelText('API key');
197
+ },
198
+ };
199
+
200
+ function fetchForScenario(scenario: Scenario): typeof fetch {
201
+ return (input) => {
202
+ const url = requestUrl(input);
203
+ const request = input instanceof Request ? input : undefined;
204
+ if (scenario === 'loading') return new Promise<Response>(() => undefined);
205
+ if (url.pathname.endsWith('/agent/model-provider-catalog')) {
206
+ if (scenario === 'catalog-error') return Promise.resolve(errorResponse());
207
+ return Promise.resolve(jsonResponse({providers: catalogForScenario(scenario)}));
208
+ }
209
+ if (request?.method === 'PUT' && url.pathname.includes('/agent/model-providers/')) {
210
+ return Promise.resolve(
211
+ jsonResponse(
212
+ providerConfig({
213
+ provider_id: url.pathname.endsWith('/openai') ? 'openai' : 'anthropic',
214
+ }),
215
+ ),
216
+ );
217
+ }
218
+ return Promise.resolve(jsonResponse({}, {status: 404}));
219
+ };
220
+ }
221
+
222
+ function catalogForScenario(scenario: Scenario): ModelProviderCatalogEntryDto[] {
223
+ if (scenario === 'no-providers') return [];
224
+ if (scenario !== 'long-names') return CATALOG;
225
+ return [
226
+ providerEntry({
227
+ id: 'anthropic',
228
+ label: 'Anthropic Enterprise Production Claude Provider With Long Label',
229
+ default_model: 'claude-opus-4-8',
230
+ }),
231
+ providerEntry({
232
+ id: 'openai',
233
+ label: 'OpenAI Platform Shared Workspace Credentials With Long Label',
234
+ default_model: 'gpt-5.5-pro',
235
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
236
+ }),
237
+ ];
238
+ }
239
+
240
+ function providerEntry(
241
+ overrides: Partial<ModelProviderCatalogEntryDto> = {},
242
+ ): ModelProviderCatalogEntryDto {
243
+ return {
244
+ id: 'anthropic',
245
+ label: 'Anthropic',
246
+ support_status: 'supported',
247
+ default_model: null,
248
+ credential_fields: [{key: 'api_key', label: 'API key', secret: true}],
249
+ unsupported_reason: null,
250
+ models: [{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'}],
251
+ ...overrides,
252
+ };
253
+ }
254
+
255
+ function providerConfig(
256
+ overrides: Partial<Omit<ModelProviderConfigDto, 'kind'>> = {},
257
+ ): ModelProviderConfigDto {
258
+ return {
259
+ kind: 'builtin',
260
+ provider_id: 'anthropic',
261
+ default_model: null,
262
+ created_at: '2026-05-08T00:00:00.000Z',
263
+ updated_at: '2026-05-08T00:00:00.000Z',
264
+ ...overrides,
265
+ };
266
+ }
267
+
268
+ function requestUrl(input: RequestInfo | URL): URL {
269
+ if (typeof input === 'string') return new URL(input);
270
+ if (input instanceof URL) return input;
271
+ return new URL(input.url);
272
+ }
273
+
274
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
275
+ return new Response(JSON.stringify(body), {
276
+ status: 200,
277
+ headers: {'content-type': 'application/json'},
278
+ ...init,
279
+ });
280
+ }
281
+
282
+ function errorResponse() {
283
+ return jsonResponse({code: 'server-error'}, {status: 500, statusText: 'Server error'});
284
+ }
@@ -0,0 +1,368 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {Toaster} from '@shipfox/react-ui/toast';
3
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
+ import {render, screen, waitFor, within} from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import type {ReactElement} from 'react';
7
+ import {isModelProviderOnboardingDismissed} from '#state/model-provider-onboarding.js';
8
+ import {
9
+ AGENT_TEST_WORKSPACE_ID,
10
+ modelProviderCatalogResponse,
11
+ modelProviderConfig,
12
+ modelProviderEntry,
13
+ testModelProviderEntries,
14
+ } from '#test/fixtures/model-providers.js';
15
+ import {ModelProviderOnboardingPage} from './model-provider-onboarding-page.js';
16
+
17
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
18
+ return new Response(JSON.stringify(body), {
19
+ status: 200,
20
+ headers: {'content-type': 'application/json'},
21
+ ...init,
22
+ });
23
+ }
24
+
25
+ function requestPath(input: RequestInfo | URL): string {
26
+ return new URL((input as Request).url).pathname;
27
+ }
28
+
29
+ function renderOnboarding(element: ReactElement) {
30
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
31
+
32
+ return render(
33
+ <QueryClientProvider client={queryClient}>
34
+ {element}
35
+ <Toaster />
36
+ </QueryClientProvider>,
37
+ );
38
+ }
39
+
40
+ describe('ModelProviderOnboardingPage', () => {
41
+ beforeEach(() => {
42
+ window.localStorage.clear();
43
+ });
44
+
45
+ test('skips setup, records the dismissed flag, and does not save a provider or harness', async () => {
46
+ const user = userEvent.setup();
47
+ const onSkip = vi.fn();
48
+ const onConfigured = vi.fn();
49
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(modelProviderCatalogResponse()));
50
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
51
+
52
+ renderOnboarding(
53
+ <ModelProviderOnboardingPage
54
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
55
+ onSkip={onSkip}
56
+ onConfigured={onConfigured}
57
+ />,
58
+ );
59
+
60
+ await user.click(screen.getByRole('button', {name: 'Skip for now'}));
61
+
62
+ expect(onSkip).toHaveBeenCalledTimes(1);
63
+ expect(onConfigured).not.toHaveBeenCalled();
64
+ expect(isModelProviderOnboardingDismissed(AGENT_TEST_WORKSPACE_ID)).toBe(true);
65
+ expect(fetchImpl.mock.calls.some(([input]) => (input as Request).method === 'PUT')).toBe(false);
66
+ });
67
+
68
+ test('places skip before the harness choices', () => {
69
+ configureApiClient({
70
+ baseUrl: 'https://api.example.test',
71
+ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(modelProviderCatalogResponse())),
72
+ });
73
+
74
+ renderOnboarding(
75
+ <ModelProviderOnboardingPage
76
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
77
+ onSkip={vi.fn()}
78
+ onConfigured={vi.fn()}
79
+ />,
80
+ );
81
+
82
+ const skip = screen.getByRole('button', {name: 'Skip for now'});
83
+ const harness = screen.getByRole('button', {name: 'Choose pi'});
84
+
85
+ expect(skip.compareDocumentPosition(harness)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
86
+ });
87
+
88
+ test('shows harnesses first, filters providers by harness, and supports going back', async () => {
89
+ const user = userEvent.setup();
90
+ configureApiClient({
91
+ baseUrl: 'https://api.example.test',
92
+ fetchImpl: vi.fn().mockResolvedValue(
93
+ jsonResponse(
94
+ modelProviderCatalogResponse([
95
+ modelProviderEntry(),
96
+ modelProviderEntry({
97
+ id: 'openai',
98
+ label: 'OpenAI',
99
+ default_model: 'gpt-5.5-pro',
100
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
101
+ }),
102
+ ]),
103
+ ),
104
+ ),
105
+ });
106
+
107
+ renderOnboarding(
108
+ <ModelProviderOnboardingPage
109
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
110
+ onSkip={vi.fn()}
111
+ onConfigured={vi.fn()}
112
+ />,
113
+ );
114
+
115
+ expect(screen.getByRole('heading', {name: 'Choose agent harness'})).toBeVisible();
116
+ expect(screen.getByRole('button', {name: 'Choose pi'})).toBeVisible();
117
+ expect(screen.getByText('Works with 30+ model providers')).toBeVisible();
118
+ expect(screen.getByRole('button', {name: 'Choose Claude'})).toBeVisible();
119
+ expect(screen.getByText('Runs on your Anthropic API key')).toBeVisible();
120
+
121
+ await user.click(screen.getByRole('button', {name: 'Choose Claude'}));
122
+
123
+ expect(await screen.findByRole('button', {name: 'Configure Anthropic'})).toBeVisible();
124
+ expect(screen.queryByRole('button', {name: 'Configure OpenAI'})).not.toBeInTheDocument();
125
+
126
+ await user.click(screen.getByRole('button', {name: 'Back'}));
127
+ await user.click(screen.getByRole('button', {name: 'Choose pi'}));
128
+
129
+ expect(await screen.findByRole('button', {name: 'Configure Anthropic'})).toBeVisible();
130
+ expect(screen.getByRole('button', {name: 'Configure OpenAI'})).toBeVisible();
131
+ });
132
+
133
+ test('filters supported providers and clears a no-match search after choosing pi', async () => {
134
+ const user = userEvent.setup();
135
+ configureApiClient({
136
+ baseUrl: 'https://api.example.test',
137
+ fetchImpl: vi
138
+ .fn()
139
+ .mockResolvedValue(jsonResponse(modelProviderCatalogResponse(testModelProviderEntries(9)))),
140
+ });
141
+
142
+ renderOnboarding(
143
+ <ModelProviderOnboardingPage
144
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
145
+ onSkip={vi.fn()}
146
+ onConfigured={vi.fn()}
147
+ />,
148
+ );
149
+ await user.click(screen.getByRole('button', {name: 'Choose pi'}));
150
+ const search = await screen.findByRole('searchbox', {name: 'Search providers'});
151
+
152
+ await user.type(search, 'provider 6');
153
+
154
+ expect(screen.getByRole('button', {name: 'Configure Provider 6'})).toBeVisible();
155
+ expect(screen.queryByRole('button', {name: 'Configure Provider 1'})).not.toBeInTheDocument();
156
+ await user.clear(search);
157
+ await user.type(search, 'missing');
158
+ expect(screen.getByText('No providers match "missing"')).toBeVisible();
159
+ await user.click(screen.getByRole('button', {name: 'Clear search'}));
160
+
161
+ expect(screen.getByRole('button', {name: 'Configure Provider 1'})).toBeVisible();
162
+ await waitFor(() => expect(search).toHaveFocus());
163
+ });
164
+
165
+ test('skip still continues when localStorage rejects the dismissed write', async () => {
166
+ const user = userEvent.setup();
167
+ const onSkip = vi.fn();
168
+ const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
169
+ throw new Error('quota exceeded');
170
+ });
171
+ try {
172
+ configureApiClient({
173
+ baseUrl: 'https://api.example.test',
174
+ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(modelProviderCatalogResponse())),
175
+ });
176
+
177
+ renderOnboarding(
178
+ <ModelProviderOnboardingPage
179
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
180
+ onSkip={onSkip}
181
+ onConfigured={vi.fn()}
182
+ />,
183
+ );
184
+
185
+ await user.click(screen.getByRole('button', {name: 'Skip for now'}));
186
+
187
+ expect(onSkip).toHaveBeenCalledTimes(1);
188
+ } finally {
189
+ setItem.mockRestore();
190
+ }
191
+ });
192
+
193
+ test('saves a pi provider as default and skips the default harness request', async () => {
194
+ const user = userEvent.setup();
195
+ const onConfigured = vi.fn();
196
+ const requestBodies: unknown[] = [];
197
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
198
+ const request = input as Request;
199
+ if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
200
+ return jsonResponse(
201
+ modelProviderCatalogResponse([
202
+ modelProviderEntry({
203
+ id: 'openai',
204
+ label: 'OpenAI',
205
+ default_model: 'gpt-5.5-pro',
206
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
207
+ }),
208
+ ]),
209
+ );
210
+ }
211
+ if (request.method === 'PUT') {
212
+ requestBodies.push(await request.clone().json());
213
+ return jsonResponse(modelProviderConfig({provider_id: 'openai', default_model: null}));
214
+ }
215
+ return jsonResponse({}, {status: 404});
216
+ });
217
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
218
+
219
+ renderOnboarding(
220
+ <ModelProviderOnboardingPage
221
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
222
+ onSkip={vi.fn()}
223
+ onConfigured={onConfigured}
224
+ />,
225
+ );
226
+
227
+ await user.click(screen.getByRole('button', {name: 'Choose pi'}));
228
+ await user.click(await screen.findByRole('button', {name: 'Configure OpenAI'}));
229
+ await user.type(await screen.findByLabelText('API key'), 'sk-proj-secret');
230
+ await user.click(screen.getByRole('button', {name: 'Test & save'}));
231
+
232
+ await waitFor(() =>
233
+ expect(requestBodies).toEqual([
234
+ {
235
+ default_model: null,
236
+ credentials: {api_key: 'sk-proj-secret'},
237
+ set_as_default: true,
238
+ },
239
+ ]),
240
+ );
241
+ expect(onConfigured).toHaveBeenCalledTimes(1);
242
+ expect(
243
+ fetchImpl.mock.calls.some(([input]) => requestPath(input).endsWith('/agent/default-harness')),
244
+ ).toBe(false);
245
+ });
246
+
247
+ test('saves the chosen Claude harness after the provider is persisted', async () => {
248
+ const user = userEvent.setup();
249
+ const onConfigured = vi.fn();
250
+ const requests: Array<{path: string; body: unknown}> = [];
251
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
252
+ const request = input as Request;
253
+ const path = requestPath(input);
254
+ if (path.endsWith('/agent/model-provider-catalog')) {
255
+ return jsonResponse(modelProviderCatalogResponse());
256
+ }
257
+ if (request.method === 'PUT') {
258
+ const body = await request.clone().json();
259
+ requests.push({path, body});
260
+ if (path.endsWith('/agent/default-harness')) {
261
+ return jsonResponse({default_harness_id: 'claude'});
262
+ }
263
+ return jsonResponse(modelProviderConfig());
264
+ }
265
+ return jsonResponse({}, {status: 404});
266
+ });
267
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
268
+
269
+ renderOnboarding(
270
+ <ModelProviderOnboardingPage
271
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
272
+ onSkip={vi.fn()}
273
+ onConfigured={onConfigured}
274
+ />,
275
+ );
276
+
277
+ await user.click(screen.getByRole('button', {name: 'Choose Claude'}));
278
+ await user.click(await screen.findByRole('button', {name: 'Configure Anthropic'}));
279
+ await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
280
+ await user.click(screen.getByRole('button', {name: 'Test & save'}));
281
+
282
+ await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1));
283
+ expect(requests).toEqual([
284
+ {
285
+ path: `/workspaces/${AGENT_TEST_WORKSPACE_ID}/agent/model-providers/anthropic`,
286
+ body: {
287
+ default_model: null,
288
+ credentials: {api_key: 'sk-ant-secret'},
289
+ set_as_default: true,
290
+ },
291
+ },
292
+ {
293
+ path: `/workspaces/${AGENT_TEST_WORKSPACE_ID}/agent/default-harness`,
294
+ body: {harness_id: 'claude'},
295
+ },
296
+ ]);
297
+ });
298
+
299
+ test('keeps the user on the page when saving the default harness fails', async () => {
300
+ const user = userEvent.setup();
301
+ const onConfigured = vi.fn();
302
+ let harnessAttempts = 0;
303
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
304
+ const request = input as Request;
305
+ const path = requestPath(input);
306
+ if (path.endsWith('/agent/model-provider-catalog')) {
307
+ return Promise.resolve(jsonResponse(modelProviderCatalogResponse()));
308
+ }
309
+ if (request.method === 'PUT' && path.endsWith('/agent/default-harness')) {
310
+ harnessAttempts += 1;
311
+ if (harnessAttempts === 1) {
312
+ return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
313
+ }
314
+ return Promise.resolve(jsonResponse({default_harness_id: 'claude'}));
315
+ }
316
+ if (request.method === 'PUT') {
317
+ return Promise.resolve(jsonResponse(modelProviderConfig()));
318
+ }
319
+ return Promise.resolve(jsonResponse({}, {status: 404}));
320
+ });
321
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
322
+
323
+ renderOnboarding(
324
+ <ModelProviderOnboardingPage
325
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
326
+ onSkip={vi.fn()}
327
+ onConfigured={onConfigured}
328
+ />,
329
+ );
330
+
331
+ await user.click(screen.getByRole('button', {name: 'Choose Claude'}));
332
+ await user.click(await screen.findByRole('button', {name: 'Configure Anthropic'}));
333
+ await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
334
+ await user.click(screen.getByRole('button', {name: 'Test & save'}));
335
+
336
+ await waitFor(() => expect(harnessAttempts).toBe(1));
337
+ expect(await screen.findByText('Could not save default harness')).toBeVisible();
338
+ expect(screen.getByRole('dialog', {name: 'Configure Anthropic'})).toBeVisible();
339
+ expect(onConfigured).not.toHaveBeenCalled();
340
+
341
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'Test & save'}));
342
+
343
+ await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1));
344
+ });
345
+
346
+ test('keeps skip available when the catalog fails to load after selecting a harness', async () => {
347
+ const user = userEvent.setup();
348
+ const onSkip = vi.fn();
349
+ configureApiClient({
350
+ baseUrl: 'https://api.example.test',
351
+ fetchImpl: vi.fn().mockResolvedValue(jsonResponse({code: 'server-error'}, {status: 500})),
352
+ });
353
+
354
+ renderOnboarding(
355
+ <ModelProviderOnboardingPage
356
+ workspaceId={AGENT_TEST_WORKSPACE_ID}
357
+ onSkip={onSkip}
358
+ onConfigured={vi.fn()}
359
+ />,
360
+ );
361
+
362
+ await user.click(screen.getByRole('button', {name: 'Choose pi'}));
363
+ expect(await screen.findByText("Couldn't load model provider catalog")).toBeInTheDocument();
364
+ await user.click(screen.getByRole('button', {name: 'Skip for now'}));
365
+
366
+ expect(onSkip).toHaveBeenCalledTimes(1);
367
+ });
368
+ });