@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.
Files changed (42) hide show
  1. package/index.d.ts +2 -2
  2. package/index.d.ts.map +1 -1
  3. package/index.js +1 -1
  4. package/index.js.map +1 -1
  5. package/models/ModelRegistryContext.d.ts +21 -0
  6. package/models/ModelRegistryContext.d.ts.map +1 -0
  7. package/models/ModelRegistryContext.js +22 -0
  8. package/models/ModelRegistryContext.js.map +1 -0
  9. package/models/ModelSelector.js +3 -3
  10. package/models/ModelSelector.js.map +1 -1
  11. package/models/__tests__/useModelRegistry.test.js +127 -32
  12. package/models/__tests__/useModelRegistry.test.js.map +1 -1
  13. package/models/index.d.ts +3 -1
  14. package/models/index.d.ts.map +1 -1
  15. package/models/index.js +2 -1
  16. package/models/index.js.map +1 -1
  17. package/models/registry.d.ts +20 -12
  18. package/models/registry.d.ts.map +1 -1
  19. package/models/registry.js +51 -27
  20. package/models/registry.js.map +1 -1
  21. package/models/useModelRegistry.d.ts +11 -3
  22. package/models/useModelRegistry.d.ts.map +1 -1
  23. package/models/useModelRegistry.js +13 -5
  24. package/models/useModelRegistry.js.map +1 -1
  25. package/package.json +4 -4
  26. package/provider.d.ts.map +1 -1
  27. package/provider.js +42 -1
  28. package/provider.js.map +1 -1
  29. package/session/__tests__/useNewSessionFlow.test.js +32 -18
  30. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  31. package/session/__tests__/usePersistedModel.test.js +26 -12
  32. package/session/__tests__/usePersistedModel.test.js.map +1 -1
  33. package/src/index.ts +4 -1
  34. package/src/models/ModelRegistryContext.ts +32 -0
  35. package/src/models/ModelSelector.tsx +4 -4
  36. package/src/models/__tests__/useModelRegistry.test.tsx +150 -41
  37. package/src/models/index.ts +3 -1
  38. package/src/models/registry.ts +51 -30
  39. package/src/models/useModelRegistry.ts +18 -7
  40. package/src/provider.tsx +58 -8
  41. package/src/session/__tests__/useNewSessionFlow.test.tsx +39 -18
  42. package/src/session/__tests__/usePersistedModel.test.tsx +33 -12
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Model registry — UI-relevant metadata for all platform-supported LLM models.
3
3
  *
4
- * Reads from the unified JSON registry at backend/libs/model-registry.json,
5
- * which is the single source of truth for model IDs, display names, pricing,
6
- * and cost tiers across all harnesses and runtimes.
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
- * Update it with: @update-model-registry
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 {@link MODEL_REGISTRY} uses this for grouping in 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 MODEL_REGISTRY so backend
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
- * Static catalog of all platform-supported LLM models, loaded from the
175
- * unified JSON registry.
174
+ * Parse raw registry JSON (from the API or a static file) into `ModelInfo[]`.
176
175
  *
177
- * {@link useModelRegistry} filters out disabled providers and provides
178
- * lookup helpers on top of this list.
176
+ * Expects the shape `{ models: RegistryJsonEntry[] }`. Filters out comment
177
+ * entries and invalid rows, then maps to the `ModelInfo` interface.
179
178
  */
180
- export const MODEL_REGISTRY: readonly ModelInfo[] = (
181
- registryData.models as RegistryJsonEntry[]
182
- )
183
- .filter(isModelEntry)
184
- .map((m) => ({
185
- modelId: m.id,
186
- provider: m.provider as Provider,
187
- displayName: m.displayName,
188
- shortDescription: m.shortDescription ?? "",
189
- speedTier: (VALID_SPEED_TIERS.has(m.speedTier ?? "") ? m.speedTier : "fast") as SpeedTier,
190
- costTier: m.costTier as CostTier,
191
- harness: m.harness as HarnessOption,
192
- featured: m.featured ?? false,
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 = MODEL_REGISTRY.find(
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 = MODEL_REGISTRY.find(
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 = MODEL_REGISTRY.find(
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 = MODEL_REGISTRY.find(
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 MODEL_REGISTRY) {
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
- <PortalContainerContext.Provider value={portalContainer}>
135
- <div
136
- className={cn("stgm", presetClass, className)}
137
- data-stgm-color-mode={resolvedMode}
138
- >
139
- {children}
140
- </div>
141
- </PortalContainerContext.Provider>
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 { DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID } from "../../models/registry";
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" });