@shipfox/client-agent 22.0.1 → 22.0.3
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.
- package/.storybook/preview.tsx +2 -2
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/components/form-errors.d.ts.map +1 -1
- package/dist/components/form-errors.js +3 -0
- package/dist/components/form-errors.js.map +1 -1
- package/dist/components/harness-availability.d.ts +2 -2
- package/dist/components/harness-availability.d.ts.map +1 -1
- package/dist/components/harness-availability.js +4 -3
- package/dist/components/harness-availability.js.map +1 -1
- package/dist/components/harnesses-section.d.ts.map +1 -1
- package/dist/components/harnesses-section.js +4 -2
- package/dist/components/harnesses-section.js.map +1 -1
- package/dist/components/model-provider-usage-modal.d.ts.map +1 -1
- package/dist/components/model-provider-usage-modal.js +23 -5
- package/dist/components/model-provider-usage-modal.js.map +1 -1
- package/dist/components/model-provider-usage-target.d.ts +6 -6
- package/dist/components/model-provider-usage-target.d.ts.map +1 -1
- package/dist/components/model-provider-usage-target.js +3 -1
- package/dist/components/model-provider-usage-target.js.map +1 -1
- package/dist/components/model-providers-section.d.ts.map +1 -1
- package/dist/components/model-providers-section.js +277 -170
- package/dist/components/model-providers-section.js.map +1 -1
- package/dist/components/model-providers-section.stories.d.ts +2 -1
- package/dist/components/model-providers-section.stories.d.ts.map +1 -1
- package/dist/components/model-providers-section.stories.js +45 -1
- package/dist/components/model-providers-section.stories.js.map +1 -1
- package/dist/core/harness-policy.d.ts +5 -2
- package/dist/core/harness-policy.d.ts.map +1 -1
- package/dist/core/harness-policy.js +7 -1
- package/dist/core/harness-policy.js.map +1 -1
- package/dist/core/models.d.ts +3 -0
- package/dist/core/models.d.ts.map +1 -1
- package/dist/core/models.js.map +1 -1
- package/dist/core/provider-policy.d.ts +3 -1
- package/dist/core/provider-policy.d.ts.map +1 -1
- package/dist/core/provider-policy.js +7 -0
- package/dist/core/provider-policy.js.map +1 -1
- package/dist/hooks/api/model-provider-mapper.d.ts.map +1 -1
- package/dist/hooks/api/model-provider-mapper.js +5 -1
- package/dist/hooks/api/model-provider-mapper.js.map +1 -1
- package/dist/pages/model-provider-onboarding-page.d.ts +1 -1
- package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -1
- package/dist/pages/model-provider-onboarding-page.js +58 -44
- package/dist/pages/model-provider-onboarding-page.js.map +1 -1
- package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -1
- package/dist/pages/model-provider-onboarding-page.stories.js +5 -17
- package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/components/form-errors.test.ts +9 -0
- package/src/components/form-errors.ts +3 -0
- package/src/components/harness-availability.test.ts +49 -2
- package/src/components/harness-availability.ts +18 -3
- package/src/components/harnesses-section.test.tsx +51 -9
- package/src/components/harnesses-section.tsx +4 -1
- package/src/components/model-provider-usage-modal.test.tsx +23 -0
- package/src/components/model-provider-usage-modal.tsx +32 -6
- package/src/components/model-provider-usage-target.ts +9 -3
- package/src/components/model-providers-section.stories.tsx +33 -1
- package/src/components/model-providers-section.test.tsx +41 -0
- package/src/components/model-providers-section.tsx +264 -155
- package/src/core/harness-policy.ts +21 -1
- package/src/core/models.ts +3 -0
- package/src/core/provider-policy.ts +12 -0
- package/src/hooks/api/model-provider-mapper.test.ts +18 -0
- package/src/hooks/api/model-provider-mapper.ts +6 -1
- package/src/hooks/api/model-providers.test.ts +11 -0
- package/src/pages/model-provider-onboarding-page.stories.tsx +10 -14
- package/src/pages/model-provider-onboarding-page.test.tsx +133 -24
- package/src/pages/model-provider-onboarding-page.tsx +47 -34
- package/test/fixtures/model-providers.ts +22 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -131,25 +131,17 @@ export const Playground: Story = {};
|
|
|
131
131
|
|
|
132
132
|
export const Loading: Story = {
|
|
133
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
134
|
};
|
|
139
135
|
|
|
140
136
|
export const CatalogError: Story = {
|
|
141
137
|
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
138
|
};
|
|
147
139
|
|
|
148
140
|
export const NoProvidersAvailable: Story = {
|
|
149
141
|
args: {scenario: 'no-providers'},
|
|
150
142
|
play: async ({canvasElement}) => {
|
|
151
143
|
const canvas = within(canvasElement);
|
|
152
|
-
await userEvent.click(canvas.
|
|
144
|
+
await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
|
|
153
145
|
},
|
|
154
146
|
};
|
|
155
147
|
|
|
@@ -157,7 +149,7 @@ export const LongNames: Story = {
|
|
|
157
149
|
args: {scenario: 'long-names'},
|
|
158
150
|
play: async ({canvasElement}) => {
|
|
159
151
|
const canvas = within(canvasElement);
|
|
160
|
-
await userEvent.click(canvas.
|
|
152
|
+
await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
|
|
161
153
|
},
|
|
162
154
|
};
|
|
163
155
|
|
|
@@ -165,7 +157,7 @@ export const FilteredProviders: Story = {
|
|
|
165
157
|
args: {scenario: 'available'},
|
|
166
158
|
play: async ({canvasElement}) => {
|
|
167
159
|
const canvas = within(canvasElement);
|
|
168
|
-
await userEvent.click(canvas.
|
|
160
|
+
await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
|
|
169
161
|
await userEvent.type(
|
|
170
162
|
await canvas.findByRole('searchbox', {name: 'Search providers'}),
|
|
171
163
|
'openrouter',
|
|
@@ -178,7 +170,7 @@ export const NoMatchingProviders: Story = {
|
|
|
178
170
|
args: {scenario: 'available'},
|
|
179
171
|
play: async ({canvasElement}) => {
|
|
180
172
|
const canvas = within(canvasElement);
|
|
181
|
-
await userEvent.click(canvas.
|
|
173
|
+
await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
|
|
182
174
|
await userEvent.type(
|
|
183
175
|
await canvas.findByRole('searchbox', {name: 'Search providers'}),
|
|
184
176
|
'not-a-provider',
|
|
@@ -191,7 +183,7 @@ export const ConfigureModalOpen: Story = {
|
|
|
191
183
|
args: {scenario: 'available'},
|
|
192
184
|
play: async ({canvasElement}) => {
|
|
193
185
|
const canvas = within(canvasElement);
|
|
194
|
-
await userEvent.click(canvas.
|
|
186
|
+
await userEvent.click(await canvas.findByRole('button', {name: 'Choose pi'}));
|
|
195
187
|
await userEvent.click(await canvas.findByRole('button', {name: 'Configure Anthropic'}));
|
|
196
188
|
await screen.findByLabelText('API key');
|
|
197
189
|
},
|
|
@@ -204,7 +196,11 @@ function fetchForScenario(scenario: Scenario): typeof fetch {
|
|
|
204
196
|
if (scenario === 'loading') return new Promise<Response>(() => undefined);
|
|
205
197
|
if (url.pathname.endsWith('/agent/model-provider-catalog')) {
|
|
206
198
|
if (scenario === 'catalog-error') return Promise.resolve(errorResponse());
|
|
207
|
-
return Promise.resolve(
|
|
199
|
+
return Promise.resolve(
|
|
200
|
+
jsonResponse({
|
|
201
|
+
providers: catalogForScenario(scenario),
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
208
204
|
}
|
|
209
205
|
if (request?.method === 'PUT' && url.pathname.includes('/agent/model-providers/')) {
|
|
210
206
|
return Promise.resolve(
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import {configureApiClient} from '@shipfox/client-api';
|
|
2
2
|
import {Toaster} from '@shipfox/react-ui/toast';
|
|
3
3
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
4
|
-
import {render, screen, waitFor, within} from '@testing-library/react';
|
|
4
|
+
import {act, render, screen, waitFor, within} from '@testing-library/react';
|
|
5
5
|
import userEvent from '@testing-library/user-event';
|
|
6
6
|
import type {ReactElement} from 'react';
|
|
7
|
+
import {modelProviderQueryKeys} from '#hooks/api/model-providers.js';
|
|
7
8
|
import {isModelProviderOnboardingDismissed} from '#state/model-provider-onboarding.js';
|
|
8
9
|
import {
|
|
9
10
|
AGENT_TEST_WORKSPACE_ID,
|
|
11
|
+
managedModelProviderEntry,
|
|
10
12
|
modelProviderCatalogResponse,
|
|
11
13
|
modelProviderConfig,
|
|
12
14
|
modelProviderEntry,
|
|
@@ -29,12 +31,15 @@ function requestPath(input: RequestInfo | URL): string {
|
|
|
29
31
|
function renderOnboarding(element: ReactElement) {
|
|
30
32
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
31
33
|
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
return {
|
|
35
|
+
queryClient,
|
|
36
|
+
...render(
|
|
37
|
+
<QueryClientProvider client={queryClient}>
|
|
38
|
+
{element}
|
|
39
|
+
<Toaster />
|
|
40
|
+
</QueryClientProvider>,
|
|
41
|
+
),
|
|
42
|
+
};
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
describe('ModelProviderOnboardingPage', () => {
|
|
@@ -42,6 +47,61 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
42
47
|
window.localStorage.clear();
|
|
43
48
|
});
|
|
44
49
|
|
|
50
|
+
test('waits for the catalog policy before rendering harness selection', async () => {
|
|
51
|
+
let resolveCatalog!: (response: Response) => void;
|
|
52
|
+
const catalogResponse = new Promise<Response>((resolve) => {
|
|
53
|
+
resolveCatalog = resolve;
|
|
54
|
+
});
|
|
55
|
+
configureApiClient({
|
|
56
|
+
baseUrl: 'https://api.example.test',
|
|
57
|
+
fetchImpl: vi.fn().mockReturnValue(catalogResponse),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
renderOnboarding(
|
|
61
|
+
<ModelProviderOnboardingPage
|
|
62
|
+
workspaceId={AGENT_TEST_WORKSPACE_ID}
|
|
63
|
+
onSkip={vi.fn()}
|
|
64
|
+
onConfigured={vi.fn()}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(screen.getByRole('status', {name: 'Loading model providers'})).toBeVisible();
|
|
69
|
+
expect(screen.queryByRole('button', {name: 'Choose pi'})).not.toBeInTheDocument();
|
|
70
|
+
expect(screen.getByRole('button', {name: 'Skip for now'})).toBeVisible();
|
|
71
|
+
|
|
72
|
+
resolveCatalog(jsonResponse(modelProviderCatalogResponse()));
|
|
73
|
+
|
|
74
|
+
expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
|
|
75
|
+
await waitFor(() =>
|
|
76
|
+
expect(screen.getByRole('heading', {name: 'Choose agent harness'})).toHaveFocus(),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('continues directly when workspace providers are managed by the instance', async () => {
|
|
81
|
+
const onConfigured = vi.fn();
|
|
82
|
+
const fetchImpl = vi
|
|
83
|
+
.fn()
|
|
84
|
+
.mockResolvedValue(
|
|
85
|
+
jsonResponse(modelProviderCatalogResponse([managedModelProviderEntry()], 'disabled')),
|
|
86
|
+
);
|
|
87
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
88
|
+
|
|
89
|
+
renderOnboarding(
|
|
90
|
+
<ModelProviderOnboardingPage
|
|
91
|
+
workspaceId={AGENT_TEST_WORKSPACE_ID}
|
|
92
|
+
onSkip={vi.fn()}
|
|
93
|
+
onConfigured={onConfigured}
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1));
|
|
98
|
+
expect(
|
|
99
|
+
screen.queryByRole('heading', {name: 'Managed inference is ready'}),
|
|
100
|
+
).not.toBeInTheDocument();
|
|
101
|
+
expect(screen.queryByRole('button', {name: 'Choose pi'})).not.toBeInTheDocument();
|
|
102
|
+
expect(screen.queryByLabelText('API key')).not.toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
45
105
|
test('skips setup, records the dismissed flag, and does not save a provider or harness', async () => {
|
|
46
106
|
const user = userEvent.setup();
|
|
47
107
|
const onSkip = vi.fn();
|
|
@@ -57,7 +117,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
57
117
|
/>,
|
|
58
118
|
);
|
|
59
119
|
|
|
60
|
-
await user.click(screen.
|
|
120
|
+
await user.click(await screen.findByRole('button', {name: 'Skip for now'}));
|
|
61
121
|
|
|
62
122
|
expect(onSkip).toHaveBeenCalledTimes(1);
|
|
63
123
|
expect(onConfigured).not.toHaveBeenCalled();
|
|
@@ -65,7 +125,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
65
125
|
expect(fetchImpl.mock.calls.some(([input]) => (input as Request).method === 'PUT')).toBe(false);
|
|
66
126
|
});
|
|
67
127
|
|
|
68
|
-
test('places skip before the harness choices', () => {
|
|
128
|
+
test('places skip before the harness choices', async () => {
|
|
69
129
|
configureApiClient({
|
|
70
130
|
baseUrl: 'https://api.example.test',
|
|
71
131
|
fetchImpl: vi.fn().mockResolvedValue(jsonResponse(modelProviderCatalogResponse())),
|
|
@@ -79,8 +139,8 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
79
139
|
/>,
|
|
80
140
|
);
|
|
81
141
|
|
|
82
|
-
const skip = screen.
|
|
83
|
-
const harness = screen.
|
|
142
|
+
const skip = await screen.findByRole('button', {name: 'Skip for now'});
|
|
143
|
+
const harness = await screen.findByRole('button', {name: 'Choose pi'});
|
|
84
144
|
|
|
85
145
|
expect(skip.compareDocumentPosition(harness)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
86
146
|
});
|
|
@@ -112,19 +172,19 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
112
172
|
/>,
|
|
113
173
|
);
|
|
114
174
|
|
|
115
|
-
expect(screen.
|
|
116
|
-
expect(screen.
|
|
175
|
+
expect(await screen.findByRole('heading', {name: 'Choose agent harness'})).toBeVisible();
|
|
176
|
+
expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
|
|
117
177
|
expect(screen.getByText('Works with 30+ model providers')).toBeVisible();
|
|
118
|
-
expect(screen.
|
|
178
|
+
expect(await screen.findByRole('button', {name: 'Choose Claude'})).toBeVisible();
|
|
119
179
|
expect(screen.getByText('Runs on your Anthropic API key')).toBeVisible();
|
|
120
180
|
|
|
121
|
-
await user.click(screen.
|
|
181
|
+
await user.click(await screen.findByRole('button', {name: 'Choose Claude'}));
|
|
122
182
|
|
|
123
183
|
expect(await screen.findByRole('button', {name: 'Configure Anthropic'})).toBeVisible();
|
|
124
184
|
expect(screen.queryByRole('button', {name: 'Configure OpenAI'})).not.toBeInTheDocument();
|
|
125
185
|
|
|
126
186
|
await user.click(screen.getByRole('button', {name: 'Back'}));
|
|
127
|
-
await user.click(screen.
|
|
187
|
+
await user.click(await screen.findByRole('button', {name: 'Choose pi'}));
|
|
128
188
|
|
|
129
189
|
expect(await screen.findByRole('button', {name: 'Configure Anthropic'})).toBeVisible();
|
|
130
190
|
expect(screen.getByRole('button', {name: 'Configure OpenAI'})).toBeVisible();
|
|
@@ -146,7 +206,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
146
206
|
onConfigured={vi.fn()}
|
|
147
207
|
/>,
|
|
148
208
|
);
|
|
149
|
-
await user.click(screen.
|
|
209
|
+
await user.click(await screen.findByRole('button', {name: 'Choose pi'}));
|
|
150
210
|
const search = await screen.findByRole('searchbox', {name: 'Search providers'});
|
|
151
211
|
|
|
152
212
|
await user.type(search, 'provider 6');
|
|
@@ -182,7 +242,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
182
242
|
/>,
|
|
183
243
|
);
|
|
184
244
|
|
|
185
|
-
await user.click(screen.
|
|
245
|
+
await user.click(await screen.findByRole('button', {name: 'Skip for now'}));
|
|
186
246
|
|
|
187
247
|
expect(onSkip).toHaveBeenCalledTimes(1);
|
|
188
248
|
} finally {
|
|
@@ -224,7 +284,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
224
284
|
/>,
|
|
225
285
|
);
|
|
226
286
|
|
|
227
|
-
await user.click(screen.
|
|
287
|
+
await user.click(await screen.findByRole('button', {name: 'Choose pi'}));
|
|
228
288
|
await user.click(await screen.findByRole('button', {name: 'Configure OpenAI'}));
|
|
229
289
|
await user.type(await screen.findByLabelText('API key'), 'sk-proj-secret');
|
|
230
290
|
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
@@ -274,7 +334,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
274
334
|
/>,
|
|
275
335
|
);
|
|
276
336
|
|
|
277
|
-
await user.click(screen.
|
|
337
|
+
await user.click(await screen.findByRole('button', {name: 'Choose Claude'}));
|
|
278
338
|
await user.click(await screen.findByRole('button', {name: 'Configure Anthropic'}));
|
|
279
339
|
await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
|
|
280
340
|
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
@@ -328,7 +388,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
328
388
|
/>,
|
|
329
389
|
);
|
|
330
390
|
|
|
331
|
-
await user.click(screen.
|
|
391
|
+
await user.click(await screen.findByRole('button', {name: 'Choose Claude'}));
|
|
332
392
|
await user.click(await screen.findByRole('button', {name: 'Configure Anthropic'}));
|
|
333
393
|
await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
|
|
334
394
|
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
@@ -343,7 +403,7 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
343
403
|
await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1));
|
|
344
404
|
});
|
|
345
405
|
|
|
346
|
-
test('
|
|
406
|
+
test('renders the catalog load error and keeps skip available', async () => {
|
|
347
407
|
const user = userEvent.setup();
|
|
348
408
|
const onSkip = vi.fn();
|
|
349
409
|
configureApiClient({
|
|
@@ -359,10 +419,59 @@ describe('ModelProviderOnboardingPage', () => {
|
|
|
359
419
|
/>,
|
|
360
420
|
);
|
|
361
421
|
|
|
362
|
-
await user.click(screen.getByRole('button', {name: 'Choose pi'}));
|
|
363
422
|
expect(await screen.findByText("Couldn't load model provider catalog")).toBeInTheDocument();
|
|
423
|
+
expect(screen.queryByRole('button', {name: 'Choose pi'})).not.toBeInTheDocument();
|
|
364
424
|
await user.click(screen.getByRole('button', {name: 'Skip for now'}));
|
|
365
|
-
|
|
366
425
|
expect(onSkip).toHaveBeenCalledTimes(1);
|
|
367
426
|
});
|
|
427
|
+
|
|
428
|
+
test('recovers from a catalog load error after retry', async () => {
|
|
429
|
+
const user = userEvent.setup();
|
|
430
|
+
const fetchImpl = vi
|
|
431
|
+
.fn()
|
|
432
|
+
.mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}))
|
|
433
|
+
.mockResolvedValueOnce(jsonResponse(modelProviderCatalogResponse()));
|
|
434
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
435
|
+
|
|
436
|
+
renderOnboarding(
|
|
437
|
+
<ModelProviderOnboardingPage
|
|
438
|
+
workspaceId={AGENT_TEST_WORKSPACE_ID}
|
|
439
|
+
onSkip={vi.fn()}
|
|
440
|
+
onConfigured={vi.fn()}
|
|
441
|
+
/>,
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(await screen.findByText("Couldn't load model provider catalog")).toBeInTheDocument();
|
|
445
|
+
await user.click(screen.getByRole('button', {name: 'Retry loading model provider catalog'}));
|
|
446
|
+
|
|
447
|
+
expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
|
|
448
|
+
await waitFor(() =>
|
|
449
|
+
expect(screen.getByRole('heading', {name: 'Choose agent harness'})).toHaveFocus(),
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('keeps the loaded harness choices when a catalog refetch fails', async () => {
|
|
454
|
+
const fetchImpl = vi
|
|
455
|
+
.fn()
|
|
456
|
+
.mockResolvedValueOnce(jsonResponse(modelProviderCatalogResponse()))
|
|
457
|
+
.mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
458
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
459
|
+
|
|
460
|
+
const {queryClient} = renderOnboarding(
|
|
461
|
+
<ModelProviderOnboardingPage
|
|
462
|
+
workspaceId={AGENT_TEST_WORKSPACE_ID}
|
|
463
|
+
onSkip={vi.fn()}
|
|
464
|
+
onConfigured={vi.fn()}
|
|
465
|
+
/>,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(await screen.findByRole('button', {name: 'Choose pi'})).toBeVisible();
|
|
469
|
+
|
|
470
|
+
await act(async () => {
|
|
471
|
+
await queryClient.refetchQueries({queryKey: modelProviderQueryKeys.catalog()});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(screen.getByRole('button', {name: 'Choose pi'})).toBeVisible();
|
|
475
|
+
expect(screen.queryByText("Couldn't load model provider catalog")).not.toBeInTheDocument();
|
|
476
|
+
});
|
|
368
477
|
});
|
|
@@ -6,7 +6,7 @@ import {Modal, ModalContent, ModalHeader, ModalTitle} from '@shipfox/react-ui/mo
|
|
|
6
6
|
import {Panel, PanelBody, PanelCell, PanelCellAction, PanelGrid} from '@shipfox/react-ui/panel';
|
|
7
7
|
import {toast} from '@shipfox/react-ui/toast';
|
|
8
8
|
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
9
|
-
import {useEffect, useMemo, useReducer} from 'react';
|
|
9
|
+
import {useEffect, useMemo, useReducer, useRef} from 'react';
|
|
10
10
|
import {AvailableProvidersGrid} from '#components/available-providers-grid.js';
|
|
11
11
|
import {modelProviderConfigErrorToFormError} from '#components/form-errors.js';
|
|
12
12
|
import {ModelProviderGridSkeleton} from '#components/model-provider-grid-skeleton.js';
|
|
@@ -14,7 +14,7 @@ import {ModelProviderTestAndSaveForm} from '#components/test-and-save-form.js';
|
|
|
14
14
|
import {DEFAULT_HARNESS, harnessSupportsProvider, listHarnesses} from '#core/harness-policy.js';
|
|
15
15
|
import type {HarnessDescriptor, HarnessId, SupportedProvider} from '#core/models.js';
|
|
16
16
|
import {initialOnboardingState, onboardingReducer} from '#core/onboarding-reducer.js';
|
|
17
|
-
import {isSupportedProvider} from '#core/provider-policy.js';
|
|
17
|
+
import {isManagedOnlyCatalog, isSupportedProvider} from '#core/provider-policy.js';
|
|
18
18
|
import {
|
|
19
19
|
useModelProviderCatalogQuery,
|
|
20
20
|
useSetDefaultHarnessMutation,
|
|
@@ -34,6 +34,11 @@ export function ModelProviderOnboardingPage({
|
|
|
34
34
|
const setDefaultHarness = useSetDefaultHarnessMutation();
|
|
35
35
|
const [onboarding, dispatch] = useReducer(onboardingReducer, initialOnboardingState);
|
|
36
36
|
const supportedProviders = catalogQuery.data?.providers.filter(isSupportedProvider) ?? [];
|
|
37
|
+
const catalogLoaded = catalogQuery.data !== undefined;
|
|
38
|
+
const catalogLoading = catalogQuery.isPending;
|
|
39
|
+
const catalogLoadError = catalogQuery.isError && catalogQuery.data === undefined;
|
|
40
|
+
const managedOnly = isManagedOnlyCatalog(catalogQuery.data);
|
|
41
|
+
const managedOnlyForwarded = useRef(false);
|
|
37
42
|
const filteredProviders = useMemo(() => {
|
|
38
43
|
if (onboarding.step === 'choose-harness') return [];
|
|
39
44
|
return supportedProviders.filter((provider) =>
|
|
@@ -42,6 +47,13 @@ export function ModelProviderOnboardingPage({
|
|
|
42
47
|
}, [onboarding, supportedProviders]);
|
|
43
48
|
|
|
44
49
|
useEffect(() => {
|
|
50
|
+
if (!managedOnly || managedOnlyForwarded.current) return;
|
|
51
|
+
managedOnlyForwarded.current = true;
|
|
52
|
+
onConfigured();
|
|
53
|
+
}, [managedOnly, onConfigured]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (managedOnly || !catalogLoaded) return;
|
|
45
57
|
document
|
|
46
58
|
.getElementById(
|
|
47
59
|
onboarding.step === 'choose-harness'
|
|
@@ -49,7 +61,9 @@ export function ModelProviderOnboardingPage({
|
|
|
49
61
|
: 'model-provider-provider-step',
|
|
50
62
|
)
|
|
51
63
|
?.focus();
|
|
52
|
-
}, [onboarding.step]);
|
|
64
|
+
}, [catalogLoaded, managedOnly, onboarding.step]);
|
|
65
|
+
|
|
66
|
+
if (managedOnly) return null;
|
|
53
67
|
|
|
54
68
|
function handleSkip() {
|
|
55
69
|
try {
|
|
@@ -120,27 +134,36 @@ export function ModelProviderOnboardingPage({
|
|
|
120
134
|
</header>
|
|
121
135
|
|
|
122
136
|
<section aria-labelledby={headingId} className="flex flex-col gap-group">
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
Back
|
|
132
|
-
</Button>
|
|
133
|
-
</div>
|
|
134
|
-
<ModelProviderPicker
|
|
135
|
-
key={onboarding.harnessId}
|
|
136
|
-
catalogQuery={catalogQuery}
|
|
137
|
-
supportedProviders={filteredProviders}
|
|
138
|
-
onSelect={(entry) => {
|
|
139
|
-
dispatch({type: 'provider-selected', provider: entry});
|
|
140
|
-
}}
|
|
137
|
+
{catalogLoading ? <ModelProviderGridSkeleton label="Loading model providers" /> : null}
|
|
138
|
+
{catalogLoadError ? (
|
|
139
|
+
<QueryLoadError query={catalogQuery} subject="model provider catalog" />
|
|
140
|
+
) : null}
|
|
141
|
+
{!catalogLoading && !catalogLoadError ? (
|
|
142
|
+
onboarding.step === 'choose-harness' ? (
|
|
143
|
+
<HarnessPicker
|
|
144
|
+
onSelect={(harnessId) => dispatch({type: 'harness-selected', harnessId})}
|
|
141
145
|
/>
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
) : (
|
|
147
|
+
<>
|
|
148
|
+
<div>
|
|
149
|
+
<Button
|
|
150
|
+
type="button"
|
|
151
|
+
variant="transparent"
|
|
152
|
+
onClick={() => dispatch({type: 'back'})}
|
|
153
|
+
>
|
|
154
|
+
Back
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
<ModelProviderPicker
|
|
158
|
+
key={onboarding.harnessId}
|
|
159
|
+
supportedProviders={filteredProviders}
|
|
160
|
+
onSelect={(entry) => {
|
|
161
|
+
dispatch({type: 'provider-selected', provider: entry});
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
</>
|
|
165
|
+
)
|
|
166
|
+
) : null}
|
|
144
167
|
</section>
|
|
145
168
|
|
|
146
169
|
<Modal
|
|
@@ -242,23 +265,13 @@ function HarnessCard({harness, onChoose}: {harness: HarnessDescriptor; onChoose:
|
|
|
242
265
|
}
|
|
243
266
|
|
|
244
267
|
function ModelProviderPicker({
|
|
245
|
-
catalogQuery,
|
|
246
268
|
supportedProviders,
|
|
247
269
|
onSelect,
|
|
248
270
|
}: {
|
|
249
|
-
catalogQuery: ReturnType<typeof useModelProviderCatalogQuery>;
|
|
250
271
|
supportedProviders: SupportedProvider[];
|
|
251
272
|
onSelect: (entry: SupportedProvider) => void;
|
|
252
273
|
}) {
|
|
253
|
-
if (
|
|
254
|
-
return <ModelProviderGridSkeleton label="Loading model providers" />;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (catalogQuery.isError && catalogQuery.data === undefined) {
|
|
258
|
-
return <QueryLoadError query={catalogQuery} subject="model provider catalog" />;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (catalogQuery.data !== undefined && supportedProviders.length === 0) {
|
|
274
|
+
if (supportedProviders.length === 0) {
|
|
262
275
|
return (
|
|
263
276
|
<EmptyState
|
|
264
277
|
icon="componentLine"
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ModelProviderCatalogEntryDto,
|
|
5
5
|
ModelProviderCatalogResponseDto,
|
|
6
6
|
ModelProviderConfigDto,
|
|
7
|
+
WorkspaceProvidersPolicy,
|
|
7
8
|
} from '@shipfox/api-agent-dto';
|
|
8
9
|
|
|
9
10
|
export const AGENT_TEST_WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
@@ -35,6 +36,22 @@ export function modelProviderEntry(
|
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
export function managedModelProviderEntry(
|
|
40
|
+
overrides: Partial<ModelProviderCatalogEntryDto> = {},
|
|
41
|
+
): ModelProviderCatalogEntryDto {
|
|
42
|
+
return modelProviderEntry({
|
|
43
|
+
id: 'shipfox',
|
|
44
|
+
label: 'Shipfox Managed',
|
|
45
|
+
default_model: 'claude-opus-4-8',
|
|
46
|
+
credential_fields: [],
|
|
47
|
+
models: [
|
|
48
|
+
{id: 'claude-opus-4-8', label: 'Claude Opus 4.8', api: 'anthropic-messages'},
|
|
49
|
+
{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro', api: 'openai-responses'},
|
|
50
|
+
],
|
|
51
|
+
...overrides,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
export function unsupportedModelProviderEntry(
|
|
39
56
|
overrides: Partial<ModelProviderCatalogEntryDto> = {},
|
|
40
57
|
): ModelProviderCatalogEntryDto {
|
|
@@ -95,8 +112,12 @@ export function customModelProviderConfig(
|
|
|
95
112
|
|
|
96
113
|
export function modelProviderCatalogResponse(
|
|
97
114
|
modelProviders: ModelProviderCatalogEntryDto[] = [modelProviderEntry()],
|
|
115
|
+
workspaceProviders?: WorkspaceProvidersPolicy,
|
|
98
116
|
): ModelProviderCatalogResponseDto {
|
|
99
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
providers: modelProviders,
|
|
119
|
+
...(workspaceProviders === undefined ? {} : {workspace_providers: workspaceProviders}),
|
|
120
|
+
};
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
export function modelProviderConfigsResponse(
|