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/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");
+ });
+});
From baaa490fad6370f9a893871461c6dcfcf604bb63 Mon Sep 17 00:00:00 2001
From: baakhoff
Date: Sat, 19 Sep 2026 21:15:20 +0000
Subject: [PATCH 2/2] test(web): parse the local-runtime payload the core
actually sends
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The seam this lane could not exercise: every local-runtime test here mocks
`api.localRuntime()`, so `LocalRuntimeStatus` — the zod schema the real
response is parsed through — was never run on a single payload. A drift in
either direction would be invisible, and invisible in the worst way: the hook
turns a parse failure into `ok`, so a hosted-only deployment would quietly go
back to rendering the full local half of the Models page instead of collapsing
it, with every suite green.
Three bodies, copied verbatim from the core's own route assertions in
`services/core-app/tests/test_llm_routes.py` (and confirmed against the running
endpoint during the merge-set review), plus the negative: an unrecognised state
must throw rather than widen the enum, because falling back to today's
behaviour is the deliberate answer for a state the shell has not been taught.
No production change. `web` stays 0.151.0.
Part of #962.
---
services/web/src/test/contracts.test.ts | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
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: {