@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,159 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {modelProviderConfigErrorField, modelProviderConfigErrorToFormError} from './form-errors.js';
3
+
4
+ describe('modelProviderConfigErrorToFormError', () => {
5
+ test.each([
6
+ [
7
+ new ApiError({
8
+ code: 'provider-validation-failed',
9
+ message: 'Validation failed',
10
+ status: 422,
11
+ details: {provider_id: 'anthropic', message: 'Provider rejected the key.'},
12
+ }),
13
+ 'Provider rejected the key.',
14
+ ],
15
+ [
16
+ new ApiError({
17
+ code: 'provider-validation-failed',
18
+ message: 'Validation failed',
19
+ status: 422,
20
+ details: {
21
+ code: 'provider-validation-failed',
22
+ message: 'Validation failed',
23
+ details: {provider_id: 'anthropic', message: 'Provider rejected the key.'},
24
+ },
25
+ }),
26
+ 'Provider rejected the key.',
27
+ ],
28
+ [
29
+ new ApiError({
30
+ code: 'provider-validation-failed',
31
+ message: 'Validation failed',
32
+ status: 422,
33
+ }),
34
+ 'Validation failed',
35
+ ],
36
+ [
37
+ new ApiError({
38
+ code: 'invalid-credential-fields',
39
+ message: 'Invalid credentials',
40
+ status: 422,
41
+ details: {
42
+ provider_id: 'azure-openai-responses',
43
+ expected_keys: ['endpoint', 'api_key'],
44
+ },
45
+ }),
46
+ 'Credentials must include exactly these fields: endpoint, api_key.',
47
+ ],
48
+ [
49
+ new ApiError({
50
+ code: 'invalid-credential-fields',
51
+ message: 'Invalid credentials',
52
+ status: 422,
53
+ }),
54
+ 'Credentials do not match the fields required by this provider.',
55
+ ],
56
+ [
57
+ new ApiError({
58
+ code: 'provider-unsupported',
59
+ message: 'Unsupported',
60
+ status: 422,
61
+ details: {provider_id: 'amazon-bedrock'},
62
+ }),
63
+ 'This provider is not supported for workspace-managed credentials.',
64
+ ],
65
+ [
66
+ new ApiError({
67
+ code: 'invalid-agent-model',
68
+ message: 'Invalid agent model',
69
+ status: 422,
70
+ details: {provider_id: 'anthropic', model: 'missing-model'},
71
+ }),
72
+ 'Choose a model from the models list.',
73
+ ],
74
+ [
75
+ new ApiError({
76
+ code: 'provider-not-configured',
77
+ message: 'Not configured',
78
+ status: 422,
79
+ details: {provider_id: 'openai'},
80
+ }),
81
+ 'Configure this provider before setting it as the default.',
82
+ ],
83
+ [
84
+ new ApiError({code: 'not-found', message: 'Provider config not found', status: 404}),
85
+ 'This custom provider no longer exists. Refresh and try again.',
86
+ ],
87
+ [
88
+ new ApiError({code: 'slug-collision', message: 'Slug collision', status: 409}),
89
+ 'A provider with this id already exists in this workspace.',
90
+ ],
91
+ [
92
+ new ApiError({
93
+ code: 'egress-denied',
94
+ message: 'Egress denied',
95
+ status: 400,
96
+ details: {target: 'http://localhost:11434', reason: 'private-network'},
97
+ }),
98
+ 'Requests to http://localhost:11434 are blocked (private-network). On Shipfox Cloud the endpoint must be reachable from the internet. Use a public HTTPS URL for hosted providers.',
99
+ ],
100
+ [
101
+ new ApiError({code: 'invalid-header-keep', message: 'Invalid keep', status: 422}),
102
+ 'A kept secret header no longer exists - re-enter its value.',
103
+ ],
104
+ [
105
+ new ApiError({
106
+ code: 'stored-secret-base-url-changed',
107
+ message: 'Stored secrets require rotation',
108
+ status: 422,
109
+ }),
110
+ 'Re-enter stored secret values before testing against a different base URL.',
111
+ ],
112
+ [
113
+ new ApiError({
114
+ code: 'workspace-secret-cap-exceeded',
115
+ message: 'Secret cap',
116
+ status: 409,
117
+ details: {cap: 64},
118
+ }),
119
+ 'This workspace has reached its secrets limit of 64.',
120
+ ],
121
+ [
122
+ new ApiError({code: 'value-too-large', message: 'Too large', status: 400}),
123
+ 'A key or header value is too large to store.',
124
+ ],
125
+ [new ApiError({code: 'unknown-code', message: 'Server copy', status: 422}), 'Server copy'],
126
+ ])('maps ApiError %s to a form-level alert', (error, expectedMessage) => {
127
+ const result = modelProviderConfigErrorToFormError(error);
128
+
129
+ expect(result).toEqual({kind: 'form', message: expectedMessage});
130
+ });
131
+
132
+ test('routes Error to a form-level alert with the error message', () => {
133
+ const result = modelProviderConfigErrorToFormError(new Error('boom'));
134
+
135
+ expect(result).toEqual({kind: 'form', message: 'boom'});
136
+ });
137
+
138
+ test('falls back to generic copy for non-Error throwables', () => {
139
+ const result = modelProviderConfigErrorToFormError('weird');
140
+
141
+ expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
142
+ });
143
+
144
+ test('routes base URL secret reuse errors to the base URL field', () => {
145
+ const result = modelProviderConfigErrorField(
146
+ new ApiError({
147
+ code: 'stored-secret-base-url-changed',
148
+ message: 'Stored secrets require rotation',
149
+ status: 422,
150
+ }),
151
+ );
152
+
153
+ expect(result).toEqual({
154
+ kind: 'field',
155
+ field: 'base_url',
156
+ message: 'Re-enter stored secret values before testing against a different base URL.',
157
+ });
158
+ });
159
+ });
@@ -0,0 +1,102 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export type ModelProviderConfigFormErrorMapping =
4
+ | {kind: 'form'; message: string}
5
+ | {kind: 'field'; field: 'slug' | 'base_url' | 'default_model'; message: string};
6
+
7
+ type ModelProviderErrorDetails = {
8
+ message?: unknown;
9
+ provider_id?: unknown;
10
+ expected_keys?: unknown;
11
+ reason?: unknown;
12
+ target?: unknown;
13
+ cap?: unknown;
14
+ details?: unknown;
15
+ };
16
+
17
+ export function modelProviderConfigErrorToFormError(
18
+ error: unknown,
19
+ ): ModelProviderConfigFormErrorMapping {
20
+ if (error instanceof ApiError) {
21
+ return {kind: 'form', message: apiErrorMessage(error)};
22
+ }
23
+ if (error instanceof Error) {
24
+ return {kind: 'form', message: error.message};
25
+ }
26
+ return {kind: 'form', message: 'Something went wrong. Try again.'};
27
+ }
28
+
29
+ function apiErrorMessage(error: ApiError): string {
30
+ const details = modelProviderErrorDetails(error.details);
31
+
32
+ if (error.code === 'provider-validation-failed') {
33
+ return typeof details.message === 'string' ? details.message : error.message;
34
+ }
35
+ if (error.code === 'invalid-credential-fields') {
36
+ const expectedKeys = Array.isArray(details.expected_keys)
37
+ ? details.expected_keys.filter((key): key is string => typeof key === 'string')
38
+ : [];
39
+ if (expectedKeys.length > 0) {
40
+ return `Credentials must include exactly these fields: ${expectedKeys.join(', ')}.`;
41
+ }
42
+ return 'Credentials do not match the fields required by this provider.';
43
+ }
44
+ if (error.code === 'invalid-agent-model') {
45
+ return 'Choose a model from the models list.';
46
+ }
47
+ if (error.code === 'slug-collision') {
48
+ return 'A provider with this id already exists in this workspace.';
49
+ }
50
+ if (error.code === 'egress-denied') {
51
+ const target = typeof details.target === 'string' ? details.target : undefined;
52
+ const reason = typeof details.reason === 'string' ? details.reason : undefined;
53
+ const prefix = target && reason ? `Requests to ${target} are blocked (${reason}). ` : '';
54
+ return `${prefix}On Shipfox Cloud the endpoint must be reachable from the internet. Use a public HTTPS URL for hosted providers.`;
55
+ }
56
+ if (error.code === 'invalid-header-keep') {
57
+ return 'A kept secret header no longer exists - re-enter its value.';
58
+ }
59
+ if (error.code === 'stored-secret-base-url-changed') {
60
+ return 'Re-enter stored secret values before testing against a different base URL.';
61
+ }
62
+ if (error.code === 'not-found') {
63
+ return 'This custom provider no longer exists. Refresh and try again.';
64
+ }
65
+ if (error.code === 'workspace-secret-cap-exceeded') {
66
+ return typeof details.cap === 'number'
67
+ ? `This workspace has reached its secrets limit of ${details.cap}.`
68
+ : 'This workspace has reached its secrets limit.';
69
+ }
70
+ if (error.code === 'value-too-large') {
71
+ return 'A key or header value is too large to store.';
72
+ }
73
+ if (error.code === 'provider-unsupported') {
74
+ return 'This provider is not supported for workspace-managed credentials.';
75
+ }
76
+ if (error.code === 'provider-not-configured') {
77
+ return 'Configure this provider before setting it as the default.';
78
+ }
79
+ return error.message;
80
+ }
81
+
82
+ export function modelProviderConfigErrorField(error: unknown): ModelProviderConfigFormErrorMapping {
83
+ const mapping = modelProviderConfigErrorToFormError(error);
84
+ if (!(error instanceof ApiError)) return mapping;
85
+ if (error.code === 'slug-collision') return {...mapping, kind: 'field', field: 'slug'};
86
+ if (error.code === 'egress-denied') return {...mapping, kind: 'field', field: 'base_url'};
87
+ if (error.code === 'invalid-agent-model')
88
+ return {...mapping, kind: 'field', field: 'default_model'};
89
+ if (error.code === 'stored-secret-base-url-changed')
90
+ return {...mapping, kind: 'field', field: 'base_url'};
91
+ return mapping;
92
+ }
93
+
94
+ function modelProviderErrorDetails(rawDetails: unknown): ModelProviderErrorDetails {
95
+ if (typeof rawDetails !== 'object' || rawDetails === null) return {};
96
+
97
+ const payload = rawDetails as ModelProviderErrorDetails;
98
+ if (typeof payload.details === 'object' && payload.details !== null) {
99
+ return payload.details as ModelProviderErrorDetails;
100
+ }
101
+ return payload;
102
+ }
@@ -0,0 +1,45 @@
1
+ import {getHarnessDescriptor} from '@shipfox/api-agent-dto';
2
+ import {customModelProviderConfig, modelProviderConfig} from '#test/fixtures/model-providers.js';
3
+ import {compatibleHarnessIds, isHarnessAvailable} from './harness-availability.js';
4
+
5
+ describe('harness availability', () => {
6
+ test('marks pi available with any builtin provider config', () => {
7
+ const result = isHarnessAvailable(getHarnessDescriptor('pi'), [
8
+ modelProviderConfig({provider_id: 'openai'}),
9
+ ]);
10
+
11
+ expect(result).toBe(true);
12
+ });
13
+
14
+ test('marks pi available with only a custom provider config', () => {
15
+ const result = isHarnessAvailable(getHarnessDescriptor('pi'), [customModelProviderConfig()]);
16
+
17
+ expect(result).toBe(true);
18
+ });
19
+
20
+ test('marks claude available only when Anthropic is configured', () => {
21
+ const openai = isHarnessAvailable(getHarnessDescriptor('claude'), [
22
+ modelProviderConfig({provider_id: 'openai'}),
23
+ ]);
24
+ const anthropic = isHarnessAvailable(getHarnessDescriptor('claude'), [
25
+ modelProviderConfig({provider_id: 'anthropic'}),
26
+ ]);
27
+
28
+ expect(openai).toBe(false);
29
+ expect(anthropic).toBe(true);
30
+ });
31
+
32
+ test('marks both harnesses unavailable when no providers are configured', () => {
33
+ expect(isHarnessAvailable(getHarnessDescriptor('pi'), [])).toBe(false);
34
+ expect(isHarnessAvailable(getHarnessDescriptor('claude'), [])).toBe(false);
35
+ });
36
+
37
+ test('returns compatible harnesses for builtin and custom providers', () => {
38
+ expect(compatibleHarnessIds({isCustom: false, providerId: 'anthropic'})).toEqual([
39
+ 'pi',
40
+ 'claude',
41
+ ]);
42
+ expect(compatibleHarnessIds({isCustom: false, providerId: 'openai'})).toEqual(['pi']);
43
+ expect(compatibleHarnessIds({isCustom: true, providerId: 'custom-provider'})).toEqual(['pi']);
44
+ });
45
+ });
@@ -0,0 +1,35 @@
1
+ import {
2
+ DEFAULT_HARNESS,
3
+ type Harness,
4
+ type HarnessDescriptor,
5
+ listHarnessDescriptors,
6
+ type ModelProviderConfigResponseDto,
7
+ } from '@shipfox/api-agent-dto';
8
+
9
+ export function configSupportsHarness(
10
+ config: ModelProviderConfigResponseDto,
11
+ descriptor: HarnessDescriptor,
12
+ ): boolean {
13
+ if (config.kind === 'custom') return descriptor.id === DEFAULT_HARNESS;
14
+ return descriptor.supportedProviderIds.includes(config.provider_id);
15
+ }
16
+
17
+ export function isHarnessAvailable(
18
+ descriptor: HarnessDescriptor,
19
+ configs: ModelProviderConfigResponseDto[],
20
+ ): boolean {
21
+ return configs.some((config) => configSupportsHarness(config, descriptor));
22
+ }
23
+
24
+ export function compatibleHarnessIds({
25
+ isCustom,
26
+ providerId,
27
+ }: {
28
+ isCustom: boolean;
29
+ providerId: string;
30
+ }): Harness[] {
31
+ if (isCustom) return [DEFAULT_HARNESS];
32
+ return listHarnessDescriptors()
33
+ .filter((descriptor) => descriptor.supportedProviderIds.includes(providerId))
34
+ .map((descriptor) => descriptor.id);
35
+ }
@@ -0,0 +1,129 @@
1
+ import type {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 {WorkspaceHarnessesSection} from './harnesses-section.js';
8
+
9
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
10
+
11
+ type Scenario =
12
+ | 'default-pi'
13
+ | 'default-claude'
14
+ | 'claude-unavailable'
15
+ | 'default-claude-unavailable'
16
+ | 'loading'
17
+ | 'error';
18
+
19
+ interface HarnessesStoryProps {
20
+ scenario: Scenario;
21
+ }
22
+
23
+ function HarnessesStory({scenario}: HarnessesStoryProps) {
24
+ configureApiClient({
25
+ baseUrl: 'https://api.example.test',
26
+ fetchImpl: fetchForScenario(scenario),
27
+ });
28
+
29
+ const queryClient = useMemo(
30
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
31
+ [],
32
+ );
33
+
34
+ return (
35
+ <QueryClientProvider client={queryClient}>
36
+ <div className="mx-auto w-full max-w-[760px] bg-background-neutral-background p-24">
37
+ <WorkspaceHarnessesSection workspaceId={WORKSPACE_ID} />
38
+ </div>
39
+ <Toaster />
40
+ </QueryClientProvider>
41
+ );
42
+ }
43
+
44
+ const meta = {
45
+ title: 'Agent/HarnessSettings',
46
+ component: HarnessesStory,
47
+ parameters: {layout: 'fullscreen'},
48
+ args: {scenario: 'default-pi'},
49
+ } satisfies Meta<typeof HarnessesStory>;
50
+
51
+ export default meta;
52
+ type Story = StoryObj<typeof meta>;
53
+
54
+ export const Playground: Story = {};
55
+
56
+ export const DefaultClaude: Story = {
57
+ args: {scenario: 'default-claude'},
58
+ };
59
+
60
+ export const ClaudeUnavailable: Story = {
61
+ args: {scenario: 'claude-unavailable'},
62
+ };
63
+
64
+ export const DefaultClaudeUnavailable: Story = {
65
+ args: {scenario: 'default-claude-unavailable'},
66
+ };
67
+
68
+ export const Loading: Story = {
69
+ args: {scenario: 'loading'},
70
+ };
71
+
72
+ export const ErrorState: Story = {
73
+ args: {scenario: 'error'},
74
+ };
75
+
76
+ function fetchForScenario(scenario: Scenario): typeof fetch {
77
+ return () => {
78
+ if (scenario === 'loading') return new Promise<Response>(() => undefined);
79
+ if (scenario === 'error') return Promise.resolve(errorResponse());
80
+ return Promise.resolve(jsonResponse(configsForScenario(scenario)));
81
+ };
82
+ }
83
+
84
+ function configsForScenario(scenario: Scenario) {
85
+ if (scenario === 'claude-unavailable') {
86
+ return {
87
+ configs: [providerConfig({provider_id: 'openai'})],
88
+ default_provider_id: 'openai',
89
+ default_harness_id: null,
90
+ };
91
+ }
92
+ if (scenario === 'default-claude-unavailable') {
93
+ return {
94
+ configs: [providerConfig({provider_id: 'openai'})],
95
+ default_provider_id: 'openai',
96
+ default_harness_id: 'claude',
97
+ };
98
+ }
99
+ return {
100
+ configs: [providerConfig({provider_id: 'anthropic'})],
101
+ default_provider_id: 'anthropic',
102
+ default_harness_id: scenario === 'default-claude' ? 'claude' : null,
103
+ };
104
+ }
105
+
106
+ function providerConfig(
107
+ overrides: Partial<Omit<ModelProviderConfigDto, 'kind'>> = {},
108
+ ): ModelProviderConfigDto {
109
+ return {
110
+ kind: 'builtin',
111
+ provider_id: 'anthropic',
112
+ default_model: null,
113
+ created_at: '2026-05-08T00:00:00.000Z',
114
+ updated_at: '2026-05-08T00:00:00.000Z',
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
120
+ return new Response(JSON.stringify(body), {
121
+ status: 200,
122
+ headers: {'content-type': 'application/json'},
123
+ ...init,
124
+ });
125
+ }
126
+
127
+ function errorResponse() {
128
+ return jsonResponse({code: 'server-error'}, {status: 500, statusText: 'Server error'});
129
+ }
@@ -0,0 +1,188 @@
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 {
8
+ AGENT_TEST_WORKSPACE_ID,
9
+ modelProviderConfig,
10
+ modelProviderConfigsResponse,
11
+ } from '#test/fixtures/model-providers.js';
12
+ import {WorkspaceHarnessesSection} from './harnesses-section.js';
13
+
14
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
15
+ return new Response(JSON.stringify(body), {
16
+ status: 200,
17
+ headers: {'content-type': 'application/json'},
18
+ ...init,
19
+ });
20
+ }
21
+
22
+ function renderHarnesses(element: ReactElement) {
23
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
24
+ const result = render(
25
+ <QueryClientProvider client={queryClient}>
26
+ {element}
27
+ <Toaster />
28
+ </QueryClientProvider>,
29
+ );
30
+
31
+ return {...result, queryClient};
32
+ }
33
+
34
+ function deferred<T>() {
35
+ let resolve!: (value: T) => void;
36
+ const promise = new Promise<T>((innerResolve) => {
37
+ resolve = innerResolve;
38
+ });
39
+ return {promise, resolve};
40
+ }
41
+
42
+ async function openHarnessActions(user: ReturnType<typeof userEvent.setup>, label: string) {
43
+ await user.click(screen.getByRole('button', {name: `Open ${label} harness actions`}));
44
+ }
45
+
46
+ describe('WorkspaceHarnessesSection', () => {
47
+ test('omits actions on the default row and enables them on an available non-default row', async () => {
48
+ const user = userEvent.setup();
49
+ configureApiClient({
50
+ baseUrl: 'https://api.example.test',
51
+ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(modelProviderConfigsResponse())),
52
+ });
53
+
54
+ renderHarnesses(<WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
55
+
56
+ expect(await screen.findByText('Harnesses')).toBeVisible();
57
+ expect(await screen.findByText('Default harness')).toHaveClass('sr-only');
58
+
59
+ expect(screen.queryByRole('button', {name: 'Open pi harness actions'})).not.toBeInTheDocument();
60
+
61
+ await openHarnessActions(user, 'Claude');
62
+ expect(screen.getByRole('menuitem', {name: 'Set as default'})).toBeVisible();
63
+ expect(screen.getByRole('menuitem', {name: 'Set as default'})).not.toHaveAttribute(
64
+ 'data-disabled',
65
+ );
66
+ });
67
+
68
+ test('disables set as default and shows a warning for an unavailable harness', async () => {
69
+ const user = userEvent.setup();
70
+ configureApiClient({
71
+ baseUrl: 'https://api.example.test',
72
+ fetchImpl: vi.fn().mockResolvedValue(
73
+ jsonResponse(
74
+ modelProviderConfigsResponse({
75
+ configs: [modelProviderConfig({provider_id: 'openai'})],
76
+ }),
77
+ ),
78
+ ),
79
+ });
80
+
81
+ renderHarnesses(<WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
82
+
83
+ const claudeRow = (await screen.findByText('Claude')).closest('li');
84
+ if (claudeRow === null) throw new Error('Expected Claude row');
85
+ expect(
86
+ within(claudeRow).getByText('Configure a compatible model provider to use this harness.'),
87
+ ).toHaveClass('sr-only');
88
+
89
+ await openHarnessActions(user, 'Claude');
90
+
91
+ expect(screen.getByRole('menuitem', {name: 'Set as default'})).toHaveAttribute('data-disabled');
92
+ });
93
+
94
+ test('sets the default harness and shows a success toast', async () => {
95
+ const user = userEvent.setup();
96
+ let requestBody: unknown;
97
+ const updateResponse = deferred<Response>();
98
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
99
+ const request = input as Request;
100
+ if (request.method === 'PUT') {
101
+ requestBody = await request.clone().json();
102
+ return await updateResponse.promise;
103
+ }
104
+ return jsonResponse(modelProviderConfigsResponse());
105
+ });
106
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
107
+
108
+ renderHarnesses(<WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
109
+
110
+ await screen.findByText('Claude');
111
+ await openHarnessActions(user, 'Claude');
112
+ await user.click(screen.getByRole('menuitem', {name: 'Set as default'}));
113
+
114
+ await waitFor(() => expect(requestBody).toEqual({harness_id: 'claude'}));
115
+ expect(screen.getByRole('button', {name: 'Open Claude harness actions'})).toBeDisabled();
116
+ updateResponse.resolve(jsonResponse({default_harness_id: 'claude'}));
117
+ expect(
118
+ await screen.findByText('Claude is now the default harness', {
119
+ selector: '[data-sonner-toast][data-mounted="true"][data-visible="true"] [data-title]',
120
+ }),
121
+ ).toBeVisible();
122
+ });
123
+
124
+ test('shows an inline error when setting the default harness fails', async () => {
125
+ const user = userEvent.setup();
126
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
127
+ const request = input as Request;
128
+ if (request.method === 'PUT') {
129
+ return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
130
+ }
131
+ return Promise.resolve(jsonResponse(modelProviderConfigsResponse()));
132
+ });
133
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
134
+
135
+ renderHarnesses(<WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
136
+
137
+ await screen.findByText('Claude');
138
+ await openHarnessActions(user, 'Claude');
139
+ await user.click(screen.getByRole('menuitem', {name: 'Set as default'}));
140
+
141
+ await waitFor(() =>
142
+ expect(fetchImpl.mock.calls.some(([input]) => (input as Request).method === 'PUT')).toBe(
143
+ true,
144
+ ),
145
+ );
146
+ expect(await screen.findByText('Could not save default harness. Try again.')).toBeVisible();
147
+ });
148
+
149
+ test('ignores stale default harness failures after changing workspaces', async () => {
150
+ const user = userEvent.setup();
151
+ const updateResponse = deferred<Response>();
152
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
153
+ const request = input as Request;
154
+ if (request.method === 'PUT') {
155
+ return updateResponse.promise;
156
+ }
157
+ return Promise.resolve(jsonResponse(modelProviderConfigsResponse()));
158
+ });
159
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
160
+ const nextWorkspaceId = '22222222-2222-4222-8222-222222222222';
161
+
162
+ const result = renderHarnesses(
163
+ <WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />,
164
+ );
165
+
166
+ await screen.findByText('Claude');
167
+ await openHarnessActions(user, 'Claude');
168
+ await user.click(screen.getByRole('menuitem', {name: 'Set as default'}));
169
+ await waitFor(() =>
170
+ expect(screen.getByRole('button', {name: 'Open Claude harness actions'})).toBeDisabled(),
171
+ );
172
+
173
+ result.rerender(
174
+ <QueryClientProvider client={result.queryClient}>
175
+ <WorkspaceHarnessesSection workspaceId={nextWorkspaceId} />
176
+ <Toaster />
177
+ </QueryClientProvider>,
178
+ );
179
+ updateResponse.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
180
+
181
+ await waitFor(() =>
182
+ expect(screen.getByRole('button', {name: 'Open Claude harness actions'})).not.toBeDisabled(),
183
+ );
184
+ expect(
185
+ screen.queryByText('Could not save default harness. Try again.'),
186
+ ).not.toBeInTheDocument();
187
+ });
188
+ });