Models
-
-
-
-
-
+ {runtime.settled &&
+ (runtime.absent ? (
+
+ ) : (
+ <>
+
+
+
+
+
+ >
+ ))}
diff --git a/services/web/src/screens/ModulesScreen.tsx b/services/web/src/screens/ModulesScreen.tsx
index c2308b38..67fac7cf 100644
--- a/services/web/src/screens/ModulesScreen.tsx
+++ b/services/web/src/screens/ModulesScreen.tsx
@@ -32,6 +32,7 @@ import type {
ToolSpec,
UiAction,
} from "@/lib/contracts";
+import { useLocalRuntime } from "@/lib/useLocalRuntime";
/**
* Reorder card (#543): drag-and-drop (mirroring the tasks board's native HTML5 pattern, #380)
@@ -293,6 +294,9 @@ function ModuleModels({ snapshot }: { snapshot: ModuleSnapshot }) {
// Saved hosted ids (#496) are assignable to a chat slot too — a module can run on a hosted
// model, not only a local one (ADR-0029).
const saved = useQuery({ queryKey: ["savedModels"], queryFn: () => api.savedModels() });
+ // With no local runtime (#962) the local half of every slot's list is permanently empty. A
+ // slot left with nothing but "Core default" has to say why, or it just looks broken.
+ const runtime = useLocalRuntime();
const save = useMutation({
mutationFn: (next: Record
) => api.setModuleModels(name, next),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["module-models", name] }),
@@ -338,6 +342,12 @@ function ModuleModels({ snapshot }: { snapshot: ModuleSnapshot }) {
)}
+ {runtime.absent && !(slot.role === "chat" && hosted.length > 0) && (
+
+ No local AI on this deployment and no hosted model available for this slot — it
+ uses the core's default.
+
+ )}
))}
diff --git a/services/web/src/test/ChatLocalRuntime.test.tsx b/services/web/src/test/ChatLocalRuntime.test.tsx
new file mode 100644
index 00000000..0a79a4cf
--- /dev/null
+++ b/services/web/src/test/ChatLocalRuntime.test.tsx
@@ -0,0 +1,140 @@
+/**
+ * Chat against a deployment that runs no local runtime (#962).
+ *
+ * The model picker used to open on a "Local" heading whatever the deployment was, with
+ * "local runtime unreachable" under it when the model list failed — a sentence that describes
+ * a fault on a deployment where there is deliberately nothing to reach. The heading goes; the
+ * core-default row stays, because the core's default may well be a hosted model and this is the
+ * only way back to it.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ChatScreen } from "@/screens/ChatScreen";
+import { usePrefs } from "@/stores/prefs";
+
+const mockModels = vi.fn();
+const mockLocalRuntime = vi.fn();
+const mockProviders = vi.fn();
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ models: () => mockModels(),
+ localRuntime: () => mockLocalRuntime(),
+ providers: () => mockProviders(),
+ savedModels: vi.fn().mockResolvedValue([]),
+ sessions: vi.fn().mockResolvedValue([]),
+ sessionMessages: vi.fn().mockResolvedValue([]),
+ deleteSession: vi.fn().mockResolvedValue({ deleted: 0 }),
+ activeRun: vi.fn().mockResolvedValue(null),
+ cancelActiveRun: vi.fn().mockResolvedValue({ cancelled: false }),
+ modules: vi.fn().mockResolvedValue([]),
+ modelDetails: vi.fn().mockResolvedValue({ capabilities: [] }),
+ setSessionModel: vi.fn().mockResolvedValue({ status: "ok" }),
+ addSavedModel: vi.fn().mockResolvedValue({ status: "ok" }),
+ llmPrefs: vi.fn().mockResolvedValue({
+ global_default: null,
+ global_embed_default: null,
+ global_context_window: null,
+ kv_cache_type: null,
+ global_agent_max_steps: null,
+ hidden: [],
+ }),
+ },
+}));
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return (
+
+ {children}
+
+ );
+}
+
+/** Open the model picker sheet from the composer header. */
+async function openPicker() {
+ fireEvent.click(await screen.findByRole("button", { name: /default model/i }));
+ await screen.findByText("Model for this chat");
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockModels.mockResolvedValue([]);
+ mockProviders.mockResolvedValue([]);
+ mockLocalRuntime.mockResolvedValue({ state: "ok", url_configured: true });
+ usePrefs.setState({ model: null });
+});
+
+afterEach(() => {
+ usePrefs.setState({ model: null });
+});
+
+describe("Chat model picker — no local runtime", () => {
+ beforeEach(() => {
+ mockLocalRuntime.mockResolvedValue({ state: "absent", url_configured: false });
+ });
+
+ it("shows no Local heading and no unreachable warning", async () => {
+ render(
, { wrapper });
+ await openPicker();
+
+ expect(screen.queryByText("Local")).toBeNull();
+ expect(screen.queryByText(/local runtime unreachable/i)).toBeNull();
+ // The hosted half is the whole picker here, and it still explains itself.
+ expect(screen.getByText("Hosted")).toBeInTheDocument();
+ });
+
+ it("still offers the core default — it may well be a hosted model", async () => {
+ render(
, { wrapper });
+ await openPicker();
+
+ expect(screen.getByRole("button", { name: /core default/i })).toBeInTheDocument();
+ });
+});
+
+describe("Chat model picker — a runtime that is there", () => {
+ it("keeps the Local heading when the runtime is ok", async () => {
+ mockModels.mockResolvedValue([
+ { name: "llama3.2:latest", size: 1, loaded: false, hidden: false, capabilities: [] },
+ ]);
+ render(
, { wrapper });
+ await openPicker();
+
+ expect(await screen.findByText("Local")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /llama3\.2:latest/ })).toBeInTheDocument();
+ });
+
+ it("warns when the runtime is unreachable, even though the model list answered 200", async () => {
+ // The pin: since #962 `/llm/models` no longer errors, so a warning driven by that query
+ // failing would simply never appear again.
+ mockLocalRuntime.mockResolvedValue({ state: "unreachable", url_configured: true });
+ mockModels.mockResolvedValue([]);
+ render(
, { wrapper });
+ await openPicker();
+
+ expect(await screen.findByText(/local runtime unreachable/i)).toBeInTheDocument();
+ expect(screen.getByText("Local")).toBeInTheDocument();
+ });
+});
+
+describe("Chat first run", () => {
+ it("does not tell a hosted-only deployment to pull a local model", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "absent", url_configured: false });
+ render(
, { wrapper });
+
+ await waitFor(() =>
+ expect(screen.getByText(/This deployment runs no local AI/i)).toBeInTheDocument(),
+ );
+ expect(screen.queryByRole("button", { name: /pull llama3\.2/i })).toBeNull();
+ });
+
+ it("still offers a pull on a deployment that has a runtime", async () => {
+ render(
, { wrapper });
+
+ expect(await screen.findByRole("button", { name: /pull llama3\.2/i })).toBeInTheDocument();
+ });
+});
diff --git a/services/web/src/test/ModelsScreenLocalRuntime.test.tsx b/services/web/src/test/ModelsScreenLocalRuntime.test.tsx
new file mode 100644
index 00000000..722bdb9a
--- /dev/null
+++ b/services/web/src/test/ModelsScreenLocalRuntime.test.tsx
@@ -0,0 +1,229 @@
+/**
+ * The Models page against the three local-runtime states (#962).
+ *
+ * `absent` is a deployment that runs no local runtime at all (`OLLAMA_URL=""`), which is a
+ * supported mode and not a fault: the local half of the page collapses into one line rather
+ * than showing five cards of Ollama controls that can never do anything. `unreachable` keeps
+ * today's warning, because that state *is* an error. `ok` must not regress — it is the common
+ * case.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ModelsScreen } from "@/screens/ModelsScreen";
+
+vi.mock("@/stores/downloads", () => ({
+ useDownloads: (selector: (s: unknown) => unknown) =>
+ selector({ active: {}, pull: vi.fn(), dismiss: vi.fn() }),
+}));
+
+const mockModels = vi.fn();
+const mockLocalRuntime = vi.fn();
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ models: (caps?: boolean) => mockModels(caps),
+ localRuntime: () => mockLocalRuntime(),
+ catalog: vi.fn().mockResolvedValue({ source: "https://example.test", entries: [] }),
+ systemInfo: vi.fn().mockResolvedValue({
+ suggested_context: { min: 2048, suggested: 8192, max: 16384 },
+ }),
+ llmPrefs: vi.fn().mockResolvedValue({
+ global_default: null,
+ global_embed_default: null,
+ global_context_window: null,
+ kv_cache_type: null,
+ global_agent_max_steps: null,
+ hidden: [],
+ }),
+ savedModels: vi.fn().mockResolvedValue([]),
+ providers: vi.fn().mockResolvedValue([]),
+ recallDimension: vi.fn().mockResolvedValue({ status: "ok", detail: "" }),
+ modelSettings: vi.fn().mockResolvedValue({ context_window: null, keep_alive: null, device: null }),
+ modelDetails: vi.fn().mockResolvedValue({ capabilities: [] }),
+ modelVariants: vi.fn().mockResolvedValue({ model: "x", variants: [] }),
+ },
+}));
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return
{children};
+}
+
+const LOCAL_MODEL = {
+ name: "llama3.2:latest",
+ size: 4_700_000_000,
+ loaded: false,
+ hidden: false,
+ capabilities: [],
+};
+
+/** The optgroup that must not be offerable when no runtime can serve it. */
+const localOptgroup = () => document.querySelector('optgroup[label="Local (Ollama)"]');
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockModels.mockResolvedValue([LOCAL_MODEL]);
+ mockLocalRuntime.mockResolvedValue({ state: "ok", url_configured: true });
+});
+
+describe("Models page — no local runtime (absent)", () => {
+ beforeEach(() => {
+ mockLocalRuntime.mockResolvedValue({ state: "absent", url_configured: false });
+ // The contract: the model list is a plain, successful, empty 200 — not an error.
+ mockModels.mockResolvedValue([]);
+ });
+
+ it("collapses the local half into one honest line", async () => {
+ render(
, { wrapper });
+
+ expect(await screen.findByTestId("local-runtime-absent")).toHaveTextContent(
+ /Local AI is not configured on this deployment/i,
+ );
+ // And says the part that matters: nothing is broken, hosted keeps working.
+ expect(screen.getByTestId("local-runtime-absent")).toHaveTextContent(
+ /Hosted models are unaffected/i,
+ );
+ });
+
+ it("removes the pull card, the catalog, the local list, the context window and the KV cache", async () => {
+ render(
, { wrapper });
+ await screen.findByTestId("local-runtime-absent");
+
+ // Removed from the render, not disabled — a disabled control still reads as a fault.
+ expect(screen.queryByText("Browse models")).toBeNull();
+ expect(screen.queryByText("Local models")).toBeNull();
+ expect(screen.queryByText("Default context window")).toBeNull();
+ expect(screen.queryByRole("heading", { name: "KV-cache type" })).toBeNull();
+ expect(screen.queryByRole("button", { name: /^pull$/i })).toBeNull();
+ });
+
+ it("never calls the absent state an error", async () => {
+ render(
, { wrapper });
+ await screen.findByTestId("local-runtime-absent");
+
+ expect(screen.queryByText(/is the ollama service up/i)).toBeNull();
+ expect(screen.queryByText(/unreachable/i)).toBeNull();
+ });
+
+ it("keeps the hosted half of the page", async () => {
+ render(
, { wrapper });
+ await screen.findByTestId("local-runtime-absent");
+
+ expect(screen.getByText("Embedding model")).toBeInTheDocument();
+ expect(screen.getByText("Hosted providers")).toBeInTheDocument();
+ expect(screen.getByText("Hosted models")).toBeInTheDocument();
+ });
+
+ it("drops the Local (Ollama) optgroup from the embedding picker", async () => {
+ render(
, { wrapper });
+ await screen.findByTestId("local-runtime-absent");
+
+ await waitFor(() => expect(screen.getByText("System default")).toBeInTheDocument());
+ expect(localOptgroup()).toBeNull();
+ });
+});
+
+describe("Models page — the runtime is unreachable", () => {
+ beforeEach(() => {
+ mockLocalRuntime.mockResolvedValue({ state: "unreachable", url_configured: true });
+ // The core answers 200 + [] now, so nothing but the state endpoint knows it is down.
+ mockModels.mockResolvedValue([]);
+ });
+
+ it("keeps the warning — that state is an error and still looks like one", async () => {
+ render(
, { wrapper });
+
+ expect(await screen.findByText(/is the ollama service up/i)).toBeInTheDocument();
+ });
+
+ it("does not claim local AI is unconfigured, and keeps the local cards", async () => {
+ render(
, { wrapper });
+ await screen.findByText(/is the ollama service up/i);
+
+ expect(screen.queryByTestId("local-runtime-absent")).toBeNull();
+ expect(screen.getByText("Local models")).toBeInTheDocument();
+ expect(screen.getByText("Browse models")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "KV-cache type" })).toBeInTheDocument();
+ });
+
+ it("does not also invite a pull into a runtime that is down", async () => {
+ render(
, { wrapper });
+ await screen.findByText(/is the ollama service up/i);
+
+ // An empty 200 used to be impossible here; now it must not read as "no models yet".
+ expect(screen.queryByText(/None yet\. Pull one above/i)).toBeNull();
+ });
+});
+
+describe("Models page — a healthy runtime (ok)", () => {
+ it("renders the full local UI", async () => {
+ render(
, { wrapper });
+
+ expect(await screen.findByText("Local models")).toBeInTheDocument();
+ expect(screen.getByText("Browse models")).toBeInTheDocument();
+ expect(screen.getByText("Default context window")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "KV-cache type" })).toBeInTheDocument();
+ expect(screen.queryByTestId("local-runtime-absent")).toBeNull();
+ expect(screen.queryByText(/is the ollama service up/i)).toBeNull();
+ });
+
+ it("offers the Local (Ollama) optgroup in the embedding picker", async () => {
+ render(
, { wrapper });
+ await screen.findByText("Embedding model");
+
+ await waitFor(() => expect(localOptgroup()).not.toBeNull());
+ expect(localOptgroup()).toHaveTextContent("llama3.2:latest");
+ });
+});
+
+/**
+ * The regression guard for the thing that will silently rot: the state is read from
+ * `/llm/local-runtime`, never inferred from `/llm/models`. The two used to be the same signal —
+ * an unreachable runtime made the model list 500 — and since #962 it does not, so any code that
+ * drifts back to inferring stops working with no test failing.
+ */
+describe("Models page — runtime state comes from its own endpoint", () => {
+ it("collapses on `absent` even though the model list answered successfully", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "absent", url_configured: false });
+ mockModels.mockResolvedValue([LOCAL_MODEL]); // a 200 with content, not an error
+
+ render(
, { wrapper });
+
+ expect(await screen.findByTestId("local-runtime-absent")).toBeInTheDocument();
+ expect(screen.queryByText("Local models")).toBeNull();
+ });
+
+ it("warns on `unreachable` even though the model list answered 200 with []", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "unreachable", url_configured: true });
+ mockModels.mockResolvedValue([]);
+
+ render(
, { wrapper });
+
+ // Nothing failed. Only the state endpoint knows, and the warning still appears.
+ expect(await screen.findByText(/is the ollama service up/i)).toBeInTheDocument();
+ });
+
+ it("does not collapse when the model list fails but the runtime reports ok", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "ok", url_configured: true });
+ mockModels.mockRejectedValue(new Error("boom"));
+
+ render(
, { wrapper });
+
+ expect(await screen.findByText("Local models")).toBeInTheDocument();
+ expect(screen.queryByTestId("local-runtime-absent")).toBeNull();
+ });
+
+ it("keeps today's behaviour when the endpoint is missing (an older core)", async () => {
+ mockLocalRuntime.mockRejectedValue(new Error("404 Not Found"));
+
+ render(
, { wrapper });
+
+ // An unknown answer is `ok`: hiding the local half from a deployment that has a runtime
+ // would be a far worse failure than showing it to one that doesn't.
+ expect(await screen.findByText("Local models")).toBeInTheDocument();
+ expect(screen.queryByTestId("local-runtime-absent")).toBeNull();
+ });
+});
diff --git a/services/web/src/test/ModuleModelsLocalRuntime.test.tsx b/services/web/src/test/ModuleModelsLocalRuntime.test.tsx
new file mode 100644
index 00000000..57f08e55
--- /dev/null
+++ b/services/web/src/test/ModuleModelsLocalRuntime.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * A module's model slots on a deployment with no local runtime (#962).
+ *
+ * The slot select is built from the local model list plus, for a chat slot, the saved hosted
+ * ones. With no runtime the local half is permanently empty, so an embedding slot collapses to
+ * a lone "Core default" — a section that reads as broken unless it says why it is empty.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ModuleSnapshot } from "@/lib/contracts";
+import { ModulesScreen } from "@/screens/ModulesScreen";
+
+const mockModules = vi.fn();
+const mockModels = vi.fn();
+const mockLocalRuntime = vi.fn();
+const mockSavedModels = vi.fn();
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ modules: () => mockModules(),
+ moduleConfig: vi.fn().mockResolvedValue({}),
+ dockerStatus: vi.fn().mockResolvedValue({ available: true, reason: null }),
+ getModuleCollections: vi.fn().mockResolvedValue({}),
+ saveModuleCollections: vi.fn().mockResolvedValue({ status: "ok" }),
+ getModuleModels: vi.fn().mockResolvedValue({}),
+ setModuleModels: vi.fn().mockResolvedValue({ status: "ok" }),
+ models: () => mockModels(),
+ savedModels: () => mockSavedModels(),
+ localRuntime: () => mockLocalRuntime(),
+ },
+}));
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return
{children};
+}
+
+const INDEXER = ModuleSnapshot.parse({
+ manifest: {
+ name: "knowledge",
+ version: "0.1.0",
+ required_models: [{ key: "embed", label: "Embedding model", role: "embedding" }],
+ },
+ status: { healthy: true, version: "0.1.0" },
+ enabled: true,
+ disabled_tools: [],
+});
+
+async function expandCard() {
+ fireEvent.click(await screen.findByRole("button", { name: /expand/i }));
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockModules.mockResolvedValue([INDEXER]);
+ mockModels.mockResolvedValue([]);
+ mockSavedModels.mockResolvedValue([]);
+ mockLocalRuntime.mockResolvedValue({ state: "ok", url_configured: true });
+});
+
+describe("Module model slots without a local runtime", () => {
+ it("explains an otherwise-empty slot instead of leaving it blank", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "absent", url_configured: false });
+ render(
, { wrapper });
+ await expandCard();
+
+ expect(await screen.findByText(/No local AI on this deployment/i)).toBeInTheDocument();
+ });
+
+ it("says nothing extra when the deployment has a runtime", async () => {
+ render(
, { wrapper });
+ await expandCard();
+
+ expect(await screen.findByText("Embedding model")).toBeInTheDocument();
+ expect(screen.queryByText(/No local AI on this deployment/i)).toBeNull();
+ });
+});
diff --git a/services/web/src/test/contracts.test.ts b/services/web/src/test/contracts.test.ts
index 5480c019..c7e5a0cd 100644
--- a/services/web/src/test/contracts.test.ts
+++ b/services/web/src/test/contracts.test.ts
@@ -10,6 +10,7 @@ import {
CalendarEvent,
CatalogResponse,
LlmPrefs,
+ LocalRuntimeStatus,
MessageRecord,
ModelVariants,
ModuleSnapshot,
@@ -57,6 +58,30 @@ describe("contracts", () => {
expect(() => Readiness.parse({ ready: true, power: "asleep", components: [] })).toThrow();
});
+ // The cross-service seam of #962: every other test in this suite mocks `api.localRuntime()`
+ // and so never runs the schema. These three objects are the core's wire payload verbatim —
+ // `GET /platform/v1/llm/local-runtime` in each of its three states, the same bodies
+ // `services/core-app/tests/test_llm_routes.py` pins on the other side. If the two ever drift,
+ // the parse fails, the hook's catch reads the answer as `ok`, and a hosted-only deployment
+ // quietly renders the full local half of the Models page again.
+ it("parses the local-runtime payload the core actually sends, in all three states", () => {
+ expect(LocalRuntimeStatus.parse({ state: "absent", url_configured: false })).toEqual({
+ state: "absent",
+ url_configured: false,
+ });
+ expect(LocalRuntimeStatus.parse({ state: "unreachable", url_configured: true }).state).toBe(
+ "unreachable",
+ );
+ expect(LocalRuntimeStatus.parse({ state: "ok", url_configured: true }).state).toBe("ok");
+ });
+
+ it("rejects an unknown local-runtime state rather than guessing at it", () => {
+ // The hook turns a parse failure into `ok` on purpose (an older core 404s), so this must
+ // throw rather than widen: a state nobody has taught the shell about has to fall back to
+ // today's behaviour, not become a fourth rendering.
+ expect(() => LocalRuntimeStatus.parse({ state: "degraded", url_configured: true })).toThrow();
+ });
+
it("parses a manifest-driven module snapshot (the ADR-0007 surface)", () => {
const snapshot = ModuleSnapshot.parse({
manifest: {
diff --git a/services/web/src/test/useLocalRuntime.test.tsx b/services/web/src/test/useLocalRuntime.test.tsx
new file mode 100644
index 00000000..21325084
--- /dev/null
+++ b/services/web/src/test/useLocalRuntime.test.tsx
@@ -0,0 +1,94 @@
+/**
+ * The one query every surface reads the local-runtime state from (#962).
+ *
+ * Two of these are guards against silent rot rather than feature tests: an unknown answer must
+ * read as `ok` (an older core 404s, and hiding the local half of the Models page from a
+ * deployment that *has* a runtime is the worse failure), and `settled` must latch — the
+ * components it gates subscribe to this same query, so a `settled` that can flip back to false
+ * makes the page oscillate forever.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useLocalRuntime } from "@/lib/useLocalRuntime";
+
+const mockLocalRuntime = vi.fn();
+vi.mock("@/lib/api", () => ({ api: { localRuntime: () => mockLocalRuntime() } }));
+
+function Probe() {
+ const runtime = useLocalRuntime();
+ return (
+
+ {runtime.state}|{String(runtime.absent)}|{String(runtime.unreachable)}|
+ {String(runtime.settled)}
+
+ );
+}
+
+/** A second subscriber mounted only once the first has settled — exactly the shape the Models
+ * page has, and the one that used to send an errored query round and round. */
+function GatedProbes() {
+ const runtime = useLocalRuntime();
+ return (
+ <>
+
+ {runtime.settled &&
}
+ >
+ );
+}
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return
{children};
+}
+
+const readout = () => screen.getAllByTestId("runtime")[0].textContent;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("useLocalRuntime", () => {
+ it("reports `absent` as absent, and not as an error", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "absent", url_configured: false });
+ render(
, { wrapper });
+
+ await waitFor(() => expect(readout()).toBe("absent|true|false|true"));
+ });
+
+ it("reports `unreachable` as the error it is", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "unreachable", url_configured: true });
+ render(
, { wrapper });
+
+ await waitFor(() => expect(readout()).toBe("unreachable|false|true|true"));
+ });
+
+ it("reports `ok` for a healthy runtime", async () => {
+ mockLocalRuntime.mockResolvedValue({ state: "ok", url_configured: true });
+ render(
, { wrapper });
+
+ await waitFor(() => expect(readout()).toBe("ok|false|false|true"));
+ });
+
+ it("falls back to `ok` when the endpoint is missing (an older core)", async () => {
+ mockLocalRuntime.mockRejectedValue(new Error("404 Not Found"));
+ render(
, { wrapper });
+
+ await waitFor(() => expect(readout()).toBe("ok|false|false|true"));
+ });
+
+ it("keeps `settled` latched while a gated subscriber mounts and refetches", async () => {
+ // An errored query is permanently stale, so mounting a second subscriber refetches it. If
+ // `settled` tracked "is a fetch in flight", that subscriber would unmount itself again on
+ // every loop. Assert it stays settled through several rounds of exactly that.
+ mockLocalRuntime.mockRejectedValue(new Error("404 Not Found"));
+ render(
, { wrapper });
+
+ await waitFor(() => expect(screen.getAllByTestId("runtime")).toHaveLength(2));
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(screen.getAllByTestId("runtime")).toHaveLength(2);
+ expect(readout()).toBe("ok|false|false|true");
+ });
+});