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)
}