fix: keep desktop plugin changes visible without restarting chats

Plugin enablement already has a live CLI reload control path, so desktop now applies plugin changes by refreshing the active session instead of waiting for a future conversation startup. The server forwards reload_plugins to the active CLI session, refreshes session slash-command cache, and notifies the client. The desktop plugin store automatically reloads after mutating plugin state, and the empty-session composer refetches skills when plugin capabilities change.

Constraint: Existing CLI exposes reload_plugins as the supported hot-refresh mechanism for commands, agents, plugins, and MCP state.

Rejected: Start a hidden replacement CLI process | higher cost, extra process lifecycle risk, and less precise than the existing control channel.

Confidence: high

Scope-risk: moderate

Directive: Keep plugin refresh routed through reload_plugins unless the CLI control contract is removed or changed.

Tested: bun test src/server/__tests__/plugins.test.ts

Tested: cd desktop && bun run test --run src/stores/pluginStore.test.ts src/__tests__/pluginsSettings.test.tsx src/pages/EmptySession.test.tsx

Tested: bun run check:server

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build

Not-tested: bun run check:desktop is blocked by a pre-existing color-mix compatibility failure in desktop/src/theme/globals.css.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 19:51:01 +08:00
parent 4e969475f2
commit de63d82633
11 changed files with 387 additions and 31 deletions

View File

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

View File

@ -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')}
</Button>
@ -255,7 +255,7 @@ export function PluginDetail() {
<Button
size="sm"
loading={isApplying && actionKey === 'enable'}
onClick={() => void runAction('enable', () => enablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
onClick={() => void runAction('enable', () => enablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))}
>
{t('settings.plugins.enable')}
</Button>
@ -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')}
</Button>
@ -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 })}

View File

@ -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', {

View File

@ -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> = {}): 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(<EmptySession />)
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(<EmptySession />)

View File

@ -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),

View File

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

View File

@ -20,11 +20,11 @@ type PluginStore = {
error: string | null
fetchPlugins: (cwd?: string) => Promise<void>
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>
reloadPlugins: (cwd?: string) => Promise<PluginReloadSummary>
enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise<string>
reloadPlugins: (cwd?: string, sessionId?: string) => Promise<PluginReloadSummary>
enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise<string>
clearSelection: () => void
}
@ -70,10 +70,10 @@ export const usePluginStore = create<PluginStore>((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<PluginStore>((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<PluginStore>) => void,
get: () => PluginStore,
cwd?: string,
sessionId?: string,
clearSelection = false,
): Promise<string> {
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({

View File

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

View File

@ -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<string, unknown> }> = []
conversationService.hasSession = ((sessionId: string) => sessionId === 'session-plugins') as typeof conversationService.hasSession
conversationService.requestControl = (async (
sessionId: string,
request: Record<string, unknown>,
) => {
controlRequests.push({ sessionId, request })
return {
commands: [
{
name: 'draw:render',
description: 'Render a drawing.',
argumentHint: '<prompt>',
},
],
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: '<prompt>',
},
])
})
})

View File

@ -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<PluginSessionReloadSummary> {
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<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>

View File

@ -32,7 +32,13 @@ const providerService = new ProviderService()
/**
* Cache slash commands from CLI init messages, keyed by sessionId.
*/
const sessionSlashCommands = new Map<string, Array<{ name: string; description: string }>>()
export type SessionSlashCommand = {
name: string
description: string
argumentHint?: string
}
const sessionSlashCommands = new Map<string, SessionSlashCommand[]>()
/**
* 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 })
}
}
@ -1568,6 +1571,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) {