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:
Raphael Westphal
2026-08-20 12:43:55 +02:00
parent 5292a69d66
commit 2d0839357d
11 changed files with 553 additions and 48 deletions
+9
View File
@@ -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;
+49 -1
View File
@@ -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"');
});
});
+38
View File
@@ -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>
</>