diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e909e6eebb..e266f6f5fd 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "تحميل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e379d4367e..aa63eb7e89 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 902cbd0b6e..d0cf8fd4ef 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6919a443a5..7d1e5a81bc 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 54cfefebe4..a30ca9b45a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "読み込み中", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 22995e17dc..2580729f32 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f7496e7498..58a8bc067e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Загрузка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index bfc8fbb294..249f775485 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "正在加载", diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index c0bed32b1b..2e680408c0 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -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) => { - 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) => + ( + ({ + "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('[data-testid="provider-actions-gemini"]'); + const remove = row.querySelector('[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(); + }); }); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 63a393a4ee..49bdcc1eda 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -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[number]; type ProviderEntry = NonNullable["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( + () => , + [theme.colors.statusDanger], + ); + + return ( + + + {({ hovered, open }) => ( + + )} + + + + {t("settings.providers.actions.remove")} + + + + ); } 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} - + + + {canRemove ? ( + + ) : null} + )} @@ -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(null); + const [removingProviderId, setRemovingProviderId] = useState(null); const [installingProviderId, setInstallingProviderId] = useState(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]; diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index b589ac3d0c..54b5275cfc 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -104,6 +104,7 @@ export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; enabled: boolean; + source?: "builtin" | "custom"; error?: string; models?: AgentModelDefinition[]; modes?: AgentMode[]; diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index e9f79ab517..15a08ce831 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -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, + }); + }); }); diff --git a/packages/protocol/src/messages.providers-snapshot.test.ts b/packages/protocol/src/messages.providers-snapshot.test.ts index fac33940a0..fc87b13b3c 100644 --- a/packages/protocol/src/messages.providers-snapshot.test.ts +++ b/packages/protocol/src/messages.providers-snapshot.test.ts @@ -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", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 75533f13e1..1ad701be2f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(), }) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 9bfcf03ef0..16da95c050 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -105,6 +105,7 @@ export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; enabled: boolean; + source?: "builtin" | "custom"; error?: string; models?: AgentModelDefinition[]; modes?: AgentMode[]; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 3223de6c30..823c3a3431 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -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(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index d6d270320e..b91b730ec1 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -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 { const entries = new Map(); 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, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 9d741da840..5491ca5734 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -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); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 1876322e42..021e151a35 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -52,6 +52,53 @@ function deepMerge>( return next as T; } +function omitProvidersFromConfig }>( + 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 | undefined, + providers: readonly string[], +): Record | 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 | undefined { + if (!agents) { + return undefined; + } + + const { providers: _providers, ...rest } = agents as Record; + 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 | undefined, + removeProviders, + ); + const providerOverrides = applyMutableProviderConfigToOverrides( + persistedProviderOverrides, mutable.providers, ); - const persistedAgents = persisted.agents as Record | 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, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 90cb496cd0..97951f4591 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, }, }; }