@stigmer/react 0.4.5 → 0.4.6
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/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/models/ModelRegistryContext.d.ts +21 -0
- package/models/ModelRegistryContext.d.ts.map +1 -0
- package/models/ModelRegistryContext.js +22 -0
- package/models/ModelRegistryContext.js.map +1 -0
- package/models/ModelSelector.js +3 -3
- package/models/ModelSelector.js.map +1 -1
- package/models/__tests__/useModelRegistry.test.js +127 -32
- package/models/__tests__/useModelRegistry.test.js.map +1 -1
- package/models/index.d.ts +3 -1
- package/models/index.d.ts.map +1 -1
- package/models/index.js +2 -1
- package/models/index.js.map +1 -1
- package/models/registry.d.ts +20 -12
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +51 -27
- package/models/registry.js.map +1 -1
- package/models/useModelRegistry.d.ts +11 -3
- package/models/useModelRegistry.d.ts.map +1 -1
- package/models/useModelRegistry.js +13 -5
- package/models/useModelRegistry.js.map +1 -1
- package/package.json +4 -4
- package/provider.d.ts.map +1 -1
- package/provider.js +42 -1
- package/provider.js.map +1 -1
- package/session/__tests__/useNewSessionFlow.test.js +32 -18
- package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
- package/session/__tests__/usePersistedModel.test.js +26 -12
- package/session/__tests__/usePersistedModel.test.js.map +1 -1
- package/src/index.ts +4 -1
- package/src/models/ModelRegistryContext.ts +32 -0
- package/src/models/ModelSelector.tsx +4 -4
- package/src/models/__tests__/useModelRegistry.test.tsx +150 -41
- package/src/models/index.ts +3 -1
- package/src/models/registry.ts +51 -30
- package/src/models/useModelRegistry.ts +18 -7
- package/src/provider.tsx +58 -8
- package/src/session/__tests__/useNewSessionFlow.test.tsx +39 -18
- package/src/session/__tests__/usePersistedModel.test.tsx +33 -12
package/src/models/registry.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model registry — UI-relevant metadata for all platform-supported LLM models.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Fetches from the public model registry API endpoint at runtime and caches
|
|
5
|
+
* the result in the {@link StigmerProvider} context. This eliminates the
|
|
6
|
+
* static JSON file that previously shipped in the npm package.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Platform consumers (React SDK, cursor-runner, graphton) all fetch from the
|
|
9
|
+
* same endpoint, each with their own local TTL cache.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { HarnessOption } from "./harness";
|
|
12
|
-
import registryData from "../../data/model-registry.json";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Pricing bracket for a model.
|
|
@@ -33,7 +33,7 @@ export type SpeedTier = "fastest" | "fast" | "balanced" | "slow";
|
|
|
33
33
|
/**
|
|
34
34
|
* LLM provider identifier. Each provider maps to a distinct inference
|
|
35
35
|
* backend (or intermediary, in the case of Cursor-served third-party
|
|
36
|
-
* models). The
|
|
36
|
+
* models). The model registry uses this for grouping in the
|
|
37
37
|
* "Show All" expanded view.
|
|
38
38
|
*/
|
|
39
39
|
export type Provider =
|
|
@@ -48,7 +48,7 @@ export type Provider =
|
|
|
48
48
|
/**
|
|
49
49
|
* Providers whose models should be hidden from the UI.
|
|
50
50
|
*
|
|
51
|
-
* The model entries themselves stay in
|
|
51
|
+
* The model entries themselves stay in the registry so backend
|
|
52
52
|
* compatibility is preserved. The useModelRegistry hook filters
|
|
53
53
|
* them out before anything reaches a component.
|
|
54
54
|
*
|
|
@@ -171,26 +171,47 @@ function isModelEntry(entry: RegistryJsonEntry): entry is Required<Pick<Registry
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
|
-
*
|
|
175
|
-
* unified JSON registry.
|
|
174
|
+
* Parse raw registry JSON (from the API or a static file) into `ModelInfo[]`.
|
|
176
175
|
*
|
|
177
|
-
* {
|
|
178
|
-
*
|
|
176
|
+
* Expects the shape `{ models: RegistryJsonEntry[] }`. Filters out comment
|
|
177
|
+
* entries and invalid rows, then maps to the `ModelInfo` interface.
|
|
179
178
|
*/
|
|
180
|
-
export
|
|
181
|
-
|
|
182
|
-
)
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
179
|
+
export function parseRegistryJson(data: unknown): ModelInfo[] {
|
|
180
|
+
if (!data || typeof data !== "object") return [];
|
|
181
|
+
const models = (data as Record<string, unknown>).models;
|
|
182
|
+
if (!Array.isArray(models)) return [];
|
|
183
|
+
|
|
184
|
+
return (models as RegistryJsonEntry[])
|
|
185
|
+
.filter(isModelEntry)
|
|
186
|
+
.map((m) => ({
|
|
187
|
+
modelId: m.id,
|
|
188
|
+
provider: m.provider as Provider,
|
|
189
|
+
displayName: m.displayName,
|
|
190
|
+
shortDescription: m.shortDescription ?? "",
|
|
191
|
+
speedTier: (VALID_SPEED_TIERS.has(m.speedTier ?? "") ? m.speedTier : "fast") as SpeedTier,
|
|
192
|
+
costTier: m.costTier as CostTier,
|
|
193
|
+
harness: m.harness as HarnessOption,
|
|
194
|
+
featured: m.featured ?? false,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch the model registry from the authenticated API endpoint.
|
|
200
|
+
*
|
|
201
|
+
* @param apiUrl - Base URL of the Stigmer Cloud API (e.g. `https://api.stigmer.ai`)
|
|
202
|
+
* @param token - Bearer token for authentication (from `client.getAuthCredential()`)
|
|
203
|
+
* @returns Parsed `ModelInfo[]`.
|
|
204
|
+
*/
|
|
205
|
+
export async function fetchModelRegistry(apiUrl: string, token: string | null): Promise<ModelInfo[]> {
|
|
206
|
+
const headers: Record<string, string> = {};
|
|
207
|
+
if (token) {
|
|
208
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
209
|
+
}
|
|
210
|
+
const res = await fetch(`${apiUrl}/v1/proxy/model-registry`, { headers });
|
|
211
|
+
if (!res.ok) throw new Error(`Model registry fetch failed: ${res.status}`);
|
|
212
|
+
const data: unknown = await res.json();
|
|
213
|
+
return parseRegistryJson(data);
|
|
214
|
+
}
|
|
194
215
|
|
|
195
216
|
/**
|
|
196
217
|
* Model ID used when no user preference is set (native harness).
|
|
@@ -236,6 +257,7 @@ export interface DefaultModelResolution {
|
|
|
236
257
|
*/
|
|
237
258
|
export function resolveDefaultModelId(
|
|
238
259
|
harness: HarnessOption,
|
|
260
|
+
models: readonly ModelInfo[],
|
|
239
261
|
options?: {
|
|
240
262
|
userPreference?: string;
|
|
241
263
|
orgDefault?: string;
|
|
@@ -243,27 +265,27 @@ export function resolveDefaultModelId(
|
|
|
243
265
|
},
|
|
244
266
|
): DefaultModelResolution {
|
|
245
267
|
if (options?.userPreference) {
|
|
246
|
-
const model =
|
|
268
|
+
const model = models.find(
|
|
247
269
|
(m) => m.harness === harness && m.modelId === options.userPreference,
|
|
248
270
|
);
|
|
249
271
|
if (model) return { modelId: model.modelId, source: "user_preference" };
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
if (options?.orgDefault) {
|
|
253
|
-
const model =
|
|
275
|
+
const model = models.find(
|
|
254
276
|
(m) => m.harness === harness && m.modelId === options.orgDefault,
|
|
255
277
|
);
|
|
256
278
|
if (model) return { modelId: model.modelId, source: "org_default" };
|
|
257
279
|
}
|
|
258
280
|
|
|
259
281
|
if (options?.agentDefault) {
|
|
260
|
-
const model =
|
|
282
|
+
const model = models.find(
|
|
261
283
|
(m) => m.harness === harness && m.modelId === options.agentDefault,
|
|
262
284
|
);
|
|
263
285
|
if (model) return { modelId: model.modelId, source: "agent_default" };
|
|
264
286
|
}
|
|
265
287
|
|
|
266
|
-
const featured =
|
|
288
|
+
const featured = models.find(
|
|
267
289
|
(m) => m.harness === harness && m.featured,
|
|
268
290
|
);
|
|
269
291
|
if (featured) return { modelId: featured.modelId, source: "harness_default" };
|
|
@@ -271,4 +293,3 @@ export function resolveDefaultModelId(
|
|
|
271
293
|
const fallbackId = harness === "cursor" ? DEFAULT_CURSOR_MODEL_ID : DEFAULT_MODEL_ID;
|
|
272
294
|
return { modelId: fallbackId, source: "platform_fallback" };
|
|
273
295
|
}
|
|
274
|
-
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo } from "react";
|
|
4
4
|
import {
|
|
5
|
-
MODEL_REGISTRY,
|
|
6
5
|
DEFAULT_MODEL_ID,
|
|
7
6
|
DEFAULT_CURSOR_MODEL_ID,
|
|
8
7
|
DISABLED_PROVIDERS,
|
|
@@ -12,6 +11,7 @@ import {
|
|
|
12
11
|
type Provider,
|
|
13
12
|
} from "./registry";
|
|
14
13
|
import type { HarnessOption } from "./harness";
|
|
14
|
+
import { useModelRegistryContext } from "./ModelRegistryContext";
|
|
15
15
|
|
|
16
16
|
/** Options for {@link useModelRegistry}. */
|
|
17
17
|
export interface UseModelRegistryOptions {
|
|
@@ -31,8 +31,8 @@ export interface UseModelRegistryReturn {
|
|
|
31
31
|
readonly models: readonly ModelInfo[];
|
|
32
32
|
/** Models grouped by provider for sectioned rendering. */
|
|
33
33
|
readonly byProvider: ReadonlyMap<Provider, readonly ModelInfo[]>;
|
|
34
|
-
/** The platform default model for the current mode. */
|
|
35
|
-
readonly defaultModel: ModelInfo;
|
|
34
|
+
/** The platform default model for the current mode. `undefined` while the registry is loading. */
|
|
35
|
+
readonly defaultModel: ModelInfo | undefined;
|
|
36
36
|
/**
|
|
37
37
|
* Look up a single model by its `modelId`.
|
|
38
38
|
*
|
|
@@ -51,6 +51,10 @@ export interface UseModelRegistryReturn {
|
|
|
51
51
|
* Always unambiguous, even in unified mode.
|
|
52
52
|
*/
|
|
53
53
|
readonly getByKey: (key: string) => ModelInfo | undefined;
|
|
54
|
+
/** `true` while the model registry is being fetched from the API. */
|
|
55
|
+
readonly isLoading: boolean;
|
|
56
|
+
/** Non-null if the API fetch failed. Models will be empty in this case. */
|
|
57
|
+
readonly error: Error | null;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/**
|
|
@@ -61,6 +65,10 @@ export interface UseModelRegistryReturn {
|
|
|
61
65
|
* who want full control over rendering import this hook and build
|
|
62
66
|
* their own UI.
|
|
63
67
|
*
|
|
68
|
+
* The model data is fetched from the public model registry API by
|
|
69
|
+
* {@link StigmerProvider} and cached in context. During loading,
|
|
70
|
+
* `isLoading` is `true` and `models` is empty.
|
|
71
|
+
*
|
|
64
72
|
* **Modes:**
|
|
65
73
|
* - `options.harness === "cursor"` — Cursor-harness models only
|
|
66
74
|
* - `options.harness === "native"` — native models, excluding disabled providers
|
|
@@ -69,7 +77,7 @@ export interface UseModelRegistryReturn {
|
|
|
69
77
|
* @example
|
|
70
78
|
* ```tsx
|
|
71
79
|
* // Unified mode — flat picker with all models
|
|
72
|
-
* const { featured, models, getByKey } = useModelRegistry();
|
|
80
|
+
* const { featured, models, getByKey, isLoading } = useModelRegistry();
|
|
73
81
|
*
|
|
74
82
|
* // Legacy single-harness mode
|
|
75
83
|
* const { models, defaultModel } = useModelRegistry({ harness: "native" });
|
|
@@ -77,11 +85,12 @@ export interface UseModelRegistryReturn {
|
|
|
77
85
|
*/
|
|
78
86
|
export function useModelRegistry(options?: UseModelRegistryOptions): UseModelRegistryReturn {
|
|
79
87
|
const harness = options?.harness;
|
|
88
|
+
const { models: allModels, isLoading, error } = useModelRegistryContext();
|
|
80
89
|
|
|
81
90
|
return useMemo(() => {
|
|
82
91
|
const isUnified = harness === undefined;
|
|
83
92
|
const { modelId: defaultId } = harness
|
|
84
|
-
? resolveDefaultModelId(harness)
|
|
93
|
+
? resolveDefaultModelId(harness, allModels)
|
|
85
94
|
: { modelId: DEFAULT_MODEL_ID };
|
|
86
95
|
|
|
87
96
|
const byProvider = new Map<Provider, ModelInfo[]>();
|
|
@@ -91,7 +100,7 @@ export function useModelRegistry(options?: UseModelRegistryOptions): UseModelReg
|
|
|
91
100
|
const featuredModels: ModelInfo[] = [];
|
|
92
101
|
let defaultModel: ModelInfo | undefined;
|
|
93
102
|
|
|
94
|
-
for (const model of
|
|
103
|
+
for (const model of allModels) {
|
|
95
104
|
if (isUnified) {
|
|
96
105
|
if (DISABLED_PROVIDERS.has(model.provider)) continue;
|
|
97
106
|
} else {
|
|
@@ -132,6 +141,8 @@ export function useModelRegistry(options?: UseModelRegistryOptions): UseModelReg
|
|
|
132
141
|
providers,
|
|
133
142
|
featured: featuredModels,
|
|
134
143
|
getByKey: (key: string) => byCompoundKey.get(key),
|
|
144
|
+
isLoading,
|
|
145
|
+
error,
|
|
135
146
|
};
|
|
136
|
-
}, [harness]);
|
|
147
|
+
}, [harness, allModels, isLoading, error]);
|
|
137
148
|
}
|
package/src/provider.tsx
CHANGED
|
@@ -9,6 +9,9 @@ import { DeploymentModeContext } from "./deployment-mode";
|
|
|
9
9
|
import type { ColorMode, ResolvedColorMode } from "./color-mode";
|
|
10
10
|
import { ColorModeContext, useSystemColorMode } from "./color-mode";
|
|
11
11
|
import { PortalContainerContext } from "./portal-container";
|
|
12
|
+
import { ModelRegistryContext } from "./models/ModelRegistryContext";
|
|
13
|
+
import type { ModelRegistryState } from "./models/ModelRegistryContext";
|
|
14
|
+
import { fetchModelRegistry } from "./models/registry";
|
|
12
15
|
|
|
13
16
|
/** Props for {@link StigmerProvider}. */
|
|
14
17
|
export interface StigmerProviderProps {
|
|
@@ -126,25 +129,72 @@ export function StigmerProvider({
|
|
|
126
129
|
const presetClass = preset ? resolvePresetClass(preset) : "";
|
|
127
130
|
|
|
128
131
|
const portalContainer = usePortalContainer(resolvedMode, presetClass);
|
|
132
|
+
const registryState = useModelRegistryFetch(client);
|
|
129
133
|
|
|
130
134
|
return (
|
|
131
135
|
<StigmerContext.Provider value={client}>
|
|
132
136
|
<DeploymentModeContext.Provider value={deploymentMode}>
|
|
133
137
|
<ColorModeContext.Provider value={resolvedMode}>
|
|
134
|
-
<
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
<ModelRegistryContext.Provider value={registryState}>
|
|
139
|
+
<PortalContainerContext.Provider value={portalContainer}>
|
|
140
|
+
<div
|
|
141
|
+
className={cn("stgm", presetClass, className)}
|
|
142
|
+
data-stgm-color-mode={resolvedMode}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
</PortalContainerContext.Provider>
|
|
147
|
+
</ModelRegistryContext.Provider>
|
|
142
148
|
</ColorModeContext.Provider>
|
|
143
149
|
</DeploymentModeContext.Provider>
|
|
144
150
|
</StigmerContext.Provider>
|
|
145
151
|
);
|
|
146
152
|
}
|
|
147
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Fetches the model registry from the authenticated API on mount and
|
|
156
|
+
* caches the result for the lifetime of the provider.
|
|
157
|
+
*
|
|
158
|
+
* Uses the client's `baseUrl` and `getAuthCredential()` so the fetch
|
|
159
|
+
* is authenticated with the same token the SDK uses for all other calls.
|
|
160
|
+
*/
|
|
161
|
+
function useModelRegistryFetch(client: Stigmer): ModelRegistryState {
|
|
162
|
+
const [state, setState] = useState<ModelRegistryState>({
|
|
163
|
+
models: [],
|
|
164
|
+
isLoading: true,
|
|
165
|
+
error: null,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const clientRef = useRef(client);
|
|
169
|
+
clientRef.current = client;
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
let cancelled = false;
|
|
173
|
+
|
|
174
|
+
const c = clientRef.current;
|
|
175
|
+
c.getAuthCredential()
|
|
176
|
+
.then((token) => fetchModelRegistry(c.baseUrl, token))
|
|
177
|
+
.then((models) => {
|
|
178
|
+
if (!cancelled) {
|
|
179
|
+
setState({ models, isLoading: false, error: null });
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
.catch((err: unknown) => {
|
|
183
|
+
if (!cancelled) {
|
|
184
|
+
setState({
|
|
185
|
+
models: [],
|
|
186
|
+
isLoading: false,
|
|
187
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return () => { cancelled = true; };
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
return state;
|
|
196
|
+
}
|
|
197
|
+
|
|
148
198
|
/**
|
|
149
199
|
* Creates and manages a portal container `<div>` appended to
|
|
150
200
|
* `document.body` that mirrors the scoping attributes of the main
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { renderHook, act } from "@testing-library/react";
|
|
3
|
-
import {
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID, parseRegistryJson } from "../../models/registry";
|
|
5
|
+
import { ModelRegistryContext } from "../../models/ModelRegistryContext";
|
|
6
|
+
import type { ModelRegistryState } from "../../models/ModelRegistryContext";
|
|
4
7
|
import type { UseCreateSessionReturn } from "../useCreateSession";
|
|
5
8
|
|
|
6
9
|
const mockCreateSession = vi.fn<UseCreateSessionReturn["create"]>();
|
|
@@ -60,6 +63,24 @@ vi.mock("../../execution/useSessionVariables", () => ({
|
|
|
60
63
|
|
|
61
64
|
import { useNewSessionFlow } from "../useNewSessionFlow";
|
|
62
65
|
|
|
66
|
+
const TEST_MODELS = parseRegistryJson({
|
|
67
|
+
models: [
|
|
68
|
+
{ id: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", shortDescription: "", speedTier: "fast", provider: "anthropic", harness: "native", costTier: "standard", featured: true, pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 } },
|
|
69
|
+
{ id: "default", displayName: "Cursor Auto", shortDescription: "", speedTier: "fast", provider: "cursor", harness: "cursor", costTier: "standard", featured: true, pricing: { inputPricePerMillion: 1.25, outputPricePerMillion: 6, cacheWritePricePerMillion: 1.25, cacheReadPricePerMillion: 0.25 } },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function createWrapper() {
|
|
74
|
+
const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null };
|
|
75
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
76
|
+
return (
|
|
77
|
+
<ModelRegistryContext.Provider value={state}>
|
|
78
|
+
{children}
|
|
79
|
+
</ModelRegistryContext.Provider>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
const STORAGE_KEY_HARNESS = "stigmer:session:harness";
|
|
64
85
|
const STORAGE_KEY_MODEL_NATIVE = "stigmer:session:model";
|
|
65
86
|
const STORAGE_KEY_MODEL_CURSOR = "stigmer:session:model:cursor";
|
|
@@ -91,24 +112,24 @@ describe("useNewSessionFlow", () => {
|
|
|
91
112
|
|
|
92
113
|
describe("harness state", () => {
|
|
93
114
|
it("defaults to native when localStorage is empty", () => {
|
|
94
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
115
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
95
116
|
expect(result.current.harness).toBe("native");
|
|
96
117
|
});
|
|
97
118
|
|
|
98
119
|
it("restores cursor harness from localStorage", () => {
|
|
99
120
|
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
100
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
121
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
101
122
|
expect(result.current.harness).toBe("cursor");
|
|
102
123
|
});
|
|
103
124
|
|
|
104
125
|
it("falls back to native for unknown localStorage values", () => {
|
|
105
126
|
localStorage.setItem(STORAGE_KEY_HARNESS, "unknown-value");
|
|
106
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
127
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
107
128
|
expect(result.current.harness).toBe("native");
|
|
108
129
|
});
|
|
109
130
|
|
|
110
131
|
it("persists harness to localStorage on change", () => {
|
|
111
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
132
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
112
133
|
|
|
113
134
|
act(() => result.current.setHarness("cursor"));
|
|
114
135
|
|
|
@@ -118,7 +139,7 @@ describe("useNewSessionFlow", () => {
|
|
|
118
139
|
|
|
119
140
|
it("persists native harness to localStorage", () => {
|
|
120
141
|
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
121
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
142
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
122
143
|
|
|
123
144
|
act(() => result.current.setHarness("native"));
|
|
124
145
|
|
|
@@ -128,7 +149,7 @@ describe("useNewSessionFlow", () => {
|
|
|
128
149
|
|
|
129
150
|
describe("per-harness model persistence", () => {
|
|
130
151
|
it("uses separate storage keys for native and cursor models", () => {
|
|
131
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
152
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
132
153
|
|
|
133
154
|
act(() => result.current.setModelId(DEFAULT_MODEL_ID));
|
|
134
155
|
|
|
@@ -140,7 +161,7 @@ describe("useNewSessionFlow", () => {
|
|
|
140
161
|
|
|
141
162
|
it("persists cursor model to cursor-specific key", () => {
|
|
142
163
|
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
143
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
164
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
144
165
|
|
|
145
166
|
act(() => result.current.setModelId(DEFAULT_CURSOR_MODEL_ID));
|
|
146
167
|
|
|
@@ -152,7 +173,7 @@ describe("useNewSessionFlow", () => {
|
|
|
152
173
|
it("restores per-harness model when switching harness", () => {
|
|
153
174
|
localStorage.setItem(STORAGE_KEY_MODEL_CURSOR, DEFAULT_CURSOR_MODEL_ID);
|
|
154
175
|
|
|
155
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
176
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
156
177
|
|
|
157
178
|
act(() => result.current.setHarness("cursor"));
|
|
158
179
|
|
|
@@ -160,7 +181,7 @@ describe("useNewSessionFlow", () => {
|
|
|
160
181
|
});
|
|
161
182
|
|
|
162
183
|
it("clears modelId when switching to a harness with no stored model", () => {
|
|
163
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
184
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
164
185
|
|
|
165
186
|
act(() => result.current.setModelId(DEFAULT_MODEL_ID));
|
|
166
187
|
expect(result.current.modelId).toBe(DEFAULT_MODEL_ID);
|
|
@@ -178,7 +199,7 @@ describe("useNewSessionFlow", () => {
|
|
|
178
199
|
});
|
|
179
200
|
|
|
180
201
|
it("invalidates modelId when it is not in the active harness registry", () => {
|
|
181
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
202
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
182
203
|
|
|
183
204
|
// Set a native-only model
|
|
184
205
|
act(() => result.current.setModelId(DEFAULT_MODEL_ID));
|
|
@@ -193,7 +214,7 @@ describe("useNewSessionFlow", () => {
|
|
|
193
214
|
|
|
194
215
|
it("strips compound keys before persisting to localStorage", () => {
|
|
195
216
|
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
196
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
217
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
197
218
|
|
|
198
219
|
// Simulate compound key from unified mode ModelSelector
|
|
199
220
|
act(() => result.current.setModelId("cursor/default"));
|
|
@@ -207,7 +228,7 @@ describe("useNewSessionFlow", () => {
|
|
|
207
228
|
// Legacy: compound key was stored before fix
|
|
208
229
|
localStorage.setItem(STORAGE_KEY_MODEL_CURSOR, "cursor/default");
|
|
209
230
|
|
|
210
|
-
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
231
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
|
|
211
232
|
|
|
212
233
|
// Should extract plain modelId and validate against registry
|
|
213
234
|
expect(result.current.modelId).toBe(DEFAULT_CURSOR_MODEL_ID);
|
|
@@ -217,7 +238,7 @@ describe("useNewSessionFlow", () => {
|
|
|
217
238
|
describe("submit with harness", () => {
|
|
218
239
|
it("passes harness field to createSession", async () => {
|
|
219
240
|
const opts = defaultOptions();
|
|
220
|
-
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
241
|
+
const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
|
|
221
242
|
|
|
222
243
|
await act(async () => {
|
|
223
244
|
await result.current.submit("Hello");
|
|
@@ -230,7 +251,7 @@ describe("useNewSessionFlow", () => {
|
|
|
230
251
|
|
|
231
252
|
it("passes cursor harness to createSession after switching", async () => {
|
|
232
253
|
const opts = defaultOptions();
|
|
233
|
-
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
254
|
+
const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
|
|
234
255
|
|
|
235
256
|
act(() => result.current.setHarness("cursor"));
|
|
236
257
|
|
|
@@ -244,7 +265,7 @@ describe("useNewSessionFlow", () => {
|
|
|
244
265
|
|
|
245
266
|
it("calls onSessionCreated on success", async () => {
|
|
246
267
|
const opts = defaultOptions();
|
|
247
|
-
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
268
|
+
const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
|
|
248
269
|
|
|
249
270
|
await act(async () => {
|
|
250
271
|
await result.current.submit("Hello");
|
|
@@ -256,7 +277,7 @@ describe("useNewSessionFlow", () => {
|
|
|
256
277
|
it("sets submitError and calls onError on failure", async () => {
|
|
257
278
|
mockCreateSession.mockRejectedValueOnce(new Error("RPC fail"));
|
|
258
279
|
const opts = defaultOptions();
|
|
259
|
-
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
280
|
+
const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
|
|
260
281
|
|
|
261
282
|
await act(async () => {
|
|
262
283
|
await result.current.submit("Hello");
|
|
@@ -268,7 +289,7 @@ describe("useNewSessionFlow", () => {
|
|
|
268
289
|
|
|
269
290
|
it("resets isSubmitting after completion", async () => {
|
|
270
291
|
const opts = defaultOptions();
|
|
271
|
-
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
292
|
+
const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
|
|
272
293
|
|
|
273
294
|
await act(async () => {
|
|
274
295
|
await result.current.submit("Hello");
|
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
3
4
|
import { usePersistedModel } from "../usePersistedModel";
|
|
4
|
-
import { DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID } from "../../models/registry";
|
|
5
|
+
import { DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID, parseRegistryJson } from "../../models/registry";
|
|
6
|
+
import { ModelRegistryContext } from "../../models/ModelRegistryContext";
|
|
7
|
+
import type { ModelRegistryState } from "../../models/ModelRegistryContext";
|
|
8
|
+
|
|
9
|
+
const TEST_MODELS = parseRegistryJson({
|
|
10
|
+
models: [
|
|
11
|
+
{ id: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", shortDescription: "", speedTier: "fast", provider: "anthropic", harness: "native", costTier: "standard", featured: true, pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 } },
|
|
12
|
+
{ id: "default", displayName: "Cursor Auto", shortDescription: "", speedTier: "fast", provider: "cursor", harness: "cursor", costTier: "standard", featured: true, pricing: { inputPricePerMillion: 1.25, outputPricePerMillion: 6, cacheWritePricePerMillion: 1.25, cacheReadPricePerMillion: 0.25 } },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function createWrapper() {
|
|
17
|
+
const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null };
|
|
18
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
19
|
+
return (
|
|
20
|
+
<ModelRegistryContext.Provider value={state}>
|
|
21
|
+
{children}
|
|
22
|
+
</ModelRegistryContext.Provider>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
5
26
|
|
|
6
27
|
const STORAGE_KEY_NATIVE = "stigmer:session:model";
|
|
7
28
|
const STORAGE_KEY_CURSOR = "stigmer:session:model:cursor";
|
|
@@ -17,24 +38,24 @@ describe("usePersistedModel", () => {
|
|
|
17
38
|
|
|
18
39
|
describe("basic persistence", () => {
|
|
19
40
|
it("returns undefined when localStorage is empty", () => {
|
|
20
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "native" }));
|
|
41
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "native" }), { wrapper: createWrapper() });
|
|
21
42
|
expect(result.current[0]).toBeUndefined();
|
|
22
43
|
});
|
|
23
44
|
|
|
24
45
|
it("restores a valid model from localStorage", () => {
|
|
25
46
|
localStorage.setItem(STORAGE_KEY_NATIVE, DEFAULT_MODEL_ID);
|
|
26
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "native" }));
|
|
47
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "native" }), { wrapper: createWrapper() });
|
|
27
48
|
expect(result.current[0]).toBe(DEFAULT_MODEL_ID);
|
|
28
49
|
});
|
|
29
50
|
|
|
30
51
|
it("returns undefined for an invalid model in localStorage", () => {
|
|
31
52
|
localStorage.setItem(STORAGE_KEY_NATIVE, "nonexistent-model-xyz");
|
|
32
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "native" }));
|
|
53
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "native" }), { wrapper: createWrapper() });
|
|
33
54
|
expect(result.current[0]).toBeUndefined();
|
|
34
55
|
});
|
|
35
56
|
|
|
36
57
|
it("persists model on change", () => {
|
|
37
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "native" }));
|
|
58
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "native" }), { wrapper: createWrapper() });
|
|
38
59
|
|
|
39
60
|
act(() => result.current[1](DEFAULT_MODEL_ID));
|
|
40
61
|
|
|
@@ -44,7 +65,7 @@ describe("usePersistedModel", () => {
|
|
|
44
65
|
|
|
45
66
|
it("uses cursor-specific key for cursor harness", () => {
|
|
46
67
|
localStorage.setItem(STORAGE_KEY_CURSOR, DEFAULT_CURSOR_MODEL_ID);
|
|
47
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "cursor" }));
|
|
68
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "cursor" }), { wrapper: createWrapper() });
|
|
48
69
|
expect(result.current[0]).toBe(DEFAULT_CURSOR_MODEL_ID);
|
|
49
70
|
});
|
|
50
71
|
});
|
|
@@ -52,19 +73,19 @@ describe("usePersistedModel", () => {
|
|
|
52
73
|
describe("compound key handling", () => {
|
|
53
74
|
it("extracts plain modelId from compound key in localStorage", () => {
|
|
54
75
|
localStorage.setItem(STORAGE_KEY_CURSOR, "cursor/default");
|
|
55
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "cursor" }));
|
|
76
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "cursor" }), { wrapper: createWrapper() });
|
|
56
77
|
expect(result.current[0]).toBe(DEFAULT_CURSOR_MODEL_ID);
|
|
57
78
|
});
|
|
58
79
|
|
|
59
80
|
it("extracts plain modelId from native compound key", () => {
|
|
60
81
|
localStorage.setItem(STORAGE_KEY_NATIVE, `native/${DEFAULT_MODEL_ID}`);
|
|
61
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "native" }));
|
|
82
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "native" }), { wrapper: createWrapper() });
|
|
62
83
|
expect(result.current[0]).toBe(DEFAULT_MODEL_ID);
|
|
63
84
|
});
|
|
64
85
|
|
|
65
86
|
it("handles non-compound values unchanged", () => {
|
|
66
87
|
localStorage.setItem(STORAGE_KEY_CURSOR, DEFAULT_CURSOR_MODEL_ID);
|
|
67
|
-
const { result } = renderHook(() => usePersistedModel({ harness: "cursor" }));
|
|
88
|
+
const { result } = renderHook(() => usePersistedModel({ harness: "cursor" }), { wrapper: createWrapper() });
|
|
68
89
|
expect(result.current[0]).toBe(DEFAULT_CURSOR_MODEL_ID);
|
|
69
90
|
});
|
|
70
91
|
});
|
|
@@ -76,7 +97,7 @@ describe("usePersistedModel", () => {
|
|
|
76
97
|
|
|
77
98
|
const { result, rerender } = renderHook(
|
|
78
99
|
({ harness }: { harness: "native" | "cursor" }) => usePersistedModel({ harness }),
|
|
79
|
-
{ initialProps: { harness: "native" } },
|
|
100
|
+
{ initialProps: { harness: "native" }, wrapper: createWrapper() },
|
|
80
101
|
);
|
|
81
102
|
|
|
82
103
|
expect(result.current[0]).toBe(DEFAULT_MODEL_ID);
|
|
@@ -91,7 +112,7 @@ describe("usePersistedModel", () => {
|
|
|
91
112
|
|
|
92
113
|
const { result, rerender } = renderHook(
|
|
93
114
|
({ harness }: { harness: "native" | "cursor" }) => usePersistedModel({ harness }),
|
|
94
|
-
{ initialProps: { harness: "native" } },
|
|
115
|
+
{ initialProps: { harness: "native" }, wrapper: createWrapper() },
|
|
95
116
|
);
|
|
96
117
|
|
|
97
118
|
expect(result.current[0]).toBe(DEFAULT_MODEL_ID);
|
|
@@ -106,7 +127,7 @@ describe("usePersistedModel", () => {
|
|
|
106
127
|
|
|
107
128
|
const { result, rerender } = renderHook(
|
|
108
129
|
({ harness }: { harness: "native" | "cursor" }) => usePersistedModel({ harness }),
|
|
109
|
-
{ initialProps: { harness: "native" } },
|
|
130
|
+
{ initialProps: { harness: "native" }, wrapper: createWrapper() },
|
|
110
131
|
);
|
|
111
132
|
|
|
112
133
|
rerender({ harness: "cursor" });
|