From bc4198be846c22b9ed5fc14ba10c5e48d0550689 Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Thu, 12 Mar 2026 14:51:33 -0400
Subject: [PATCH 1/9] Add control-plane setup readiness surfaces
---
interface/src/api/client.test.ts | 50 +++
interface/src/api/client.ts | 67 ++++
interface/src/components/SetupBanner.tsx | 25 +-
.../src/components/SetupReadiness.test.ts | 219 +++++++++++
interface/src/components/SetupReadiness.tsx | 340 ++++++++++++++++++
interface/src/router.tsx | 2 +
interface/src/routes/Overview.tsx | 2 +
interface/src/routes/Settings.tsx | 263 +++++++++++++-
8 files changed, 942 insertions(+), 26 deletions(-)
create mode 100644 interface/src/api/client.test.ts
create mode 100644 interface/src/components/SetupReadiness.test.ts
create mode 100644 interface/src/components/SetupReadiness.tsx
diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts
new file mode 100644
index 000000000..4ca5cdcae
--- /dev/null
+++ b/interface/src/api/client.test.ts
@@ -0,0 +1,50 @@
+import {afterEach, beforeEach, describe, expect, mock, test} from "bun:test";
+
+if (!("window" in globalThis)) {
+ (globalThis as typeof globalThis & {window: Record}).window = {};
+}
+
+const {api} = await import("./client");
+
+const originalFetch = globalThis.fetch;
+
+describe("api client runtime health actions", () => {
+ beforeEach(() => {
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
+ return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }) as typeof fetch;
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ test("triggerWarmup posts agent and force payload", async () => {
+ await api.triggerWarmup({ agentId: "main", force: true });
+
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ "/api/agents/warmup",
+ expect.objectContaining({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ agent_id: "main", force: true }),
+ }),
+ );
+ });
+
+ test("reconnectMcpServer posts to the reconnect endpoint", async () => {
+ await api.reconnectMcpServer("filesystem");
+
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ "/api/mcp/servers/filesystem/reconnect",
+ expect.objectContaining({
+ method: "POST",
+ }),
+ );
+ });
+});
diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts
index e89ca0abb..c0fb15394 100644
--- a/interface/src/api/client.ts
+++ b/interface/src/api/client.ts
@@ -940,6 +940,31 @@ export interface ProvidersResponse {
has_any: boolean;
}
+export type WarmupState = "cold" | "warming" | "warm" | "degraded";
+
+export interface WarmupStatus {
+ state: WarmupState;
+ embedding_ready: boolean;
+ last_refresh_unix_ms: number | null;
+ last_error: string | null;
+ bulletin_age_secs: number | null;
+}
+
+export interface WarmupStatusEntry {
+ agent_id: string;
+ status: WarmupStatus;
+}
+
+export interface WarmupStatusResponse {
+ statuses: WarmupStatusEntry[];
+}
+
+export interface WarmupTriggerResponse {
+ status: string;
+ forced: boolean;
+ accepted_agents: string[];
+}
+
export interface ProviderActionResponse {
success: boolean;
message: string;
@@ -1220,6 +1245,23 @@ export interface MessagingInstanceActionResponse {
message: string;
}
+export interface McpServerStatusInfo {
+ name: string;
+ transport: string;
+ enabled: boolean;
+ state: string;
+}
+
+export interface McpAgentStatus {
+ agent_id: string;
+ servers: McpServerStatusInfo[];
+}
+
+export interface McpMutationResponse {
+ success: boolean;
+ message: string;
+}
+
export interface BindingInfo {
agent_id: string;
channel: string;
@@ -1931,6 +1973,21 @@ export const api = {
// Provider management
providers: () => fetchJson("/providers"),
+ warmupStatus: () => fetchJson("/agents/warmup"),
+ triggerWarmup: async (params?: {agentId?: string; force?: boolean}) => {
+ const response = await fetch(`${API_BASE}/agents/warmup`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ agent_id: params?.agentId ?? null,
+ force: params?.force ?? false,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+ return response.json() as Promise;
+ },
updateProvider: async (provider: string, apiKey: string, model: string) => {
const response = await fetch(`${API_BASE}/providers`, {
method: "PUT",
@@ -2035,6 +2092,16 @@ export const api = {
// Messaging / Bindings API
messagingStatus: () => fetchJson("/messaging/status"),
+ mcpStatus: () => fetchJson("/mcp/status"),
+ reconnectMcpServer: async (serverName: string) => {
+ const response = await fetch(`${API_BASE}/mcp/servers/${encodeURIComponent(serverName)}/reconnect`, {
+ method: "POST",
+ });
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+ return response.json() as Promise;
+ },
bindings: (agentId?: string) => {
const params = agentId
diff --git a/interface/src/components/SetupBanner.tsx b/interface/src/components/SetupBanner.tsx
index 62b79da81..d196757f9 100644
--- a/interface/src/components/SetupBanner.tsx
+++ b/interface/src/components/SetupBanner.tsx
@@ -1,24 +1 @@
-import { useQuery } from "@tanstack/react-query";
-import { Link } from "@tanstack/react-router";
-import { api } from "@/api/client";
-import { Banner } from "@/ui";
-
-export function SetupBanner() {
- const { data } = useQuery({
- queryKey: ["providers"],
- queryFn: api.providers,
- staleTime: 10_000,
- });
-
- if (!data || data.has_any) return null;
-
- return (
-
- No LLM provider configured.{" "}
-
- Add an API key in Settings
- {" "}
- to get started.
-
- );
-}
+export {SetupBanner} from "./SetupReadiness";
diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts
new file mode 100644
index 000000000..7334ecd6c
--- /dev/null
+++ b/interface/src/components/SetupReadiness.test.ts
@@ -0,0 +1,219 @@
+import {describe, expect, test} from "bun:test";
+import type {
+ McpAgentStatus,
+ MessagingStatusResponse,
+ ProvidersResponse,
+ SecretStoreStatus,
+ WarmupStatusResponse,
+} from "@/api/client";
+
+if (!("window" in globalThis)) {
+ (globalThis as typeof globalThis & {window: Record}).window = {};
+}
+
+const {classifySetupReadiness} = await import("./SetupReadiness");
+
+function configuredProviders(hasAny = true): ProvidersResponse {
+ return {
+ has_any: hasAny,
+ providers: {
+ anthropic: hasAny,
+ openai: false,
+ openai_chatgpt: false,
+ openrouter: false,
+ kilo: false,
+ zhipu: false,
+ groq: false,
+ together: false,
+ fireworks: false,
+ deepseek: false,
+ xai: false,
+ mistral: false,
+ gemini: false,
+ ollama: false,
+ opencode_zen: false,
+ opencode_go: false,
+ nvidia: false,
+ minimax: false,
+ minimax_cn: false,
+ moonshot: false,
+ zai_coding_plan: false,
+ },
+ };
+}
+
+function secretsStatus(overrides: Partial = {}): SecretStoreStatus {
+ return {
+ state: "unlocked",
+ encrypted: true,
+ secret_count: 0,
+ system_count: 0,
+ tool_count: 0,
+ platform_managed: false,
+ ...overrides,
+ };
+}
+
+function messagingStatus(
+ instances: MessagingStatusResponse["instances"] = [],
+): MessagingStatusResponse {
+ return {
+ discord: {configured: false, enabled: false},
+ slack: {configured: false, enabled: false},
+ telegram: {configured: false, enabled: false},
+ webhook: {configured: false, enabled: false},
+ twitch: {configured: false, enabled: false},
+ email: {configured: false, enabled: false},
+ instances,
+ };
+}
+
+function warmupStatus(
+ statuses: WarmupStatusResponse["statuses"],
+): WarmupStatusResponse {
+ return {statuses};
+}
+
+function mcpStatus(servers: McpAgentStatus["servers"]): McpAgentStatus[] {
+ return [{agent_id: "main", servers}];
+}
+
+describe("classifySetupReadiness", () => {
+ test("flags missing provider as a blocker", () => {
+ const items = classifySetupReadiness({
+ providers: configuredProviders(false),
+ });
+
+ expect(items).toEqual([
+ expect.objectContaining({
+ id: "provider",
+ severity: "blocker",
+ tab: "providers",
+ }),
+ ]);
+ });
+
+ test("flags locked and unencrypted secrets separately", () => {
+ const lockedItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ secrets: secretsStatus({state: "locked", encrypted: true, secret_count: 2}),
+ });
+ expect(lockedItems).toContainEqual(
+ expect.objectContaining({
+ id: "secrets_locked",
+ severity: "warning",
+ tab: "secrets",
+ }),
+ );
+
+ const unencryptedItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ secrets: secretsStatus({encrypted: false, secret_count: 2}),
+ });
+ expect(unencryptedItems).toContainEqual(
+ expect.objectContaining({
+ id: "secrets_unencrypted",
+ severity: "warning",
+ tab: "secrets",
+ }),
+ );
+ });
+
+ test("reports degraded warmup only when providers are configured", () => {
+ const items = classifySetupReadiness({
+ providers: configuredProviders(),
+ warmup: warmupStatus([
+ {
+ agent_id: "alpha",
+ status: {
+ state: "degraded",
+ embedding_ready: false,
+ last_refresh_unix_ms: null,
+ last_error: "boom",
+ bulletin_age_secs: null,
+ },
+ },
+ ]),
+ });
+
+ expect(items).toContainEqual(
+ expect.objectContaining({
+ id: "warmup_degraded",
+ severity: "warning",
+ }),
+ );
+
+ const noProviderItems = classifySetupReadiness({
+ providers: configuredProviders(false),
+ warmup: warmupStatus([
+ {
+ agent_id: "alpha",
+ status: {
+ state: "degraded",
+ embedding_ready: false,
+ last_refresh_unix_ms: null,
+ last_error: "boom",
+ bulletin_age_secs: null,
+ },
+ },
+ ]),
+ });
+
+ expect(noProviderItems.some((item) => item.id === "warmup_degraded")).toBe(false);
+ });
+
+ test("reports disconnected MCP servers and ignores disabled ones", () => {
+ const items = classifySetupReadiness({
+ providers: configuredProviders(),
+ mcp: mcpStatus([
+ {name: "filesystem", transport: "stdio", enabled: true, state: "failed: timeout"},
+ {name: "disabled", transport: "stdio", enabled: false, state: "failed: ignored"},
+ ]),
+ });
+
+ expect(items).toContainEqual(
+ expect.objectContaining({
+ id: "mcp_failed",
+ severity: "warning",
+ }),
+ );
+ expect(items.some((item) => item.description.includes("2 enabled MCP servers"))).toBe(false);
+ });
+
+ test("reports messaging setup and binding gaps as informational", () => {
+ const missingItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ messaging: messagingStatus(),
+ });
+
+ expect(missingItems).toContainEqual(
+ expect.objectContaining({
+ id: "messaging_missing",
+ severity: "info",
+ tab: "channels",
+ }),
+ );
+
+ const unboundItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ messaging: messagingStatus([
+ {
+ platform: "discord",
+ name: null,
+ runtime_key: "discord",
+ configured: true,
+ enabled: true,
+ binding_count: 0,
+ },
+ ]),
+ });
+
+ expect(unboundItems).toContainEqual(
+ expect.objectContaining({
+ id: "messaging_unbound",
+ severity: "info",
+ tab: "channels",
+ }),
+ );
+ });
+});
diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx
new file mode 100644
index 000000000..fea01a4ea
--- /dev/null
+++ b/interface/src/components/SetupReadiness.tsx
@@ -0,0 +1,340 @@
+import {useMemo} from "react";
+import {useQueries} from "@tanstack/react-query";
+import {Link} from "@tanstack/react-router";
+import {
+ api,
+ type McpAgentStatus,
+ type MessagingStatusResponse,
+ type ProvidersResponse,
+ type SecretStoreStatus,
+ type WarmupStatusResponse,
+} from "@/api/client";
+import {Banner, BannerActions, cx} from "@/ui";
+
+type SetupSeverity = "blocker" | "warning" | "info";
+type SettingsTab = "providers" | "secrets" | "channels";
+
+interface SetupReadinessItem {
+ id: string;
+ severity: SetupSeverity;
+ title: string;
+ description: string;
+ tab?: SettingsTab;
+}
+
+interface SetupReadinessState {
+ items: SetupReadinessItem[];
+ actionableItems: SetupReadinessItem[];
+ blockerCount: number;
+ warningCount: number;
+ isLoading: boolean;
+}
+
+function pluralize(count: number, singular: string, plural = `${singular}s`) {
+ return `${count} ${count === 1 ? singular : plural}`;
+}
+
+function formatAgentList(agentIds: string[]) {
+ if (agentIds.length === 0) return "";
+ if (agentIds.length === 1) return agentIds[0];
+ if (agentIds.length === 2) return `${agentIds[0]} and ${agentIds[1]}`;
+ return `${agentIds.slice(0, 2).join(", ")}, and ${agentIds.length - 2} more`;
+}
+
+export function classifySetupReadiness(params: {
+ providers?: ProvidersResponse;
+ secrets?: SecretStoreStatus;
+ messaging?: MessagingStatusResponse;
+ warmup?: WarmupStatusResponse;
+ mcp?: McpAgentStatus[];
+}): SetupReadinessItem[] {
+ const items: SetupReadinessItem[] = [];
+ const {providers, secrets, messaging, warmup, mcp} = params;
+
+ if (providers && !providers.has_any) {
+ items.push({
+ id: "provider",
+ severity: "blocker",
+ title: "No LLM provider configured",
+ description: "Add a provider key or OAuth connection before Spacebot can do useful work.",
+ tab: "providers",
+ });
+ }
+
+ if (secrets?.state === "locked") {
+ items.push({
+ id: "secrets_locked",
+ severity: "warning",
+ title: "Secrets store is locked",
+ description: "Unlock the secret store before editing or using encrypted instance secrets.",
+ tab: "secrets",
+ });
+ } else if (
+ secrets &&
+ !secrets.platform_managed &&
+ !secrets.encrypted &&
+ secrets.secret_count > 0
+ ) {
+ items.push({
+ id: "secrets_unencrypted",
+ severity: "warning",
+ title: "Stored secrets are unencrypted",
+ description: "Enable encryption so instance secrets are protected at rest.",
+ tab: "secrets",
+ });
+ }
+
+ if (providers?.has_any && warmup?.statuses.length) {
+ const degradedAgents = warmup.statuses
+ .filter((entry) => entry.status.state === "degraded")
+ .map((entry) => entry.agent_id);
+ const warmAgents = warmup.statuses.filter(
+ (entry) => entry.status.state === "warm",
+ );
+
+ if (degradedAgents.length > 0) {
+ items.push({
+ id: "warmup_degraded",
+ severity: "warning",
+ title: "Warmup is degraded",
+ description: `Warmup is degraded for ${formatAgentList(degradedAgents)}.`,
+ });
+ } else if (warmAgents.length === 0) {
+ items.push({
+ id: "warmup_pending",
+ severity: "info",
+ title: "Warmup is still settling",
+ description: "Models and bulletin state are still warming up, so first responses may be slower.",
+ });
+ }
+ }
+
+ if (mcp) {
+ const enabledServers = mcp.flatMap((agentStatus) =>
+ agentStatus.servers
+ .filter((server) => server.enabled)
+ .map((server) => ({agent_id: agentStatus.agent_id, ...server})),
+ );
+ const failedServers = enabledServers.filter(
+ (server) =>
+ server.state !== "connected" && server.state !== "connecting",
+ );
+ const connectingServers = enabledServers.filter(
+ (server) => server.state === "connecting",
+ );
+
+ if (failedServers.length > 0) {
+ items.push({
+ id: "mcp_failed",
+ severity: "warning",
+ title: "Some MCP servers are disconnected",
+ description: `${pluralize(failedServers.length, "enabled MCP server")} ${failedServers.length === 1 ? "is" : "are"} not connected.`,
+ });
+ } else if (connectingServers.length > 0) {
+ items.push({
+ id: "mcp_connecting",
+ severity: "info",
+ title: "MCP servers are still connecting",
+ description: `${pluralize(connectingServers.length, "enabled MCP server")} ${connectingServers.length === 1 ? "is" : "are"} still connecting.`,
+ });
+ }
+ }
+
+ if (messaging) {
+ const configuredInstances = messaging.instances.filter(
+ (instance) => instance.configured,
+ );
+ const boundInstances = configuredInstances.filter(
+ (instance) => instance.binding_count > 0,
+ );
+
+ if (configuredInstances.length === 0) {
+ items.push({
+ id: "messaging_missing",
+ severity: "info",
+ title: "No messaging platforms configured",
+ description: "Connect Discord, Telegram, Slack, or another adapter when you want Spacebot to handle real conversations.",
+ tab: "channels",
+ });
+ } else if (boundInstances.length === 0) {
+ items.push({
+ id: "messaging_unbound",
+ severity: "info",
+ title: "Messaging is configured but not routed",
+ description: "Add bindings so configured platforms actually deliver conversations to an agent.",
+ tab: "channels",
+ });
+ }
+ }
+
+ return items;
+}
+
+export function useSetupReadiness(): SetupReadinessState {
+ const [providersQuery, secretsQuery, messagingQuery, warmupQuery, mcpQuery] = useQueries({
+ queries: [
+ {
+ queryKey: ["providers"],
+ queryFn: api.providers,
+ staleTime: 10_000,
+ },
+ {
+ queryKey: ["secrets-status"],
+ queryFn: api.secretsStatus,
+ staleTime: 10_000,
+ retry: false,
+ },
+ {
+ queryKey: ["messaging-status"],
+ queryFn: api.messagingStatus,
+ staleTime: 10_000,
+ },
+ {
+ queryKey: ["warmup-status"],
+ queryFn: api.warmupStatus,
+ staleTime: 10_000,
+ },
+ {
+ queryKey: ["mcp-status"],
+ queryFn: api.mcpStatus,
+ staleTime: 10_000,
+ },
+ ],
+ });
+
+ return useMemo(() => {
+ const items = classifySetupReadiness({
+ providers: providersQuery.data,
+ secrets: secretsQuery.data,
+ messaging: messagingQuery.data,
+ warmup: warmupQuery.data,
+ mcp: mcpQuery.data,
+ });
+ const actionableItems = items.filter((item) => item.severity !== "info");
+ const blockerCount = items.filter((item) => item.severity === "blocker").length;
+ const warningCount = items.filter((item) => item.severity === "warning").length;
+ const isLoading = [providersQuery, secretsQuery, messagingQuery, warmupQuery, mcpQuery]
+ .some((query) => query.isLoading && !query.data);
+
+ return {
+ items,
+ actionableItems,
+ blockerCount,
+ warningCount,
+ isLoading,
+ };
+ }, [mcpQuery, messagingQuery, providersQuery, secretsQuery, warmupQuery]);
+}
+
+export function SetupBanner() {
+ const readiness = useSetupReadiness();
+
+ if (readiness.isLoading || readiness.actionableItems.length === 0) {
+ return null;
+ }
+
+ const primaryItem = readiness.actionableItems[0];
+ const remainingCount = readiness.actionableItems.length - 1;
+ const bannerVariant = readiness.blockerCount > 0 ? "error" : "warning";
+
+ return (
+
+ {primaryItem.title}.
+ {primaryItem.description}
+ {remainingCount > 0 && (
+
+ {` ${pluralize(remainingCount, "more issue")} need attention.`}
+
+ )}
+
+ {primaryItem.tab ? (
+
+ Open Settings
+
+ ) : (
+
+ View Overview
+
+ )}
+
+
+ );
+}
+
+const severityStyles: Record = {
+ blocker: "border-red-500/20 bg-red-500/8 text-red-300",
+ warning: "border-amber-500/20 bg-amber-500/8 text-amber-300",
+ info: "border-blue-500/20 bg-blue-500/8 text-blue-300",
+};
+
+const severityLabelStyles: Record = {
+ blocker: "bg-red-500/15 text-red-300",
+ warning: "bg-amber-500/15 text-amber-300",
+ info: "bg-blue-500/15 text-blue-300",
+};
+
+export function SetupReadinessCard() {
+ const readiness = useSetupReadiness();
+
+ if (readiness.isLoading || readiness.items.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
Setup Readiness
+
+ Spacebot surfaces setup and runtime readiness here in the control plane instead of through a separate doctor command.
+
+
+
+
+ {readiness.items.map((item) => (
+
+
+
+
+
+ {item.severity}
+
+
{item.title}
+
+
{item.description}
+
+ {item.tab ? (
+
+ Fix
+
+ ) : null}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/interface/src/router.tsx b/interface/src/router.tsx
index 1914bda3e..432410ff3 100644
--- a/interface/src/router.tsx
+++ b/interface/src/router.tsx
@@ -8,6 +8,7 @@ import {useQuery} from "@tanstack/react-query";
import {api, BASE_PATH} from "@/api/client";
import {ConnectionBanner} from "@/components/ConnectionBanner";
import {TopBar, TopBarProvider, useSetTopBar} from "@/components/TopBar";
+import {SetupBanner} from "@/components/SetupBanner";
import {Sidebar} from "@/components/Sidebar";
import {Overview} from "@/routes/Overview";
import {AgentDetail} from "@/routes/AgentDetail";
@@ -37,6 +38,7 @@ function RootLayout() {
+
diff --git a/interface/src/routes/Overview.tsx b/interface/src/routes/Overview.tsx
index c852bc92c..e4d252f55 100644
--- a/interface/src/routes/Overview.tsx
+++ b/interface/src/routes/Overview.tsx
@@ -5,6 +5,7 @@ import {SparklesIcon, IdeaIcon} from "@hugeicons/core-free-icons";
import {HugeiconsIcon} from "@hugeicons/react";
import {api} from "@/api/client";
import {CreateAgentDialog} from "@/components/CreateAgentDialog";
+import {SetupReadinessCard} from "@/components/SetupReadiness";
import {TopologyGraph} from "@/components/TopologyGraph";
import {UpdatePill} from "@/components/UpdatePill";
import {useSetTopBar} from "@/components/TopBar";
@@ -119,6 +120,7 @@ export function Overview({liveStates, activeLinks}: OverviewProps) {
return (
+
{/* Full-screen topology */}
{overviewLoading ? (
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index 8086d9194..1bc1c3fbc 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -1,10 +1,11 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { api, type GlobalSettingsResponse, type UpdateStatus, type SecretCategory, type SecretListItem, type StoreState } from "@/api/client";
+import { api, type GlobalSettingsResponse, type McpServerStatusInfo, type SecretCategory, type SecretListItem, type StoreState, type UpdateStatus, type WarmupStatusEntry } from "@/api/client";
import { Badge, Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle } from "@/ui";
import { useSearch, useNavigate } from "@tanstack/react-router";
import { PlatformCatalog, InstanceCard, AddInstanceCard } from "@/components/ChannelSettingCard";
import { ModelSelect } from "@/components/ModelSelect";
+import { useSetupReadiness } from "@/components/SetupReadiness";
import { ProviderIcon } from "@/lib/providerIcons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
@@ -14,7 +15,7 @@ import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme";
import { Markdown } from "@/components/Markdown";
import { useSetTopBar } from "@/components/TopBar";
-type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "server" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog";
+type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "system-health" | "server" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog";
const SECTIONS = [
{
@@ -41,6 +42,12 @@ const SECTIONS = [
group: "general" as const,
description: "Encrypted secret storage",
},
+ {
+ id: "system-health" as const,
+ label: "System Health",
+ group: "system" as const,
+ description: "Warmup and MCP runtime readiness",
+ },
{
id: "server" as const,
label: "Server",
@@ -712,6 +719,8 @@ export function Settings() {
) : activeSection === "secrets" ? (
+ ) : activeSection === "system-health" ? (
+
) : activeSection === "server" ? (
) : activeSection === "opencode" ? (
@@ -1624,6 +1633,256 @@ function SecretsSection() {
);
}
+function warmupBadgeVariant(state: WarmupStatusEntry["status"]["state"]) {
+ switch (state) {
+ case "warm":
+ return "green";
+ case "warming":
+ return "blue";
+ case "degraded":
+ return "red";
+ case "cold":
+ default:
+ return "amber";
+ }
+}
+
+function formatWarmupDetails(entry: WarmupStatusEntry) {
+ const details: string[] = [];
+ if (entry.status.embedding_ready) {
+ details.push("embeddings ready");
+ }
+ if (entry.status.bulletin_age_secs != null) {
+ details.push(`bulletin age ${entry.status.bulletin_age_secs}s`);
+ }
+ if (entry.status.last_error) {
+ details.push(entry.status.last_error);
+ }
+ return details;
+}
+
+function mcpBadgeVariant(server: McpServerStatusInfo) {
+ if (server.state === "connected") return "green";
+ if (server.state === "connecting") return "blue";
+ if (server.state.startsWith("failed")) return "red";
+ return "amber";
+}
+
+function SystemHealthSection() {
+ const queryClient = useQueryClient();
+ const readiness = useSetupReadiness();
+ const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
+
+ const { data: warmupData, isLoading: warmupLoading } = useQuery({
+ queryKey: ["warmup-status"],
+ queryFn: api.warmupStatus,
+ staleTime: 5_000,
+ });
+
+ const { data: mcpData, isLoading: mcpLoading } = useQuery({
+ queryKey: ["mcp-status"],
+ queryFn: api.mcpStatus,
+ staleTime: 5_000,
+ });
+
+ const triggerWarmupMutation = useMutation({
+ mutationFn: (params?: {agentId?: string; force?: boolean}) => api.triggerWarmup(params),
+ onSuccess: (result) => {
+ setMessage({
+ text: result.accepted_agents.length > 0
+ ? `Warmup triggered for ${result.accepted_agents.join(", ")}.`
+ : "Warmup trigger accepted.",
+ type: "success",
+ });
+ queryClient.invalidateQueries({ queryKey: ["warmup-status"] });
+ queryClient.invalidateQueries({ queryKey: ["providers"] });
+ },
+ onError: (error) => {
+ setMessage({ text: `Failed: ${error.message}`, type: "error" });
+ },
+ });
+
+ const reconnectMcpMutation = useMutation({
+ mutationFn: (serverName: string) => api.reconnectMcpServer(serverName),
+ onSuccess: (result) => {
+ setMessage({
+ text: result.message,
+ type: result.success ? "success" : "error",
+ });
+ queryClient.invalidateQueries({ queryKey: ["mcp-status"] });
+ },
+ onError: (error) => {
+ setMessage({ text: `Failed: ${error.message}`, type: "error" });
+ },
+ });
+
+ const warmupStatuses = warmupData?.statuses ?? [];
+ const enabledMcpServers = (mcpData ?? []).flatMap((agentStatus) =>
+ agentStatus.servers
+ .filter((server) => server.enabled)
+ .map((server) => ({agent_id: agentStatus.agent_id, ...server})),
+ );
+ const actionableCount = readiness.blockerCount + readiness.warningCount;
+
+ return (
+
+
+
System Health
+
+ Runtime readiness for warmup and MCP, using the same control-plane signals surfaced on the overview page.
+
+
+
+
+
+
+
+ {actionableCount > 0
+ ? `${actionableCount} setup or runtime issue${actionableCount === 1 ? "" : "s"} need attention`
+ : "No blocking setup issues detected"}
+
+
+ Providers, secrets, messaging, warmup, and MCP are all evaluated from live control-plane state.
+
+
+
triggerWarmupMutation.mutate({ force: true })}
+ loading={triggerWarmupMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
+ Trigger all warmups
+
+
+
+
+
+
+
+
Warmup
+
+ Per-agent readiness for embeddings and bulletin-backed startup warmup.
+
+
+
+ {warmupLoading ? (
+
+
+ Loading warmup status...
+
+ ) : warmupStatuses.length > 0 ? (
+
+ {warmupStatuses.map((entry) => {
+ const details = formatWarmupDetails(entry);
+ return (
+
+
+
+
+
{entry.agent_id}
+
+ {entry.status.state}
+
+
+ {details.length > 0 && (
+
+ {details.map((detail) => (
+
{detail}
+ ))}
+
+ )}
+
+
triggerWarmupMutation.mutate({ agentId: entry.agent_id, force: true })}
+ loading={triggerWarmupMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
+ Refresh
+
+
+
+ );
+ })}
+
+ ) : (
+
+ No agents are currently reporting warmup state.
+
+ )}
+
+
+
+
+
MCP Connections
+
+ Live status for enabled MCP servers across all agents.
+
+
+
+ {mcpLoading ? (
+
+
+ Loading MCP status...
+
+ ) : enabledMcpServers.length > 0 ? (
+
+ {enabledMcpServers.map((server) => (
+
+
+
+
+
{server.name}
+
+ {server.state}
+
+
+
+ Agent {server.agent_id} via {server.transport}
+
+
+ {server.state !== "connected" && (
+
reconnectMcpMutation.mutate(server.name)}
+ loading={reconnectMcpMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
+ Reconnect
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+ No enabled MCP servers are configured.
+
+ )}
+
+
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+ );
+}
+
interface GlobalSettingsSectionProps {
settings: GlobalSettingsResponse | undefined;
isLoading: boolean;
From 7e4df0e446a360d12afb4cfd10293cc6e5d4c4e6 Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Thu, 12 Mar 2026 16:24:05 -0400
Subject: [PATCH 2/9] Polish system health settings and docs
---
docs/content/docs/(deployment)/roadmap.mdx | 2 +-
.../docs/(getting-started)/quickstart.mdx | 4 ++
interface/src/api/client.test.ts | 14 ++++++
interface/src/routes/Settings.tsx | 50 ++++++++++++++++++-
4 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx
index 61afccb9e..33e72f1d1 100644
--- a/docs/content/docs/(deployment)/roadmap.mdx
+++ b/docs/content/docs/(deployment)/roadmap.mdx
@@ -125,7 +125,7 @@ If a future channel adapter requires inbound HTTP callbacks (e.g. WhatsApp Busin
Some agent frameworks ship a `doctor` CLI command that checks daemon heartbeats, scheduler health, and channel connectivity. This usually exists to compensate for a fragile runtime — if you need a separate command to tell you your process is dead, the process management is the problem.
-Spacebot handles this through its control interface and the cortex. The embedded UI shows adapter status, agent health, and active connections in real-time. The cortex observes system-wide signals and surfaces issues proactively. Channel token validation happens at setup time through the bindings API — if something is misconfigured, you find out immediately when you add the binding, not by running `doctor` ten minutes later.
+Spacebot handles this through its control interface and the cortex. The embedded UI now keeps setup and runtime readiness visible in the normal workflow: the shell banner and Overview page call out missing providers, secrets problems, warmup drift, and MCP connectivity issues, while **Settings → System Health** shows per-agent warmup state and enabled MCP server status. The cortex still observes system-wide signals and surfaces issues proactively. Channel token validation happens at setup time through the bindings API — if something is misconfigured, you find out immediately when you add the binding, not by running `doctor` ten minutes later.
## Post-Launch
diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx
index 1c33d0beb..facdce53e 100644
--- a/docs/content/docs/(getting-started)/quickstart.mdx
+++ b/docs/content/docs/(getting-started)/quickstart.mdx
@@ -86,6 +86,8 @@ This is enough. Spacebot will create a default `main` agent with sensible defaul
Just run `spacebot` with no config file and no API key env var set. It will walk you through provider selection, API key entry, agent naming, and optional Discord setup.
+After first launch, the web UI keeps the remaining setup work visible. The Overview page shows a setup readiness checklist, and **Settings → System Health** shows live warmup and MCP connection state so you can fix runtime issues from the control interface instead of guessing.
+
### Option C: Config file
Create `~/.spacebot/config.toml`:
@@ -227,6 +229,8 @@ cd interface && bun run dev
# UI at http://localhost:3000, proxies /api to http://localhost:19898
```
+If the instance is only partially configured, the UI surfaces that immediately with a setup banner on the shell, a fuller checklist on the Overview page, and runtime details in **Settings → System Health**.
+
## Messaging platforms
| Platform | Status | Setup guide |
diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts
index 4ca5cdcae..98ca7e36c 100644
--- a/interface/src/api/client.test.ts
+++ b/interface/src/api/client.test.ts
@@ -36,6 +36,20 @@ describe("api client runtime health actions", () => {
);
});
+ test("triggerWarmup defaults to all agents without force", async () => {
+ await api.triggerWarmup();
+
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ "/api/agents/warmup",
+ expect.objectContaining({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ agent_id: null, force: false }),
+ }),
+ );
+ });
+
test("reconnectMcpServer posts to the reconnect endpoint", async () => {
await api.reconnectMcpServer("filesystem");
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index 1bc1c3fbc..55a0acd15 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -1658,7 +1658,10 @@ function formatWarmupDetails(entry: WarmupStatusEntry) {
if (entry.status.last_error) {
details.push(entry.status.last_error);
}
- return details;
+ if (details.length > 0) {
+ return details;
+ }
+ return [entry.status.state === "warm" ? "Warmup is healthy." : "Warmup has not completed yet."];
}
function mcpBadgeVariant(server: McpServerStatusInfo) {
@@ -1723,6 +1726,10 @@ function SystemHealthSection() {
.map((server) => ({agent_id: agentStatus.agent_id, ...server})),
);
const actionableCount = readiness.blockerCount + readiness.warningCount;
+ const warmAgentsCount = warmupStatuses.filter((entry) => entry.status.state === "warm").length;
+ const degradedAgentsCount = warmupStatuses.filter((entry) => entry.status.state === "degraded").length;
+ const connectedMcpCount = enabledMcpServers.filter((server) => server.state === "connected").length;
+ const disconnectedMcpCount = enabledMcpServers.filter((server) => server.state !== "connected").length;
return (
@@ -1754,6 +1761,42 @@ function SystemHealthSection() {
Trigger all warmups
+
+
+
Actionable
+
{actionableCount}
+
+ {readiness.blockerCount} blocker{readiness.blockerCount === 1 ? "" : "s"}, {readiness.warningCount} warning{readiness.warningCount === 1 ? "" : "s"}
+
+
+
+
Warm Agents
+
{warmAgentsCount}
+
+ {degradedAgentsCount > 0
+ ? `${degradedAgentsCount} degraded`
+ : warmupStatuses.length > 0
+ ? "No degraded agents"
+ : "No warmup data yet"}
+
+
+
+
MCP Connected
+
{connectedMcpCount}
+
+ {enabledMcpServers.length > 0
+ ? `${enabledMcpServers.length} enabled server${enabledMcpServers.length === 1 ? "" : "s"}`
+ : "No enabled servers"}
+
+
+
+
Needs Reconnect
+
{disconnectedMcpCount}
+
+ {disconnectedMcpCount > 0 ? "Reconnect from this panel" : "All enabled servers are live"}
+
+
+
@@ -1846,6 +1889,11 @@ function SystemHealthSection() {
Agent {server.agent_id} via {server.transport}
+ {server.state !== "connected" && (
+
+ State: {server.state}
+
+ )}
{server.state !== "connected" && (
Date: Thu, 12 Mar 2026 17:12:58 -0400
Subject: [PATCH 3/9] Fix system health review findings
---
interface/src/api/client.test.ts | 8 ++-
interface/src/api/client.ts | 17 +++++-
.../src/components/SetupReadiness.test.ts | 55 +++++++++++++++++--
interface/src/components/SetupReadiness.tsx | 6 +-
interface/src/routes/Settings.tsx | 21 ++++---
5 files changed, 87 insertions(+), 20 deletions(-)
diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts
index 98ca7e36c..37afc17cf 100644
--- a/interface/src/api/client.test.ts
+++ b/interface/src/api/client.test.ts
@@ -50,14 +50,16 @@ describe("api client runtime health actions", () => {
);
});
- test("reconnectMcpServer posts to the reconnect endpoint", async () => {
- await api.reconnectMcpServer("filesystem");
+ test("reconnectMcpServer posts the agent-scoped reconnect payload", async () => {
+ await api.reconnectMcpServer({ agentId: "main", serverName: "filesystem" });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
- "/api/mcp/servers/filesystem/reconnect",
+ "/api/agents/mcp/reconnect",
expect.objectContaining({
method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ agent_id: "main", server_name: "filesystem" }),
}),
);
});
diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts
index c0fb15394..efa69e83e 100644
--- a/interface/src/api/client.ts
+++ b/interface/src/api/client.ts
@@ -1262,6 +1262,12 @@ export interface McpMutationResponse {
message: string;
}
+export interface ReconnectAgentMcpResponse {
+ success: boolean;
+ agent_id: string;
+ server_name: string;
+}
+
export interface BindingInfo {
agent_id: string;
channel: string;
@@ -2093,14 +2099,19 @@ export const api = {
// Messaging / Bindings API
messagingStatus: () => fetchJson("/messaging/status"),
mcpStatus: () => fetchJson("/mcp/status"),
- reconnectMcpServer: async (serverName: string) => {
- const response = await fetch(`${API_BASE}/mcp/servers/${encodeURIComponent(serverName)}/reconnect`, {
+ reconnectMcpServer: async (params: {agentId: string; serverName: string}) => {
+ const response = await fetch(`${API_BASE}/agents/mcp/reconnect`, {
method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ agent_id: params.agentId,
+ server_name: params.serverName,
+ }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
- return response.json() as Promise;
+ return response.json() as Promise;
},
bindings: (agentId?: string) => {
diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts
index 7334ecd6c..9454eded6 100644
--- a/interface/src/components/SetupReadiness.test.ts
+++ b/interface/src/components/SetupReadiness.test.ts
@@ -136,12 +136,13 @@ describe("classifySetupReadiness", () => {
]),
});
- expect(items).toContainEqual(
- expect.objectContaining({
- id: "warmup_degraded",
- severity: "warning",
- }),
- );
+ expect(items).toContainEqual(
+ expect.objectContaining({
+ id: "warmup_degraded",
+ severity: "warning",
+ tab: "system-health",
+ }),
+ );
const noProviderItems = classifySetupReadiness({
providers: configuredProviders(false),
@@ -175,11 +176,53 @@ describe("classifySetupReadiness", () => {
expect.objectContaining({
id: "mcp_failed",
severity: "warning",
+ tab: "system-health",
}),
);
expect(items.some((item) => item.description.includes("2 enabled MCP servers"))).toBe(false);
});
+ test("routes settling runtime signals to system health", () => {
+ const warmupItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ warmup: warmupStatus([
+ {
+ agent_id: "alpha",
+ status: {
+ state: "cold",
+ embedding_ready: false,
+ last_refresh_unix_ms: null,
+ last_error: null,
+ bulletin_age_secs: null,
+ },
+ },
+ ]),
+ });
+
+ expect(warmupItems).toContainEqual(
+ expect.objectContaining({
+ id: "warmup_pending",
+ severity: "info",
+ tab: "system-health",
+ }),
+ );
+
+ const mcpItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ mcp: mcpStatus([
+ {name: "filesystem", transport: "stdio", enabled: true, state: "connecting"},
+ ]),
+ });
+
+ expect(mcpItems).toContainEqual(
+ expect.objectContaining({
+ id: "mcp_connecting",
+ severity: "info",
+ tab: "system-health",
+ }),
+ );
+ });
+
test("reports messaging setup and binding gaps as informational", () => {
const missingItems = classifySetupReadiness({
providers: configuredProviders(),
diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx
index fea01a4ea..41b7792ec 100644
--- a/interface/src/components/SetupReadiness.tsx
+++ b/interface/src/components/SetupReadiness.tsx
@@ -12,7 +12,7 @@ import {
import {Banner, BannerActions, cx} from "@/ui";
type SetupSeverity = "blocker" | "warning" | "info";
-type SettingsTab = "providers" | "secrets" | "channels";
+type SettingsTab = "providers" | "secrets" | "channels" | "system-health";
interface SetupReadinessItem {
id: string;
@@ -98,6 +98,7 @@ export function classifySetupReadiness(params: {
severity: "warning",
title: "Warmup is degraded",
description: `Warmup is degraded for ${formatAgentList(degradedAgents)}.`,
+ tab: "system-health",
});
} else if (warmAgents.length === 0) {
items.push({
@@ -105,6 +106,7 @@ export function classifySetupReadiness(params: {
severity: "info",
title: "Warmup is still settling",
description: "Models and bulletin state are still warming up, so first responses may be slower.",
+ tab: "system-health",
});
}
}
@@ -129,6 +131,7 @@ export function classifySetupReadiness(params: {
severity: "warning",
title: "Some MCP servers are disconnected",
description: `${pluralize(failedServers.length, "enabled MCP server")} ${failedServers.length === 1 ? "is" : "are"} not connected.`,
+ tab: "system-health",
});
} else if (connectingServers.length > 0) {
items.push({
@@ -136,6 +139,7 @@ export function classifySetupReadiness(params: {
severity: "info",
title: "MCP servers are still connecting",
description: `${pluralize(connectingServers.length, "enabled MCP server")} ${connectingServers.length === 1 ? "is" : "are"} still connecting.`,
+ tab: "system-health",
});
}
}
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index 55a0acd15..09c71f7ee 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -1671,6 +1671,10 @@ function mcpBadgeVariant(server: McpServerStatusInfo) {
return "amber";
}
+function needsMcpReconnect(server: McpServerStatusInfo) {
+ return server.state === "disconnected" || server.state.startsWith("failed");
+}
+
function SystemHealthSection() {
const queryClient = useQueryClient();
const readiness = useSetupReadiness();
@@ -1706,11 +1710,11 @@ function SystemHealthSection() {
});
const reconnectMcpMutation = useMutation({
- mutationFn: (serverName: string) => api.reconnectMcpServer(serverName),
+ mutationFn: (params: {agentId: string; serverName: string}) => api.reconnectMcpServer(params),
onSuccess: (result) => {
setMessage({
- text: result.message,
- type: result.success ? "success" : "error",
+ text: `Server '${result.server_name}' reconnected for agent '${result.agent_id}'.`,
+ type: "success",
});
queryClient.invalidateQueries({ queryKey: ["mcp-status"] });
},
@@ -1729,7 +1733,7 @@ function SystemHealthSection() {
const warmAgentsCount = warmupStatuses.filter((entry) => entry.status.state === "warm").length;
const degradedAgentsCount = warmupStatuses.filter((entry) => entry.status.state === "degraded").length;
const connectedMcpCount = enabledMcpServers.filter((server) => server.state === "connected").length;
- const disconnectedMcpCount = enabledMcpServers.filter((server) => server.state !== "connected").length;
+ const disconnectedMcpCount = enabledMcpServers.filter((server) => needsMcpReconnect(server)).length;
return (
@@ -1889,15 +1893,18 @@ function SystemHealthSection() {
Agent {server.agent_id} via {server.transport}
- {server.state !== "connected" && (
+ {needsMcpReconnect(server) && (
State: {server.state}
)}
- {server.state !== "connected" && (
+ {needsMcpReconnect(server) && (
reconnectMcpMutation.mutate(server.name)}
+ onClick={() => reconnectMcpMutation.mutate({
+ agentId: server.agent_id,
+ serverName: server.name,
+ })}
loading={reconnectMcpMutation.isPending}
variant="outline"
size="sm"
From edd801e67fcc28c9e33ef70047eb5dbec297f648 Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Thu, 12 Mar 2026 22:20:34 -0400
Subject: [PATCH 4/9] Close readiness review feedback
---
interface/src/api/client.test.ts | 26 +++-
interface/src/api/client.ts | 9 +-
.../src/components/SetupReadiness.test.ts | 66 ++++++++-
interface/src/components/SetupReadiness.tsx | 33 ++++-
interface/src/routes/Settings.tsx | 80 ++++++-----
src/tools/send_message_to_another_channel.rs | 136 +++++++++++++++++-
6 files changed, 301 insertions(+), 49 deletions(-)
diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts
index 37afc17cf..4e1958d13 100644
--- a/interface/src/api/client.test.ts
+++ b/interface/src/api/client.test.ts
@@ -60,7 +60,27 @@ describe("api client runtime health actions", () => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: "main", server_name: "filesystem" }),
- }),
- );
+ }),
+ );
+ });
+
+ test("triggerWarmup includes response text in thrown API errors", async () => {
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
+ return new Response("warmup unavailable", { status: 503 });
+ }) as typeof fetch;
+
+ await expect(api.triggerWarmup({ agentId: "main" })).rejects.toThrow(
+ "API error: 503: warmup unavailable",
+ );
+ });
+
+ test("reconnectMcpServer includes response text in thrown API errors", async () => {
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
+ return new Response("reconnect failed", { status: 500 });
+ }) as typeof fetch;
+
+ await expect(
+ api.reconnectMcpServer({ agentId: "main", serverName: "filesystem" }),
+ ).rejects.toThrow("API error: 500: reconnect failed");
+ });
});
-});
diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts
index efa69e83e..2208c3b89 100644
--- a/interface/src/api/client.ts
+++ b/interface/src/api/client.ts
@@ -187,6 +187,11 @@ async function fetchJson(path: string): Promise {
return response.json();
}
+async function readApiError(response: Response): Promise {
+ const errorText = await response.text().catch(() => "");
+ return new Error(`API error: ${response.status}${errorText ? `: ${errorText}` : ""}`);
+}
+
export interface TimelineMessage {
type: "message";
id: string;
@@ -1990,7 +1995,7 @@ export const api = {
}),
});
if (!response.ok) {
- throw new Error(`API error: ${response.status}`);
+ throw await readApiError(response);
}
return response.json() as Promise;
},
@@ -2109,7 +2114,7 @@ export const api = {
}),
});
if (!response.ok) {
- throw new Error(`API error: ${response.status}`);
+ throw await readApiError(response);
}
return response.json() as Promise;
},
diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts
index 9454eded6..3dbca4df9 100644
--- a/interface/src/components/SetupReadiness.test.ts
+++ b/interface/src/components/SetupReadiness.test.ts
@@ -136,13 +136,13 @@ describe("classifySetupReadiness", () => {
]),
});
- expect(items).toContainEqual(
- expect.objectContaining({
- id: "warmup_degraded",
- severity: "warning",
- tab: "system-health",
- }),
- );
+ expect(items).toContainEqual(
+ expect.objectContaining({
+ id: "warmup_degraded",
+ severity: "warning",
+ tab: "system-health",
+ }),
+ );
const noProviderItems = classifySetupReadiness({
providers: configuredProviders(false),
@@ -258,5 +258,57 @@ describe("classifySetupReadiness", () => {
tab: "channels",
}),
);
+
+ const partiallyBoundItems = classifySetupReadiness({
+ providers: configuredProviders(),
+ messaging: messagingStatus([
+ {
+ platform: "discord",
+ name: "ops",
+ runtime_key: "discord:ops",
+ configured: true,
+ enabled: true,
+ binding_count: 2,
+ },
+ {
+ platform: "telegram",
+ name: "alerts",
+ runtime_key: "telegram:alerts",
+ configured: true,
+ enabled: true,
+ binding_count: 0,
+ },
+ ]),
+ });
+
+ expect(partiallyBoundItems).toContainEqual(
+ expect.objectContaining({
+ id: "messaging_unbound",
+ severity: "info",
+ tab: "channels",
+ description: "Some configured platforms still need bindings before they deliver conversations to an agent.",
+ }),
+ );
+ });
+
+ test("surfaces probe errors without suppressing other readiness items", () => {
+ const items = classifySetupReadiness({
+ providers: configuredProviders(false),
+ probeErrors: ["warmup", "mcp"],
+ });
+
+ expect(items).toContainEqual(
+ expect.objectContaining({
+ id: "probe_error",
+ severity: "warning",
+ }),
+ );
+ expect(items).toContainEqual(
+ expect.objectContaining({
+ id: "provider",
+ severity: "blocker",
+ tab: "providers",
+ }),
+ );
});
});
diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx
index 41b7792ec..c82d533fc 100644
--- a/interface/src/components/SetupReadiness.tsx
+++ b/interface/src/components/SetupReadiness.tsx
@@ -47,9 +47,19 @@ export function classifySetupReadiness(params: {
messaging?: MessagingStatusResponse;
warmup?: WarmupStatusResponse;
mcp?: McpAgentStatus[];
+ probeErrors?: string[];
}): SetupReadinessItem[] {
const items: SetupReadinessItem[] = [];
- const {providers, secrets, messaging, warmup, mcp} = params;
+ const {providers, secrets, messaging, warmup, mcp, probeErrors} = params;
+
+ if (probeErrors && probeErrors.length > 0) {
+ items.push({
+ id: "probe_error",
+ severity: "warning",
+ title: "Some readiness checks failed to load",
+ description: `${pluralize(probeErrors.length, "check")} failed: ${probeErrors.join(", ")}. Existing readiness results may be incomplete until those probes recover.`,
+ });
+ }
if (providers && !providers.has_any) {
items.push({
@@ -151,6 +161,9 @@ export function classifySetupReadiness(params: {
const boundInstances = configuredInstances.filter(
(instance) => instance.binding_count > 0,
);
+ const hasUnboundConfiguredInstance = configuredInstances.some(
+ (instance) => instance.binding_count === 0,
+ );
if (configuredInstances.length === 0) {
items.push({
@@ -160,12 +173,14 @@ export function classifySetupReadiness(params: {
description: "Connect Discord, Telegram, Slack, or another adapter when you want Spacebot to handle real conversations.",
tab: "channels",
});
- } else if (boundInstances.length === 0) {
+ } else if (hasUnboundConfiguredInstance) {
items.push({
id: "messaging_unbound",
severity: "info",
title: "Messaging is configured but not routed",
- description: "Add bindings so configured platforms actually deliver conversations to an agent.",
+ description: boundInstances.length === 0
+ ? "Add bindings so configured platforms actually deliver conversations to an agent."
+ : "Some configured platforms still need bindings before they deliver conversations to an agent.",
tab: "channels",
});
}
@@ -207,18 +222,28 @@ export function useSetupReadiness(): SetupReadinessState {
});
return useMemo(() => {
+ const probeErrors = [
+ ["providers", providersQuery],
+ ["secrets", secretsQuery],
+ ["messaging", messagingQuery],
+ ["warmup", warmupQuery],
+ ["mcp", mcpQuery],
+ ]
+ .filter(([, query]) => query.isError)
+ .map(([label]) => label);
const items = classifySetupReadiness({
providers: providersQuery.data,
secrets: secretsQuery.data,
messaging: messagingQuery.data,
warmup: warmupQuery.data,
mcp: mcpQuery.data,
+ probeErrors,
});
const actionableItems = items.filter((item) => item.severity !== "info");
const blockerCount = items.filter((item) => item.severity === "blocker").length;
const warningCount = items.filter((item) => item.severity === "warning").length;
const isLoading = [providersQuery, secretsQuery, messagingQuery, warmupQuery, mcpQuery]
- .some((query) => query.isLoading && !query.data);
+ .some((query) => query.isLoading && !query.isError && !query.data);
return {
items,
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index 09c71f7ee..89ac94c64 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -350,7 +350,8 @@ export function Settings() {
}
},
onError: (error) => {
- setMessage({ text: `Failed: ${error.message}`, type: "error" });
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ setMessage({ text: `Failed: ${errorMessage}`, type: "error" });
},
});
@@ -373,7 +374,8 @@ export function Settings() {
}
},
onError: (error) => {
- setMessage({ text: `Failed: ${error.message}`, type: "error" });
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ setMessage({ text: `Failed: ${errorMessage}`, type: "error" });
},
});
@@ -1679,6 +1681,8 @@ function SystemHealthSection() {
const queryClient = useQueryClient();
const readiness = useSetupReadiness();
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
+ const [pendingWarmupTarget, setPendingWarmupTarget] = useState(null);
+ const [pendingReconnectTarget, setPendingReconnectTarget] = useState(null);
const { data: warmupData, isLoading: warmupLoading } = useQuery({
queryKey: ["warmup-status"],
@@ -1694,6 +1698,9 @@ function SystemHealthSection() {
const triggerWarmupMutation = useMutation({
mutationFn: (params?: {agentId?: string; force?: boolean}) => api.triggerWarmup(params),
+ onMutate: (params) => {
+ setPendingWarmupTarget(params?.agentId ?? "__all__");
+ },
onSuccess: (result) => {
setMessage({
text: result.accepted_agents.length > 0
@@ -1707,10 +1714,16 @@ function SystemHealthSection() {
onError: (error) => {
setMessage({ text: `Failed: ${error.message}`, type: "error" });
},
+ onSettled: () => {
+ setPendingWarmupTarget(null);
+ },
});
const reconnectMcpMutation = useMutation({
mutationFn: (params: {agentId: string; serverName: string}) => api.reconnectMcpServer(params),
+ onMutate: (params) => {
+ setPendingReconnectTarget(`${params.agentId}:${params.serverName}`);
+ },
onSuccess: (result) => {
setMessage({
text: `Server '${result.server_name}' reconnected for agent '${result.agent_id}'.`,
@@ -1721,6 +1734,9 @@ function SystemHealthSection() {
onError: (error) => {
setMessage({ text: `Failed: ${error.message}`, type: "error" });
},
+ onSettled: () => {
+ setPendingReconnectTarget(null);
+ },
});
const warmupStatuses = warmupData?.statuses ?? [];
@@ -1756,12 +1772,12 @@ function SystemHealthSection() {
Providers, secrets, messaging, warmup, and MCP are all evaluated from live control-plane state.
-
triggerWarmupMutation.mutate({ force: true })}
- loading={triggerWarmupMutation.isPending}
- variant="outline"
- size="sm"
- >
+ triggerWarmupMutation.mutate({ force: true })}
+ loading={pendingWarmupTarget === "__all__" && triggerWarmupMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
Trigger all warmups
@@ -1834,20 +1850,20 @@ function SystemHealthSection() {
{entry.status.state}
- {details.length > 0 && (
-
- {details.map((detail) => (
-
{detail}
- ))}
-
- )}
-
- triggerWarmupMutation.mutate({ agentId: entry.agent_id, force: true })}
- loading={triggerWarmupMutation.isPending}
- variant="outline"
- size="sm"
- >
+ {details.length > 0 && (
+
+ {details.map((detail, index) => (
+
{detail}
+ ))}
+
+ )}
+
+ triggerWarmupMutation.mutate({ agentId: entry.agent_id, force: true })}
+ loading={pendingWarmupTarget === entry.agent_id && triggerWarmupMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
Refresh
@@ -1899,16 +1915,16 @@ function SystemHealthSection() {
)}
- {needsMcpReconnect(server) && (
- reconnectMcpMutation.mutate({
- agentId: server.agent_id,
- serverName: server.name,
- })}
- loading={reconnectMcpMutation.isPending}
- variant="outline"
- size="sm"
- >
+ {needsMcpReconnect(server) && (
+ reconnectMcpMutation.mutate({
+ agentId: server.agent_id,
+ serverName: server.name,
+ })}
+ loading={pendingReconnectTarget === `${server.agent_id}:${server.name}` && reconnectMcpMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
Reconnect
)}
diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs
index b2232f637..f294d5f6b 100644
--- a/src/tools/send_message_to_another_channel.rs
+++ b/src/tools/send_message_to_another_channel.rs
@@ -427,9 +427,110 @@ fn parse_explicit_email_target(raw: &str) -> Option>,
+ }
+
+ impl TestSignalAdapter {
+ fn new(name: impl Into) -> Self {
+ Self {
+ name: name.into(),
+ broadcasts: Mutex::new(Vec::new()),
+ }
+ }
+
+ fn broadcasts(&self) -> Vec {
+ self.broadcasts
+ .lock()
+ .expect("broadcast mutex poisoned")
+ .clone()
+ }
+ }
+
+ impl Messaging for TestSignalAdapter {
+ fn name(&self) -> &str {
+ &self.name
+ }
+
+ async fn start(&self) -> crate::error::Result {
+ Ok(Box::pin(tokio_stream::empty()))
+ }
+
+ async fn respond(
+ &self,
+ _message: &crate::InboundMessage,
+ _response: OutboundResponse,
+ ) -> crate::error::Result<()> {
+ Ok(())
+ }
+
+ async fn send_status(
+ &self,
+ _message: &crate::InboundMessage,
+ _status: StatusUpdate,
+ ) -> crate::error::Result<()> {
+ Ok(())
+ }
+
+ async fn broadcast(
+ &self,
+ target: &str,
+ response: OutboundResponse,
+ ) -> crate::error::Result<()> {
+ self.broadcasts
+ .lock()
+ .expect("broadcast mutex poisoned")
+ .push(BroadcastRecord {
+ target: target.to_string(),
+ response,
+ });
+ Ok(())
+ }
+
+ async fn health_check(&self) -> crate::error::Result<()> {
+ Ok(())
+ }
+ }
+
+ async fn test_send_message_tool(
+ messaging_manager: Arc,
+ current_adapter: Option,
+ ) -> SendMessageTool {
+ let pool = SqlitePool::connect("sqlite::memory:")
+ .await
+ .expect("sqlite memory pool");
+ let channel_store = ChannelStore::new(pool.clone());
+ let conversation_logger = ConversationLogger::new(pool);
+
+ SendMessageTool::new(
+ messaging_manager,
+ channel_store,
+ conversation_logger,
+ "Spacebot".to_string(),
+ current_adapter,
+ )
+ }
#[test]
fn parses_prefixed_email_target() {
@@ -620,4 +721,37 @@ mod tests {
.expect_err("should be validation error");
assert!(error.contains("requires an ID"), "{error}");
}
+
+ #[tokio::test]
+ async fn explicit_signal_prefix_uses_named_current_adapter() {
+ let messaging_manager = Arc::new(MessagingManager::new());
+ let signal_adapter = Arc::new(TestSignalAdapter::new("signal:gvoice1"));
+ messaging_manager
+ .register_shared(signal_adapter.clone())
+ .await;
+
+ let tool = test_send_message_tool(
+ messaging_manager,
+ Some("signal:gvoice1".to_string()),
+ )
+ .await;
+
+ let output = tool
+ .call(SendMessageArgs {
+ target: "signal:+1234567890".to_string(),
+ message: "hello".to_string(),
+ })
+ .await
+ .expect("send message succeeds");
+
+ assert_eq!(output.platform, "signal:gvoice1");
+ assert_eq!(output.target, "+1234567890");
+ let broadcasts = signal_adapter.broadcasts();
+ assert_eq!(broadcasts.len(), 1);
+ assert_eq!(broadcasts[0].target, "+1234567890");
+ assert!(matches!(
+ &broadcasts[0].response,
+ OutboundResponse::Text(message) if message == "hello"
+ ));
+ }
}
From 6fbf3e1ffaa011618f4fe4b338d916dd8c05c8c9 Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Thu, 12 Mar 2026 22:21:21 -0400
Subject: [PATCH 5/9] Format signal adapter regression test
---
src/tools/send_message_to_another_channel.rs | 15 ++++++---------
1 file changed, 6 insertions(+), 9 deletions(-)
diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs
index f294d5f6b..840139a39 100644
--- a/src/tools/send_message_to_another_channel.rs
+++ b/src/tools/send_message_to_another_channel.rs
@@ -427,15 +427,15 @@ fn parse_explicit_email_target(raw: &str) -> Option
Date: Thu, 12 Mar 2026 23:14:24 -0400
Subject: [PATCH 6/9] Surface system health fetch errors
---
interface/src/routes/Settings.tsx | 42 +++++++++++++++++++++++--------
1 file changed, 32 insertions(+), 10 deletions(-)
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index 89ac94c64..eeb91023e 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -1677,6 +1677,10 @@ function needsMcpReconnect(server: McpServerStatusInfo) {
return server.state === "disconnected" || server.state.startsWith("failed");
}
+function describeQueryError(error: unknown) {
+ return error instanceof Error ? error.message : String(error);
+}
+
function SystemHealthSection() {
const queryClient = useQueryClient();
const readiness = useSetupReadiness();
@@ -1684,13 +1688,23 @@ function SystemHealthSection() {
const [pendingWarmupTarget, setPendingWarmupTarget] = useState(null);
const [pendingReconnectTarget, setPendingReconnectTarget] = useState(null);
- const { data: warmupData, isLoading: warmupLoading } = useQuery({
+ const {
+ data: warmupData,
+ isLoading: warmupLoading,
+ isError: warmupError,
+ error: warmupFetchError,
+ } = useQuery({
queryKey: ["warmup-status"],
queryFn: api.warmupStatus,
staleTime: 5_000,
});
- const { data: mcpData, isLoading: mcpLoading } = useQuery({
+ const {
+ data: mcpData,
+ isLoading: mcpLoading,
+ isError: mcpError,
+ error: mcpFetchError,
+ } = useQuery({
queryKey: ["mcp-status"],
queryFn: api.mcpStatus,
staleTime: 5_000,
@@ -1828,10 +1842,14 @@ function SystemHealthSection() {
- {warmupLoading ? (
-
-
- Loading warmup status...
+ {warmupError ? (
+
+ Failed to load warmup status: {describeQueryError(warmupFetchError)}
+
+ ) : warmupLoading ? (
+
+
+ Loading warmup status...
) : warmupStatuses.length > 0 ? (
@@ -1886,10 +1904,14 @@ function SystemHealthSection() {
- {mcpLoading ? (
-
-
- Loading MCP status...
+ {mcpError ? (
+
+ Failed to load MCP status: {describeQueryError(mcpFetchError)}
+
+ ) : mcpLoading ? (
+
+
+ Loading MCP status...
) : enabledMcpServers.length > 0 ? (
From 4dad55562cd6cd4ff40e978a976235c5603d946b Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Fri, 13 Mar 2026 00:02:04 -0400
Subject: [PATCH 7/9] Fix interface typecheck for Bun tests
---
interface/bun.lock | 7 ++++++
interface/package.json | 1 +
interface/src/api/client.test.ts | 23 ++++++++++---------
.../src/components/SetupReadiness.test.ts | 4 +++-
interface/src/components/SetupReadiness.tsx | 14 +++++------
interface/tsconfig.json | 2 +-
6 files changed, 31 insertions(+), 20 deletions(-)
diff --git a/interface/bun.lock b/interface/bun.lock
index e3aa5cebf..95e6002e5 100644
--- a/interface/bun.lock
+++ b/interface/bun.lock
@@ -63,6 +63,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.18",
+ "bun-types": "^1.3.10",
"postcss": "^8.4.36",
"sass": "^1.72.0",
"tailwindcss": "^3.4.1",
@@ -603,6 +604,8 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, ""],
+ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
+
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, ""],
@@ -665,6 +668,8 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, ""],
+ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
+
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, ""],
@@ -1499,6 +1504,8 @@
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, ""],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
diff --git a/interface/package.json b/interface/package.json
index 4eae240e7..b95f6f3ec 100644
--- a/interface/package.json
+++ b/interface/package.json
@@ -67,6 +67,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.18",
+ "bun-types": "^1.3.10",
"postcss": "^8.4.36",
"sass": "^1.72.0",
"tailwindcss": "^3.4.1",
diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts
index 4e1958d13..2bfdfa8d3 100644
--- a/interface/src/api/client.test.ts
+++ b/interface/src/api/client.test.ts
@@ -1,7 +1,8 @@
import {afterEach, beforeEach, describe, expect, mock, test} from "bun:test";
if (!("window" in globalThis)) {
- (globalThis as typeof globalThis & {window: Record}).window = {};
+ (globalThis as typeof globalThis & {window: Window & typeof globalThis}).window =
+ globalThis as Window & typeof globalThis;
}
const {api} = await import("./client");
@@ -9,14 +10,14 @@ const {api} = await import("./client");
const originalFetch = globalThis.fetch;
describe("api client runtime health actions", () => {
- beforeEach(() => {
- globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
- return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- });
- }) as typeof fetch;
- });
+ beforeEach(() => {
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
+ return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }) as unknown as typeof fetch;
+ });
afterEach(() => {
globalThis.fetch = originalFetch;
@@ -67,7 +68,7 @@ describe("api client runtime health actions", () => {
test("triggerWarmup includes response text in thrown API errors", async () => {
globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response("warmup unavailable", { status: 503 });
- }) as typeof fetch;
+ }) as unknown as typeof fetch;
await expect(api.triggerWarmup({ agentId: "main" })).rejects.toThrow(
"API error: 503: warmup unavailable",
@@ -77,7 +78,7 @@ describe("api client runtime health actions", () => {
test("reconnectMcpServer includes response text in thrown API errors", async () => {
globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response("reconnect failed", { status: 500 });
- }) as typeof fetch;
+ }) as unknown as typeof fetch;
await expect(
api.reconnectMcpServer({ agentId: "main", serverName: "filesystem" }),
diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts
index 3dbca4df9..d050980f1 100644
--- a/interface/src/components/SetupReadiness.test.ts
+++ b/interface/src/components/SetupReadiness.test.ts
@@ -8,7 +8,8 @@ import type {
} from "@/api/client";
if (!("window" in globalThis)) {
- (globalThis as typeof globalThis & {window: Record}).window = {};
+ (globalThis as typeof globalThis & {window: Window & typeof globalThis}).window =
+ globalThis as Window & typeof globalThis;
}
const {classifySetupReadiness} = await import("./SetupReadiness");
@@ -38,6 +39,7 @@ function configuredProviders(hasAny = true): ProvidersResponse {
minimax_cn: false,
moonshot: false,
zai_coding_plan: false,
+ github_copilot: false,
},
};
}
diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx
index c82d533fc..39c061a46 100644
--- a/interface/src/components/SetupReadiness.tsx
+++ b/interface/src/components/SetupReadiness.tsx
@@ -223,14 +223,14 @@ export function useSetupReadiness(): SetupReadinessState {
return useMemo(() => {
const probeErrors = [
- ["providers", providersQuery],
- ["secrets", secretsQuery],
- ["messaging", messagingQuery],
- ["warmup", warmupQuery],
- ["mcp", mcpQuery],
+ {label: "providers", isError: providersQuery.isError},
+ {label: "secrets", isError: secretsQuery.isError},
+ {label: "messaging", isError: messagingQuery.isError},
+ {label: "warmup", isError: warmupQuery.isError},
+ {label: "mcp", isError: mcpQuery.isError},
]
- .filter(([, query]) => query.isError)
- .map(([label]) => label);
+ .filter((query) => query.isError)
+ .map((query) => query.label);
const items = classifySetupReadiness({
providers: providersQuery.data,
secrets: secretsQuery.data,
diff --git a/interface/tsconfig.json b/interface/tsconfig.json
index a292be7f9..2b07a7007 100644
--- a/interface/tsconfig.json
+++ b/interface/tsconfig.json
@@ -3,7 +3,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
- "types": ["vite/client"],
+ "types": ["vite/client", "bun-types"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
From 977587cb8171b2fcbf26fd8847c7af4c9a581e84 Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Fri, 13 Mar 2026 00:05:15 -0400
Subject: [PATCH 8/9] Harden system health status rendering
---
interface/src/routes/Settings.tsx | 111 ++++++++++++++++++++++--------
1 file changed, 81 insertions(+), 30 deletions(-)
diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx
index eeb91023e..1a9fed1d4 100644
--- a/interface/src/routes/Settings.tsx
+++ b/interface/src/routes/Settings.tsx
@@ -1658,7 +1658,7 @@ function formatWarmupDetails(entry: WarmupStatusEntry) {
details.push(`bulletin age ${entry.status.bulletin_age_secs}s`);
}
if (entry.status.last_error) {
- details.push(entry.status.last_error);
+ details.push(sanitizeRuntimeError(entry.status.last_error));
}
if (details.length > 0) {
return details;
@@ -1681,12 +1681,43 @@ function describeQueryError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+function sanitizeRuntimeError(errorText: string) {
+ const normalized = errorText.toLowerCase();
+ let code = "internal";
+ if (normalized.includes("timeout") || normalized.includes("deadline")) {
+ code = "timeout";
+ } else if (
+ normalized.includes("unauthorized") ||
+ normalized.includes("forbidden") ||
+ normalized.includes("auth") ||
+ normalized.includes("401") ||
+ normalized.includes("403")
+ ) {
+ code = "auth";
+ } else if (normalized.includes("not found") || normalized.includes("404")) {
+ code = "missing_dependency";
+ } else if (
+ normalized.includes("unavailable") ||
+ normalized.includes("connection refused") ||
+ normalized.includes("econnrefused") ||
+ normalized.includes("econnreset") ||
+ normalized.includes("network") ||
+ normalized.includes("503")
+ ) {
+ code = "unavailable";
+ } else if (normalized.includes("rate limit") || normalized.includes("429")) {
+ code = "rate_limited";
+ }
+
+ return `Error occurred (code: ${code})`;
+}
+
function SystemHealthSection() {
const queryClient = useQueryClient();
const readiness = useSetupReadiness();
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
- const [pendingWarmupTarget, setPendingWarmupTarget] = useState(null);
- const [pendingReconnectTarget, setPendingReconnectTarget] = useState(null);
+ const [pendingWarmupTargets, setPendingWarmupTargets] = useState>(() => new Set());
+ const [pendingReconnectTargets, setPendingReconnectTargets] = useState>(() => new Set());
const {
data: warmupData,
@@ -1713,7 +1744,12 @@ function SystemHealthSection() {
const triggerWarmupMutation = useMutation({
mutationFn: (params?: {agentId?: string; force?: boolean}) => api.triggerWarmup(params),
onMutate: (params) => {
- setPendingWarmupTarget(params?.agentId ?? "__all__");
+ const targetId = params?.agentId ?? "__all__";
+ setPendingWarmupTargets((currentTargets) => {
+ const nextTargets = new Set(currentTargets);
+ nextTargets.add(targetId);
+ return nextTargets;
+ });
},
onSuccess: (result) => {
setMessage({
@@ -1728,15 +1764,25 @@ function SystemHealthSection() {
onError: (error) => {
setMessage({ text: `Failed: ${error.message}`, type: "error" });
},
- onSettled: () => {
- setPendingWarmupTarget(null);
+ onSettled: (_result, _error, params) => {
+ const targetId = params?.agentId ?? "__all__";
+ setPendingWarmupTargets((currentTargets) => {
+ const nextTargets = new Set(currentTargets);
+ nextTargets.delete(targetId);
+ return nextTargets;
+ });
},
});
const reconnectMcpMutation = useMutation({
mutationFn: (params: {agentId: string; serverName: string}) => api.reconnectMcpServer(params),
onMutate: (params) => {
- setPendingReconnectTarget(`${params.agentId}:${params.serverName}`);
+ const targetId = `${params.agentId}:${params.serverName}`;
+ setPendingReconnectTargets((currentTargets) => {
+ const nextTargets = new Set(currentTargets);
+ nextTargets.add(targetId);
+ return nextTargets;
+ });
},
onSuccess: (result) => {
setMessage({
@@ -1748,8 +1794,13 @@ function SystemHealthSection() {
onError: (error) => {
setMessage({ text: `Failed: ${error.message}`, type: "error" });
},
- onSettled: () => {
- setPendingReconnectTarget(null);
+ onSettled: (_result, _error, params) => {
+ const targetId = `${params.agentId}:${params.serverName}`;
+ setPendingReconnectTargets((currentTargets) => {
+ const nextTargets = new Set(currentTargets);
+ nextTargets.delete(targetId);
+ return nextTargets;
+ });
},
});
@@ -1786,12 +1837,12 @@ function SystemHealthSection() {
Providers, secrets, messaging, warmup, and MCP are all evaluated from live control-plane state.
-
triggerWarmupMutation.mutate({ force: true })}
- loading={pendingWarmupTarget === "__all__" && triggerWarmupMutation.isPending}
- variant="outline"
- size="sm"
- >
+ triggerWarmupMutation.mutate({ force: true })}
+ loading={pendingWarmupTargets.has("__all__") && triggerWarmupMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
Trigger all warmups
@@ -1876,12 +1927,12 @@ function SystemHealthSection() {
)}
- triggerWarmupMutation.mutate({ agentId: entry.agent_id, force: true })}
- loading={pendingWarmupTarget === entry.agent_id && triggerWarmupMutation.isPending}
- variant="outline"
- size="sm"
- >
+ triggerWarmupMutation.mutate({ agentId: entry.agent_id, force: true })}
+ loading={pendingWarmupTargets.has(entry.agent_id) && triggerWarmupMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
Refresh
@@ -1938,15 +1989,15 @@ function SystemHealthSection() {
)}
{needsMcpReconnect(server) && (
- reconnectMcpMutation.mutate({
- agentId: server.agent_id,
- serverName: server.name,
- })}
- loading={pendingReconnectTarget === `${server.agent_id}:${server.name}` && reconnectMcpMutation.isPending}
- variant="outline"
- size="sm"
- >
+ reconnectMcpMutation.mutate({
+ agentId: server.agent_id,
+ serverName: server.name,
+ })}
+ loading={pendingReconnectTargets.has(`${server.agent_id}:${server.name}`) && reconnectMcpMutation.isPending}
+ variant="outline"
+ size="sm"
+ >
Reconnect
)}
From e220f2eb385e242790b8b5d6f665e4dec9384b77 Mon Sep 17 00:00:00 2001
From: Victor Sumner
Date: Fri, 13 Mar 2026 08:43:19 -0400
Subject: [PATCH 9/9] Ungate warmup readiness from provider status
---
interface/src/components/SetupReadiness.test.ts | 11 ++++++++---
interface/src/components/SetupReadiness.tsx | 2 +-
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts
index d050980f1..a1d20cce8 100644
--- a/interface/src/components/SetupReadiness.test.ts
+++ b/interface/src/components/SetupReadiness.test.ts
@@ -121,7 +121,7 @@ describe("classifySetupReadiness", () => {
);
});
- test("reports degraded warmup only when providers are configured", () => {
+ test("reports degraded warmup whenever warmup data is present", () => {
const items = classifySetupReadiness({
providers: configuredProviders(),
warmup: warmupStatus([
@@ -147,7 +147,6 @@ describe("classifySetupReadiness", () => {
);
const noProviderItems = classifySetupReadiness({
- providers: configuredProviders(false),
warmup: warmupStatus([
{
agent_id: "alpha",
@@ -162,7 +161,13 @@ describe("classifySetupReadiness", () => {
]),
});
- expect(noProviderItems.some((item) => item.id === "warmup_degraded")).toBe(false);
+ expect(noProviderItems).toContainEqual(
+ expect.objectContaining({
+ id: "warmup_degraded",
+ severity: "warning",
+ tab: "system-health",
+ }),
+ );
});
test("reports disconnected MCP servers and ignores disabled ones", () => {
diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx
index 39c061a46..32714d1c4 100644
--- a/interface/src/components/SetupReadiness.tsx
+++ b/interface/src/components/SetupReadiness.tsx
@@ -94,7 +94,7 @@ export function classifySetupReadiness(params: {
});
}
- if (providers?.has_any && warmup?.statuses.length) {
+ if (warmup?.statuses.length) {
const degradedAgents = warmup.statuses
.filter((entry) => entry.status.state === "degraded")
.map((entry) => entry.agent_id);