diff --git a/packages/app/src/components/ui/switch.test.tsx b/packages/app/src/components/ui/switch.test.tsx
new file mode 100644
index 000000000..3d448c1cd
--- /dev/null
+++ b/packages/app/src/components/ui/switch.test.tsx
@@ -0,0 +1,163 @@
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { JSDOM } from "jsdom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Switch } from "./switch";
+
+const { theme } = vi.hoisted(() => ({
+ theme: {
+ opacity: { 50: 0.5 },
+ colors: {
+ surface3: "#333",
+ accent: "#0a84ff",
+ palette: { white: "#fff" },
+ },
+ },
+}));
+
+vi.mock("react-native-reanimated", () => ({
+ default: {
+ View: "div",
+ },
+ Easing: {
+ ease: "ease",
+ inOut: (value: unknown) => value,
+ },
+ interpolateColor: (value: number, _input: number[], output: string[]) =>
+ value >= 1 ? output[1] : output[0],
+ useAnimatedStyle: (factory: () => unknown) => factory(),
+ useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
+ withTiming: (value: unknown) => value,
+}));
+
+vi.mock("react-native", () => ({
+ Pressable: ({
+ accessibilityLabel,
+ accessibilityRole,
+ accessibilityState,
+ children,
+ disabled,
+ onPress,
+ testID,
+ }: {
+ accessibilityLabel?: string;
+ accessibilityRole?: string;
+ accessibilityState?: { checked?: boolean; disabled?: boolean };
+ children: React.ReactNode;
+ disabled?: boolean;
+ onPress?: (event: { stopPropagation: () => void }) => void;
+ testID?: string;
+ }) =>
+ React.createElement(
+ "button",
+ {
+ "aria-checked": accessibilityState?.checked,
+ "aria-disabled": accessibilityState?.disabled,
+ "aria-label": accessibilityLabel,
+ "data-disabled": disabled,
+ "data-testid": testID,
+ onClick: () => onPress?.({ stopPropagation: vi.fn() }),
+ role: accessibilityRole,
+ type: "button",
+ },
+ children,
+ ),
+}));
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
+ },
+ useUnistyles: () => ({ theme }),
+}));
+
+describe("Switch", () => {
+ let root: Root | null = null;
+ let container: HTMLElement | null = null;
+
+ beforeEach(() => {
+ const dom = new JSDOM("
");
+ vi.stubGlobal("React", React);
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("window", dom.window);
+ vi.stubGlobal("document", dom.window.document);
+ vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
+ vi.stubGlobal("Node", dom.window.Node);
+ vi.stubGlobal("navigator", dom.window.navigator);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ if (root) {
+ act(() => {
+ root?.unmount();
+ });
+ }
+ root = null;
+ container = null;
+ vi.unstubAllGlobals();
+ });
+
+ function renderSwitch(props: React.ComponentProps): HTMLElement {
+ act(() => {
+ root?.render();
+ });
+
+ const switchElement = container?.querySelector('[role="switch"]') as HTMLElement | null;
+ if (!switchElement) {
+ throw new Error("Expected switch element to render");
+ }
+ return switchElement;
+ }
+
+ function pressSwitch(switchElement: HTMLElement): void {
+ act(() => {
+ switchElement.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ });
+ }
+
+ it("renders with accessibility role switch", () => {
+ const switchElement = renderSwitch({
+ value: false,
+ accessibilityLabel: "Enable Claude",
+ testID: "provider-switch",
+ });
+
+ expect(switchElement.getAttribute("role")).toBe("switch");
+ expect(switchElement.getAttribute("aria-label")).toBe("Enable Claude");
+ });
+
+ it("calls onValueChange with the toggled value when pressed", () => {
+ const onValueChange = vi.fn();
+ const switchElement = renderSwitch({ value: false, onValueChange });
+
+ pressSwitch(switchElement);
+
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange).toHaveBeenCalledWith(true);
+ });
+
+ it("does not call onValueChange when disabled", () => {
+ const onValueChange = vi.fn();
+ const switchElement = renderSwitch({ value: false, onValueChange, disabled: true });
+
+ pressSwitch(switchElement);
+
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it("reflects checked accessibility state from value", () => {
+ const switchElement = renderSwitch({ value: true });
+
+ expect(switchElement.getAttribute("aria-checked")).toBe("true");
+
+ act(() => {
+ root?.render();
+ });
+
+ expect(switchElement.getAttribute("aria-checked")).toBe("false");
+ });
+});
diff --git a/packages/app/src/components/ui/switch.tsx b/packages/app/src/components/ui/switch.tsx
new file mode 100644
index 000000000..79ee066dd
--- /dev/null
+++ b/packages/app/src/components/ui/switch.tsx
@@ -0,0 +1,133 @@
+import { useCallback, useMemo } from "react";
+import {
+ Pressable,
+ type GestureResponderEvent,
+ type StyleProp,
+ type ViewStyle,
+} from "react-native";
+import Animated, {
+ Easing,
+ interpolateColor,
+ useAnimatedStyle,
+ useDerivedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+
+type SwitchSize = "sm" | "md";
+
+interface SwitchProps {
+ value: boolean;
+ onValueChange?: (value: boolean) => void;
+ disabled?: boolean;
+ size?: SwitchSize;
+ accessibilityLabel?: string;
+ testID?: string;
+ style?: StyleProp;
+}
+
+const SIZES: Record = {
+ md: { track: { width: 44, height: 26 }, thumb: 22 },
+ sm: { track: { width: 34, height: 20 }, thumb: 16 },
+};
+
+const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
+
+export function Switch({
+ value,
+ onValueChange,
+ disabled = false,
+ size = "md",
+ accessibilityLabel,
+ testID,
+ style,
+}: SwitchProps) {
+ const { theme } = useUnistyles();
+ const { track, thumb } = SIZES[size];
+ const padding = (track.height - thumb) / 2;
+ const thumbTravel = track.width - thumb - padding * 2;
+
+ const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
+
+ const trackAnimatedStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ progress.value,
+ [0, 1],
+ [theme.colors.surface3, theme.colors.accent],
+ ),
+ }));
+
+ const thumbAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: progress.value * thumbTravel }],
+ }));
+
+ const handlePress = useCallback(
+ (event: GestureResponderEvent) => {
+ event.stopPropagation();
+ if (disabled) return;
+ onValueChange?.(!value);
+ },
+ [disabled, onValueChange, value],
+ );
+
+ const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
+ const pressableStyle = useMemo(
+ () => [disabled ? styles.disabled : null, style],
+ [disabled, style],
+ );
+ const trackStyle = useMemo(
+ () => [
+ styles.track,
+ {
+ width: track.width,
+ height: track.height,
+ borderRadius: track.height / 2,
+ padding,
+ },
+ trackAnimatedStyle,
+ ],
+ [track.width, track.height, padding, trackAnimatedStyle],
+ );
+ const thumbStyle = useMemo(
+ () => [
+ styles.thumb,
+ { width: thumb, height: thumb, borderRadius: thumb / 2 },
+ thumbAnimatedStyle,
+ ],
+ [thumb, thumbAnimatedStyle],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ track: {
+ justifyContent: "center",
+ },
+ thumb: {
+ backgroundColor: theme.colors.palette.white,
+ shadowColor: "rgba(0, 0, 0, 0.25)",
+ shadowOffset: { width: 0, height: 1 },
+ shadowRadius: 2,
+ shadowOpacity: 1,
+ elevation: 2,
+ },
+ disabled: {
+ opacity: theme.opacity[50],
+ },
+}));
diff --git a/packages/app/src/hooks/use-agent-form-state.live.test.ts b/packages/app/src/hooks/use-agent-form-state.live.test.ts
index 966607cbe..ddb44077d 100644
--- a/packages/app/src/hooks/use-agent-form-state.live.test.ts
+++ b/packages/app/src/hooks/use-agent-form-state.live.test.ts
@@ -78,6 +78,7 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "codex",
status: "ready",
+ enabled: true,
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
@@ -107,6 +108,7 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "claude",
status: "ready",
+ enabled: true,
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts
index 7349db9c5..5bb0f76a6 100644
--- a/packages/app/src/hooks/use-agent-form-state.test.ts
+++ b/packages/app/src/hooks/use-agent-form-state.test.ts
@@ -53,6 +53,7 @@ describe("useAgentFormState", () => {
{
provider: "zai",
status: "ready",
+ enabled: true,
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
@@ -69,6 +70,7 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
+ enabled: true,
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
@@ -544,6 +546,7 @@ describe("useAgentFormState", () => {
{
provider: "codex",
status: "loading",
+ enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -552,6 +555,7 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
+ enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
@@ -596,11 +600,12 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
- it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
- const unavailableEntries: ProviderSnapshotEntry[] = [
+ it("ignores disabled ready providers when resolving selectable defaults", () => {
+ const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
- status: "unavailable",
+ status: "ready",
+ enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -609,6 +614,106 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
+ enabled: false,
+ label: TEST_CLAUDE_DEFINITION.label,
+ description: TEST_CLAUDE_DEFINITION.description,
+ defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
+ modes: TEST_CLAUDE_DEFINITION.modes,
+ },
+ ];
+ const providerDefinitions = buildProviderDefinitions(entries);
+ const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
+ snapshotEntries: entries,
+ providerDefinitions,
+ statuses: new Set(["ready"]),
+ });
+
+ const resolved = __private__.resolveFormState(
+ undefined,
+ { provider: "claude" },
+ null,
+ {
+ serverId: false,
+ provider: false,
+ modeId: false,
+ model: false,
+ thinkingOptionId: false,
+ workingDir: false,
+ },
+ {
+ serverId: null,
+ provider: "codex",
+ modeId: "",
+ model: "",
+ thinkingOptionId: "",
+ workingDir: "",
+ },
+ new Set(),
+ selectableProviderMap,
+ );
+
+ expect(resolved.provider).toBe("codex");
+ expect(resolved.modeId).toBe("auto");
+ });
+
+ it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
+ const entries: ProviderSnapshotEntry[] = [
+ {
+ provider: "codex",
+ status: "ready",
+ enabled: true,
+ label: TEST_CODEX_DEFINITION.label,
+ description: TEST_CODEX_DEFINITION.description,
+ defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
+ modes: TEST_CODEX_DEFINITION.modes,
+ },
+ {
+ provider: "claude",
+ status: "ready",
+ enabled: false,
+ label: TEST_CLAUDE_DEFINITION.label,
+ description: TEST_CLAUDE_DEFINITION.description,
+ defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
+ modes: TEST_CLAUDE_DEFINITION.modes,
+ },
+ ];
+ const providerDefinitions = buildProviderDefinitions(entries);
+
+ const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
+ snapshotEntries: entries,
+ providerDefinitions,
+ statuses: new Set(["ready"]),
+ });
+
+ const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
+ const snapshotProviderStates = [
+ { provider: entries[0]?.provider, enabled: entries[0]?.enabled },
+ { provider: entries[1]?.provider, enabled: entries[1]?.enabled },
+ ];
+
+ expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
+ expect(providerDefinitionIds).toEqual(["codex", "claude"]);
+ expect(snapshotProviderStates).toEqual([
+ { provider: "codex", enabled: true },
+ { provider: "claude", enabled: false },
+ ]);
+ });
+
+ it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
+ const unavailableEntries: ProviderSnapshotEntry[] = [
+ {
+ provider: "codex",
+ status: "unavailable",
+ enabled: true,
+ label: TEST_CODEX_DEFINITION.label,
+ description: TEST_CODEX_DEFINITION.description,
+ defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
+ modes: TEST_CODEX_DEFINITION.modes,
+ },
+ {
+ provider: "claude",
+ status: "ready",
+ enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts
index 4f71a6b19..df99f28ba 100644
--- a/packages/app/src/hooks/use-agent-form-state.ts
+++ b/packages/app/src/hooks/use-agent-form-state.ts
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
const matchingProviders = new Set(
args.snapshotEntries
- .filter((entry) => args.statuses.has(entry.status))
+ .filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
.map((entry) => entry.provider),
);
diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts
index abfca7c1e..a53993c7d 100644
--- a/packages/app/src/hooks/use-providers-snapshot.test.ts
+++ b/packages/app/src/hooks/use-providers-snapshot.test.ts
@@ -101,6 +101,7 @@ function codexEntry(
return {
provider: "codex",
status,
+ enabled: true,
...(models ? { models } : {}),
};
}
diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx
new file mode 100644
index 000000000..29b72979a
--- /dev/null
+++ b/packages/app/src/screens/settings/providers-section.test.tsx
@@ -0,0 +1,347 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
+import type { MutableDaemonConfig } from "@server/shared/messages";
+
+const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
+ theme: {
+ spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
+ iconSize: { sm: 14, md: 20 },
+ fontSize: { xs: 11, sm: 13, base: 15 },
+ fontWeight: { normal: "400" },
+ borderRadius: { lg: 8 },
+ opacity: { 50: 0.5 },
+ colors: {
+ surface1: "#111",
+ surface2: "#222",
+ surface3: "#333",
+ foreground: "#fff",
+ foregroundMuted: "#aaa",
+ border: "#555",
+ accent: "#0a84ff",
+ statusSuccess: "#00ff00",
+ statusWarning: "#ff9500",
+ statusDanger: "#ff0000",
+ palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
+ },
+ },
+ snapshotState: {
+ entries: undefined as ProviderSnapshotEntry[] | undefined,
+ isLoading: false,
+ isRefreshing: false,
+ },
+ configState: {
+ config: null as MutableDaemonConfig | null,
+ },
+ patchConfigMock: vi.fn(async () => undefined),
+ refreshMock: vi.fn(async () => {}),
+}));
+
+vi.mock("react-native", () => ({
+ View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
+ React.createElement("div", { "data-testid": testID }, children),
+ Text: ({ children }: { children?: React.ReactNode }) =>
+ React.createElement("span", null, children),
+ Pressable: ({
+ children,
+ onPress,
+ onHoverIn,
+ onHoverOut,
+ accessibilityRole,
+ accessibilityLabel,
+ disabled,
+ testID,
+ }: {
+ children?:
+ | React.ReactNode
+ | ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
+ onPress?: (event: React.MouseEvent) => void;
+ onHoverIn?: () => void;
+ onHoverOut?: () => void;
+ accessibilityRole?: string;
+ accessibilityLabel?: string;
+ disabled?: boolean;
+ testID?: string;
+ }) =>
+ React.createElement(
+ "div",
+ {
+ role: accessibilityRole,
+ "aria-label": accessibilityLabel,
+ "aria-disabled": disabled ? "true" : undefined,
+ "data-testid": testID,
+ onClick: disabled ? undefined : onPress,
+ onMouseEnter: onHoverIn,
+ onMouseLeave: onHoverOut,
+ },
+ typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
+ ),
+ ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
+}));
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (factory: unknown) =>
+ typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
+ },
+ useUnistyles: () => ({ theme }),
+}));
+
+vi.mock("lucide-react-native", () => {
+ const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
+ return {
+ ChevronRight: icon("ChevronRight"),
+ RotateCw: icon("RotateCw"),
+ };
+});
+
+vi.mock("@/components/ui/switch", () => ({
+ Switch: ({
+ value,
+ onValueChange,
+ disabled,
+ accessibilityLabel,
+ testID,
+ }: {
+ value: boolean;
+ onValueChange?: (next: boolean) => void;
+ disabled?: boolean;
+ accessibilityLabel?: string;
+ testID?: string;
+ }) =>
+ React.createElement("div", {
+ role: "switch",
+ "aria-checked": value ? "true" : "false",
+ "aria-disabled": disabled ? "true" : undefined,
+ "aria-label": accessibilityLabel,
+ "data-testid": testID ?? "provider-switch",
+ onClick: (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (disabled) return;
+ onValueChange?.(!value);
+ },
+ }),
+}));
+
+vi.mock("@/components/ui/loading-spinner", () => ({
+ LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
+}));
+
+vi.mock("@/components/provider-icons", () => ({
+ getProviderIcon: (provider: string) => () =>
+ React.createElement("span", { "data-icon": `provider-${provider}` }),
+}));
+
+vi.mock("@/components/provider-diagnostic-sheet", () => ({
+ ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
+ React.createElement("div", {
+ "data-testid": "provider-diagnostic-sheet",
+ "data-provider": provider,
+ }),
+}));
+
+vi.mock("@/hooks/use-providers-snapshot", () => ({
+ useProvidersSnapshot: () => ({
+ entries: snapshotState.entries,
+ isLoading: snapshotState.isLoading,
+ isFetching: false,
+ isRefreshing: snapshotState.isRefreshing,
+ error: null,
+ supportsSnapshot: true,
+ refresh: refreshMock,
+ refetchIfStale: vi.fn(),
+ }),
+}));
+
+vi.mock("@/hooks/use-daemon-config", () => ({
+ useDaemonConfig: () => ({
+ config: configState.config,
+ isLoading: false,
+ patchConfig: patchConfigMock,
+ }),
+}));
+
+vi.mock("@/runtime/host-runtime", () => ({
+ useHostRuntimeIsConnected: () => true,
+}));
+
+import { ProvidersSection } from "./providers-section";
+
+const claudeEntry: ProviderSnapshotEntry = {
+ provider: "claude",
+ status: "ready",
+ enabled: true,
+ label: "Claude",
+ description: "Claude Code",
+ defaultModeId: null,
+ modes: [],
+ models: [
+ { provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" },
+ { provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
+ { provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
+ ],
+};
+
+const disabledCodexEntry: ProviderSnapshotEntry = {
+ provider: "codex",
+ status: "unavailable",
+ enabled: false,
+ label: "Codex",
+ description: "OpenAI Codex",
+ defaultModeId: null,
+ modes: [],
+};
+
+function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
+ return { mcp: { injectIntoAgents: false }, providers };
+}
+
+function descendants(el: HTMLElement): HTMLElement[] {
+ return Array.from(el.querySelectorAll("*"));
+}
+
+function indexOfMatches(nodes: HTMLElement[], selector: string): number {
+ return nodes.findIndex((node) => node.matches(selector));
+}
+
+function indexOfText(nodes: HTMLElement[], text: string): number {
+ return nodes.findIndex((node) => node.textContent?.trim() === text);
+}
+
+describe("ProvidersSection", () => {
+ let root: Root | null = null;
+ let container: HTMLElement | null = null;
+
+ beforeEach(() => {
+ vi.stubGlobal("React", React);
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ snapshotState.entries = undefined;
+ snapshotState.isLoading = false;
+ snapshotState.isRefreshing = false;
+ configState.config = null;
+ patchConfigMock.mockReset();
+ patchConfigMock.mockResolvedValue(undefined);
+ refreshMock.mockReset();
+ refreshMock.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ if (root) {
+ act(() => {
+ root?.unmount();
+ });
+ }
+ root = null;
+ container?.remove();
+ container = null;
+ vi.unstubAllGlobals();
+ });
+
+ function render(): void {
+ act(() => {
+ root?.render();
+ });
+ }
+
+ function findRow(accessibilityLabel: string): HTMLElement {
+ const row = container?.querySelector(
+ `[role="button"][aria-label="${accessibilityLabel}"]`,
+ );
+ if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`);
+ return row;
+ }
+
+ it("renders the disabled provider with its server-provided label in snapshot order", () => {
+ snapshotState.entries = [claudeEntry, disabledCodexEntry];
+ configState.config = makeConfig({ codex: { enabled: false } });
+
+ render();
+
+ const rows = Array.from(
+ container?.querySelectorAll('[role="button"][aria-label$="provider details"]') ??
+ [],
+ );
+ expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([
+ "Claude provider details",
+ "Codex provider details",
+ ]);
+
+ const codexRow = findRow("Codex provider details");
+ const codexNodes = descendants(codexRow);
+ expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0);
+ expect(indexOfText(codexNodes, "codex")).toBe(-1);
+ expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0);
+ });
+
+ it("composes the row as chevron, icon, label, status, model count, then switch", () => {
+ snapshotState.entries = [claudeEntry];
+ configState.config = makeConfig();
+
+ render();
+
+ const row = findRow("Claude provider details");
+ const nodes = descendants(row);
+ const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]');
+ const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]');
+ const label = indexOfText(nodes, "Claude");
+ const status = indexOfText(nodes, "Available");
+ const modelCount = indexOfText(nodes, "3 models");
+ const switchEl = indexOfMatches(nodes, '[role="switch"]');
+
+ expect(chevron).toBeGreaterThanOrEqual(0);
+ expect(icon).toBeGreaterThan(chevron);
+ expect(label).toBeGreaterThan(icon);
+ expect(status).toBeGreaterThan(label);
+ expect(modelCount).toBeGreaterThan(status);
+ expect(switchEl).toBeGreaterThan(modelCount);
+ });
+
+ it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => {
+ snapshotState.entries = [disabledCodexEntry];
+ configState.config = makeConfig({ codex: { enabled: false } });
+
+ render();
+
+ expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
+
+ const row = findRow("Codex provider details");
+ act(() => {
+ row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ });
+
+ const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
+ expect(sheet).not.toBeNull();
+ expect(sheet?.getAttribute("data-provider")).toBe("codex");
+ });
+
+ it("toggles the provider enabled flag through patchConfig when the switch is pressed", () => {
+ snapshotState.entries = [claudeEntry];
+ configState.config = makeConfig();
+
+ render();
+
+ const row = findRow("Claude provider details");
+ const switchEl = row.querySelector('[role="switch"]');
+ expect(switchEl).not.toBeNull();
+ expect(switchEl?.getAttribute("aria-checked")).toBe("true");
+
+ act(() => {
+ switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(patchConfigMock).toHaveBeenCalledTimes(1);
+ expect(patchConfigMock).toHaveBeenCalledWith({
+ providers: { claude: { enabled: false } },
+ });
+ expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
+ });
+});
diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx
index b309f9abc..cb2b27c3a 100644
--- a/packages/app/src/screens/settings/providers-section.tsx
+++ b/packages/app/src/screens/settings/providers-section.tsx
@@ -1,83 +1,177 @@
import { useCallback, useMemo, useState } from "react";
-import { Pressable, Text, View } from "react-native";
+import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
+import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
-import { StatusBadge } from "@/components/ui/status-badge";
+import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
-import { RotateCw } from "lucide-react-native";
+import { ChevronRight, RotateCw } from "lucide-react-native";
type ProviderDefinition = ReturnType[number];
type ProviderEntry = NonNullable["entries"]>[number];
-function getProviderStatusLabel(status: string): string {
- if (status === "ready") return "Available";
- if (status === "error") return "Error";
- if (status === "loading") return "Loading...";
- return "Not installed";
+type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
+
+interface ProviderStatus {
+ tone: StatusTone;
+ label: string;
+ modelCount: number | null;
}
-function getProviderStatusVariant(status: string): "success" | "error" | "muted" {
- if (status === "ready") return "success";
- if (status === "error") return "error";
- return "muted";
+function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus {
+ if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null };
+ if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null };
+ if (status === "error") return { tone: "danger", label: "Error", modelCount: null };
+ if (status === "ready") {
+ return {
+ tone: "success",
+ label: "Available",
+ modelCount: modelCount > 0 ? modelCount : null,
+ };
+ }
+ return { tone: "warning", label: "Not installed", modelCount: null };
}
interface ProviderRowProps {
def: ProviderDefinition;
- entry: ProviderEntry | undefined;
+ entry: ProviderEntry;
+ enabled: boolean;
+ isToggling: boolean;
isFirst: boolean;
onPress: (providerId: string) => void;
+ onToggleEnabled: (providerId: string, enabled: boolean) => void;
}
-function ProviderRow({ def, entry, isFirst, onPress }: ProviderRowProps) {
+function ProviderRow({
+ def,
+ entry,
+ enabled,
+ isToggling,
+ isFirst,
+ onPress,
+ onToggleEnabled,
+}: ProviderRowProps) {
const { theme } = useUnistyles();
- const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
const providerError =
- status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
+ enabled &&
+ entry.status === "error" &&
+ typeof entry.error === "string" &&
+ entry.error.trim().length > 0
? entry.error.trim()
: null;
- const modelCount = entry?.models?.length ?? 0;
+ const modelCount = entry.models?.length ?? 0;
+ const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
- const handlePress = useCallback(() => onPress(def.id), [def.id, onPress]);
-
- const rowStyle = useMemo(
- () => [settingsStyles.row, !isFirst && settingsStyles.rowBorder],
+ const handlePress = useCallback(() => {
+ onPress(def.id);
+ }, [def.id, onPress]);
+ const handleToggleValueChange = useCallback(
+ (value: boolean) => {
+ onToggleEnabled(def.id, value);
+ },
+ [def.id, onToggleEnabled],
+ );
+ const rowStyle = useCallback(
+ ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
+ settingsStyles.row,
+ !isFirst && settingsStyles.rowBorder,
+ styles.row,
+ hovered && styles.rowHovered,
+ pressed && styles.rowPressed,
+ ],
[isFirst],
);
return (
-
-
-
-
- {def.label}
-
- {providerError ? (
-
- {providerError}
-
- ) : null}
- {status === "ready" && modelCount > 0 ? (
-
- {modelCount === 1 ? "1 model" : `${modelCount} models`}
-
- ) : null}
-
-
+
+ {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
+ <>
+
+
+
+
+
+
+ {def.label}
+
+ ·
+
+
+ {providerError ? (
+
+ {providerError}
+
+ ) : null}
+
+
+
+ >
+ )}
);
}
+function getDotColor(tone: StatusTone, theme: ReturnType["theme"]): string {
+ switch (tone) {
+ case "success":
+ return theme.colors.statusSuccess;
+ case "warning":
+ return theme.colors.statusWarning;
+ case "danger":
+ return theme.colors.statusDanger;
+ default:
+ return theme.colors.foregroundMuted;
+ }
+}
+
+function StatusIndicator({ status }: { status: ProviderStatus }) {
+ const { theme } = useUnistyles();
+ const dotStyle = useMemo(
+ () => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
+ [status.tone, theme],
+ );
+
+ return (
+
+ {status.tone === "loading" ? (
+
+ ) : (
+
+ )}
+ {status.label}
+ {status.modelCount !== null ? (
+ <>
+ ·
+
+ {status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
+
+ >
+ ) : null}
+
+ );
+}
+
export interface ProvidersSectionProps {
serverId: string;
}
@@ -86,8 +180,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(serverId);
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId);
+ const { patchConfig } = useDaemonConfig(serverId);
const [diagnosticProvider, setDiagnosticProvider] = useState(null);
- const providerDefinitions = buildProviderDefinitions(entries);
+ const [pendingProviderId, setPendingProviderId] = useState(null);
+
+ const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = serverId.length > 0;
@@ -97,6 +194,22 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
}, [refresh]);
const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
+ const handleToggleEnabled = useCallback(
+ async (providerId: string, enabled: boolean) => {
+ setPendingProviderId(providerId);
+ try {
+ await patchConfig({ providers: { [providerId]: { enabled } } });
+ } catch (error) {
+ Alert.alert(
+ "Unable to update provider",
+ error instanceof Error ? error.message : String(error),
+ );
+ } finally {
+ setPendingProviderId((current) => (current === providerId ? null : current));
+ }
+ },
+ [patchConfig],
+ );
const refreshAction = useMemo(
() =>
@@ -146,17 +259,24 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
Loading...
) : null}
- {hasServer && isConnected && !isLoading ? (
+ {hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
- {providerDefinitions.map((def, index) => (
- e.provider === def.id)}
- isFirst={index === 0}
- onPress={setDiagnosticProvider}
- />
- ))}
+ {providerDefinitions.map((def, index) => {
+ const entry = entries?.find((candidate) => candidate.provider === def.id);
+ if (!entry) return null;
+ return (
+
+ );
+ })}
) : null}
@@ -185,11 +305,49 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
+ row: {
+ gap: theme.spacing[3],
+ minHeight: 56,
+ },
+ rowHovered: {
+ backgroundColor: theme.colors.surface2,
+ },
+ rowPressed: {
+ backgroundColor: theme.colors.surface3,
+ },
+ rowContent: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ textColumn: {
+ flex: 1,
+ minWidth: 0,
+ },
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
+ statusRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1.5],
+ },
+ statusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ statusLabel: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ separator: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts
index ea3056082..a529ec28e 100644
--- a/packages/cli/src/commands/provider/ls.ts
+++ b/packages/cli/src/commands/provider/ls.ts
@@ -1,13 +1,13 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
-import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
+import { AGENT_PROVIDER_DEFINITIONS, type ProviderSnapshotEntry } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
-/** Provider list item for display */
export interface ProviderListItem {
- provider: string;
+ provider: ProviderSnapshotEntry["provider"];
label: string;
status: string;
+ enabled: "Enabled" | "Disabled";
defaultMode: string;
modes: string;
}
@@ -17,6 +17,7 @@ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
+ enabled: "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
@@ -41,6 +42,7 @@ export const providerLsSchema: OutputSchema = {
return undefined;
},
},
+ { header: "ENABLED", field: "enabled", width: 10 },
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
{ header: "MODES", field: "modes", width: 30 },
],
@@ -74,6 +76,7 @@ export async function runLsCommand(
provider: entry.provider,
label: entry.label ?? entry.provider,
status: entry.status === "ready" ? "available" : entry.status,
+ enabled: entry.enabled === false ? "Disabled" : "Enabled",
defaultMode: entry.defaultModeId ?? "default",
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
})),
diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts
index 2510f5670..5e5683f74 100644
--- a/packages/cli/tests/15-provider.test.ts
+++ b/packages/cli/tests/15-provider.test.ts
@@ -20,7 +20,14 @@
*/
import assert from "node:assert";
-import { createE2ETestContext } from "./helpers/test-daemon.ts";
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import {
+ createE2ETestContext,
+ createTempDirs,
+ runPaseoCli,
+ startTestDaemon,
+} from "./helpers/test-daemon.ts";
console.log("=== Provider Commands ===\n");
@@ -30,6 +37,13 @@ interface ProviderModel {
description?: string;
}
+interface ProviderListRow {
+ provider: string;
+ label: string;
+ status: string;
+ enabled: string;
+}
+
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-opus-4-7[1m]",
@@ -140,6 +154,8 @@ try {
assert(result.stdout.includes("claude"), "output should include claude");
assert(result.stdout.includes("codex"), "output should include codex");
assert(result.stdout.includes("opencode"), "output should include opencode");
+ assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
+ assert(result.stdout.includes("Enabled"), "output should show enabled providers");
assert(
result.stdout.includes("available") ||
result.stdout.includes("loading") ||
@@ -169,12 +185,72 @@ try {
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
+ assert(
+ data.every((p: ProviderListRow) => p.enabled === "Enabled"),
+ "enabled providers should report Enabled",
+ );
console.log("✓ provider ls --json outputs valid JSON\n");
}
- // Test 4: provider ls --quiet outputs provider names only
+ // Test 4: provider ls includes disabled providers
{
- console.log("Test 4: provider ls --quiet outputs provider names only");
+ console.log("Test 4: provider ls includes disabled providers");
+ const { paseoHome, workDir } = await createTempDirs();
+ await writeFile(
+ join(paseoHome, "config.json"),
+ JSON.stringify(
+ {
+ version: 1,
+ agents: {
+ providers: {
+ claude: {
+ enabled: false,
+ },
+ },
+ },
+ },
+ null,
+ 2,
+ ) + "\n",
+ );
+
+ const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
+ try {
+ const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
+ assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
+ const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
+ const claude = data.find((p) => p.provider === "claude");
+ assert(claude, "disabled claude provider should stay in provider ls");
+ assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
+
+ const opencode = data.find((p) => p.provider === "opencode");
+ assert(opencode, "enabled opencode provider should stay in provider ls");
+ assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
+
+ const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
+ assert.notStrictEqual(
+ modelsResult.exitCode,
+ 0,
+ "provider models should fail for disabled providers",
+ );
+ const output = modelsResult.stdout + modelsResult.stderr;
+ assert(
+ output.includes("Provider claude is not available"),
+ "provider models should surface the daemon disabled error",
+ );
+ assert(
+ !output.includes("claude-sonnet"),
+ "provider models should not print fallback models for disabled providers",
+ );
+ } finally {
+ await disabledCtx.stop();
+ }
+ console.log("✓ provider ls includes disabled providers\n");
+ }
+
+ // Test 5: provider ls --quiet outputs provider names only
+ {
+ console.log("Test 5: provider ls --quiet outputs provider names only");
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n");
@@ -185,17 +261,17 @@ try {
console.log("✓ provider ls --quiet outputs provider names only\n");
}
- // Test 5: provider models claude lists canonical model aliases
+ // Test 6: provider models claude lists canonical model aliases
{
- console.log("Test 5: provider models claude lists canonical model aliases");
+ console.log("Test 6: provider models claude lists canonical model aliases");
const data = await runProviderModelsJson("claude");
assertClaudeModels(data);
console.log("✓ provider models claude lists canonical model aliases\n");
}
- // Test 6: provider models codex includes concrete codex model IDs
+ // Test 7: provider models codex includes concrete codex model IDs
{
- console.log("Test 6: provider models codex includes concrete codex model IDs");
+ console.log("Test 7: provider models codex includes concrete codex model IDs");
const data = await runProviderModelsJson("codex");
assert(data.length >= 1, "codex model list should not be empty");
const ids = data.map((m) => m.id);
@@ -215,9 +291,9 @@ try {
console.log("✓ provider models codex includes concrete codex model IDs\n");
}
- // Test 7: provider models opencode returns namespaced model IDs
+ // Test 8: provider models opencode returns namespaced model IDs
{
- console.log("Test 7: provider models opencode returns namespaced model IDs");
+ console.log("Test 8: provider models opencode returns namespaced model IDs");
const data = await runProviderModelsJson("opencode");
assert(data.length >= 1, "opencode model list should not be empty");
const ids = data.map((m) => m.id);
@@ -236,9 +312,9 @@ try {
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
- // Test 8: provider models unknown fails with error
+ // Test 9: provider models unknown fails with error
{
- console.log("Test 8: provider models unknown fails with error");
+ console.log("Test 9: provider models unknown fails with error");
const result = await ctx.paseo(["provider", "models", "unknown"]);
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
const output = result.stdout + result.stderr;
@@ -249,9 +325,9 @@ try {
console.log("✓ provider models unknown fails with error\n");
}
- // Test 9: provider models --json outputs valid JSON
+ // Test 10: provider models --json outputs valid JSON
{
- console.log("Test 9: provider models --json outputs valid JSON");
+ console.log("Test 10: provider models --json outputs valid JSON");
const data = await runProviderModelsJson("claude");
assert(Array.isArray(data), "output should be an array");
assert(
@@ -264,9 +340,9 @@ try {
console.log("✓ provider models --json outputs valid JSON\n");
}
- // Test 10: provider models --quiet outputs model IDs only
+ // Test 11: provider models --quiet outputs model IDs only
{
- console.log("Test 10: provider models --quiet outputs model IDs only");
+ console.log("Test 11: provider models --quiet outputs model IDs only");
assert(
claudeModelIdsFromJson.length > 0,
"claude model IDs should be captured from --json output",
diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts
index 0ce7a2200..42d6867fc 100644
--- a/packages/server/src/server/agent/agent-manager.test.ts
+++ b/packages/server/src/server/agent/agent-manager.test.ts
@@ -11,6 +11,7 @@ import type {
AgentClient,
AgentFeature,
AgentLaunchContext,
+ AgentProvider,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
@@ -18,6 +19,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
} from "./agent-sdk-types.js";
+import type { ProviderDefinition } from "./provider-registry.js";
interface Deferred {
promise: Promise;
@@ -842,6 +844,119 @@ test("createAgent reports available providers when selected provider is unavaila
);
});
+test("createAgent rejects a disabled provider without creating a session", async () => {
+ const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+ const storagePath = join(workdir, "agents");
+ const storage = new AgentStorage(storagePath, logger);
+
+ class DisabledCodexClient extends TestAgentClient {
+ createSessionCalls = 0;
+
+ override async createSession(config: AgentSessionConfig): Promise {
+ this.createSessionCalls += 1;
+ return await super.createSession(config);
+ }
+ }
+
+ const disabledClient = new DisabledCodexClient();
+ const providerDefinitions = {
+ codex: {
+ enabled: false,
+ },
+ } satisfies Partial>>;
+ const manager = new AgentManager({
+ clients: {
+ codex: disabledClient,
+ },
+ providerDefinitions,
+ registry: storage,
+ logger,
+ });
+
+ await expect(
+ manager.createAgent({
+ provider: "codex",
+ cwd: workdir,
+ }),
+ ).rejects.toThrow("Provider 'codex' is disabled");
+ expect(disabledClient.createSessionCalls).toBe(0);
+ expect(await storage.list()).toHaveLength(0);
+});
+
+test("updateProviderRegistry re-enables a previously disabled provider", async () => {
+ const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+ const storagePath = join(workdir, "agents");
+ const storage = new AgentStorage(storagePath, logger);
+ const client = new TestAgentClient();
+ const manager = new AgentManager({
+ clients: { codex: client },
+ providerDefinitions: {
+ codex: { enabled: false },
+ },
+ registry: storage,
+ logger,
+ });
+
+ await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
+ "Provider 'codex' is disabled",
+ );
+
+ manager.updateProviderRegistry({
+ providerDefinitions: { codex: { enabled: true } },
+ clients: { codex: client },
+ });
+
+ const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
+ expect(snapshot.config.provider).toBe("codex");
+});
+
+test("updateProviderRegistry disables a previously enabled provider", async () => {
+ const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+ const storagePath = join(workdir, "agents");
+ const storage = new AgentStorage(storagePath, logger);
+ const client = new TestAgentClient();
+ const manager = new AgentManager({
+ clients: { codex: client },
+ providerDefinitions: {
+ codex: { enabled: true },
+ },
+ registry: storage,
+ logger,
+ });
+
+ manager.updateProviderRegistry({
+ providerDefinitions: { codex: { enabled: false } },
+ clients: { codex: client },
+ });
+
+ await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
+ "Provider 'codex' is disabled",
+ );
+});
+
+test("updateProviderRegistry registers a previously unknown provider", async () => {
+ const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+ const storagePath = join(workdir, "agents");
+ const storage = new AgentStorage(storagePath, logger);
+ const manager = new AgentManager({
+ clients: {},
+ providerDefinitions: {},
+ registry: storage,
+ logger,
+ });
+
+ expect(manager.getRegisteredProviderIds()).not.toContain("codex");
+
+ manager.updateProviderRegistry({
+ providerDefinitions: { codex: { enabled: true } },
+ clients: { codex: new TestAgentClient() },
+ });
+
+ expect(manager.getRegisteredProviderIds()).toContain("codex");
+ const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
+ expect(snapshot.config.provider).toBe("codex");
+});
+
test("createAgent passes explicit model strings through to the provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts
index cf39b7f4e..df91225e8 100644
--- a/packages/server/src/server/agent/agent-manager.ts
+++ b/packages/server/src/server/agent/agent-manager.ts
@@ -113,8 +113,15 @@ interface AgentManagerRescueTimeouts {
interruptSessionMs?: number;
}
+interface ProviderEnabledFlag {
+ enabled: boolean;
+}
+type ProviderEnabledMap = Partial>;
+type ProviderClientMap = Partial>;
+
export interface AgentManagerOptions {
- clients?: Partial>;
+ clients?: ProviderClientMap;
+ providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
@@ -387,6 +394,7 @@ function buildExplicitTimelineSeedForRegister(
export class AgentManager {
private readonly clients = new Map();
+ private readonly providerEnabled = new Map();
private readonly agents = new Map();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set();
@@ -425,19 +433,32 @@ export class AgentManager {
this.notifyForegroundTurnWaiters(agentId, event);
},
});
- if (options?.clients) {
- for (const [provider, client] of Object.entries(options.clients)) {
- if (client) {
- this.registerClient(provider as AgentProvider, client);
- }
- }
- }
+ this.updateProviderRegistry({
+ providerDefinitions: options.providerDefinitions ?? {},
+ clients: options.clients ?? {},
+ });
}
registerClient(provider: AgentProvider, client: AgentClient): void {
this.clients.set(provider, client);
}
+ updateProviderRegistry(input: {
+ providerDefinitions: ProviderEnabledMap;
+ clients: ProviderClientMap;
+ }): void {
+ for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
+ if (definition) {
+ this.providerEnabled.set(provider, definition.enabled);
+ }
+ }
+ for (const [provider, client] of Object.entries(input.clients)) {
+ if (client) {
+ this.clients.set(provider, client);
+ }
+ }
+ }
+
getRegisteredProviderIds(): AgentProvider[] {
return Array.from(this.clients.keys());
}
@@ -718,6 +739,7 @@ export class AgentManager {
...config.mcpServers,
},
};
+ this.requireEnabledProvider(injectedConfig.provider);
const normalizedConfig = await this.normalizeConfig(injectedConfig);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = await this.requireAvailableClient({
@@ -3082,7 +3104,7 @@ export class AgentManager {
private async requireAvailableClient(options: { provider: AgentProvider }): Promise {
const client = this.clients.get(options.provider);
if (!client) {
- const configuredProviders = Array.from(this.clients.keys());
+ const configuredProviders = this.getConfiguredProviderIds();
throw new Error(
`Unknown provider '${options.provider}'. Configured providers: ${formatProviderList(
configuredProviders,
@@ -3110,6 +3132,16 @@ export class AgentManager {
);
}
+ private requireEnabledProvider(provider: AgentProvider): void {
+ if (this.providerEnabled.get(provider) === false) {
+ throw new Error(`Provider '${provider}' is disabled`);
+ }
+ }
+
+ private getConfiguredProviderIds(): AgentProvider[] {
+ return Array.from(new Set([...this.providerEnabled.keys(), ...this.clients.keys()]));
+ }
+
private requireClient(provider: AgentProvider): AgentClient {
const client = this.clients.get(provider);
if (!client) {
diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts
index f6e8596a8..9d5c58242 100644
--- a/packages/server/src/server/agent/agent-sdk-types.ts
+++ b/packages/server/src/server/agent/agent-sdk-types.ts
@@ -74,6 +74,7 @@ export interface AgentSelectOption {
export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
+ enabled: boolean;
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts
index cab35af87..0c0db97f1 100644
--- a/packages/server/src/server/agent/mcp-server.test.ts
+++ b/packages/server/src/server/agent/mcp-server.test.ts
@@ -146,6 +146,7 @@ function createProviderDefinition(overrides: Partial): Provi
id: provider,
label: "Claude",
description: "Test provider",
+ enabled: true,
defaultModeId: "default",
modes: [],
createClient: vi.fn(() => ({
@@ -1037,6 +1038,8 @@ describe("provider listing MCP tool", () => {
{
id: "claude",
label: "Claude",
+ description: "Test provider",
+ enabled: true,
status: "available",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
},
@@ -1044,11 +1047,116 @@ describe("provider listing MCP tool", () => {
id: "zai",
label: "ZAI",
status: "available",
+ description: "Custom Claude profile",
+ enabled: true,
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
},
],
});
});
+
+ it("returns disabled providers with metadata without checking availability", async () => {
+ const { agentManager, agentStorage } = createTestDeps();
+ const baseProvider = createProviderDefinition({ id: "codex" });
+ const client = baseProvider.createClient(logger);
+ const isAvailable = vi.fn().mockResolvedValue(true);
+ const createClient = vi.fn(() => ({ ...client, isAvailable }));
+ const providerRegistry = {
+ codex: createProviderDefinition({
+ id: "codex",
+ label: "Codex",
+ description: "OpenAI coding agent",
+ enabled: false,
+ modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
+ createClient,
+ }),
+ };
+
+ const server = await createAgentMcpServer({
+ agentManager,
+ agentStorage,
+ providerRegistry,
+ logger,
+ });
+ const tool = registeredTool(server, "list_providers");
+ const response = await tool.callback({});
+
+ expect(response.structuredContent).toEqual({
+ providers: [
+ {
+ id: "codex",
+ label: "Codex",
+ description: "OpenAI coding agent",
+ enabled: false,
+ status: "unavailable",
+ modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
+ },
+ ],
+ });
+ expect(createClient).not.toHaveBeenCalled();
+ expect(isAvailable).not.toHaveBeenCalled();
+ });
+
+ it("checks availability for enabled providers", async () => {
+ const { agentManager, agentStorage } = createTestDeps();
+ const baseProvider = createProviderDefinition({ id: "claude" });
+ const client = baseProvider.createClient(logger);
+ const isAvailable = vi.fn().mockResolvedValue(true);
+ const providerRegistry = {
+ claude: createProviderDefinition({
+ createClient: vi.fn(() => ({ ...client, isAvailable })),
+ }),
+ };
+
+ const server = await createAgentMcpServer({
+ agentManager,
+ agentStorage,
+ providerRegistry,
+ logger,
+ });
+ const tool = registeredTool(server, "list_providers");
+
+ await tool.callback({});
+
+ expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
+ expect(isAvailable).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("model listing MCP tool", () => {
+ const logger = createTestLogger();
+
+ it("rejects disabled providers without fetching models", async () => {
+ const { agentManager, agentStorage } = createTestDeps();
+ const fetchModels = vi.fn().mockResolvedValue([
+ {
+ provider: "codex",
+ id: "gpt-5.4",
+ label: "GPT-5.4",
+ },
+ ]);
+ const providerRegistry = {
+ codex: createProviderDefinition({
+ id: "codex",
+ label: "Codex",
+ enabled: false,
+ fetchModels,
+ }),
+ };
+
+ const server = await createAgentMcpServer({
+ agentManager,
+ agentStorage,
+ providerRegistry,
+ logger,
+ });
+ const tool = registeredTool(server, "list_models");
+
+ await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
+ "Provider 'codex' is disabled",
+ );
+ expect(fetchModels).not.toHaveBeenCalled();
+ });
});
describe("speak MCP tool", () => {
diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts
index 1b0f6c8f1..3935c9720 100644
--- a/packages/server/src/server/agent/mcp-server.ts
+++ b/packages/server/src/server/agent/mcp-server.ts
@@ -169,6 +169,43 @@ function resolveRegisteredProviderIds(
: agentManager.getRegisteredProviderIds();
}
+interface ProviderSummary {
+ id: AgentProvider;
+ label: string;
+ description: string;
+ enabled: boolean;
+ modes: ProviderDefinition["modes"];
+ status: string;
+ error?: string;
+}
+
+async function resolveProviderSummary(
+ provider: ProviderDefinition,
+ logger: Logger,
+): Promise {
+ const base = {
+ id: provider.id,
+ label: provider.label,
+ description: provider.description,
+ modes: provider.modes,
+ };
+ if (!provider.enabled) {
+ return { ...base, enabled: false, status: "unavailable" };
+ }
+ try {
+ const available = await provider.createClient(logger).isAvailable();
+ return { ...base, enabled: true, status: available ? "available" : "unavailable" };
+ } catch (availabilityError) {
+ return {
+ ...base,
+ enabled: true,
+ status: "unavailable",
+ error:
+ availabilityError instanceof Error ? availabilityError.message : String(availabilityError),
+ };
+ }
+}
+
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
const attentionDelta =
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
@@ -1650,35 +1687,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
async () => {
const providers = await Promise.all(
- Object.values(providerRegistry ?? {}).map(async (provider) => {
- let status = "unknown";
- let error: string | undefined;
- try {
- status = (await provider.createClient(childLogger).isAvailable())
- ? "available"
- : "unavailable";
- } catch (availabilityError) {
- status = "unavailable";
- error =
- availabilityError instanceof Error
- ? availabilityError.message
- : String(availabilityError);
- }
- const summary = {
- id: provider.id,
- label: provider.label,
- status,
- modes: provider.modes.map((mode) => ({
- id: mode.id,
- label: mode.label,
- ...(mode.description ? { description: mode.description } : {}),
- })),
- };
- if (error) {
- return Object.assign(summary, { error });
- }
- return summary;
- }),
+ Object.values(providerRegistry ?? {}).map((provider) =>
+ resolveProviderSummary(provider, childLogger),
+ ),
);
return {
content: [],
@@ -1709,6 +1720,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!definition) {
throw new Error(`Provider ${provider} is not configured`);
}
+ if (!definition.enabled) {
+ throw new Error(`Provider '${provider}' is disabled`);
+ }
const models = await definition.fetchModels({ cwd: resolveSnapshotCwd(), force: false });
return {
diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts
index 286e5a5b7..db2f75bf8 100644
--- a/packages/server/src/server/agent/mcp-shared.ts
+++ b/packages/server/src/server/agent/mcp-shared.ts
@@ -32,6 +32,8 @@ export const ProviderSummarySchema = z
.object({
id: z.string(),
label: z.string().nullish(),
+ description: z.string().nullish(),
+ enabled: z.boolean().optional().default(true),
modes: z.array(ProviderModeSchema).nullish(),
})
.passthrough();
diff --git a/packages/server/src/server/agent/model-resolver.test.ts b/packages/server/src/server/agent/model-resolver.test.ts
index bd4178eb0..4502f347a 100644
--- a/packages/server/src/server/agent/model-resolver.test.ts
+++ b/packages/server/src/server/agent/model-resolver.test.ts
@@ -5,6 +5,7 @@ import { resolveAgentModel } from "./model-resolver.js";
vi.mock("./provider-registry.js", () => ({
buildProviderRegistry: vi.fn(),
+ isProviderEnabled: vi.fn((definition: { enabled: boolean }) => definition.enabled === true),
}));
import { buildProviderRegistry } from "./provider-registry.js";
@@ -21,6 +22,12 @@ describe("resolveAgentModel", () => {
});
it("returns the trimmed requested model when provided", async () => {
+ mockedBuildProviderRegistry.mockReturnValue({
+ claude: { enabled: true, fetchModels: vi.fn() },
+ codex: { enabled: true, fetchModels: vi.fn() },
+ opencode: { enabled: true, fetchModels: vi.fn() },
+ } as unknown as ProviderRegistryMock);
+
const result = await resolveAgentModel({
provider: "codex",
requestedModel: " gpt-5.1 ",
@@ -29,7 +36,7 @@ describe("resolveAgentModel", () => {
});
expect(result).toBe("gpt-5.1");
- expect(mockedBuildProviderRegistry).not.toHaveBeenCalled();
+ expect(mockedBuildProviderRegistry).toHaveBeenCalledWith(testLogger);
});
it("uses the default model from the provider catalog when no model specified", async () => {
@@ -38,9 +45,9 @@ describe("resolveAgentModel", () => {
{ id: "claude-3.5-sonnet", isDefault: true },
]);
mockedBuildProviderRegistry.mockReturnValue({
- claude: { fetchModels },
- codex: { fetchModels: vi.fn() },
- opencode: { fetchModels: vi.fn() },
+ claude: { enabled: true, fetchModels },
+ codex: { enabled: true, fetchModels: vi.fn() },
+ opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
@@ -62,9 +69,9 @@ describe("resolveAgentModel", () => {
{ id: "model-b", isDefault: false },
]);
mockedBuildProviderRegistry.mockReturnValue({
- claude: { fetchModels: vi.fn() },
- codex: { fetchModels },
- opencode: { fetchModels: vi.fn() },
+ claude: { enabled: true, fetchModels: vi.fn() },
+ codex: { enabled: true, fetchModels },
+ opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -75,9 +82,9 @@ describe("resolveAgentModel", () => {
it("returns undefined when the catalog lookup fails", async () => {
const fetchModels = vi.fn().mockRejectedValue(new Error("boom"));
mockedBuildProviderRegistry.mockReturnValue({
- claude: { fetchModels: vi.fn() },
- codex: { fetchModels },
- opencode: { fetchModels: vi.fn() },
+ claude: { enabled: true, fetchModels: vi.fn() },
+ codex: { enabled: true, fetchModels },
+ opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -85,4 +92,38 @@ describe("resolveAgentModel", () => {
expect(result).toBeUndefined();
expect(testLoggerWarn).toHaveBeenCalled();
});
+
+ it("returns undefined for a disabled provider without fetching default models", async () => {
+ const fetchModels = vi.fn().mockResolvedValue([{ id: "model-a", isDefault: true }]);
+ mockedBuildProviderRegistry.mockReturnValue({
+ claude: { enabled: true, fetchModels: vi.fn() },
+ codex: { enabled: false, fetchModels },
+ opencode: { enabled: true, fetchModels: vi.fn() },
+ } as unknown as ProviderRegistryMock);
+
+ const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
+
+ expect(result).toBeUndefined();
+ expect(fetchModels).not.toHaveBeenCalled();
+ expect(testLoggerWarn).toHaveBeenCalled();
+ });
+
+ it("returns undefined for a requested model from a disabled provider", async () => {
+ const fetchModels = vi.fn();
+ mockedBuildProviderRegistry.mockReturnValue({
+ claude: { enabled: true, fetchModels: vi.fn() },
+ codex: { enabled: false, fetchModels },
+ opencode: { enabled: true, fetchModels: vi.fn() },
+ } as unknown as ProviderRegistryMock);
+
+ const result = await resolveAgentModel({
+ provider: "codex",
+ requestedModel: "gpt-5.1",
+ logger: testLogger,
+ });
+
+ expect(result).toBeUndefined();
+ expect(fetchModels).not.toHaveBeenCalled();
+ expect(testLoggerWarn).toHaveBeenCalled();
+ });
});
diff --git a/packages/server/src/server/agent/model-resolver.ts b/packages/server/src/server/agent/model-resolver.ts
index ac24aeb2c..27f8008d8 100644
--- a/packages/server/src/server/agent/model-resolver.ts
+++ b/packages/server/src/server/agent/model-resolver.ts
@@ -14,14 +14,19 @@ interface ResolveAgentModelOptions {
export async function resolveAgentModel(
options: ResolveAgentModelOptions,
): Promise {
- const trimmed = options.requestedModel?.trim();
- if (trimmed) {
- return trimmed;
- }
-
try {
const providerRegistry = buildProviderRegistry(options.logger);
- const models = await providerRegistry[options.provider].fetchModels({
+ const providerDefinition = providerRegistry[options.provider];
+ if (!providerDefinition.enabled) {
+ throw new Error(`Provider '${options.provider}' is disabled`);
+ }
+
+ const trimmed = options.requestedModel?.trim();
+ if (trimmed) {
+ return trimmed;
+ }
+
+ const models = await providerDefinition.fetchModels({
cwd: resolveSnapshotCwd(options.cwd ? expandTilde(options.cwd) : undefined),
force: false,
});
diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts
index 14f26ae63..1575bcaaa 100644
--- a/packages/server/src/server/agent/provider-registry.test.ts
+++ b/packages/server/src/server/agent/provider-registry.test.ts
@@ -317,7 +317,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
},
}));
-import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js";
+import {
+ AGENT_PROVIDER_DEFINITIONS,
+ buildProviderRegistry,
+ createAllClients,
+} from "./provider-registry.js";
const logger = createTestLogger();
@@ -458,7 +462,7 @@ test("custom provider without label throws", () => {
).toThrowError("Custom provider 'zai' requires a label");
});
-test("enabled: false excludes provider from registry", () => {
+test("enabled: false keeps provider metadata in registry", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
@@ -467,7 +471,31 @@ test("enabled: false excludes provider from registry", () => {
},
});
- expect(registry.claude).toBeUndefined();
+ expect(registry.claude).toMatchObject({
+ id: "claude",
+ label: "Claude",
+ description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
+ defaultModeId: "default",
+ enabled: false,
+ });
+ expect(registry.claude.modes).toEqual(
+ AGENT_PROVIDER_DEFINITIONS.find((definition) => definition.id === "claude")?.modes,
+ );
+ expect(registry.codex.enabled).toBe(true);
+});
+
+test("enabled: false still produces a client (enabled gate is enforced elsewhere)", () => {
+ const clients = createAllClients(logger, {
+ providerOverrides: {
+ claude: {
+ enabled: false,
+ },
+ },
+ });
+
+ expect(clients.claude).toBeDefined();
+ expect(mockState.constructorArgs.claude.length).toBeGreaterThan(0);
+ expect(clients.codex).toBeDefined();
});
test("provider override command can be PATH-resolved and still report available", async () => {
diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts
index 5b149cb2f..2d913ac8c 100644
--- a/packages/server/src/server/agent/provider-registry.ts
+++ b/packages/server/src/server/agent/provider-registry.ts
@@ -41,6 +41,7 @@ export type { AgentProviderDefinition };
export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
+ enabled: boolean;
createClient: (logger: Logger) => AgentClient;
fetchModels: (options: ListModelsOptions) => Promise;
fetchModes: (options: ListModesOptions) => Promise;
@@ -364,10 +365,9 @@ function createRegistryEntry(
return {
...resolved.definition,
- createClient: (providerLogger: Logger) => {
- const inner = resolved.createBaseClient(providerLogger);
- return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
- },
+ enabled: resolved.enabled,
+ createClient: (providerLogger: Logger) =>
+ createResolvedProviderClient(providerLogger, provider, resolved),
fetchModels: async (options: ListModelsOptions) =>
mergeModels(
provider,
@@ -392,6 +392,15 @@ function createRegistryEntry(
};
}
+function createResolvedProviderClient(
+ logger: Logger,
+ provider: AgentProvider,
+ resolved: ResolvedProvider,
+): AgentClient {
+ const inner = resolved.createBaseClient(logger);
+ return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
+}
+
function buildResolvedBuiltinProviders(
providerOverrides: Record,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
@@ -514,9 +523,10 @@ export function buildProviderRegistry(
addDerivedProviders(resolvedProviders, providerOverrides);
return Object.fromEntries(
- [...resolvedProviders.entries()]
- .filter(([, resolved]) => resolved.enabled)
- .map(([provider, resolved]) => [provider, createRegistryEntry(logger, provider, resolved)]),
+ [...resolvedProviders.entries()].map(([provider, resolved]) => [
+ provider,
+ createRegistryEntry(logger, provider, resolved),
+ ]),
) as Record;
}
@@ -534,7 +544,13 @@ export function createAllClients(
logger: Logger,
options?: BuildProviderRegistryOptions,
): Record {
- const registry = buildProviderRegistry(logger, options);
+ return createClientsFromRegistry(buildProviderRegistry(logger, options), logger);
+}
+
+export function createClientsFromRegistry(
+ registry: Record,
+ logger: Logger,
+): Record {
return Object.fromEntries(
Object.entries(registry).map(([provider, definition]) => [
provider,
diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts
index b90211cf6..22e1a4f18 100644
--- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts
+++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts
@@ -23,9 +23,11 @@ interface Deferred {
interface MockProviderOptions {
provider: AgentProvider;
+ enabled?: boolean;
label?: string;
description?: string;
defaultModeId?: string | null;
+ modes?: AgentMode[];
isAvailable?: () => Promise;
fetchModels?: (cwd: string, force: boolean) => Promise;
fetchModes?: (cwd: string, force: boolean) => Promise;
@@ -33,6 +35,7 @@ interface MockProviderOptions {
interface MockProviderHandle {
definition: ProviderDefinition;
+ createClient: ReturnType;
isAvailable: ReturnType;
fetchModels: ReturnType;
fetchModes: ReturnType;
@@ -158,6 +161,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "unavailable",
+ enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -189,6 +193,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "error",
+ enabled: true,
error: "model lookup failed",
label: "codex",
description: "codex test provider",
@@ -278,6 +283,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "loading",
+ enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -385,6 +391,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "loading",
+ enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -392,6 +399,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "claude",
status: "loading",
+ enabled: true,
label: "claude",
description: "claude test provider",
defaultModeId: null,
@@ -465,6 +473,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "loading",
+ enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -1120,6 +1129,72 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
+ test("disabled providers stay in the snapshot without probing or fetching", async () => {
+ const disabledModels = [createModel("zai", "glm-4.6")];
+ const disabledMode = createMode("plan");
+ const { registry, handles } = createRegistry([
+ createMockProvider({
+ provider: "codex",
+ fetchModels: async () => [createModel("codex", "gpt-5.2")],
+ fetchModes: async () => [createMode("auto")],
+ }),
+ createMockProvider({
+ provider: "zai",
+ enabled: false,
+ label: "Z.AI",
+ description: "Custom disabled Claude profile",
+ defaultModeId: "plan",
+ models: disabledModels,
+ modes: [disabledMode],
+ fetchModels: async () => [createModel("zai", "glm-4.6")],
+ fetchModes: async () => [disabledMode],
+ }),
+ createMockProvider({
+ provider: "claude",
+ fetchModels: async () => [createModel("claude", "sonnet")],
+ fetchModes: async () => [createMode("default")],
+ }),
+ ]);
+ const manager = new ProviderSnapshotManager(registry, createTestLogger());
+
+ manager.getSnapshot(projectCwd);
+
+ await vi.waitFor(() => {
+ expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
+ expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
+ expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("unavailable");
+ });
+
+ const snapshot = manager.getSnapshot(projectCwd);
+ expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "zai", "claude"]);
+ const zaiEntry = getProviderEntry(snapshot, "zai");
+ expect(zaiEntry).toMatchObject({
+ provider: "zai",
+ status: "unavailable",
+ enabled: false,
+ label: "Z.AI",
+ description: "Custom disabled Claude profile",
+ defaultModeId: "plan",
+ });
+ expect(zaiEntry?.models).toBeUndefined();
+ expect(zaiEntry?.modes).toBeUndefined();
+
+ expect(handles.zai?.createClient).not.toHaveBeenCalled();
+ expect(handles.zai?.isAvailable).not.toHaveBeenCalled();
+ expect(handles.zai?.fetchModels).not.toHaveBeenCalled();
+ expect(handles.zai?.fetchModes).not.toHaveBeenCalled();
+ expect(handles.codex?.createClient).toHaveBeenCalledTimes(1);
+ expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
+ expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
+ expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
+ expect(handles.claude?.createClient).toHaveBeenCalledTimes(1);
+ expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
+ expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1);
+ expect(handles.claude?.fetchModes).toHaveBeenCalledTimes(1);
+
+ manager.destroy();
+ });
+
test("enabled false providers are omitted when absent from the registry", () => {
const { registry } = createRegistry([createMockProvider({ provider: "claude" })]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
@@ -1151,6 +1226,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "zai",
status: "loading",
+ enabled: true,
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "plan",
@@ -1200,6 +1276,7 @@ function createRegistry(handles: MockProviderHandle[]): {
}
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
+ const createClient = vi.fn();
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
const fetchModels = vi.fn(
async (listOptions: ListModelsOptions) =>
@@ -1214,14 +1291,16 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
],
);
- const definition: ProviderDefinition = {
+ const definition = {
id: options.provider,
+ enabled: options.enabled ?? true,
label: options.label ?? options.provider,
description: options.description ?? `${options.provider} test provider`,
defaultModeId: options.defaultModeId ?? null,
- modes: [],
- createClient: () =>
- ({
+ modes: options.modes ?? [],
+ createClient: () => {
+ createClient();
+ return {
provider: options.provider,
capabilities: TEST_CAPABILITIES,
async createSession() {
@@ -1236,13 +1315,15 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
async isAvailable() {
return isAvailable();
},
- }) satisfies AgentClient,
+ } satisfies AgentClient;
+ },
fetchModels,
fetchModes,
- };
+ } satisfies ProviderDefinition;
return {
definition,
+ createClient,
isAvailable,
fetchModels,
fetchModes,
diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts
index 071224715..22991c8ba 100644
--- a/packages/server/src/server/agent/provider-snapshot-manager.ts
+++ b/packages/server/src/server/agent/provider-snapshot-manager.ts
@@ -165,6 +165,7 @@ export class ProviderSnapshotManager {
entries.set(provider, {
provider,
status: "loading",
+ enabled: definition?.enabled ?? true,
label: definition?.label,
description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null,
@@ -242,8 +243,27 @@ export class ProviderSnapshotManager {
}): Promise {
const { cwd, provider, definition, load, force } = options;
const snapshot = this.getOrCreateSnapshot(options.cwd);
+ const base = {
+ provider,
+ label: definition.label,
+ description: definition.description,
+ defaultModeId: definition.defaultModeId,
+ };
+ const setEntry = (entry: ProviderSnapshotEntry) => {
+ if (!this.isCurrentProviderLoad(cwd, provider, load)) {
+ return false;
+ }
+ snapshot.set(provider, entry);
+ this.emitChange(cwd);
+ return true;
+ };
try {
+ if (!definition.enabled) {
+ setEntry({ ...base, status: "unavailable", enabled: false });
+ return;
+ }
+
const client = definition.createClient(this.logger);
const available = await withTimeout(
client.isAvailable(),
@@ -251,17 +271,7 @@ export class ProviderSnapshotManager {
`Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`,
);
if (!available) {
- if (!this.isCurrentProviderLoad(cwd, provider, load)) {
- return;
- }
- snapshot.set(provider, {
- provider,
- status: "unavailable",
- label: definition.label,
- description: definition.description,
- defaultModeId: definition.defaultModeId,
- });
- this.emitChange(cwd);
+ setEntry({ ...base, status: "unavailable", enabled: true });
return;
}
@@ -274,34 +284,24 @@ export class ProviderSnapshotManager {
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
);
- if (!this.isCurrentProviderLoad(cwd, provider, load)) {
- return;
- }
- snapshot.set(provider, {
- provider,
+ setEntry({
+ ...base,
status: "ready",
+ enabled: true,
models,
modes,
fetchedAt: new Date().toISOString(),
- label: definition.label,
- description: definition.description,
- defaultModeId: definition.defaultModeId,
});
- this.emitChange(cwd);
} catch (error) {
- if (!this.isCurrentProviderLoad(cwd, provider, load)) {
- return;
- }
- snapshot.set(provider, {
- provider,
+ const emitted = setEntry({
+ ...base,
status: "error",
+ enabled: true,
error: toErrorMessage(error),
- label: definition.label,
- description: definition.description,
- defaultModeId: definition.defaultModeId,
});
- this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
- this.emitChange(cwd);
+ if (emitted) {
+ this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
+ }
}
}
diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts
index 265f951c8..b2418b56b 100644
--- a/packages/server/src/server/agent/providers/claude-agent.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.ts
@@ -3019,7 +3019,7 @@ class ClaudeAgentSession implements AgentSession {
}
private appendUserContentArrayEvents(
- content: ClaudeContentChunk[],
+ content: ReadonlyArray,
messageId: string | undefined,
events: AgentStreamEvent[],
): void {
@@ -3507,7 +3507,7 @@ class ClaudeAgentSession implements AgentSession {
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
// for user entries. Both paths must produce equivalent user_message items.
private mapBlocksToTimeline(
- content: string | ClaudeContentChunk[],
+ content: string | ReadonlyArray,
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
@@ -3537,6 +3537,9 @@ class ClaudeAgentSession implements AgentSession {
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
const userTextParts: string[] = [];
for (const block of content) {
+ if (!isClaudeContentChunk(block)) {
+ continue;
+ }
this.mapBlockToTimeline(block, {
items,
userTextParts,
diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts
index 8aa106106..cfb2e477e 100644
--- a/packages/server/src/server/bootstrap.ts
+++ b/packages/server/src/server/bootstrap.ts
@@ -101,7 +101,7 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import {
buildProviderRegistry,
- createAllClients,
+ createClientsFromRegistry,
shutdownProviders,
} from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
@@ -434,25 +434,21 @@ export async function createPaseoDaemon(
github,
},
});
- const agentManager = new AgentManager({
- clients: {
- ...createAllClients(logger, {
- runtimeSettings: config.agentProviderSettings,
- providerOverrides: config.providerOverrides,
- workspaceGitService,
- isDev: config.isDev === true,
- }),
- ...config.agentClients,
- },
- registry: agentStorage,
- logger,
- });
const providerRegistry = buildProviderRegistry(logger, {
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
workspaceGitService,
isDev: config.isDev === true,
});
+ const agentManager = new AgentManager({
+ clients: {
+ ...createClientsFromRegistry(providerRegistry, logger),
+ ...config.agentClients,
+ },
+ providerDefinitions: providerRegistry,
+ registry: agentStorage,
+ logger,
+ });
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts
index ee3d0fcb8..99405ec87 100644
--- a/packages/server/src/server/exports.ts
+++ b/packages/server/src/server/exports.ts
@@ -52,6 +52,7 @@ export type {
AgentCapabilityFlags,
AgentPermissionRequest,
AgentTimelineItem,
+ ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
// Agent activity curator for CLI logs
diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts
index c1d1846f4..8597f6d01 100644
--- a/packages/server/src/server/session.test.ts
+++ b/packages/server/src/server/session.test.ts
@@ -87,6 +87,37 @@ const TEST_CAPABILITIES = {
supportsToolInvocations: false,
} as const;
+function createTestProviderDefinition(overrides?: Partial): ProviderDefinition {
+ return {
+ id: "codex",
+ label: "Codex",
+ description: "Codex test provider",
+ enabled: true,
+ defaultModeId: null,
+ modes: [],
+ createClient: () =>
+ ({
+ provider: "codex",
+ capabilities: TEST_CAPABILITIES,
+ async createSession() {
+ throw new Error("not implemented");
+ },
+ async resumeSession() {
+ throw new Error("not implemented");
+ },
+ async listModels() {
+ return [];
+ },
+ async isAvailable() {
+ return true;
+ },
+ }) satisfies AgentClient,
+ fetchModels: async () => [],
+ fetchModes: async () => [],
+ ...overrides,
+ };
+}
+
function deferred(): Deferred {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -364,10 +395,10 @@ describe("session provider refresh cwd routing", () => {
const fetchModels = vi.fn(async () => []);
const fetchModes = vi.fn(async () => []);
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
- codex: {
+ codex: createTestProviderDefinition({
fetchModels,
fetchModes,
- },
+ }),
});
await session.handleMessage({
@@ -385,6 +416,123 @@ describe("session provider refresh cwd routing", () => {
expect(fetchModes).toHaveBeenCalledWith({ cwd: homedir(), force: false });
});
+ test("legacy model list request treats disabled snapshot entries as unavailable without warming", async () => {
+ const messages: unknown[] = [];
+ const providerSnapshotManager = createProviderSnapshotManagerStub();
+ providerSnapshotManager.getSnapshot = vi.fn(() => [
+ {
+ provider: "codex",
+ status: "loading",
+ enabled: false,
+ },
+ ]);
+ const session = createSessionForTest({ messages, providerSnapshotManager });
+
+ await session.handleMessage({
+ type: "list_provider_models_request",
+ provider: "codex",
+ requestId: "models-disabled",
+ });
+
+ expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
+ expect(messages).toContainEqual({
+ type: "list_provider_models_response",
+ payload: {
+ provider: "codex",
+ error: "Provider codex is disabled",
+ fetchedAt: expect.any(String),
+ requestId: "models-disabled",
+ },
+ });
+ });
+
+ test("legacy mode list request treats disabled snapshot entries as unavailable without warming", async () => {
+ const messages: unknown[] = [];
+ const providerSnapshotManager = createProviderSnapshotManagerStub();
+ providerSnapshotManager.getSnapshot = vi.fn(() => [
+ {
+ provider: "codex",
+ status: "loading",
+ enabled: false,
+ },
+ ]);
+ const session = createSessionForTest({ messages, providerSnapshotManager });
+
+ await session.handleMessage({
+ type: "list_provider_modes_request",
+ provider: "codex",
+ requestId: "modes-disabled",
+ });
+
+ expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
+ expect(messages).toContainEqual({
+ type: "list_provider_modes_response",
+ payload: {
+ provider: "codex",
+ error: "Provider codex is disabled",
+ fetchedAt: expect.any(String),
+ requestId: "modes-disabled",
+ },
+ });
+ });
+
+ test("legacy model and mode list fallback treats disabled registry definitions as unavailable without fetching", async () => {
+ const messages: unknown[] = [];
+ const session = createSessionForTest({ messages });
+ const fetchModels = vi.fn(async () => [
+ {
+ provider: "codex" as const,
+ id: "should-not-fetch",
+ label: "Should not fetch",
+ },
+ ]);
+ const fetchModes = vi.fn(async () => [
+ {
+ id: "should-not-fetch",
+ label: "Should not fetch",
+ },
+ ]);
+ (session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
+ codex: createTestProviderDefinition({
+ enabled: false,
+ fetchModels,
+ fetchModes,
+ }),
+ });
+
+ await session.handleMessage({
+ type: "list_provider_models_request",
+ provider: "codex",
+ requestId: "fallback-models-disabled",
+ });
+ await session.handleMessage({
+ type: "list_provider_modes_request",
+ provider: "codex",
+ requestId: "fallback-modes-disabled",
+ });
+
+ expect(fetchModels).not.toHaveBeenCalled();
+ expect(fetchModes).not.toHaveBeenCalled();
+ expect(messages).toContainEqual({
+ type: "list_provider_models_response",
+ payload: {
+ provider: "codex",
+ error: "Provider codex is disabled",
+ fetchedAt: expect.any(String),
+ requestId: "fallback-models-disabled",
+ },
+ });
+ expect(messages).toContainEqual({
+ type: "list_provider_modes_response",
+ payload: {
+ provider: "codex",
+ error: "Provider codex is disabled",
+ fetchedAt: expect.any(String),
+ requestId: "fallback-modes-disabled",
+ },
+ });
+ });
+
test("legacy model list request without cwd awaits loading snapshot without forced discovery", async () => {
const messages: unknown[] = [];
const models = deferred();
@@ -395,12 +543,7 @@ describe("session provider refresh cwd routing", () => {
},
);
const fetchModes = vi.fn(async (_options: ListModesOptions): Promise => []);
- const providerDefinition: ProviderDefinition = {
- id: "codex",
- label: "Codex",
- description: "Codex test provider",
- defaultModeId: null,
- modes: [],
+ const providerDefinition = createTestProviderDefinition({
createClient: () =>
({
provider: "codex",
@@ -420,7 +563,7 @@ describe("session provider refresh cwd routing", () => {
}) satisfies AgentClient,
fetchModels,
fetchModes,
- };
+ });
const providerSnapshotManager = new ProviderSnapshotManager(
{ codex: providerDefinition },
pino({ level: "silent" }),
@@ -930,6 +1073,7 @@ describe("session checkout status handling", () => {
cwd: "/tmp/service-worktree",
isGit: true,
repoRoot: "/tmp/service-worktree",
+ mainRepoRoot: null,
currentBranch: "feature/service",
isDirty: true,
baseRef: "main",
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 67e66f721..7682ce759 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -3195,6 +3195,25 @@ export class Session {
);
}
+ private emitProviderDisabledResponse(
+ kind: "models" | "modes",
+ provider: AgentProvider,
+ requestId: string,
+ fetchedAt: string,
+ ): void {
+ const payload = {
+ provider,
+ error: `Provider ${provider} is disabled`,
+ fetchedAt,
+ requestId,
+ };
+ if (kind === "models") {
+ this.emit({ type: "list_provider_models_response", payload });
+ } else {
+ this.emit({ type: "list_provider_modes_response", payload });
+ }
+ }
+
private async handleListProviderModelsRequest(
msg: Extract,
): Promise {
@@ -3204,7 +3223,13 @@ export class Session {
if (!manager) {
try {
- const models = await this.getProviderRegistry()[msg.provider].fetchModels({
+ const definition = this.getProviderRegistry()[msg.provider];
+ if (!definition.enabled) {
+ this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt);
+ return;
+ }
+
+ const models = await definition.fetchModels({
cwd,
force: false,
});
@@ -3251,6 +3276,11 @@ export class Session {
return;
}
+ if (!entry.enabled) {
+ this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt);
+ return;
+ }
+
if (entry.status === "ready") {
this.emit({
type: "list_provider_models_response",
@@ -3304,6 +3334,11 @@ export class Session {
return;
}
+ if (!entry.enabled) {
+ this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt);
+ return;
+ }
+
if (entry.status === "ready") {
this.emit({
type: "list_provider_modes_response",
@@ -3336,7 +3371,13 @@ export class Session {
}
try {
- const modes = await this.getProviderRegistry()[msg.provider].fetchModes({
+ const definition = this.getProviderRegistry()[msg.provider];
+ if (!definition.enabled) {
+ this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt);
+ return;
+ }
+
+ const modes = await definition.fetchModes({
cwd,
force: false,
});
@@ -3380,6 +3421,9 @@ export class Session {
manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider);
let entry = findEntry();
+ if (entry && !entry.enabled) {
+ return entry;
+ }
if (!entry || entry.status === "loading") {
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
// a resolved answer rather than a loading placeholder.
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index 679b0138a..11f8485c1 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -36,7 +36,7 @@ import type {
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
-import { buildProviderRegistry } from "./agent/provider-registry.js";
+import { buildProviderRegistry, createClientsFromRegistry } from "./agent/provider-registry.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js";
@@ -497,13 +497,14 @@ export class VoiceAssistantWebSocketServer {
this.providerOverrides,
config.providers,
);
- this.providerSnapshotManager.replaceRegistry(
- buildProviderRegistry(providerSnapshotLogger, {
- runtimeSettings: this.agentProviderRuntimeSettings,
- providerOverrides: this.providerOverrides,
- isDev: this.isDev,
- }),
- );
+ const registry = buildProviderRegistry(providerSnapshotLogger, {
+ runtimeSettings: this.agentProviderRuntimeSettings,
+ providerOverrides: this.providerOverrides,
+ isDev: this.isDev,
+ });
+ const clients = createClientsFromRegistry(registry, providerSnapshotLogger);
+ this.providerSnapshotManager.replaceRegistry(registry);
+ this.agentManager.updateProviderRegistry({ providerDefinitions: registry, clients });
this.broadcastDaemonConfigChanged(config);
});
diff --git a/packages/server/src/shared/messages.providers-snapshot.test.ts b/packages/server/src/shared/messages.providers-snapshot.test.ts
new file mode 100644
index 000000000..fac33940a
--- /dev/null
+++ b/packages/server/src/shared/messages.providers-snapshot.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, test } from "vitest";
+import {
+ GetProvidersSnapshotResponseMessageSchema,
+ ProviderSnapshotEntrySchema,
+ ProvidersSnapshotUpdateMessageSchema,
+} from "./messages.js";
+
+describe("provider snapshot message schemas", () => {
+ test("defaults missing provider snapshot entry enabled state to true", () => {
+ const parsed = ProviderSnapshotEntrySchema.parse({
+ provider: "codex",
+ status: "ready",
+ label: "Codex",
+ });
+
+ expect(parsed.enabled).toBe(true);
+ });
+
+ test("preserves disabled provider snapshot entries", () => {
+ const parsed = ProviderSnapshotEntrySchema.parse({
+ provider: "claude",
+ status: "unavailable",
+ enabled: false,
+ label: "Claude",
+ });
+
+ expect(parsed.enabled).toBe(false);
+ });
+
+ test("preserves enabled provider snapshot entries", () => {
+ const parsed = ProviderSnapshotEntrySchema.parse({
+ provider: "opencode",
+ status: "loading",
+ enabled: true,
+ label: "OpenCode",
+ });
+
+ expect(parsed.enabled).toBe(true);
+ });
+
+ test("defaults missing enabled state in providers snapshot response entries", () => {
+ const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
+ type: "get_providers_snapshot_response",
+ payload: {
+ entries: [
+ {
+ provider: "codex",
+ status: "ready",
+ label: "Codex",
+ },
+ {
+ provider: "claude",
+ status: "unavailable",
+ enabled: false,
+ label: "Claude",
+ },
+ ],
+ generatedAt: "2026-04-24T00:00:00.000Z",
+ requestId: "req-providers",
+ },
+ });
+
+ expect(parsed.payload.entries.map((entry) => entry.enabled)).toEqual([true, false]);
+ });
+
+ test("defaults missing enabled state in providers snapshot update entries", () => {
+ const parsed = ProvidersSnapshotUpdateMessageSchema.parse({
+ type: "providers_snapshot_update",
+ payload: {
+ cwd: "/tmp/repo",
+ entries: [
+ {
+ provider: "codex",
+ status: "ready",
+ label: "Codex",
+ },
+ ],
+ generatedAt: "2026-04-24T00:00:00.000Z",
+ },
+ });
+
+ expect(parsed.payload.entries[0]?.enabled).toBe(true);
+ });
+});
diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts
index 873bf5160..7b735eb36 100644
--- a/packages/server/src/shared/messages.ts
+++ b/packages/server/src/shared/messages.ts
@@ -98,7 +98,6 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
- ProviderSnapshotEntry,
ProviderStatus,
AgentRuntimeInfo,
AgentTimelineItem,
@@ -169,9 +168,10 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({
defaultThinkingOptionId: z.string().optional(),
});
-const ProviderSnapshotEntrySchema: z.ZodType = z.object({
+export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema,
status: ProviderStatusSchema,
+ enabled: z.boolean().optional().default(true),
error: z.string().optional(),
models: z.array(AgentModelDefinitionSchema).optional(),
modes: z.array(AgentModeSchema).optional(),
diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts
index db081cd54..75c5b8612 100644
--- a/packages/server/src/utils/executable.ts
+++ b/packages/server/src/utils/executable.ts
@@ -44,7 +44,7 @@ async function probeExecutable(executablePath: string): Promise {
return await new Promise((resolve) => {
let pendingResolve: ((result: boolean) => void) | null = resolve;
let started = false;
- let timer: ReturnType | undefined;
+ let timer: NodeJS.Timeout | undefined;
const settle = (result: boolean) => {
if (!pendingResolve) {
@@ -78,8 +78,8 @@ async function probeExecutable(executablePath: string): Promise {
return;
}
settle(false);
- }, PROBE_TIMEOUT_MS);
- timer.unref?.();
+ }, PROBE_TIMEOUT_MS) as unknown as NodeJS.Timeout;
+ timer.unref();
child.once("spawn", () => {
started = true;