diff --git a/.gitignore b/.gitignore index 53a2169c67..6d6bb279b7 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ app.json # Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging. *.heapsnapshot + +# Project-local Serena configuration and memories. +.serena/ diff --git a/packages/app/e2e/browser/provider-removal.spec.ts b/packages/app/e2e/browser/provider-removal.spec.ts index 7e17a1fbe0..b72fb868d6 100644 --- a/packages/app/e2e/browser/provider-removal.spec.ts +++ b/packages/app/e2e/browser/provider-removal.spec.ts @@ -10,16 +10,29 @@ import { openSettingsHost, openSettingsHostSection, } from "../support/helpers/settings"; +import { copyPluginExample } from "../support/helpers/plugin-fixture"; const CUSTOM_PROVIDER = { id: "junie", name: "Junie", } as const; +const PLUGIN_PROVIDER = { + id: "direct-example", + name: "Direct provider example", + pluginId: "provider-direct-example", +} as const; + interface ProviderRemovalDaemonClient { connect(): Promise; close(): Promise; - patchDaemonConfig(config: { removeProviders?: string[] }): Promise; + getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>; + patchDaemonConfig(config: { + removeProviders?: string[]; + pluginsEnabled?: boolean; + }): Promise; + installDirectoryPlugin(path: string): Promise; + removePlugin(pluginId: string): Promise; getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string; source?: "builtin" | "custom" }>; }>; @@ -93,4 +106,45 @@ test.describe("provider removal", () => { await client.close().catch(() => undefined); } }); + + test("does not offer Remove for a plugin-registered provider", async ({ page }) => { + test.setTimeout(120_000); + const client = await connectDaemonClient({ + clientIdPrefix: "provider-removal-e2e", + }); + + const plugin = await copyPluginExample("provider-direct"); + try { + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + + const pluginProviderRow = page.getByRole("button", { + name: `${PLUGIN_PROVIDER.name} provider details`, + exact: true, + }); + await expect(pluginProviderRow).toBeVisible(); + await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0); + + // The provider survives a removeProviders patch pointed at it: the + // plugin, not config.json, owns the definition. + await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] }); + await expect(pluginProviderRow).toBeVisible(); + } finally { + await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined); + await client + .patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }) + .catch(() => undefined); + } + } finally { + await client.close().catch(() => undefined); + await plugin.cleanup(); + } + }); }); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 4495acba83..4fb0a43278 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -328,7 +328,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const isConnected = useHostRuntimeIsConnected(serverId); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); @@ -338,6 +338,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; + /** Custom providers are deletable only when config.json holds their `extends` definition. */ + const isConfigOwnedProvider = useCallback( + (providerId: string) => Boolean(config?.providers?.[providerId]?.extends), + [config], + ); + const handleOpenProviderSettings = useCallback( (providerId: string) => { openProviderSettings({ serverId, provider: providerId }); @@ -444,7 +450,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { enabled={entry.enabled ?? true} isToggling={pendingProviderId === def.id} isRemoving={removingProviderId === def.id} - canRemove={supportsProviderRemoval && entry.source === "custom"} + canRemove={ + supportsProviderRemoval && + entry.source === "custom" && + isConfigOwnedProvider(def.id) + } isFirst={index === 0} onPress={handleOpenProviderSettings} onToggleEnabled={handleToggleEnabled}