@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/dist/components/add-custom-provider-card.d.ts +4 -0
- package/dist/components/add-custom-provider-card.d.ts.map +1 -0
- package/dist/components/add-custom-provider-card.js +41 -0
- package/dist/components/add-custom-provider-card.js.map +1 -0
- package/dist/components/agent-workflow-example.d.ts +11 -0
- package/dist/components/agent-workflow-example.d.ts.map +1 -0
- package/dist/components/agent-workflow-example.js +38 -0
- package/dist/components/agent-workflow-example.js.map +1 -0
- package/dist/components/available-provider-card.d.ts +6 -0
- package/dist/components/available-provider-card.d.ts.map +1 -0
- package/dist/components/available-provider-card.js +41 -0
- package/dist/components/available-provider-card.js.map +1 -0
- package/dist/components/available-providers-grid.d.ts +10 -0
- package/dist/components/available-providers-grid.d.ts.map +1 -0
- package/dist/components/available-providers-grid.js +89 -0
- package/dist/components/available-providers-grid.js.map +1 -0
- package/dist/components/change-default-model-form.d.ts +9 -0
- package/dist/components/change-default-model-form.d.ts.map +1 -0
- package/dist/components/change-default-model-form.js +98 -0
- package/dist/components/change-default-model-form.js.map +1 -0
- package/dist/components/custom-model-provider-api-options.d.ts +7 -0
- package/dist/components/custom-model-provider-api-options.d.ts.map +1 -0
- package/dist/components/custom-model-provider-api-options.js +23 -0
- package/dist/components/custom-model-provider-api-options.js.map +1 -0
- package/dist/components/custom-model-provider-form.d.ts +8 -0
- package/dist/components/custom-model-provider-form.d.ts.map +1 -0
- package/dist/components/custom-model-provider-form.js +667 -0
- package/dist/components/custom-model-provider-form.js.map +1 -0
- package/dist/components/custom-model-provider-form.stories.d.ts +20 -0
- package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -0
- package/dist/components/custom-model-provider-form.stories.js +104 -0
- package/dist/components/custom-model-provider-form.stories.js.map +1 -0
- package/dist/components/custom-model-provider-payload.d.ts +39 -0
- package/dist/components/custom-model-provider-payload.d.ts.map +1 -0
- package/dist/components/custom-model-provider-payload.js +203 -0
- package/dist/components/custom-model-provider-payload.js.map +1 -0
- package/dist/components/custom-model-provider-slug.d.ts +3 -0
- package/dist/components/custom-model-provider-slug.d.ts.map +1 -0
- package/dist/components/custom-model-provider-slug.js +14 -0
- package/dist/components/custom-model-provider-slug.js.map +1 -0
- package/dist/components/default-model-field.d.ts +16 -0
- package/dist/components/default-model-field.d.ts.map +1 -0
- package/dist/components/default-model-field.js +60 -0
- package/dist/components/default-model-field.js.map +1 -0
- package/dist/components/form-errors.d.ts +11 -0
- package/dist/components/form-errors.d.ts.map +1 -0
- package/dist/components/form-errors.js +101 -0
- package/dist/components/form-errors.js.map +1 -0
- package/dist/components/harness-availability.d.ts +8 -0
- package/dist/components/harness-availability.d.ts.map +1 -0
- package/dist/components/harness-availability.js +16 -0
- package/dist/components/harness-availability.js.map +1 -0
- package/dist/components/harnesses-section.d.ts +4 -0
- package/dist/components/harnesses-section.d.ts.map +1 -0
- package/dist/components/harnesses-section.js +249 -0
- package/dist/components/harnesses-section.js.map +1 -0
- package/dist/components/harnesses-section.stories.d.ts +25 -0
- package/dist/components/harnesses-section.stories.d.ts.map +1 -0
- package/dist/components/harnesses-section.stories.js +138 -0
- package/dist/components/harnesses-section.stories.js.map +1 -0
- package/dist/components/model-provider-usage-modal.d.ts +11 -0
- package/dist/components/model-provider-usage-modal.d.ts.map +1 -0
- package/dist/components/model-provider-usage-modal.js +349 -0
- package/dist/components/model-provider-usage-modal.js.map +1 -0
- package/dist/components/model-provider-usage-modal.stories.d.ts +21 -0
- package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -0
- package/dist/components/model-provider-usage-modal.stories.js +115 -0
- package/dist/components/model-provider-usage-modal.stories.js.map +1 -0
- package/dist/components/model-provider-usage-target.d.ts +14 -0
- package/dist/components/model-provider-usage-target.d.ts.map +1 -0
- package/dist/components/model-provider-usage-target.js +20 -0
- package/dist/components/model-provider-usage-target.js.map +1 -0
- package/dist/components/model-providers-section.d.ts +4 -0
- package/dist/components/model-providers-section.d.ts.map +1 -0
- package/dist/components/model-providers-section.js +668 -0
- package/dist/components/model-providers-section.js.map +1 -0
- package/dist/components/model-providers-section.stories.d.ts +32 -0
- package/dist/components/model-providers-section.stories.d.ts.map +1 -0
- package/dist/components/model-providers-section.stories.js +442 -0
- package/dist/components/model-providers-section.stories.js.map +1 -0
- package/dist/components/provider-search.d.ts +4 -0
- package/dist/components/provider-search.d.ts.map +1 -0
- package/dist/components/provider-search.js +13 -0
- package/dist/components/provider-search.js.map +1 -0
- package/dist/components/supported-model-provider-catalog-entry.d.ts +8 -0
- package/dist/components/supported-model-provider-catalog-entry.d.ts.map +1 -0
- package/dist/components/supported-model-provider-catalog-entry.js +9 -0
- package/dist/components/supported-model-provider-catalog-entry.js.map +1 -0
- package/dist/components/test-and-save-form.d.ts +10 -0
- package/dist/components/test-and-save-form.d.ts.map +1 -0
- package/dist/components/test-and-save-form.js +138 -0
- package/dist/components/test-and-save-form.js.map +1 -0
- package/dist/feature.d.ts +9 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +13 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/model-providers.d.ts +334 -0
- package/dist/hooks/api/model-providers.d.ts.map +1 -0
- package/dist/hooks/api/model-providers.js +190 -0
- package/dist/hooks/api/model-providers.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/model-provider-onboarding-page.d.ts +6 -0
- package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -0
- package/dist/pages/model-provider-onboarding-page.js +295 -0
- package/dist/pages/model-provider-onboarding-page.js.map +1 -0
- package/dist/pages/model-provider-onboarding-page.stories.d.ts +27 -0
- package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -0
- package/dist/pages/model-provider-onboarding-page.stories.js +368 -0
- package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -0
- package/dist/routes/model-provider.d.ts +5 -0
- package/dist/routes/model-provider.d.ts.map +1 -0
- package/dist/routes/model-provider.js +28 -0
- package/dist/routes/model-provider.js.map +1 -0
- package/dist/state/model-provider-onboarding.d.ts +3 -0
- package/dist/state/model-provider-onboarding.d.ts.map +1 -0
- package/dist/state/model-provider-onboarding.js +28 -0
- package/dist/state/model-provider-onboarding.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +114 -0
- package/src/components/add-custom-provider-card.tsx +32 -0
- package/src/components/agent-workflow-example.test.ts +74 -0
- package/src/components/agent-workflow-example.ts +55 -0
- package/src/components/available-provider-card.tsx +39 -0
- package/src/components/available-providers-grid.test.tsx +55 -0
- package/src/components/available-providers-grid.tsx +106 -0
- package/src/components/change-default-model-form.tsx +104 -0
- package/src/components/custom-model-provider-api-options.test.ts +18 -0
- package/src/components/custom-model-provider-api-options.ts +12 -0
- package/src/components/custom-model-provider-form.stories.tsx +91 -0
- package/src/components/custom-model-provider-form.tsx +710 -0
- package/src/components/custom-model-provider-payload.test.ts +170 -0
- package/src/components/custom-model-provider-payload.ts +264 -0
- package/src/components/custom-model-provider-slug.test.ts +40 -0
- package/src/components/custom-model-provider-slug.ts +23 -0
- package/src/components/default-model-field.tsx +85 -0
- package/src/components/form-errors.test.ts +159 -0
- package/src/components/form-errors.ts +102 -0
- package/src/components/harness-availability.test.ts +45 -0
- package/src/components/harness-availability.ts +35 -0
- package/src/components/harnesses-section.stories.tsx +129 -0
- package/src/components/harnesses-section.test.tsx +188 -0
- package/src/components/harnesses-section.tsx +245 -0
- package/src/components/model-provider-usage-modal.stories.tsx +106 -0
- package/src/components/model-provider-usage-modal.test.tsx +210 -0
- package/src/components/model-provider-usage-modal.tsx +326 -0
- package/src/components/model-provider-usage-target.ts +36 -0
- package/src/components/model-providers-section.stories.tsx +345 -0
- package/src/components/model-providers-section.test.tsx +831 -0
- package/src/components/model-providers-section.tsx +645 -0
- package/src/components/provider-search.test.ts +45 -0
- package/src/components/provider-search.ts +18 -0
- package/src/components/supported-model-provider-catalog-entry.ts +19 -0
- package/src/components/test-and-save-form.tsx +166 -0
- package/src/feature.ts +12 -0
- package/src/hooks/api/model-providers.test.ts +167 -0
- package/src/hooks/api/model-providers.ts +281 -0
- package/src/index.ts +8 -0
- package/src/pages/model-provider-onboarding-page.stories.tsx +284 -0
- package/src/pages/model-provider-onboarding-page.test.tsx +368 -0
- package/src/pages/model-provider-onboarding-page.tsx +290 -0
- package/src/routes/model-provider.tsx +20 -0
- package/src/state/model-provider-onboarding.test.ts +55 -0
- package/src/state/model-provider-onboarding.ts +34 -0
- package/test/fixtures/model-providers.ts +111 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- 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
|
+
});
|