@stigmer/react 0.4.5 → 0.4.7

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 (52) hide show
  1. package/composer/ComposerToolbar.d.ts.map +1 -1
  2. package/composer/ComposerToolbar.js +1 -1
  3. package/composer/ComposerToolbar.js.map +1 -1
  4. package/index.d.ts +2 -2
  5. package/index.d.ts.map +1 -1
  6. package/index.js +1 -1
  7. package/index.js.map +1 -1
  8. package/models/ModelRegistryContext.d.ts +21 -0
  9. package/models/ModelRegistryContext.d.ts.map +1 -0
  10. package/models/ModelRegistryContext.js +22 -0
  11. package/models/ModelRegistryContext.js.map +1 -0
  12. package/models/ModelSelector.d.ts +9 -1
  13. package/models/ModelSelector.d.ts.map +1 -1
  14. package/models/ModelSelector.js +10 -5
  15. package/models/ModelSelector.js.map +1 -1
  16. package/models/__tests__/useModelRegistry.test.js +127 -32
  17. package/models/__tests__/useModelRegistry.test.js.map +1 -1
  18. package/models/index.d.ts +3 -1
  19. package/models/index.d.ts.map +1 -1
  20. package/models/index.js +2 -1
  21. package/models/index.js.map +1 -1
  22. package/models/registry.d.ts +20 -12
  23. package/models/registry.d.ts.map +1 -1
  24. package/models/registry.js +51 -27
  25. package/models/registry.js.map +1 -1
  26. package/models/useModelRegistry.d.ts +11 -3
  27. package/models/useModelRegistry.d.ts.map +1 -1
  28. package/models/useModelRegistry.js +13 -5
  29. package/models/useModelRegistry.js.map +1 -1
  30. package/package.json +4 -4
  31. package/provider.d.ts.map +1 -1
  32. package/provider.js +42 -1
  33. package/provider.js.map +1 -1
  34. package/session/__tests__/useNewSessionFlow.test.js +89 -18
  35. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  36. package/session/__tests__/usePersistedModel.test.js +26 -12
  37. package/session/__tests__/usePersistedModel.test.js.map +1 -1
  38. package/session/useNewSessionFlow.d.ts.map +1 -1
  39. package/session/useNewSessionFlow.js +20 -6
  40. package/session/useNewSessionFlow.js.map +1 -1
  41. package/src/composer/ComposerToolbar.tsx +1 -0
  42. package/src/index.ts +4 -1
  43. package/src/models/ModelRegistryContext.ts +32 -0
  44. package/src/models/ModelSelector.tsx +22 -5
  45. package/src/models/__tests__/useModelRegistry.test.tsx +150 -41
  46. package/src/models/index.ts +3 -1
  47. package/src/models/registry.ts +51 -30
  48. package/src/models/useModelRegistry.ts +18 -7
  49. package/src/provider.tsx +58 -8
  50. package/src/session/__tests__/useNewSessionFlow.test.tsx +120 -18
  51. package/src/session/__tests__/usePersistedModel.test.tsx +33 -12
  52. package/src/session/useNewSessionFlow.ts +17 -6
@@ -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"]>();
@@ -58,8 +61,37 @@ vi.mock("../../execution/useSessionVariables", () => ({
58
61
  useSessionVariables: () => mockSessionVariables,
59
62
  }));
60
63
 
64
+ const mockRunnerList = {
65
+ runners: [] as { metadata?: { id: string; name?: string } }[],
66
+ isLoading: false,
67
+ isRefetching: false,
68
+ error: null,
69
+ refetch: vi.fn(),
70
+ };
71
+ vi.mock("../../runner/useRunnerList", () => ({
72
+ useRunnerList: () => mockRunnerList,
73
+ }));
74
+
61
75
  import { useNewSessionFlow } from "../useNewSessionFlow";
62
76
 
77
+ const TEST_MODELS = parseRegistryJson({
78
+ models: [
79
+ { 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 } },
80
+ { 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 } },
81
+ ],
82
+ });
83
+
84
+ function createWrapper() {
85
+ const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null };
86
+ return function Wrapper({ children }: { children: ReactNode }) {
87
+ return (
88
+ <ModelRegistryContext.Provider value={state}>
89
+ {children}
90
+ </ModelRegistryContext.Provider>
91
+ );
92
+ };
93
+ }
94
+
63
95
  const STORAGE_KEY_HARNESS = "stigmer:session:harness";
64
96
  const STORAGE_KEY_MODEL_NATIVE = "stigmer:session:model";
65
97
  const STORAGE_KEY_MODEL_CURSOR = "stigmer:session:model:cursor";
@@ -80,6 +112,8 @@ describe("useNewSessionFlow", () => {
80
112
  };
81
113
  mockDefaultAgent.isLoading = false;
82
114
  mockDefaultAgent.error = null;
115
+ mockRunnerList.runners = [];
116
+ mockRunnerList.isLoading = false;
83
117
  mockCreateSession.mockResolvedValue({ sessionId: "sess-new" });
84
118
  mockCreateExecution.mockResolvedValue({});
85
119
  });
@@ -91,24 +125,24 @@ describe("useNewSessionFlow", () => {
91
125
 
92
126
  describe("harness state", () => {
93
127
  it("defaults to native when localStorage is empty", () => {
94
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
128
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
95
129
  expect(result.current.harness).toBe("native");
96
130
  });
97
131
 
98
132
  it("restores cursor harness from localStorage", () => {
99
133
  localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
100
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
134
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
101
135
  expect(result.current.harness).toBe("cursor");
102
136
  });
103
137
 
104
138
  it("falls back to native for unknown localStorage values", () => {
105
139
  localStorage.setItem(STORAGE_KEY_HARNESS, "unknown-value");
106
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
140
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
107
141
  expect(result.current.harness).toBe("native");
108
142
  });
109
143
 
110
144
  it("persists harness to localStorage on change", () => {
111
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
145
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
112
146
 
113
147
  act(() => result.current.setHarness("cursor"));
114
148
 
@@ -118,7 +152,7 @@ describe("useNewSessionFlow", () => {
118
152
 
119
153
  it("persists native harness to localStorage", () => {
120
154
  localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
121
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
155
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
122
156
 
123
157
  act(() => result.current.setHarness("native"));
124
158
 
@@ -128,7 +162,7 @@ describe("useNewSessionFlow", () => {
128
162
 
129
163
  describe("per-harness model persistence", () => {
130
164
  it("uses separate storage keys for native and cursor models", () => {
131
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
165
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
132
166
 
133
167
  act(() => result.current.setModelId(DEFAULT_MODEL_ID));
134
168
 
@@ -140,7 +174,7 @@ describe("useNewSessionFlow", () => {
140
174
 
141
175
  it("persists cursor model to cursor-specific key", () => {
142
176
  localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
143
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
177
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
144
178
 
145
179
  act(() => result.current.setModelId(DEFAULT_CURSOR_MODEL_ID));
146
180
 
@@ -152,7 +186,7 @@ describe("useNewSessionFlow", () => {
152
186
  it("restores per-harness model when switching harness", () => {
153
187
  localStorage.setItem(STORAGE_KEY_MODEL_CURSOR, DEFAULT_CURSOR_MODEL_ID);
154
188
 
155
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
189
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
156
190
 
157
191
  act(() => result.current.setHarness("cursor"));
158
192
 
@@ -160,7 +194,7 @@ describe("useNewSessionFlow", () => {
160
194
  });
161
195
 
162
196
  it("clears modelId when switching to a harness with no stored model", () => {
163
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
197
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
164
198
 
165
199
  act(() => result.current.setModelId(DEFAULT_MODEL_ID));
166
200
  expect(result.current.modelId).toBe(DEFAULT_MODEL_ID);
@@ -178,7 +212,7 @@ describe("useNewSessionFlow", () => {
178
212
  });
179
213
 
180
214
  it("invalidates modelId when it is not in the active harness registry", () => {
181
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
215
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
182
216
 
183
217
  // Set a native-only model
184
218
  act(() => result.current.setModelId(DEFAULT_MODEL_ID));
@@ -193,7 +227,7 @@ describe("useNewSessionFlow", () => {
193
227
 
194
228
  it("strips compound keys before persisting to localStorage", () => {
195
229
  localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
196
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
230
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
197
231
 
198
232
  // Simulate compound key from unified mode ModelSelector
199
233
  act(() => result.current.setModelId("cursor/default"));
@@ -207,17 +241,85 @@ describe("useNewSessionFlow", () => {
207
241
  // Legacy: compound key was stored before fix
208
242
  localStorage.setItem(STORAGE_KEY_MODEL_CURSOR, "cursor/default");
209
243
 
210
- const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
244
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
211
245
 
212
246
  // Should extract plain modelId and validate against registry
213
247
  expect(result.current.modelId).toBe(DEFAULT_CURSOR_MODEL_ID);
214
248
  });
215
249
  });
216
250
 
251
+ describe("runner validation gate", () => {
252
+ it("restores runner when it exists in the live runner list", () => {
253
+ localStorage.setItem("stigmer:session:runner", "runner-abc");
254
+ mockRunnerList.runners = [{ metadata: { id: "runner-abc", name: "My Runner" } }];
255
+ mockRunnerList.isLoading = false;
256
+
257
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
258
+
259
+ expect(result.current.runnerId).toBe("runner-abc");
260
+ });
261
+
262
+ it("discards stale runner that no longer exists in the runner list", () => {
263
+ localStorage.setItem("stigmer:session:runner", "deleted-runner");
264
+ mockRunnerList.runners = [{ metadata: { id: "runner-abc", name: "My Runner" } }];
265
+ mockRunnerList.isLoading = false;
266
+
267
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
268
+
269
+ expect(result.current.runnerId).toBeNull();
270
+ expect(localStorage.getItem("stigmer:session:runner")).toBeNull();
271
+ });
272
+
273
+ it("does not restore runner while runner list is loading", () => {
274
+ localStorage.setItem("stigmer:session:runner", "runner-abc");
275
+ mockRunnerList.runners = [];
276
+ mockRunnerList.isLoading = true;
277
+
278
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
279
+
280
+ expect(result.current.runnerId).toBeNull();
281
+ });
282
+ });
283
+
284
+ describe("model validation timing", () => {
285
+ it("does not restore model while registry is loading", () => {
286
+ localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, DEFAULT_MODEL_ID);
287
+ const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null };
288
+
289
+ function LoadingWrapper({ children }: { children: ReactNode }) {
290
+ return (
291
+ <ModelRegistryContext.Provider value={loadingState}>
292
+ {children}
293
+ </ModelRegistryContext.Provider>
294
+ );
295
+ }
296
+
297
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: LoadingWrapper });
298
+
299
+ expect(result.current.modelId).toBeUndefined();
300
+ });
301
+
302
+ it("restores model once registry has loaded", () => {
303
+ localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, DEFAULT_MODEL_ID);
304
+
305
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
306
+
307
+ expect(result.current.modelId).toBe(DEFAULT_MODEL_ID);
308
+ });
309
+
310
+ it("discards model that no longer exists in the registry", () => {
311
+ localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, "removed-model-xyz");
312
+
313
+ const { result } = renderHook(() => useNewSessionFlow(defaultOptions()), { wrapper: createWrapper() });
314
+
315
+ expect(result.current.modelId).toBeUndefined();
316
+ });
317
+ });
318
+
217
319
  describe("submit with harness", () => {
218
320
  it("passes harness field to createSession", async () => {
219
321
  const opts = defaultOptions();
220
- const { result } = renderHook(() => useNewSessionFlow(opts));
322
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
221
323
 
222
324
  await act(async () => {
223
325
  await result.current.submit("Hello");
@@ -230,7 +332,7 @@ describe("useNewSessionFlow", () => {
230
332
 
231
333
  it("passes cursor harness to createSession after switching", async () => {
232
334
  const opts = defaultOptions();
233
- const { result } = renderHook(() => useNewSessionFlow(opts));
335
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
234
336
 
235
337
  act(() => result.current.setHarness("cursor"));
236
338
 
@@ -244,7 +346,7 @@ describe("useNewSessionFlow", () => {
244
346
 
245
347
  it("calls onSessionCreated on success", async () => {
246
348
  const opts = defaultOptions();
247
- const { result } = renderHook(() => useNewSessionFlow(opts));
349
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
248
350
 
249
351
  await act(async () => {
250
352
  await result.current.submit("Hello");
@@ -256,7 +358,7 @@ describe("useNewSessionFlow", () => {
256
358
  it("sets submitError and calls onError on failure", async () => {
257
359
  mockCreateSession.mockRejectedValueOnce(new Error("RPC fail"));
258
360
  const opts = defaultOptions();
259
- const { result } = renderHook(() => useNewSessionFlow(opts));
361
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
260
362
 
261
363
  await act(async () => {
262
364
  await result.current.submit("Hello");
@@ -268,7 +370,7 @@ describe("useNewSessionFlow", () => {
268
370
 
269
371
  it("resets isSubmitting after completion", async () => {
270
372
  const opts = defaultOptions();
271
- const { result } = renderHook(() => useNewSessionFlow(opts));
373
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
272
374
 
273
375
  await act(async () => {
274
376
  await result.current.submit("Hello");