feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare)
This commit is contained in:
@@ -58,6 +58,15 @@ export function groupCatalog(entries: ModelCatalogEntry[]): CatalogGroup[] {
|
||||
);
|
||||
}
|
||||
|
||||
/** Load the model catalog once (module cache shared across views). */
|
||||
export function fetchCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
if (catalogCache !== null) return Promise.resolve(catalogCache);
|
||||
return fetchJson<ModelCatalogEntry[]>(Route.ModelCatalog).then((list) => {
|
||||
catalogCache = list;
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
/** Test seam: drop the module-level catalog cache. */
|
||||
export function resetCatalogCache(): void {
|
||||
catalogCache = null;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Repo, RepoImage, SessionListItem } from "./protocol";
|
||||
import { Route as ApiRoute } from "./protocol";
|
||||
import { fetchJson } from "./api";
|
||||
import { resetCatalogCache } from "./ChatView";
|
||||
import SpawnView from "./SpawnView";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
@@ -667,3 +668,50 @@ describe("SpawnView repo images (registry)", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawn model selection", () => {
|
||||
beforeEach(() => resetCatalogCache());
|
||||
it("model select lists catalog grouped by provider", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/model-catalog"))
|
||||
return [
|
||||
{ provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Sonnet" },
|
||||
];
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
const sel = screen.getByLabelText("Initial model");
|
||||
await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2));
|
||||
expect(sel.querySelector('option[value="zai-renaud/glm-5.3"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("selected model is sent in the spawn body", async () => {
|
||||
const bodies: string[] = [];
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
bodies.push(String(init.body));
|
||||
return { sessionId: "sm1", imageUsed: "lvmh-worker:latest" };
|
||||
}
|
||||
if (url.endsWith("/api/model-catalog"))
|
||||
return [{ provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" }];
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/p"));
|
||||
const sel = screen.getByLabelText("Initial model");
|
||||
await waitFor(() => expect(sel.querySelectorAll("option").length).toBeGreaterThan(1));
|
||||
fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } });
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type {
|
||||
GitlabStatus,
|
||||
ModelCatalogEntry,
|
||||
Repo,
|
||||
RepoImage,
|
||||
SpawnJob,
|
||||
@@ -10,6 +11,7 @@ import type {
|
||||
import { Route } from "./protocol";
|
||||
import { errMessage, fetchJson } from "./api";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { fetchCatalog, groupCatalog } from "./ChatView";
|
||||
|
||||
const POLL_MS: number = 1500;
|
||||
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
|
||||
@@ -59,10 +61,22 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selected, setSelected] = useState<Repo | null>(null);
|
||||
const [branch, setBranch] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void fetchCatalog().then((c) => {
|
||||
if (alive) setCatalog(c);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const tickRef = useRef<number>(0);
|
||||
|
||||
@@ -161,6 +175,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
const body = {
|
||||
repo: selected!.path,
|
||||
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||
...(model.length > 0 ? { model } : {}),
|
||||
};
|
||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||
method: "POST",
|
||||
@@ -391,6 +406,29 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
{registration(selected.path)?.image ?? "base worker image"}
|
||||
</p>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<select
|
||||
aria-label="Initial model"
|
||||
value={model}
|
||||
disabled={selected === null}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
>
|
||||
<option value="">Default model (settings)</option>
|
||||
{catalog !== null &&
|
||||
groupCatalog(catalog).map((g) => (
|
||||
<optgroup key={g.provider} label={g.provider}>
|
||||
{g.models.map((m) => (
|
||||
<option
|
||||
key={`${m.provider}/${m.id}`}
|
||||
value={`${m.provider}/${m.id}`}
|
||||
>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user