@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.
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +1 -1
- package/composer/ComposerToolbar.js.map +1 -1
- 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.d.ts +9 -1
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +10 -5
- 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 +89 -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/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +20 -6
- package/session/useNewSessionFlow.js.map +1 -1
- package/src/composer/ComposerToolbar.tsx +1 -0
- package/src/index.ts +4 -1
- package/src/models/ModelRegistryContext.ts +32 -0
- package/src/models/ModelSelector.tsx +22 -5
- 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 +120 -18
- package/src/session/__tests__/usePersistedModel.test.tsx +33 -12
- package/src/session/useNewSessionFlow.ts +17 -6
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"]>();
|
|
@@ -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");
|