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: "تحميل...",
|
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: "تحميل",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "読み込み中",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Загрузка",
|
||||||
|
|||||||
@@ -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: "正在加载",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user