feat(providers): remove custom providers from settings

Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.
This commit is contained in:
Mohamed Boudra
2026-07-16 15:10:52 +02:00
parent 6aba0370ae
commit 8b8f35fc37
20 changed files with 654 additions and 63 deletions
+11
View File
@@ -1909,6 +1909,17 @@ export const ar: TranslationResources = {
loading: "تحميل...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "غير قادر على تحديث الموفر",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "عاجز",
loading: "تحميل",
+11
View File
@@ -1918,6 +1918,17 @@ export const en = {
loading: "Loading...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "Unable to update provider",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Disabled",
loading: "Loading",
+11
View File
@@ -1956,6 +1956,17 @@ export const es: TranslationResources = {
loading: "Cargando...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "No se puede actualizar el proveedor",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Desactivado",
loading: "Cargando",
+11
View File
@@ -1959,6 +1959,17 @@ export const fr: TranslationResources = {
loading: "Chargement...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "Impossible de mettre à jour le fournisseur",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Désactivé",
loading: "Chargement",
+11
View File
@@ -1931,6 +1931,17 @@ export const ja: TranslationResources = {
loading: "読み込み中...",
addErrorTitle: "プロバイダーを追加できません",
updateErrorTitle: "プロバイダーを更新できません",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "無効",
loading: "読み込み中",
+11
View File
@@ -1941,6 +1941,17 @@ export const ptBR: TranslationResources = {
loading: "Carregando...",
addErrorTitle: "Não foi possível adicionar provedor",
updateErrorTitle: "Não foi possível atualizar provedor",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Desativado",
loading: "Carregando",
+11
View File
@@ -1946,6 +1946,17 @@ export const ru: TranslationResources = {
loading: "Загрузка...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "Невозможно обновить провайдера",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Неполноценный",
loading: "Загрузка",
+11
View File
@@ -1886,6 +1886,17 @@ export const zhCN: TranslationResources = {
loading: "正在加载...",
addErrorTitle: "无法添加 Provider",
updateErrorTitle: "无法更新 Provider",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "已禁用",
loading: "正在加载",
@@ -7,41 +7,51 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = 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" },
},
const {
theme,
snapshotState,
configState,
hostFeatureState,
patchConfigMock,
openProviderSettingsMock,
confirmDialogMock,
} = 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),
openProviderSettingsMock: vi.fn(),
}),
);
},
snapshotState: {
entries: undefined as ProviderSnapshotEntry[] | undefined,
isLoading: false,
isRefreshing: false,
},
configState: {
config: null as MutableDaemonConfig | null,
},
hostFeatureState: {
providerRemoval: false,
},
patchConfigMock: vi.fn(async () => undefined),
openProviderSettingsMock: vi.fn(),
confirmDialogMock: vi.fn(async () => true),
}));
vi.mock("react-native", () => ({
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
@@ -97,25 +107,39 @@ vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
ChevronRight: icon("ChevronRight"),
MoreHorizontal: icon("MoreHorizontal"),
Trash2: icon("Trash2"),
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => {
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
if (key === "settings.providers.statuses.disabled") return "Disabled";
if (key === "settings.providers.statuses.available") return "Available";
if (key === "settings.providers.statuses.loading") return "Loading";
if (key === "settings.providers.statuses.error") return "Error";
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
if (key === "settings.providers.models.one") return "1 model";
if (key === "settings.providers.models.many") return `${values?.count} models`;
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
return key;
},
t: (key: string, values?: Record<string, string | number>) =>
(
({
"settings.providers.providerDetails": "{{name}} provider details",
"settings.providers.enableProvider": "Enable {{name}}",
"settings.providers.statuses.disabled": "Disabled",
"settings.providers.statuses.available": "Available",
"settings.providers.statuses.loading": "Loading",
"settings.providers.statuses.error": "Error",
"settings.providers.statuses.notInstalled": "Not installed",
"settings.providers.models.one": "1 model",
"settings.providers.models.many": "{{count}} models",
"settings.providers.addErrorTitle": "Unable to add provider",
"settings.providers.updateErrorTitle": "Unable to update provider",
"settings.providers.actions.menu": "{{name}} actions",
"settings.providers.actions.remove": "Remove provider",
"settings.providers.actions.removing": "Removing...",
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
"settings.providers.remove.confirmMessage":
"This deletes the provider entry from config.json. It cannot be undone.",
"settings.providers.remove.confirm": "Remove",
"settings.providers.remove.errorTitle": "Unable to remove provider",
})[key] ?? key
)
.replaceAll("{{name}}", String(values?.name ?? ""))
.replaceAll("{{count}}", String(values?.count ?? "")),
}),
}));
@@ -151,6 +175,68 @@ vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
DropdownMenuTrigger: ({
children,
onPressIn,
accessibilityRole,
accessibilityLabel,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
onPressIn?: (event: { stopPropagation: () => void }) => void;
accessibilityRole?: string;
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
role: accessibilityRole,
"aria-label": accessibilityLabel,
"data-testid": testID,
onMouseDown: (event: React.MouseEvent) => onPressIn?.(event),
onClick: (event: React.MouseEvent) => event.stopPropagation(),
},
typeof children === "function"
? children({ pressed: false, hovered: false, open: false })
: children,
),
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
DropdownMenuItem: ({
children,
onSelect,
status,
pendingLabel,
testID,
}: {
children?: React.ReactNode;
onSelect?: () => void;
status?: "idle" | "pending" | "success";
pendingLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
disabled: status === "pending" || status === "success",
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
onSelect?.();
},
},
status === "pending" ? pendingLabel : children,
),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }),
@@ -190,6 +276,17 @@ vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: (serverId: string, feature: string) =>
serverId === "server-1" && feature === "providerRemoval"
? hostFeatureState.providerRemoval
: false,
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: confirmDialogMock,
}));
import { ProvidersSection } from "./providers-section";
const claudeEntry: ProviderSnapshotEntry = {
@@ -217,6 +314,18 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
modes: [],
};
const customGeminiEntry: ProviderSnapshotEntry = {
provider: "gemini",
status: "ready",
enabled: true,
source: "custom",
label: "Gemini",
description: "Gemini CLI",
defaultModeId: null,
modes: [],
models: [{ provider: "gemini", id: "gemini-3-pro", label: "Gemini 3 Pro" }],
};
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return {
mcp: { injectIntoAgents: false },
@@ -257,9 +366,12 @@ describe("ProvidersSection", () => {
snapshotState.isLoading = false;
snapshotState.isRefreshing = false;
configState.config = null;
hostFeatureState.providerRemoval = false;
patchConfigMock.mockReset();
patchConfigMock.mockResolvedValue(undefined);
openProviderSettingsMock.mockReset();
confirmDialogMock.mockReset();
confirmDialogMock.mockResolvedValue(true);
});
afterEach(() => {
@@ -373,4 +485,41 @@ describe("ProvidersSection", () => {
providers: { claude: { enabled: false } },
});
});
it("removes a custom provider through the actions menu after confirmation", async () => {
snapshotState.entries = [customGeminiEntry];
configState.config = makeConfig({ gemini: {} });
hostFeatureState.providerRemoval = true;
render();
const row = findRow("Gemini provider details");
const menu = row.querySelector<HTMLElement>('[data-testid="provider-actions-gemini"]');
const remove = row.querySelector<HTMLElement>('[data-testid="provider-remove-gemini"]');
expect(menu?.getAttribute("aria-label")).toBe("Gemini actions");
expect(remove?.textContent).toBe("Remove provider");
await act(async () => {
remove?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(confirmDialogMock).toHaveBeenCalledWith({
title: "Remove Gemini?",
message: "This deletes the provider entry from config.json. It cannot be undone.",
confirmLabel: "Remove",
destructive: true,
});
expect(patchConfigMock).toHaveBeenCalledWith({ removeProviders: ["gemini"] });
});
it("does not show provider removal for built-in providers", () => {
snapshotState.entries = [claudeEntry];
configState.config = makeConfig();
hostFeatureState.providerRemoval = true;
render();
const row = findRow("Claude provider details");
expect(row.querySelector('[data-testid="provider-actions-claude"]')).toBeNull();
});
});
@@ -1,10 +1,18 @@
import { useCallback, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
Alert,
Pressable,
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
@@ -16,9 +24,16 @@ import { ProviderCatalogList } from "@/components/provider-catalog-list";
import { getProviderIcon } from "@/components/provider-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { ChevronRight } from "lucide-react-native";
import { confirmDialog } from "@/utils/confirm-dialog";
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
@@ -64,9 +79,84 @@ interface ProviderRowProps {
entry: ProviderEntry;
enabled: boolean;
isToggling: boolean;
isRemoving: boolean;
canRemove: boolean;
isFirst: boolean;
onPress: (providerId: string) => void;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string, providerLabel: string) => void;
}
function stopPressInPropagation(event: GestureResponderEvent) {
event.stopPropagation();
}
interface ProviderActionsMenuProps {
providerId: string;
providerLabel: string;
isRemoving: boolean;
onRemove: (providerId: string, providerLabel: string) => void;
}
function ProviderActionsMenu({
providerId,
providerLabel,
isRemoving,
onRemove,
}: ProviderActionsMenuProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const handleRemove = useCallback(() => {
onRemove(providerId, providerLabel);
}, [onRemove, providerId, providerLabel]);
const triggerStyle = useCallback(
({
pressed,
hovered,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) => [
styles.menuButton,
(hovered || open) && styles.menuButtonHovered,
pressed && styles.menuButtonPressed,
],
[],
);
const trashLeading = useMemo(
() => <Trash2 size={16} color={theme.colors.statusDanger} />,
[theme.colors.statusDanger],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
onPressIn={stopPressInPropagation}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.actions.menu", { name: providerLabel })}
testID={`provider-actions-${providerId}`}
>
{({ hovered, open }) => (
<MoreHorizontal
size={theme.iconSize.sm}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
destructive
leading={trashLeading}
onSelect={handleRemove}
status={isRemoving ? "pending" : "idle"}
pendingLabel={t("settings.providers.actions.removing")}
testID={`provider-remove-${providerId}`}
>
{t("settings.providers.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProviderRow({
@@ -74,9 +164,12 @@ function ProviderRow({
entry,
enabled,
isToggling,
isRemoving,
canRemove,
isFirst,
onPress,
onToggleEnabled,
onRemove,
}: ProviderRowProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
@@ -141,12 +234,22 @@ function ProviderRow({
) : null}
</View>
</View>
<Switch
value={enabled}
onValueChange={handleToggleValueChange}
disabled={isToggling}
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
/>
<View style={styles.trailingControls}>
<Switch
value={enabled}
onValueChange={handleToggleValueChange}
disabled={isToggling || isRemoving}
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
/>
{canRemove ? (
<ProviderActionsMenu
providerId={def.id}
providerLabel={def.label}
isRemoving={isRemoving}
onRemove={onRemove}
/>
) : null}
</View>
</>
)}
</Pressable>
@@ -203,10 +306,12 @@ export interface ProvidersSectionProps {
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
@@ -236,6 +341,33 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
[patchConfig, t],
);
const handleRemoveProvider = useCallback(
async (providerId: string, providerLabel: string) => {
const confirmed = await confirmDialog({
title: t("settings.providers.remove.confirmTitle", { name: providerLabel }),
message: t("settings.providers.remove.confirmMessage"),
confirmLabel: t("settings.providers.remove.confirm"),
destructive: true,
});
if (!confirmed) {
return;
}
setRemovingProviderId(providerId);
try {
await patchConfig({ removeProviders: [providerId] });
} catch (error) {
Alert.alert(
t("settings.providers.remove.errorTitle"),
error instanceof Error ? error.message : String(error),
);
} finally {
setRemovingProviderId((current) => (current === providerId ? null : current));
}
},
[patchConfig, t],
);
const handleInstall = useCallback(
async (entry: AcpProviderCatalogItem) => {
if (installingProviderId) return;
@@ -284,9 +416,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
entry={entry}
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isRemoving={removingProviderId === def.id}
canRemove={supportsProviderRemoval && entry.source === "custom"}
isFirst={index === 0}
onPress={handleOpenProviderSettings}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
/>
);
})}
@@ -374,6 +509,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
trailingControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
menuButton: {
width: 32,
height: 32,
borderRadius: theme.borderRadius.lg,
alignItems: "center",
justifyContent: "center",
},
menuButtonHovered: {
backgroundColor: theme.colors.surface2,
},
menuButtonPressed: {
backgroundColor: theme.colors.surface3,
},
}));
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
+1
View File
@@ -104,6 +104,7 @@ export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
enabled: boolean;
source?: "builtin" | "custom";
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
@@ -368,4 +368,18 @@ describe("checkout PR schemas", () => {
projectAdd: true,
});
});
test("accepts the provider removal server_info feature flag", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "srv_test",
features: {
providerRemoval: true,
},
}).features,
).toEqual({
providerRemoval: true,
});
});
});
@@ -38,6 +38,18 @@ describe("provider snapshot message schemas", () => {
expect(parsed.enabled).toBe(true);
});
test("preserves provider snapshot entry source", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "gemini",
status: "ready",
enabled: true,
source: "custom",
label: "Gemini",
});
expect(parsed.source).toBe("custom");
});
test("defaults missing enabled state in providers snapshot response entries", () => {
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
type: "get_providers_snapshot_response",
+4
View File
@@ -163,6 +163,7 @@ export const MutableDaemonConfigPatchSchema = z
providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
.optional(),
removeProviders: z.array(z.string().min(1)).optional(),
metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(),
autoArchiveAfterMerge: z.boolean().optional(),
enableTerminalAgentHooks: z.boolean().optional(),
@@ -263,6 +264,7 @@ export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema,
status: ProviderStatusSchema,
enabled: z.boolean().optional().default(true),
source: z.enum(["builtin", "custom"]).optional(),
error: z.string().optional(),
models: z.array(AgentModelDefinitionSchema).optional(),
modes: z.array(AgentModeSchema).optional(),
@@ -2471,6 +2473,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceGithubRepositorySearch: z.boolean().optional(),
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: z.boolean().optional(),
})
.optional(),
})
@@ -105,6 +105,7 @@ export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
enabled: boolean;
source?: "builtin" | "custom";
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
@@ -952,6 +952,9 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
expect(manager.hasProvider("zai-claude")).toBe(true);
expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true });
expect(manager.listRegisteredProviderIds()).toContain("zai-claude");
expect(manager.getSnapshot().find((entry) => entry.provider === "zai-claude")?.source).toBe(
"custom",
);
} finally {
manager.destroy();
}
@@ -27,6 +27,7 @@ import {
shutdownAgentClients,
type ProviderDefinition,
} from "./provider-registry.js";
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
import {
formatProviderDiagnostic,
@@ -503,6 +504,7 @@ export class ProviderSnapshotManager {
provider,
status: "error",
enabled: definition.enabled,
source: this.getProviderSource(provider),
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
@@ -536,6 +538,11 @@ export class ProviderSnapshotManager {
}
}
private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] {
const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider);
return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin";
}
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) {
@@ -544,6 +551,7 @@ export class ProviderSnapshotManager {
provider,
status: "loading",
enabled: definition?.enabled ?? true,
source: this.getProviderSource(provider),
label: definition?.label,
description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null,
@@ -562,6 +570,7 @@ export class ProviderSnapshotManager {
const metadata = {
provider,
enabled: definition?.enabled ?? true,
source: this.getProviderSource(provider),
label: definition?.label,
description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null,
@@ -725,6 +734,7 @@ export class ProviderSnapshotManager {
const snapshot = this.getOrCreateSnapshot(snapshotCwd);
const base = {
provider,
source: this.getProviderSource(provider),
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
@@ -119,6 +119,105 @@ describe("DaemonConfigStore", () => {
});
});
test("patch removes provider entries from config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const configPath = path.join(paseoHome, "config.json");
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
agents: {
providers: {
gemini: {
extends: "acp",
label: "Gemini",
command: ["gemini", "--acp"],
},
claude: {
enabled: false,
},
},
},
},
null,
2,
)}\n`,
);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {
gemini: {},
claude: { enabled: false },
},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
expect(next.providers.gemini).toBeUndefined();
expect(next.providers.claude).toEqual({ enabled: false });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.providers?.gemini).toBeUndefined();
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
});
test("patch removes the providers object when the last provider is deleted", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const configPath = path.join(paseoHome, "config.json");
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
agents: {
providers: {
gemini: {
extends: "acp",
label: "Gemini",
command: ["gemini", "--acp"],
},
},
},
},
null,
2,
)}\n`,
);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: { gemini: {} },
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
);
store.patch({ removeProviders: ["gemini"] });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.providers).toBeUndefined();
});
test("patch persists append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -52,6 +52,53 @@ function deepMerge<T extends Record<string, unknown>>(
return next as T;
}
function omitProvidersFromConfig<T extends { providers?: Record<string, unknown> }>(
config: T,
providers: readonly string[],
): T {
if (providers.length === 0 || !config.providers) {
return config;
}
let changed = false;
const nextProviders = { ...config.providers };
for (const provider of providers) {
if (provider in nextProviders) {
delete nextProviders[provider];
changed = true;
}
}
return changed ? ({ ...config, providers: nextProviders } as T) : config;
}
function omitProvidersFromOverrides(
overrides: Record<string, ProviderOverride> | undefined,
providers: readonly string[],
): Record<string, ProviderOverride> | undefined {
if (!overrides) {
return undefined;
}
const nextOverrides = { ...overrides };
for (const provider of providers) {
delete nextOverrides[provider];
}
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
}
function omitProvidersFromPersistedAgents(
agents: PersistedConfig["agents"],
): Record<string, unknown> | undefined {
if (!agents) {
return undefined;
}
const { providers: _providers, ...rest } = agents as Record<string, unknown>;
return Object.keys(rest).length > 0 ? rest : undefined;
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path
.split(".")
@@ -100,7 +147,9 @@ export class DaemonConfigStore {
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch));
const { removeProviders = [], ...configPatch } = parsedPatch;
const merged = deepMerge(this.current, configPatch);
const next = MutableDaemonConfigSchema.parse(omitProvidersFromConfig(merged, removeProviders));
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
@@ -112,7 +161,7 @@ export class DaemonConfigStore {
// Persist before updating in-memory state so that if persistence fails,
// runtime and disk stay consistent.
this.persistConfig(next);
this.persistConfig(next, removeProviders);
this.current = next;
for (const path of changedFieldPaths) {
@@ -157,11 +206,12 @@ export class DaemonConfigStore {
};
}
private persistConfig(config: MutableDaemonConfig): void {
private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted,
mutable: config,
removeProviders,
});
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
}
@@ -170,22 +220,27 @@ export class DaemonConfigStore {
function mergeMutableConfigIntoPersistedConfig(params: {
persisted: PersistedConfig;
mutable: MutableDaemonConfig;
removeProviders: readonly string[];
}): PersistedConfig {
const { persisted, mutable } = params;
const { persisted, mutable, removeProviders } = params;
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
const providerOverrides = applyMutableProviderConfigToOverrides(
const persistedProviderOverrides = omitProvidersFromOverrides(
persisted.agents?.providers as Record<string, ProviderOverride> | undefined,
removeProviders,
);
const providerOverrides = applyMutableProviderConfigToOverrides(
persistedProviderOverrides,
mutable.providers,
);
const persistedAgents = persisted.agents as Record<string, unknown> | undefined;
const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents);
const persistedMetadataGeneration = {
providers: metadataGenerationProviders,
};
const shouldPersistMetadataGeneration =
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
let nextAgents = persisted.agents as PersistedConfig["agents"];
let nextAgents = persistedAgents as PersistedConfig["agents"];
if (providerOverrides && Object.keys(providerOverrides).length > 0) {
nextAgents = {
...persistedAgents,
@@ -1252,6 +1252,8 @@ export class VoiceAssistantWebSocketServer {
workspaceGithubRepositorySearch: true,
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: true,
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: true,
},
};
}