feat: enable/disable providers from settings
Adds a per-provider toggle in Settings → Providers, persisted via daemon config, so users can turn off providers they don't have installed (or don't want to use) without removing them from the registry. Disabled providers stay visible everywhere — settings, `paseo provider ls`, MCP `list_providers`, model/mode lists — marked as disabled rather than silently filtered. Availability probes are skipped for them, and attempts to launch an agent or fetch models/modes from a disabled provider return a clear "Provider '<id>' is disabled" error. Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider summary) are backward-compatible: optional with a default of true so older clients continue to parse new daemon messages. CLI: `paseo provider ls` gains an ENABLED column; JSON output includes the new field.
This commit is contained in:
@@ -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("<!doctype html><html><body></body></html>");
|
||||
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<typeof Switch>): HTMLElement {
|
||||
act(() => {
|
||||
root?.render(<Switch {...props} />);
|
||||
});
|
||||
|
||||
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(<Switch value={false} />);
|
||||
});
|
||||
|
||||
expect(switchElement.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -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<ViewStyle>;
|
||||
}
|
||||
|
||||
const SIZES: Record<SwitchSize, { track: { width: number; height: number }; thumb: number }> = {
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
hitSlop={8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<Animated.View style={trackStyle}>
|
||||
<Animated.View style={thumbStyle} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ProviderSnapshotEntry["status"]>(["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<string>(),
|
||||
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<ProviderSnapshotEntry["status"]>(["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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ function codexEntry(
|
||||
return {
|
||||
provider: "codex",
|
||||
status,
|
||||
enabled: true,
|
||||
...(models ? { models } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>("*"));
|
||||
}
|
||||
|
||||
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(<ProvidersSection serverId="server-1" />);
|
||||
});
|
||||
}
|
||||
|
||||
function findRow(accessibilityLabel: string): HTMLElement {
|
||||
const row = container?.querySelector<HTMLElement>(
|
||||
`[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<HTMLElement>('[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<HTMLElement>('[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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["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 (
|
||||
<Pressable style={rowStyle} onPress={handlePress} accessibilityRole="button">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
{status === "ready" && modelCount > 0 ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<StatusBadge
|
||||
label={getProviderStatusLabel(status)}
|
||||
variant={getProviderStatusVariant(status)}
|
||||
/>
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${def.label} provider details`}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={styles.rowContent}>
|
||||
<ChevronRight
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<View style={styles.textColumn}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{def.label}
|
||||
</Text>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<StatusIndicator status={providerStatus} />
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
size="sm"
|
||||
accessibilityLabel={`Enable ${def.label}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["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 (
|
||||
<View style={styles.statusRow}>
|
||||
{status.tone === "loading" ? (
|
||||
<LoadingSpinner size={10} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={dotStyle} />
|
||||
)}
|
||||
<Text style={styles.statusLabel}>{status.label}</Text>
|
||||
{status.modelCount !== null ? (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(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) {
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && !isLoading ? (
|
||||
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{providerDefinitions.map((def, index) => (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
def={def}
|
||||
entry={entries?.find((e) => 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 (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
def={def}
|
||||
entry={entry}
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isFirst={index === 0}
|
||||
onPress={setDiagnosticProvider}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ProviderListItem> = {
|
||||
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(", "),
|
||||
})),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -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<AgentSession> {
|
||||
this.createSessionCalls += 1;
|
||||
return await super.createSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const disabledClient = new DisabledCodexClient();
|
||||
const providerDefinitions = {
|
||||
codex: {
|
||||
enabled: false,
|
||||
},
|
||||
} satisfies Partial<Record<AgentProvider, Pick<ProviderDefinition, "enabled">>>;
|
||||
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");
|
||||
|
||||
@@ -113,8 +113,15 @@ interface AgentManagerRescueTimeouts {
|
||||
interruptSessionMs?: number;
|
||||
}
|
||||
|
||||
interface ProviderEnabledFlag {
|
||||
enabled: boolean;
|
||||
}
|
||||
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
|
||||
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
clients?: ProviderClientMap;
|
||||
providerDefinitions?: ProviderEnabledMap;
|
||||
idFactory?: () => string;
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
@@ -387,6 +394,7 @@ function buildExplicitTimelineSeedForRegister(
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
@@ -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<AgentClient> {
|
||||
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) {
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AgentSelectOption {
|
||||
export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
|
||||
@@ -146,6 +146,7 @@ function createProviderDefinition(overrides: Partial<ProviderDefinition>): 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", () => {
|
||||
|
||||
@@ -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<ProviderSummary> {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,14 +14,19 @@ interface ResolveAgentModelOptions {
|
||||
export async function resolveAgentModel(
|
||||
options: ResolveAgentModelOptions,
|
||||
): Promise<string | undefined> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<AgentModelDefinition[]>;
|
||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||
@@ -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<string, ProviderOverride>,
|
||||
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<AgentProvider, ProviderDefinition>;
|
||||
}
|
||||
|
||||
@@ -534,7 +544,13 @@ export function createAllClients(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
const registry = buildProviderRegistry(logger, options);
|
||||
return createClientsFromRegistry(buildProviderRegistry(logger, options), logger);
|
||||
}
|
||||
|
||||
export function createClientsFromRegistry(
|
||||
registry: Record<AgentProvider, ProviderDefinition>,
|
||||
logger: Logger,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(registry).map(([provider, definition]) => [
|
||||
provider,
|
||||
|
||||
@@ -23,9 +23,11 @@ interface Deferred<T> {
|
||||
|
||||
interface MockProviderOptions {
|
||||
provider: AgentProvider;
|
||||
enabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultModeId?: string | null;
|
||||
modes?: AgentMode[];
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
|
||||
@@ -33,6 +35,7 @@ interface MockProviderOptions {
|
||||
|
||||
interface MockProviderHandle {
|
||||
definition: ProviderDefinition;
|
||||
createClient: ReturnType<typeof vi.fn>;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
fetchModels: ReturnType<typeof vi.fn>;
|
||||
fetchModes: ReturnType<typeof vi.fn>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3019,7 +3019,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private appendUserContentArrayEvents(
|
||||
content: ClaudeContentChunk[],
|
||||
content: ReadonlyArray<unknown>,
|
||||
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<unknown>,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,6 +52,7 @@ export type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPermissionRequest,
|
||||
AgentTimelineItem,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
// Agent activity curator for CLI logs
|
||||
|
||||
@@ -87,6 +87,37 @@ const TEST_CAPABILITIES = {
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
function createTestProviderDefinition(overrides?: Partial<ProviderDefinition>): 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<T>(): Deferred<T> {
|
||||
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<AgentModelDefinition[]>();
|
||||
@@ -395,12 +543,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
},
|
||||
);
|
||||
const fetchModes = vi.fn(async (_options: ListModesOptions): Promise<AgentMode[]> => []);
|
||||
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",
|
||||
|
||||
@@ -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<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,6 @@ import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
ProviderSnapshotEntry,
|
||||
ProviderStatus,
|
||||
AgentRuntimeInfo,
|
||||
AgentTimelineItem,
|
||||
@@ -169,9 +168,10 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
||||
defaultThinkingOptionId: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProviderSnapshotEntrySchema: z.ZodType<ProviderSnapshotEntry> = 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(),
|
||||
|
||||
@@ -44,7 +44,7 @@ async function probeExecutable(executablePath: string): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
let pendingResolve: ((result: boolean) => void) | null = resolve;
|
||||
let started = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const settle = (result: boolean) => {
|
||||
if (!pendingResolve) {
|
||||
@@ -78,8 +78,8 @@ async function probeExecutable(executablePath: string): Promise<boolean> {
|
||||
return;
|
||||
}
|
||||
settle(false);
|
||||
}, PROBE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}, PROBE_TIMEOUT_MS) as unknown as NodeJS.Timeout;
|
||||
timer.unref();
|
||||
|
||||
child.once("spawn", () => {
|
||||
started = true;
|
||||
|
||||
Reference in New Issue
Block a user