mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
96
packages/app/e2e/provider-removal.spec.ts
Normal file
96
packages/app/e2e/provider-removal.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: "تحميل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "読み込み中",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Загрузка",
|
||||
|
||||
@@ -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: "正在加载",
|
||||
|
||||
@@ -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<string, string | number>) => {
|
||||
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
|
||||
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
|
||||
if (key === "settings.providers.statuses.disabled") return "Disabled";
|
||||
if (key === "settings.providers.statuses.available") return "Available";
|
||||
if (key === "settings.providers.statuses.loading") return "Loading";
|
||||
if (key === "settings.providers.statuses.error") return "Error";
|
||||
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
|
||||
if (key === "settings.providers.models.one") return "1 model";
|
||||
if (key === "settings.providers.models.many") return `${values?.count} models`;
|
||||
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
|
||||
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
|
||||
return key;
|
||||
},
|
||||
t: (key: string, values?: Record<string, string | number>) =>
|
||||
(
|
||||
({
|
||||
"settings.providers.providerDetails": "{{name}} provider details",
|
||||
"settings.providers.enableProvider": "Enable {{name}}",
|
||||
"settings.providers.statuses.disabled": "Disabled",
|
||||
"settings.providers.statuses.available": "Available",
|
||||
"settings.providers.statuses.loading": "Loading",
|
||||
"settings.providers.statuses.error": "Error",
|
||||
"settings.providers.statuses.notInstalled": "Not installed",
|
||||
"settings.providers.models.one": "1 model",
|
||||
"settings.providers.models.many": "{{count}} models",
|
||||
"settings.providers.addErrorTitle": "Unable to add provider",
|
||||
"settings.providers.updateErrorTitle": "Unable to update provider",
|
||||
"settings.providers.actions.menu": "{{name}} actions",
|
||||
"settings.providers.actions.remove": "Remove provider",
|
||||
"settings.providers.actions.removing": "Removing...",
|
||||
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
|
||||
"settings.providers.remove.confirmMessage":
|
||||
"This deletes the provider entry from config.json. It cannot be undone.",
|
||||
"settings.providers.remove.confirm": "Remove",
|
||||
"settings.providers.remove.errorTitle": "Unable to remove provider",
|
||||
})[key] ?? key
|
||||
)
|
||||
.replaceAll("{{name}}", String(values?.name ?? ""))
|
||||
.replaceAll("{{count}}", String(values?.count ?? "")),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -151,6 +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 = {
|
||||
|
||||
@@ -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<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["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(() => <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({
|
||||
@@ -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}
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
<View style={styles.trailingControls}>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling || isRemoving}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
{canRemove ? (
|
||||
<ProviderActionsMenu
|
||||
providerId={def.id}
|
||||
providerLabel={def.label}
|
||||
isRemoving={isRemoving}
|
||||
iconSize={theme.iconSize.sm}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
dangerColor={theme.colors.statusDanger}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -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<string | null>(null);
|
||||
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
|
||||
const removingProviderIdRef = useRef<string | null>(null);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(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];
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
source?: "builtin" | "custom";
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
|
||||
@@ -368,4 +368,18 @@ describe("checkout PR schemas", () => {
|
||||
projectAdd: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the provider removal server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
providerRemoval: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
providerRemoval: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,18 @@ describe("provider snapshot message schemas", () => {
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves provider snapshot entry source", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "gemini",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
source: "custom",
|
||||
label: "Gemini",
|
||||
});
|
||||
|
||||
expect(parsed.source).toBe("custom");
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot response entries", () => {
|
||||
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
|
||||
type: "get_providers_snapshot_response",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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<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 () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
source?: "builtin" | "custom";
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
|
||||
@@ -952,6 +952,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();
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
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<Record<AgentProvider, AgentClient>>;
|
||||
private runtimeSettings: AgentProviderRuntimeSettingsMap | 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 providerClients: Record<AgentProvider, AgentClient>;
|
||||
|
||||
@@ -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<AgentProvider, ProviderSnapshotEntry> {
|
||||
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T extends Record<string, unknown>>(
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function omitProvidersFromConfig<T extends { providers?: Record<string, unknown> }>(
|
||||
config: T,
|
||||
providers: readonly string[],
|
||||
): T {
|
||||
if (providers.length === 0 || !config.providers) {
|
||||
return config;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextProviders = { ...config.providers };
|
||||
for (const provider of providers) {
|
||||
if (provider in nextProviders) {
|
||||
delete nextProviders[provider];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? ({ ...config, providers: nextProviders } as T) : config;
|
||||
}
|
||||
|
||||
function 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 {
|
||||
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<string, ProviderOverride> | undefined,
|
||||
removeProviders,
|
||||
);
|
||||
const providerOverrides = applyMutableProviderConfigToOverrides(
|
||||
persistedProviderOverrides,
|
||||
mutable.providers,
|
||||
);
|
||||
const persistedAgents = persisted.agents as Record<string, unknown> | undefined;
|
||||
const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents);
|
||||
const persistedMetadataGeneration = {
|
||||
providers: metadataGenerationProviders,
|
||||
};
|
||||
const shouldPersistMetadataGeneration =
|
||||
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
|
||||
|
||||
let nextAgents = persisted.agents as PersistedConfig["agents"];
|
||||
let nextAgents = persistedAgents as PersistedConfig["agents"];
|
||||
if (providerOverrides && Object.keys(providerOverrides).length > 0) {
|
||||
nextAgents = {
|
||||
...persistedAgents,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user