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
This commit is contained in:
Mohamed Boudra
2026-07-16 16:11:51 +02:00
committed by GitHub
parent 6aba0370ae
commit e528a0db06
23 changed files with 920 additions and 35 deletions

View File

@@ -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<void>;
close(): Promise<void>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
getProvidersSnapshot(): Promise<{
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
}>;
}
async function removeCustomProvider(client: ProviderRemovalDaemonClient): Promise<void> {
await client.patchDaemonConfig({ removeProviders: [CUSTOM_PROVIDER.id] });
}
async function expectProviderSource(
client: ProviderRemovalDaemonClient,
source: "custom" | undefined,
): Promise<void> {
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<Dialog> {
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<ProviderRemovalDaemonClient>({
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);
}
});
});

View File

@@ -1909,6 +1909,17 @@ export const ar: TranslationResources = {
loading: "تحميل...", loading: "تحميل...",
addErrorTitle: "Unable to add provider", addErrorTitle: "Unable to add provider",
updateErrorTitle: "غير قادر على تحديث الموفر", 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: { statuses: {
disabled: "عاجز", disabled: "عاجز",
loading: "تحميل", loading: "تحميل",

View File

@@ -1918,6 +1918,17 @@ export const en = {
loading: "Loading...", loading: "Loading...",
addErrorTitle: "Unable to add provider", addErrorTitle: "Unable to add provider",
updateErrorTitle: "Unable to update 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: { statuses: {
disabled: "Disabled", disabled: "Disabled",
loading: "Loading", loading: "Loading",

View File

@@ -1956,6 +1956,17 @@ export const es: TranslationResources = {
loading: "Cargando...", loading: "Cargando...",
addErrorTitle: "Unable to add provider", addErrorTitle: "Unable to add provider",
updateErrorTitle: "No se puede actualizar el proveedor", 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: { statuses: {
disabled: "Desactivado", disabled: "Desactivado",
loading: "Cargando", loading: "Cargando",

View File

@@ -1959,6 +1959,17 @@ export const fr: TranslationResources = {
loading: "Chargement...", loading: "Chargement...",
addErrorTitle: "Unable to add provider", addErrorTitle: "Unable to add provider",
updateErrorTitle: "Impossible de mettre à jour le fournisseur", 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: { statuses: {
disabled: "Désactivé", disabled: "Désactivé",
loading: "Chargement", loading: "Chargement",

View File

@@ -1931,6 +1931,17 @@ export const ja: TranslationResources = {
loading: "読み込み中...", loading: "読み込み中...",
addErrorTitle: "プロバイダーを追加できません", addErrorTitle: "プロバイダーを追加できません",
updateErrorTitle: "プロバイダーを更新できません", 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: { statuses: {
disabled: "無効", disabled: "無効",
loading: "読み込み中", loading: "読み込み中",

View File

@@ -1941,6 +1941,17 @@ export const ptBR: TranslationResources = {
loading: "Carregando...", loading: "Carregando...",
addErrorTitle: "Não foi possível adicionar provedor", addErrorTitle: "Não foi possível adicionar provedor",
updateErrorTitle: "Não foi possível atualizar 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: { statuses: {
disabled: "Desativado", disabled: "Desativado",
loading: "Carregando", loading: "Carregando",

View File

@@ -1946,6 +1946,17 @@ export const ru: TranslationResources = {
loading: "Загрузка...", loading: "Загрузка...",
addErrorTitle: "Unable to add provider", addErrorTitle: "Unable to add provider",
updateErrorTitle: "Невозможно обновить провайдера", 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: { statuses: {
disabled: "Неполноценный", disabled: "Неполноценный",
loading: "Загрузка", loading: "Загрузка",

View File

@@ -1886,6 +1886,17 @@ export const zhCN: TranslationResources = {
loading: "正在加载...", loading: "正在加载...",
addErrorTitle: "无法添加 Provider", addErrorTitle: "无法添加 Provider",
updateErrorTitle: "无法更新 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: { statuses: {
disabled: "已禁用", disabled: "已禁用",
loading: "正在加载", loading: "正在加载",

View File

@@ -97,25 +97,39 @@ vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name }); const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return { return {
ChevronRight: icon("ChevronRight"), ChevronRight: icon("ChevronRight"),
MoreHorizontal: icon("MoreHorizontal"),
Trash2: icon("Trash2"),
}; };
}); });
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => { 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"; "settings.providers.providerDetails": "{{name}} provider details",
if (key === "settings.providers.statuses.available") return "Available"; "settings.providers.enableProvider": "Enable {{name}}",
if (key === "settings.providers.statuses.loading") return "Loading"; "settings.providers.statuses.disabled": "Disabled",
if (key === "settings.providers.statuses.error") return "Error"; "settings.providers.statuses.available": "Available",
if (key === "settings.providers.statuses.notInstalled") return "Not installed"; "settings.providers.statuses.loading": "Loading",
if (key === "settings.providers.models.one") return "1 model"; "settings.providers.statuses.error": "Error",
if (key === "settings.providers.models.many") return `${values?.count} models`; "settings.providers.statuses.notInstalled": "Not installed",
if (key === "settings.providers.addErrorTitle") return "Unable to add provider"; "settings.providers.models.one": "1 model",
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider"; "settings.providers.models.many": "{{count}} models",
return key; "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" }), 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", () => ({ vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () => getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }), React.createElement("span", { "data-icon": `provider-${provider}` }),
@@ -190,6 +266,14 @@ vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => true, useHostRuntimeIsConnected: () => true,
})); }));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => false,
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: vi.fn(async () => true),
}));
import { ProvidersSection } from "./providers-section"; import { ProvidersSection } from "./providers-section";
const claudeEntry: ProviderSnapshotEntry = { const claudeEntry: ProviderSnapshotEntry = {

View File

@@ -1,10 +1,18 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildProviderDefinitions } from "@/utils/provider-definitions";
@@ -16,9 +24,16 @@ import { ProviderCatalogList } from "@/components/provider-catalog-list";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { useProviderSettingsStore } from "@/stores/provider-settings-store"; 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 ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number]; type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
@@ -64,9 +79,89 @@ interface ProviderRowProps {
entry: ProviderEntry; entry: ProviderEntry;
enabled: boolean; enabled: boolean;
isToggling: boolean; isToggling: boolean;
isRemoving: boolean;
canRemove: boolean;
isFirst: boolean; isFirst: boolean;
onPress: (providerId: string) => void; onPress: (providerId: string) => void;
onToggleEnabled: (providerId: string, enabled: boolean) => 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(() => <Trash2 size={16} color={dangerColor} />, [dangerColor]);
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={isRemoving}
hitSlop={8}
onPressIn={stopPressInPropagation}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.actions.menu", { name: providerLabel })}
testID={`provider-actions-${providerId}`}
>
{({ hovered, open }) => (
<MoreHorizontal
size={iconSize}
color={hovered || open ? foregroundColor : foregroundMutedColor}
/>
)}
</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({ function ProviderRow({
@@ -74,9 +169,12 @@ function ProviderRow({
entry, entry,
enabled, enabled,
isToggling, isToggling,
isRemoving,
canRemove,
isFirst, isFirst,
onPress, onPress,
onToggleEnabled, onToggleEnabled,
onRemove,
}: ProviderRowProps) { }: ProviderRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -141,12 +239,26 @@ function ProviderRow({
) : null} ) : null}
</View> </View>
</View> </View>
<Switch <View style={styles.trailingControls}>
value={enabled} <Switch
onValueChange={handleToggleValueChange} value={enabled}
disabled={isToggling} onValueChange={handleToggleValueChange}
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })} disabled={isToggling || isRemoving}
/> accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
/>
{canRemove ? (
<ProviderActionsMenu
providerId={def.id}
providerLabel={def.label}
isRemoving={isRemoving}
iconSize={theme.iconSize.sm}
foregroundColor={theme.colors.foreground}
foregroundMutedColor={theme.colors.foregroundMuted}
dangerColor={theme.colors.statusDanger}
onRemove={onRemove}
/>
) : null}
</View>
</> </>
)} )}
</Pressable> </Pressable>
@@ -203,10 +315,13 @@ export interface ProvidersSectionProps {
export function ProvidersSection({ serverId }: ProvidersSectionProps) { export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId); const { patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open); const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null); const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
const removingProviderIdRef = useRef<string | null>(null);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null); const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
@@ -236,6 +351,38 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
[patchConfig, t], [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( const handleInstall = useCallback(
async (entry: AcpProviderCatalogItem) => { async (entry: AcpProviderCatalogItem) => {
if (installingProviderId) return; if (installingProviderId) return;
@@ -284,9 +431,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
entry={entry} entry={entry}
enabled={entry.enabled ?? true} enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id} isToggling={pendingProviderId === def.id}
isRemoving={removingProviderId === def.id}
canRemove={supportsProviderRemoval && entry.source === "custom"}
isFirst={index === 0} isFirst={index === 0}
onPress={handleOpenProviderSettings} onPress={handleOpenProviderSettings}
onToggleEnabled={handleToggleEnabled} onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
/> />
); );
})} })}
@@ -374,6 +524,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1], 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]; const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];

View File

@@ -104,6 +104,7 @@ export interface ProviderSnapshotEntry {
provider: AgentProvider; provider: AgentProvider;
status: ProviderStatus; status: ProviderStatus;
enabled: boolean; enabled: boolean;
source?: "builtin" | "custom";
error?: string; error?: string;
models?: AgentModelDefinition[]; models?: AgentModelDefinition[];
modes?: AgentMode[]; modes?: AgentMode[];

View File

@@ -368,4 +368,18 @@ describe("checkout PR schemas", () => {
projectAdd: true, 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,
});
});
}); });

View File

@@ -38,6 +38,18 @@ describe("provider snapshot message schemas", () => {
expect(parsed.enabled).toBe(true); 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", () => { test("defaults missing enabled state in providers snapshot response entries", () => {
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({ const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
type: "get_providers_snapshot_response", type: "get_providers_snapshot_response",

View File

@@ -163,6 +163,7 @@ export const MutableDaemonConfigPatchSchema = z
providers: z providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
.optional(), .optional(),
removeProviders: z.array(z.string().min(1)).optional(),
metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(),
autoArchiveAfterMerge: z.boolean().optional(), autoArchiveAfterMerge: z.boolean().optional(),
enableTerminalAgentHooks: z.boolean().optional(), enableTerminalAgentHooks: z.boolean().optional(),
@@ -263,6 +264,7 @@ export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema, provider: AgentProviderSchema,
status: ProviderStatusSchema, status: ProviderStatusSchema,
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
source: z.enum(["builtin", "custom"]).optional(),
error: z.string().optional(), error: z.string().optional(),
models: z.array(AgentModelDefinitionSchema).optional(), models: z.array(AgentModelDefinitionSchema).optional(),
modes: z.array(AgentModeSchema).optional(), modes: z.array(AgentModeSchema).optional(),
@@ -2471,6 +2473,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceGithubRepositorySearch: z.boolean().optional(), workspaceGithubRepositorySearch: z.boolean().optional(),
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: z.boolean().optional(), projectCreateDirectory: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: z.boolean().optional(),
}) })
.optional(), .optional(),
}) })

View File

@@ -2272,6 +2272,47 @@ test("updateProviderRegistry registers a previously unknown provider", async ()
expect(snapshot.config.provider).toBe("codex"); 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<AgentSession> {
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 () => { test("createAgent passes explicit model strings through to the provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");

View File

@@ -622,11 +622,14 @@ export class AgentManager {
providerDefinitions: ProviderEnabledMap; providerDefinitions: ProviderEnabledMap;
clients: ProviderClientMap; clients: ProviderClientMap;
}): void { }): void {
this.providerEnabled.clear();
for (const [provider, definition] of Object.entries(input.providerDefinitions)) { for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
if (definition) { if (definition) {
this.providerEnabled.set(provider, definition.enabled); this.providerEnabled.set(provider, definition.enabled);
} }
} }
this.clients.clear();
for (const [provider, client] of Object.entries(input.clients)) { for (const [provider, client] of Object.entries(input.clients)) {
if (client) { if (client) {
this.clients.set(provider, client); this.clients.set(provider, client);

View File

@@ -105,6 +105,7 @@ export interface ProviderSnapshotEntry {
provider: AgentProvider; provider: AgentProvider;
status: ProviderStatus; status: ProviderStatus;
enabled: boolean; enabled: boolean;
source?: "builtin" | "custom";
error?: string; error?: string;
models?: AgentModelDefinition[]; models?: AgentModelDefinition[];
modes?: AgentMode[]; modes?: AgentMode[];

View File

@@ -952,6 +952,32 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
expect(manager.hasProvider("zai-claude")).toBe(true); expect(manager.hasProvider("zai-claude")).toBe(true);
expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true }); expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true });
expect(manager.listRegisteredProviderIds()).toContain("zai-claude"); 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 { } finally {
manager.destroy(); manager.destroy();
} }

View File

@@ -27,6 +27,7 @@ import {
shutdownAgentClients, shutdownAgentClients,
type ProviderDefinition, type ProviderDefinition,
} from "./provider-registry.js"; } from "./provider-registry.js";
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js"; import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
import { import {
formatProviderDiagnostic, formatProviderDiagnostic,
@@ -64,6 +65,22 @@ function resolveDiagnosticTimeoutMs(option: number | undefined, refreshTimeoutMs
return Math.max(refreshTimeoutMs, DEFAULT_DIAGNOSTIC_TIMEOUT_MS); return Math.max(refreshTimeoutMs, DEFAULT_DIAGNOSTIC_TIMEOUT_MS);
} }
function omitProviderOverrides(
overrides: Record<string, ProviderOverride> | undefined,
providers: readonly string[],
): Record<string, ProviderOverride> | 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; type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
export interface ProviderSnapshotManagerOptions { export interface ProviderSnapshotManagerOptions {
@@ -94,6 +111,10 @@ interface ProviderSnapshotReadOptions {
wait?: boolean; wait?: boolean;
} }
interface ApplyMutableProviderConfigOptions {
removeProviders?: readonly string[];
}
interface ProviderSnapshotProviderOptions { interface ProviderSnapshotProviderOptions {
cwd?: string | null; cwd?: string | null;
provider: AgentProvider; provider: AgentProvider;
@@ -163,7 +184,7 @@ export class ProviderSnapshotManager {
private readonly extraClients: Partial<Record<AgentProvider, AgentClient>>; private readonly extraClients: Partial<Record<AgentProvider, AgentClient>>;
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private providerOverrides: Record<string, ProviderOverride> | undefined; private providerOverrides: Record<string, ProviderOverride> | undefined;
private readonly baseProviderOverrides: Record<string, ProviderOverride> | undefined; private baseProviderOverrides: Record<string, ProviderOverride> | undefined;
private providerRegistry: Record<AgentProvider, ProviderDefinition>; private providerRegistry: Record<AgentProvider, ProviderDefinition>;
private providerClients: Record<AgentProvider, AgentClient>; private providerClients: Record<AgentProvider, AgentClient>;
@@ -369,7 +390,12 @@ export class ProviderSnapshotManager {
applyMutableProviderConfig( applyMutableProviderConfig(
mutableProviders: MutableDaemonConfig["providers"] | undefined, mutableProviders: MutableDaemonConfig["providers"] | undefined,
options: ApplyMutableProviderConfigOptions = {},
): AgentManagerProviderState { ): AgentManagerProviderState {
this.baseProviderOverrides = omitProviderOverrides(
this.baseProviderOverrides,
options.removeProviders ?? [],
);
this.providerOverrides = applyMutableProviderConfigToOverrides( this.providerOverrides = applyMutableProviderConfigToOverrides(
this.baseProviderOverrides, this.baseProviderOverrides,
mutableProviders, mutableProviders,
@@ -503,6 +529,7 @@ export class ProviderSnapshotManager {
provider, provider,
status: "error", status: "error",
enabled: definition.enabled, enabled: definition.enabled,
source: this.getProviderSource(provider),
label: definition.label, label: definition.label,
description: definition.description, description: definition.description,
defaultModeId: definition.defaultModeId, 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<AgentProvider, ProviderSnapshotEntry> { private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>(); const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) { for (const provider of this.getProviderIds()) {
@@ -544,6 +576,7 @@ export class ProviderSnapshotManager {
provider, provider,
status: "loading", status: "loading",
enabled: definition?.enabled ?? true, enabled: definition?.enabled ?? true,
source: this.getProviderSource(provider),
label: definition?.label, label: definition?.label,
description: definition?.description, description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null, defaultModeId: definition?.defaultModeId ?? null,
@@ -562,6 +595,7 @@ export class ProviderSnapshotManager {
const metadata = { const metadata = {
provider, provider,
enabled: definition?.enabled ?? true, enabled: definition?.enabled ?? true,
source: this.getProviderSource(provider),
label: definition?.label, label: definition?.label,
description: definition?.description, description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null, defaultModeId: definition?.defaultModeId ?? null,
@@ -725,6 +759,7 @@ export class ProviderSnapshotManager {
const snapshot = this.getOrCreateSnapshot(snapshotCwd); const snapshot = this.getOrCreateSnapshot(snapshotCwd);
const base = { const base = {
provider, provider,
source: this.getProviderSource(provider),
label: definition.label, label: definition.label,
description: definition.description, description: definition.description,
defaultModeId: definition.defaultModeId, defaultModeId: definition.defaultModeId,

View File

@@ -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", () => { test("patch persists append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome); tempDirs.push(paseoHome);

View File

@@ -20,7 +20,11 @@ interface LoggerLike {
info(...args: unknown[]): void; 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; type FieldChangeHandler = (value: unknown) => void;
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
@@ -52,6 +56,77 @@ function deepMerge<T extends Record<string, unknown>>(
return next as T; 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 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<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 { function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path return path
.split(".") .split(".")
@@ -100,19 +175,32 @@ export class DaemonConfigStore {
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial); 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) => { const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, 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; return this.current;
} }
// Persist before updating in-memory state so that if persistence fails, // Persist before updating in-memory state so that if persistence fails,
// runtime and disk stay consistent. // runtime and disk stay consistent.
this.persistConfig(next); this.persistConfig(next, removedProviders);
if (!configChanged) {
return this.current;
}
this.current = next; this.current = next;
for (const path of changedFieldPaths) { for (const path of changedFieldPaths) {
@@ -126,8 +214,9 @@ export class DaemonConfigStore {
} }
} }
const changeDetails: DaemonConfigChangeDetails = { removedProviders };
for (const listener of this.changeListeners) { for (const listener of this.changeListeners) {
listener(next); listener(next, changeDetails);
} }
return next; 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 persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({ const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted, persisted,
mutable: config, mutable: config,
removeProviders,
}); });
savePersistedConfig(this.paseoHome, nextPersisted, this.logger); savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
} }
@@ -170,22 +260,27 @@ export class DaemonConfigStore {
function mergeMutableConfigIntoPersistedConfig(params: { function mergeMutableConfigIntoPersistedConfig(params: {
persisted: PersistedConfig; persisted: PersistedConfig;
mutable: MutableDaemonConfig; mutable: MutableDaemonConfig;
removeProviders: readonly string[];
}): PersistedConfig { }): PersistedConfig {
const { persisted, mutable } = params; const { persisted, mutable, removeProviders } = params;
const browserToolsEnabled = readBrowserToolsEnabled(mutable); const browserToolsEnabled = readBrowserToolsEnabled(mutable);
const metadataGenerationProviders = readMetadataGenerationProviders(mutable); const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
const providerOverrides = applyMutableProviderConfigToOverrides( const persistedProviderOverrides = omitProvidersFromOverrides(
persisted.agents?.providers as Record<string, ProviderOverride> | undefined, persisted.agents?.providers as Record<string, ProviderOverride> | undefined,
removeProviders,
);
const providerOverrides = applyMutableProviderConfigToOverrides(
persistedProviderOverrides,
mutable.providers, mutable.providers,
); );
const persistedAgents = persisted.agents as Record<string, unknown> | undefined; const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents);
const persistedMetadataGeneration = { const persistedMetadataGeneration = {
providers: metadataGenerationProviders, providers: metadataGenerationProviders,
}; };
const shouldPersistMetadataGeneration = const shouldPersistMetadataGeneration =
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined; 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) { if (providerOverrides && Object.keys(providerOverrides).length > 0) {
nextAgents = { nextAgents = {
...persistedAgents, ...persistedAgents,

View File

@@ -554,9 +554,10 @@ export class VoiceAssistantWebSocketServer {
this.speech?.onReadinessChange((snapshot) => { this.speech?.onReadinessChange((snapshot) => {
this.publishSpeechReadiness(snapshot); this.publishSpeechReadiness(snapshot);
}) ?? null; }) ?? null;
this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config) => { this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config, details) => {
const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig( const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig(
config.providers, config.providers,
{ removeProviders: details.removedProviders },
); );
this.agentManager.updateProviderRegistry(nextAgentManagerState); this.agentManager.updateProviderRegistry(nextAgentManagerState);
this.broadcastDaemonConfigChanged(config); this.broadcastDaemonConfigChanged(config);
@@ -1252,6 +1253,8 @@ export class VoiceAssistantWebSocketServer {
workspaceGithubRepositorySearch: true, workspaceGithubRepositorySearch: true,
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: true, projectCreateDirectory: true,
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: true,
}, },
}; };
} }