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;