diff --git a/apps/desktop/src/app/settings/plugins-settings.test.tsx b/apps/desktop/src/app/settings/plugins-settings.test.tsx new file mode 100644 index 0000000000000..f23551c555a38 --- /dev/null +++ b/apps/desktop/src/app/settings/plugins-settings.test.tsx @@ -0,0 +1,88 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestGateway } = vi.hoisted(() => ({ requestGateway: vi.fn() })) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +import { $pluginRecords } from '@/contrib/plugins-store' +import { + $agentPluginBusy, + $agentPlugins, + $agentPluginsError, + $agentPluginsStatus, + type AgentPluginRow +} from '@/store/agent-plugins' +import { $connection, $gatewayState } from '@/store/session' + +import { PluginsSettings } from './plugins-settings' + +const legacyRow = { + name: 'Legacy plugin', + version: '0.20.0', + description: 'Returned by a pre-key backend', + source: 'user', + status: 'disabled' +} satisfies AgentPluginRow + +beforeEach(() => { + requestGateway.mockReset() + $pluginRecords.set({}) + $agentPlugins.set([legacyRow]) + $agentPluginsStatus.set('ready') + $agentPluginsError.set(null) + $agentPluginBusy.set(null) + $gatewayState.set('idle') + $connection.set(null) +}) + +afterEach(() => { + cleanup() +}) + +describe('PluginsSettings', () => { + it('renders and searches plugin rows returned without a canonical key', () => { + render() + + expect(screen.getByText('Legacy plugin')).toBeTruthy() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'pre-key' } }) + + expect(screen.getByText('Legacy plugin')).toBeTruthy() + }) + + it('uses the legacy name address when toggling a row without a key', async () => { + requestGateway.mockResolvedValue({ ok: true, plugin: { ...legacyRow, status: 'enabled' } }) + + render() + fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' })) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { + action: 'toggle', + name: 'Legacy plugin', + enable: true + }) + ) + }) + + it('keeps using the canonical key when the backend provides one', async () => { + const keyedRow = { ...legacyRow, key: 'image_gen/legacy' } + + $agentPlugins.set([keyedRow]) + requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } }) + + render() + fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' })) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { + action: 'toggle', + key: 'image_gen/legacy', + enable: true + }) + ) + }) +}) diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 9224c0af9a0d2..b66c7f19a7a3d 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -39,7 +39,11 @@ const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entr // the user-facing control for any of them, so listing them here is noise. const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/'] -const isDesktopRelevant = (row: AgentPluginRow) => !HIDDEN_KEY_PREFIXES.some(prefix => row.key.startsWith(prefix)) +const isDesktopRelevant = (row: AgentPluginRow) => { + const key = row.key + + return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix)) +} function reveal(file: string) { void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) @@ -134,10 +138,10 @@ function AgentPluginRowView({ row }: { row: AgentPluginRow }) { { triggerHaptic('selection') - void toggleAgentPlugin(requestGateway, row.key, on, p.agent.toggleFailed(row.name)) + void toggleAgentPlugin(requestGateway, row, on, p.agent.toggleFailed(row.name)) }} /> } @@ -180,7 +184,7 @@ function AgentPluginsSection() { row => !needle || row.name.toLowerCase().includes(needle) || - row.key.toLowerCase().includes(needle) || + (row.key ?? '').toLowerCase().includes(needle) || row.description.toLowerCase().includes(needle) ) .sort((a, b) => (SOURCE_ORDER[a.source] ?? 9) - (SOURCE_ORDER[b.source] ?? 9) || a.name.localeCompare(b.name)) diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts index b9422cfa24d7d..7e927cbd8988d 100644 --- a/apps/desktop/src/store/agent-plugins.ts +++ b/apps/desktop/src/store/agent-plugins.ts @@ -17,8 +17,8 @@ import { notifyError } from '@/store/notifications' export interface AgentPluginRow { name: string - /** Canonical registry key (e.g. `image_gen/fal`) — names can collide. */ - key: string + /** Canonical registry key (e.g. `image_gen/fal`) — absent on legacy backends. */ + key?: string version: string description: string /** 'bundled' | 'user' | 'git' | 'project' | 'entrypoint' */ @@ -36,7 +36,7 @@ export type GatewayRequest = (method: string, params?: Record([]) export const $agentPluginsStatus = atom('idle') export const $agentPluginsError = atom(null) -/** Key of the row whose toggle RPC is in flight (disables its switch). */ +/** Best available address of the row whose toggle RPC is in flight. */ export const $agentPluginBusy = atom(null) let inflight: Promise | null = null @@ -70,20 +70,22 @@ export function loadAgentPlugins(request: GatewayRequest): Promise { } /** Flip a backend plugin on/off and patch the row from the RPC's refreshed - * copy. Addressed by canonical key — bare names collide (image_gen/fal vs - * video_gen/fal). Returns whether the toggle stuck. */ + * copy. Current backends use canonical keys; legacy keyless rows fall back + * to the name-addressed protocol they were returned by. */ export async function toggleAgentPlugin( request: GatewayRequest, - key: string, + row: Pick, enable: boolean, failMessage: string ): Promise { - $agentPluginBusy.set(key) + const address = row.key ?? row.name + + $agentPluginBusy.set(address) try { const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>('plugins.manage', { action: 'toggle', - key, + ...(row.key ? { key: row.key } : { name: row.name }), enable }) @@ -94,7 +96,11 @@ export async function toggleAgentPlugin( const refreshed = result.plugin if (refreshed) { - $agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row))) + $agentPlugins.set( + $agentPlugins + .get() + .map(current => ((current.key ?? current.name) === address ? { ...current, ...refreshed } : current)) + ) } else { await loadAgentPlugins(request) }