From e528a0db067cf32f2a615347290a1f72ac40a010 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 16:11:51 +0200 Subject: [PATCH] Remove custom providers from settings (#1951) * 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. * test(app): cover provider removal with e2e Move provider removal coverage out of mocked component tests and into the real Settings flow. * fix(providers): keep removal live after config updates --- packages/app/e2e/provider-removal.spec.ts | 96 ++++++++ packages/app/src/i18n/resources/ar.ts | 11 + packages/app/src/i18n/resources/en.ts | 11 + packages/app/src/i18n/resources/es.ts | 11 + packages/app/src/i18n/resources/fr.ts | 11 + packages/app/src/i18n/resources/ja.ts | 11 + packages/app/src/i18n/resources/pt-BR.ts | 11 + packages/app/src/i18n/resources/ru.ts | 11 + packages/app/src/i18n/resources/zh-CN.ts | 11 + .../settings/providers-section.test.tsx | 112 +++++++-- .../screens/settings/providers-section.tsx | 186 ++++++++++++++- packages/protocol/src/agent-types.ts | 1 + .../src/messages.checkout-pr-schema.test.ts | 14 ++ .../src/messages.providers-snapshot.test.ts | 12 + packages/protocol/src/messages.ts | 4 + .../src/server/agent/agent-manager.test.ts | 41 ++++ .../server/src/server/agent/agent-manager.ts | 3 + .../src/server/agent/agent-sdk-types.ts | 1 + .../agent/provider-snapshot-manager.test.ts | 26 +++ .../server/agent/provider-snapshot-manager.ts | 37 ++- .../src/server/daemon-config-store.test.ts | 214 ++++++++++++++++++ .../server/src/server/daemon-config-store.ts | 115 +++++++++- .../server/src/server/websocket-server.ts | 5 +- 23 files changed, 920 insertions(+), 35 deletions(-) create mode 100644 packages/app/e2e/provider-removal.spec.ts diff --git a/packages/app/e2e/provider-removal.spec.ts b/packages/app/e2e/provider-removal.spec.ts new file mode 100644 index 000000000..dcddc3ad2 --- /dev/null +++ b/packages/app/e2e/provider-removal.spec.ts @@ -0,0 +1,96 @@ +import type { Dialog } from "@playwright/test"; +import { expect, test, type Page } from "./fixtures"; +import { gotoAppShell, openSettings } from "./helpers/app"; +import { connectDaemonClient } from "./helpers/daemon-client-loader"; +import { getServerId } from "./helpers/server-id"; +import { + expectProviderInstalledInSettings, + installAcpCatalogProvider, + openAddProviderArea, + openSettingsHost, + openSettingsHostSection, +} from "./helpers/settings"; + +const CUSTOM_PROVIDER = { + id: "junie", + name: "Junie", +} as const; + +interface ProviderRemovalDaemonClient { + connect(): Promise; + close(): Promise; + patchDaemonConfig(config: { removeProviders?: string[] }): Promise; + getProvidersSnapshot(): Promise<{ + entries: Array<{ provider: string; source?: "builtin" | "custom" }>; + }>; +} + +async function removeCustomProvider(client: ProviderRemovalDaemonClient): Promise { + await client.patchDaemonConfig({ removeProviders: [CUSTOM_PROVIDER.id] }); +} + +async function expectProviderSource( + client: ProviderRemovalDaemonClient, + source: "custom" | undefined, +): Promise { + await expect + .poll(async () => { + const snapshot = await client.getProvidersSnapshot(); + return snapshot.entries.find((entry) => entry.provider === CUSTOM_PROVIDER.id)?.source; + }) + .toBe(source); +} + +async function clickRemoveProviderAndAcceptWarning(page: Page): Promise { + let warning: Dialog | undefined; + page.once("dialog", (dialog) => { + warning = dialog; + expect(dialog.message()).toContain(`Remove ${CUSTOM_PROVIDER.name}?`); + expect(dialog.message()).toContain("This deletes the provider entry from config.json."); + void dialog.accept(); + }); + await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click(); + if (!warning) { + throw new Error("Expected a provider removal confirmation dialog, but none was shown."); + } + return warning; +} + +test.describe("provider removal", () => { + test("removes a custom provider from Settings", async ({ page }) => { + test.setTimeout(120_000); + const client = await connectDaemonClient({ + clientIdPrefix: "provider-removal-e2e", + }); + + try { + await removeCustomProvider(client); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + + await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0); + await openAddProviderArea(page); + await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name); + await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name); + await expectProviderSource(client, "custom"); + + await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click(); + await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible(); + await clickRemoveProviderAndAcceptWarning(page); + + await expect( + page.getByRole("button", { + name: `${CUSTOM_PROVIDER.name} provider details`, + exact: true, + }), + ).toHaveCount(0); + await expectProviderSource(client, undefined); + } finally { + await removeCustomProvider(client).catch(() => undefined); + await client.close().catch(() => undefined); + } + }); +}); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e909e6eeb..e266f6f5f 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 e379d4367..aa63eb7e8 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 902cbd0b6..d0cf8fd4e 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 6919a443a..7d1e5a81b 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 54cfefebe..a30ca9b45 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 22995e17d..2580729f3 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 f7496e749..58a8bc067 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 bfc8fbb29..249f77548 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 c0bed32b1..6af35b675 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -97,25 +97,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 +165,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 +266,14 @@ vi.mock("@/runtime/host-runtime", () => ({ useHostRuntimeIsConnected: () => true, })); +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: () => false, +})); + +vi.mock("@/utils/confirm-dialog", () => ({ + confirmDialog: vi.fn(async () => true), +})); + import { ProvidersSection } from "./providers-section"; const claudeEntry: ProviderSnapshotEntry = { diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 63a393a4e..a21ac56a8 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 { useCallback, useMemo, useRef, 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,89 @@ 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; + iconSize: number; + foregroundColor: string; + foregroundMutedColor: string; + dangerColor: string; + onRemove: (providerId: string, providerLabel: string) => void; +} + +function ProviderActionsMenu({ + providerId, + providerLabel, + isRemoving, + iconSize, + foregroundColor, + foregroundMutedColor, + dangerColor, + onRemove, +}: ProviderActionsMenuProps) { + const { t } = useTranslation(); + 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(() => , [dangerColor]); + + return ( + + + {({ hovered, open }) => ( + + )} + + + + {t("settings.providers.actions.remove")} + + + + ); } function ProviderRow({ @@ -74,9 +169,12 @@ function ProviderRow({ entry, enabled, isToggling, + isRemoving, + canRemove, isFirst, onPress, onToggleEnabled, + onRemove, }: ProviderRowProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); @@ -141,12 +239,26 @@ function ProviderRow({ ) : null} - + + + {canRemove ? ( + + ) : null} + )} @@ -203,10 +315,13 @@ 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 removingProviderIdRef = useRef(null); const [installingProviderId, setInstallingProviderId] = useState(null); const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); @@ -236,6 +351,38 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { [patchConfig, t], ); + const handleRemoveProvider = useCallback( + async (providerId: string, providerLabel: string) => { + if (removingProviderIdRef.current) return; + removingProviderIdRef.current = providerId; + setRemovingProviderId(providerId); + try { + 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; + } + + await patchConfig({ removeProviders: [providerId] }); + } catch (error) { + Alert.alert( + t("settings.providers.remove.errorTitle"), + error instanceof Error ? error.message : String(error), + ); + } finally { + if (removingProviderIdRef.current === providerId) { + removingProviderIdRef.current = null; + } + setRemovingProviderId((current) => (current === providerId ? null : current)); + } + }, + [patchConfig, t], + ); + const handleInstall = useCallback( async (entry: AcpProviderCatalogItem) => { if (installingProviderId) return; @@ -284,9 +431,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 +524,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 b589ac3d0..54b5275cf 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 e9f79ab51..15a08ce83 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 fac33940a..fc87b13b3 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 75533f13e..1ad701be2 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-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 759f9b2f4..98814047e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2272,6 +2272,47 @@ test("updateProviderRegistry registers a previously unknown provider", async () expect(snapshot.config.provider).toBe("codex"); }); +test("updateProviderRegistry removes providers omitted from the next registry", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const removedProvider = "zai-claude" as AgentProvider; + class RemovedProviderClient extends TestAgentClient { + createSessionCalls = 0; + + override async createSession(config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + return await super.createSession(config); + } + } + + const removedClient = new RemovedProviderClient(); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient(), [removedProvider]: removedClient }, + providerDefinitions: { + codex: { enabled: true }, + [removedProvider]: { enabled: true }, + }, + registry: storage, + logger, + }); + + expect(manager.getRegisteredProviderIds()).toContain(removedProvider); + + manager.updateProviderRegistry({ + providerDefinitions: { codex: { enabled: true } }, + clients: { codex: new TestAgentClient() }, + }); + + expect(manager.getRegisteredProviderIds()).not.toContain(removedProvider); + await expect( + manager.createAgent({ provider: removedProvider, cwd: workdir }, undefined, { + workspaceId: undefined, + }), + ).rejects.toThrow("Unknown provider 'zai-claude'"); + expect(removedClient.createSessionCalls).toBe(0); +}); + test("createAgent passes explicit model strings through to the provider", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fa34cbaa1..20d1da830 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -622,11 +622,14 @@ export class AgentManager { providerDefinitions: ProviderEnabledMap; clients: ProviderClientMap; }): void { + this.providerEnabled.clear(); for (const [provider, definition] of Object.entries(input.providerDefinitions)) { if (definition) { this.providerEnabled.set(provider, definition.enabled); } } + + this.clients.clear(); for (const [provider, client] of Object.entries(input.clients)) { if (client) { this.clients.set(provider, client); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 9bfcf03ef..16da95c05 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 3223de6c3..4b6a3eac5 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,32 @@ 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(); + } + }); + + test("removes startup provider overrides from the live registry", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, + }, + }); + try { + expect(manager.hasProvider("zai-claude")).toBe(true); + + const state = manager.applyMutableProviderConfig({}, { removeProviders: ["zai-claude"] }); + + expect(manager.hasProvider("zai-claude")).toBe(false); + expect(state.providerDefinitions["zai-claude"]).toBeUndefined(); + expect(manager.getSnapshot().some((entry) => entry.provider === "zai-claude")).toBe(false); + + manager.applyMutableProviderConfig({ codex: { enabled: false } }); + expect(manager.hasProvider("zai-claude")).toBe(false); } 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 d6d270320..f21d91923 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, @@ -64,6 +65,22 @@ function resolveDiagnosticTimeoutMs(option: number | undefined, refreshTimeoutMs return Math.max(refreshTimeoutMs, DEFAULT_DIAGNOSTIC_TIMEOUT_MS); } +function omitProviderOverrides( + overrides: Record | undefined, + providers: readonly string[], +): Record | undefined { + if (!overrides || providers.length === 0) { + return overrides; + } + + const nextOverrides = { ...overrides }; + for (const provider of providers) { + delete nextOverrides[provider]; + } + + return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; +} + type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; export interface ProviderSnapshotManagerOptions { @@ -94,6 +111,10 @@ interface ProviderSnapshotReadOptions { wait?: boolean; } +interface ApplyMutableProviderConfigOptions { + removeProviders?: readonly string[]; +} + interface ProviderSnapshotProviderOptions { cwd?: string | null; provider: AgentProvider; @@ -163,7 +184,7 @@ export class ProviderSnapshotManager { private readonly extraClients: Partial>; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; - private readonly baseProviderOverrides: Record | undefined; + private baseProviderOverrides: Record | undefined; private providerRegistry: Record; private providerClients: Record; @@ -369,7 +390,12 @@ export class ProviderSnapshotManager { applyMutableProviderConfig( mutableProviders: MutableDaemonConfig["providers"] | undefined, + options: ApplyMutableProviderConfigOptions = {}, ): AgentManagerProviderState { + this.baseProviderOverrides = omitProviderOverrides( + this.baseProviderOverrides, + options.removeProviders ?? [], + ); this.providerOverrides = applyMutableProviderConfigToOverrides( this.baseProviderOverrides, mutableProviders, @@ -503,6 +529,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 +563,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 +576,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 +595,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 +759,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 9d741da84..4b4df23a5 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -119,6 +119,220 @@ 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 removes deleted providers from metadata generation", () => { + 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, + }, + }, + metadataGeneration: { + providers: [ + { provider: "gemini", model: "flash" }, + { provider: "claude", model: "haiku" }, + ], + }, + }, + }, + null, + 2, + )}\n`, + ); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: { + gemini: {}, + claude: { enabled: false }, + }, + metadataGeneration: { + providers: [ + { provider: "gemini", model: "flash" }, + { provider: "claude", model: "haiku" }, + ], + }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + ); + + const next = store.patch({ removeProviders: ["gemini"] }); + + expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]); + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.metadataGeneration).toEqual({ + providers: [{ provider: "claude", model: "haiku" }], + }); + }); + + test("patch persists provider removal when in-memory config is already clean", () => { + 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"], + }, + }, + metadataGeneration: { + providers: [{ provider: "gemini", model: "flash" }], + }, + }, + }, + null, + 2, + )}\n`, + ); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + ); + + const next = store.patch({ removeProviders: ["gemini"] }); + + expect(next.providers.gemini).toBeUndefined(); + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.providers).toBeUndefined(); + expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); + }); + 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 1876322e4..080535532 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -20,7 +20,11 @@ interface LoggerLike { info(...args: unknown[]): void; } -type ConfigListener = (config: MutableDaemonConfig) => void; +export interface DaemonConfigChangeDetails { + removedProviders: readonly string[]; +} + +type ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void; type FieldChangeHandler = (value: unknown) => void; function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { @@ -52,6 +56,77 @@ 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 omitMetadataGenerationProvidersFromConfig< + T extends { metadataGeneration?: { providers?: Array<{ provider?: unknown }> } }, +>(config: T, providers: readonly string[]): T { + if (providers.length === 0 || !config.metadataGeneration?.providers) { + return config; + } + + const removedProviderIds = new Set(providers); + const nextProviders = config.metadataGeneration.providers.filter((entry) => { + return typeof entry.provider !== "string" || !removedProviderIds.has(entry.provider); + }); + if (nextProviders.length === config.metadataGeneration.providers.length) { + return config; + } + + return { + ...config, + metadataGeneration: { + ...config.metadataGeneration, + providers: nextProviders, + }, + } as T; +} + +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,19 +175,32 @@ 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 removedProviders = Array.from(new Set(removeProviders)); + const merged = deepMerge(this.current, configPatch); + const next = MutableDaemonConfigSchema.parse( + omitMetadataGenerationProvidersFromConfig( + omitProvidersFromConfig(merged, removedProviders), + removedProviders, + ), + ); const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => { return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path)); }); + const configChanged = !isEqualValue(this.current, next); - if (changedFieldPaths.length === 0 && isEqualValue(this.current, next)) { + if (!configChanged && removedProviders.length === 0) { return this.current; } // Persist before updating in-memory state so that if persistence fails, // runtime and disk stay consistent. - this.persistConfig(next); + this.persistConfig(next, removedProviders); + if (!configChanged) { + return this.current; + } + this.current = next; for (const path of changedFieldPaths) { @@ -126,8 +214,9 @@ export class DaemonConfigStore { } } + const changeDetails: DaemonConfigChangeDetails = { removedProviders }; for (const listener of this.changeListeners) { - listener(next); + listener(next, changeDetails); } return next; @@ -157,11 +246,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 +260,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 90cb496cd..74a890628 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -554,9 +554,10 @@ export class VoiceAssistantWebSocketServer { this.speech?.onReadinessChange((snapshot) => { this.publishSpeechReadiness(snapshot); }) ?? null; - this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config) => { + this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config, details) => { const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig( config.providers, + { removeProviders: details.removedProviders }, ); this.agentManager.updateProviderRegistry(nextAgentManagerState); this.broadcastDaemonConfigChanged(config); @@ -1252,6 +1253,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, }, }; }