diff --git a/desktop/src/api/plugins.ts b/desktop/src/api/plugins.ts index 56bddc8b..4708c847 100644 --- a/desktop/src/api/plugins.ts +++ b/desktop/src/api/plugins.ts @@ -3,6 +3,7 @@ import type { PluginDetail, PluginListResponse, PluginReloadSummary, + PluginSessionReloadSummary, PluginScope, } from '../types/plugin' @@ -36,10 +37,17 @@ export const pluginsApi = { uninstall: (payload: PluginActionPayload) => api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload), - reload: (cwd?: string) => { - const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' - return api.post<{ ok: true; summary: PluginReloadSummary }>( - `/api/plugins/reload${query}`, + reload: (cwd?: string, sessionId?: string) => { + const query = new URLSearchParams() + if (cwd) query.set('cwd', cwd) + if (sessionId) query.set('sessionId', sessionId) + const suffix = query.size > 0 ? `?${query.toString()}` : '' + return api.post<{ + ok: true + summary: PluginReloadSummary + session?: PluginSessionReloadSummary + }>( + `/api/plugins/reload${suffix}`, undefined, { timeout: 120_000 }, ) diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx index 14c0d897..abe9f5d6 100644 --- a/desktop/src/components/plugins/PluginDetail.tsx +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -82,7 +82,7 @@ export function PluginDetail() { const handleReload = async () => { setActionKey('reload') try { - const summary = await reloadPlugins(currentWorkDir) + const summary = await reloadPlugins(currentWorkDir, activeSessionId || undefined) addToast({ type: summary.errors > 0 ? 'warning' : 'success', message: t('settings.plugins.reloadToast', { @@ -247,7 +247,7 @@ export function PluginDetail() { variant="secondary" size="sm" loading={isApplying && actionKey === 'disable'} - onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))} + onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))} > {t('settings.plugins.disable')} @@ -255,7 +255,7 @@ export function PluginDetail() { @@ -267,7 +267,7 @@ export function PluginDetail() { variant="secondary" size="sm" loading={isApplying && actionKey === 'update'} - onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))} + onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))} > {t('settings.plugins.update')} @@ -489,7 +489,7 @@ export function PluginDetail() { }} onConfirm={async () => { setShowUninstallDialog(false) - await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir)) + await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir, activeSessionId || undefined)) }} title={t('settings.plugins.uninstall')} body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })} diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx index 7d8df76a..e7b5d4a0 100644 --- a/desktop/src/components/plugins/PluginList.tsx +++ b/desktop/src/components/plugins/PluginList.tsx @@ -54,7 +54,7 @@ export function PluginList() { const handleReload = async () => { try { - const reloadSummary = await reloadPlugins(currentWorkDir) + const reloadSummary = await reloadPlugins(currentWorkDir, activeSessionId || undefined) addToast({ type: reloadSummary.errors > 0 ? 'warning' : 'success', message: t('settings.plugins.reloadToast', { diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 7c24562b..dfa90160 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' @@ -98,6 +98,7 @@ import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' import { useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' +import { usePluginStore } from '../stores/pluginStore' import type { RepositoryContextResult } from '../api/sessions' function okRepositoryContext(overrides: Partial = {}): RepositoryContextResult { @@ -146,6 +147,7 @@ describe('EmptySession', () => { const initialTabState = useTabStore.getInitialState() const initialRuntimeState = useSessionRuntimeStore.getInitialState() const initialUiState = useUIStore.getInitialState() + const initialPluginState = usePluginStore.getInitialState() beforeEach(() => { vi.clearAllMocks() @@ -157,6 +159,7 @@ describe('EmptySession', () => { useTabStore.setState(initialTabState, true) useSessionRuntimeStore.setState(initialRuntimeState, true) useUIStore.setState(initialUiState, true) + usePluginStore.setState(initialPluginState, true) mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' }) mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext()) @@ -187,6 +190,7 @@ describe('EmptySession', () => { useTabStore.setState(initialTabState, true) useSessionRuntimeStore.setState(initialRuntimeState, true) useUIStore.setState(initialUiState, true) + usePluginStore.setState(initialPluginState, true) }) it('uses compact composer controls on phone-sized H5 browsers', async () => { @@ -203,6 +207,45 @@ describe('EmptySession', () => { expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-2xl') }) + it('refreshes empty-session slash commands after plugin reloads', async () => { + mocks.listSkills + .mockResolvedValueOnce({ skills: [] }) + .mockResolvedValueOnce({ + skills: [ + { + name: 'draw:render', + description: 'Render with the drawing plugin.', + userInvocable: true, + }, + ], + }) + + render() + + await waitFor(() => { + expect(mocks.listSkills).toHaveBeenCalledTimes(1) + }) + + act(() => { + usePluginStore.setState({ + lastReloadSummary: { + enabled: 1, + disabled: 0, + skills: 1, + agents: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + errors: 0, + }, + }) + }) + + await waitFor(() => { + expect(mocks.listSkills).toHaveBeenCalledTimes(2) + }) + }) + it('integrates repository launch controls into the desktop composer panel', async () => { render() diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index b1618838..64e02fd1 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -4,6 +4,7 @@ import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' +import { usePluginStore } from '../stores/pluginStore' import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' @@ -103,6 +104,7 @@ export function EmptySession() { const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) const currentModel = useSettingsStore((state) => state.currentModel) + const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) const draftRuntimeSelectionKey = draftRuntimeSelection ? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}` @@ -198,7 +200,7 @@ export function EmptySession() { return () => { cancelled = true } - }, [workDir]) + }, [workDir, lastPluginReloadSummary]) const allSlashCommands = useMemo( () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), diff --git a/desktop/src/stores/pluginStore.test.ts b/desktop/src/stores/pluginStore.test.ts new file mode 100644 index 00000000..1a6be4b8 --- /dev/null +++ b/desktop/src/stores/pluginStore.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { usePluginStore } from './pluginStore' +import { pluginsApi } from '../api/plugins' + +vi.mock('../api/plugins', () => ({ + pluginsApi: { + list: vi.fn(), + detail: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + update: vi.fn(), + uninstall: vi.fn(), + reload: vi.fn(), + }, +})) + +const mockedPluginsApi = vi.mocked(pluginsApi) + +describe('pluginStore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockedPluginsApi.list.mockResolvedValue({ + plugins: [], + marketplaces: [], + summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 }, + }) + mockedPluginsApi.reload.mockResolvedValue({ + ok: true, + summary: { + enabled: 1, + disabled: 0, + skills: 1, + agents: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + errors: 0, + }, + session: { + applied: true, + commands: 1, + agents: 0, + plugins: 1, + mcpServers: 0, + errors: 0, + }, + }) + usePluginStore.setState({ + plugins: [], + marketplaces: [], + summary: null, + selectedPlugin: null, + lastReloadSummary: null, + isLoading: false, + isDetailLoading: false, + isApplying: false, + error: null, + }) + }) + + it('reloads the active CLI session after enabling a plugin', async () => { + mockedPluginsApi.enable.mockResolvedValue({ + ok: true, + message: 'enabled', + }) + + const message = await usePluginStore + .getState() + .enablePlugin('draw@test', 'user', '/workspace/project', 'session-1') + + expect(message).toBe('enabled') + expect(mockedPluginsApi.enable).toHaveBeenCalledWith({ + id: 'draw@test', + scope: 'user', + }) + expect(mockedPluginsApi.reload).toHaveBeenCalledWith( + '/workspace/project', + 'session-1', + ) + expect(usePluginStore.getState().lastReloadSummary).toEqual({ + enabled: 1, + disabled: 0, + skills: 1, + agents: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + errors: 0, + }) + }) +}) diff --git a/desktop/src/stores/pluginStore.ts b/desktop/src/stores/pluginStore.ts index 5fb39637..9c70b9ba 100644 --- a/desktop/src/stores/pluginStore.ts +++ b/desktop/src/stores/pluginStore.ts @@ -20,11 +20,11 @@ type PluginStore = { error: string | null fetchPlugins: (cwd?: string) => Promise fetchPluginDetail: (id: string, cwd?: string) => Promise - reloadPlugins: (cwd?: string) => Promise - enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise - disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise - updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise - uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise + reloadPlugins: (cwd?: string, sessionId?: string) => Promise + enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise + disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise + updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise + uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise clearSelection: () => void } @@ -70,10 +70,10 @@ export const usePluginStore = create((set, get) => ({ } }, - reloadPlugins: async (cwd) => { + reloadPlugins: async (cwd, sessionId) => { set({ isApplying: true, error: null }) try { - const { summary } = await pluginsApi.reload(cwd) + const { summary } = await pluginsApi.reload(cwd, sessionId) await get().fetchPlugins(cwd) const selected = get().selectedPlugin if (selected) { @@ -88,39 +88,43 @@ export const usePluginStore = create((set, get) => ({ } }, - enablePlugin: async (id, scope, cwd) => { + enablePlugin: async (id, scope, cwd, sessionId) => { return runAction( () => pluginsApi.enable({ id, scope }), set, get, cwd, + sessionId, ) }, - disablePlugin: async (id, scope, cwd) => { + disablePlugin: async (id, scope, cwd, sessionId) => { return runAction( () => pluginsApi.disable({ id, scope }), set, get, cwd, + sessionId, ) }, - updatePlugin: async (id, scope, cwd) => { + updatePlugin: async (id, scope, cwd, sessionId) => { return runAction( () => pluginsApi.update({ id, scope }), set, get, cwd, + sessionId, ) }, - uninstallPlugin: async (id, scope, keepData = false, cwd) => { + uninstallPlugin: async (id, scope, keepData = false, cwd, sessionId) => { return runAction( () => pluginsApi.uninstall({ id, scope, keepData }), set, get, cwd, + sessionId, true, ) }, @@ -133,11 +137,13 @@ async function runAction( set: (updater: Partial) => void, get: () => PluginStore, cwd?: string, + sessionId?: string, clearSelection = false, ): Promise { set({ isApplying: true, error: null }) try { const { message } = await action() + const { summary } = await pluginsApi.reload(cwd, sessionId) await get().fetchPlugins(cwd) const selected = get().selectedPlugin if (clearSelection) { @@ -145,7 +151,7 @@ async function runAction( } else if (selected) { await get().fetchPluginDetail(selected.id, cwd) } - set({ isApplying: false }) + set({ isApplying: false, lastReloadSummary: summary }) return message } catch (err) { set({ diff --git a/desktop/src/types/plugin.ts b/desktop/src/types/plugin.ts index eb948b9a..266ae2a2 100644 --- a/desktop/src/types/plugin.ts +++ b/desktop/src/types/plugin.ts @@ -99,3 +99,14 @@ export type PluginReloadSummary = { lspServers: number errors: number } + +export type PluginSessionReloadSummary = { + applied: boolean + reason?: 'not_running' | 'failed' + commands: number + agents: number + plugins: number + mcpServers: number + errors: number + error?: string +} diff --git a/src/server/__tests__/plugins.test.ts b/src/server/__tests__/plugins.test.ts index 8fbcf452..31694ce3 100644 --- a/src/server/__tests__/plugins.test.ts +++ b/src/server/__tests__/plugins.test.ts @@ -7,9 +7,13 @@ import { clearInstalledPluginsCache } from '../../utils/plugins/installedPlugins import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' import { resetSettingsCache } from '../../utils/settings/settingsCache.js' import { handlePluginsApi } from '../api/plugins.js' +import { conversationService } from '../services/conversationService.js' +import { __resetWebSocketHandlerStateForTests, getSlashCommands } from '../ws/handler.js' let tmpDir: string let originalConfigDir: string | undefined +let originalHasSession: typeof conversationService.hasSession +let originalRequestControl: typeof conversationService.requestControl function makeRequest( method: string, @@ -38,9 +42,15 @@ describe('Plugins API', () => { clearInstalledPluginsCache() clearPluginCache('plugins-api-test-setup') resetSettingsCache() + __resetWebSocketHandlerStateForTests() + originalHasSession = conversationService.hasSession.bind(conversationService) + originalRequestControl = conversationService.requestControl.bind(conversationService) }) afterEach(async () => { + conversationService.hasSession = originalHasSession + conversationService.requestControl = originalRequestControl + __resetWebSocketHandlerStateForTests() clearInstalledPluginsCache() clearPluginCache('plugins-api-test-teardown') resetSettingsCache() @@ -166,4 +176,69 @@ describe('Plugins API', () => { expect(typeof body.summary.skills).toBe('number') expect(typeof body.summary.errors).toBe('number') }) + + it('POST /api/plugins/reload hot-reloads an active CLI session and updates slash commands', async () => { + const controlRequests: Array<{ sessionId: string; request: Record }> = [] + conversationService.hasSession = ((sessionId: string) => sessionId === 'session-plugins') as typeof conversationService.hasSession + conversationService.requestControl = (async ( + sessionId: string, + request: Record, + ) => { + controlRequests.push({ sessionId, request }) + return { + commands: [ + { + name: 'draw:render', + description: 'Render a drawing.', + argumentHint: '', + }, + ], + agents: [{ name: 'draw-agent' }], + plugins: [{ name: 'draw', path: '/tmp/draw', source: 'draw@test' }], + mcpServers: [{ name: 'plugin:draw:server', type: 'connected' }], + error_count: 0, + } + }) as typeof conversationService.requestControl + + const { req, url, segments } = makeRequest( + 'POST', + '/api/plugins/reload?sessionId=session-plugins', + {}, + ) + const res = await handlePluginsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() as { + session: { + applied: boolean + commands: number + agents: number + plugins: number + mcpServers: number + errors: number + } + } + + expect(controlRequests).toEqual([ + { + sessionId: 'session-plugins', + request: { subtype: 'reload_plugins' }, + }, + ]) + expect(body.session).toEqual({ + applied: true, + commands: 1, + agents: 1, + plugins: 1, + mcpServers: 1, + errors: 0, + }) + expect(getSlashCommands('session-plugins')).toEqual([ + { + name: 'draw:render', + description: 'Render a drawing.', + argumentHint: '', + }, + ]) + }) }) diff --git a/src/server/api/plugins.ts b/src/server/api/plugins.ts index 7a6f9794..1ed6602f 100644 --- a/src/server/api/plugins.ts +++ b/src/server/api/plugins.ts @@ -1,9 +1,22 @@ import type { PluginScope } from '../../utils/plugins/schemas.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { PluginService } from '../services/pluginService.js' +import { conversationService } from '../services/conversationService.js' +import { updateSessionSlashCommands } from '../ws/handler.js' const pluginService = new PluginService() +type PluginSessionReloadSummary = { + applied: boolean + reason?: 'not_running' | 'failed' + commands: number + agents: number + plugins: number + mcpServers: number + errors: number + error?: string +} + export async function handlePluginsApi( req: Request, url: URL, @@ -29,7 +42,16 @@ export async function handlePluginsApi( } if (method === 'POST' && sub === 'reload') { - return Response.json(await pluginService.reloadPlugins(cwd)) + const sessionId = url.searchParams.get('sessionId') || undefined + const response = await pluginService.reloadPlugins(cwd) + if (!sessionId) { + return Response.json(response) + } + + return Response.json({ + ...response, + session: await reloadSessionPlugins(sessionId), + }) } if (method === 'POST' && sub) { @@ -73,6 +95,52 @@ export async function handlePluginsApi( } } +async function reloadSessionPlugins( + sessionId: string, +): Promise { + if (!conversationService.hasSession(sessionId)) { + return { + applied: false, + reason: 'not_running', + commands: 0, + agents: 0, + plugins: 0, + mcpServers: 0, + errors: 0, + } + } + + try { + const response = await conversationService.requestControl( + sessionId, + { subtype: 'reload_plugins' }, + 120_000, + ) + const commands = Array.isArray(response.commands) ? response.commands : [] + const normalizedCommands = updateSessionSlashCommands(sessionId, commands) + + return { + applied: true, + commands: normalizedCommands.length, + agents: Array.isArray(response.agents) ? response.agents.length : 0, + plugins: Array.isArray(response.plugins) ? response.plugins.length : 0, + mcpServers: Array.isArray(response.mcpServers) ? response.mcpServers.length : 0, + errors: typeof response.error_count === 'number' ? response.error_count : 0, + } + } catch (error) { + return { + applied: false, + reason: 'failed', + commands: 0, + agents: 0, + plugins: 0, + mcpServers: 0, + errors: 0, + error: error instanceof Error ? error.message : String(error), + } + } +} + async function parseJsonBody(req: Request): Promise> { try { return (await req.json()) as Record diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index dc798417..553b8b43 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -32,7 +32,13 @@ const providerService = new ProviderService() /** * Cache slash commands from CLI init messages, keyed by sessionId. */ -const sessionSlashCommands = new Map>() +export type SessionSlashCommand = { + name: string + description: string + argumentHint?: string +} + +const sessionSlashCommands = new Map() /** * Timers for delayed session cleanup after client disconnect. @@ -86,7 +92,7 @@ async function sendRepositoryStartupStatus( } } -export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> { +export function getSlashCommands(sessionId: string): SessionSlashCommand[] { return sessionSlashCommands.get(sessionId) || [] } @@ -785,10 +791,7 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { })() } if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) { - sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({ - name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''), - description: typeof cmd === 'string' ? '' : (cmd.description || ''), - }))) + updateSessionSlashCommands(sessionId, cliMsg.slash_commands, { notifyClient: false }) } } @@ -1575,6 +1578,55 @@ export function sendToSession(sessionId: string, message: ServerMessage): boolea return true } +export function updateSessionSlashCommands( + sessionId: string, + commands: unknown[], + options: { notifyClient?: boolean } = {}, +): SessionSlashCommand[] { + const normalized = commands + .map(normalizeSessionSlashCommand) + .filter((command): command is SessionSlashCommand => command !== null) + + sessionSlashCommands.set(sessionId, normalized) + + if (options.notifyClient !== false) { + sendToSession(sessionId, { + type: 'system_notification', + subtype: 'slash_commands', + data: normalized, + }) + } + + return normalized +} + +function normalizeSessionSlashCommand(command: unknown): SessionSlashCommand | null { + if (typeof command === 'string') { + return command.trim() ? { name: command, description: '' } : null + } + if (!command || typeof command !== 'object') return null + + const record = command as { + name?: unknown + command?: unknown + description?: unknown + argumentHint?: unknown + } + const name = + typeof record.name === 'string' + ? record.name + : typeof record.command === 'string' + ? record.command + : '' + if (!name.trim()) return null + + return { + name, + description: typeof record.description === 'string' ? record.description : '', + ...(typeof record.argumentHint === 'string' ? { argumentHint: record.argumentHint } : {}), + } +} + export function closeSessionConnection(sessionId: string, reason = 'session closed'): boolean { const cleanupTimer = sessionCleanupTimers.get(sessionId) if (cleanupTimer) {