mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +08:00
Merge desktop plugin hot refresh into main
This commit is contained in:
commit
059d2f0ee0
@ -3,6 +3,7 @@ import type {
|
|||||||
PluginDetail,
|
PluginDetail,
|
||||||
PluginListResponse,
|
PluginListResponse,
|
||||||
PluginReloadSummary,
|
PluginReloadSummary,
|
||||||
|
PluginSessionReloadSummary,
|
||||||
PluginScope,
|
PluginScope,
|
||||||
} from '../types/plugin'
|
} from '../types/plugin'
|
||||||
|
|
||||||
@ -36,10 +37,17 @@ export const pluginsApi = {
|
|||||||
uninstall: (payload: PluginActionPayload) =>
|
uninstall: (payload: PluginActionPayload) =>
|
||||||
api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload),
|
api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload),
|
||||||
|
|
||||||
reload: (cwd?: string) => {
|
reload: (cwd?: string, sessionId?: string) => {
|
||||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
const query = new URLSearchParams()
|
||||||
return api.post<{ ok: true; summary: PluginReloadSummary }>(
|
if (cwd) query.set('cwd', cwd)
|
||||||
`/api/plugins/reload${query}`,
|
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,
|
undefined,
|
||||||
{ timeout: 120_000 },
|
{ timeout: 120_000 },
|
||||||
)
|
)
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export function PluginDetail() {
|
|||||||
const handleReload = async () => {
|
const handleReload = async () => {
|
||||||
setActionKey('reload')
|
setActionKey('reload')
|
||||||
try {
|
try {
|
||||||
const summary = await reloadPlugins(currentWorkDir)
|
const summary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
|
||||||
addToast({
|
addToast({
|
||||||
type: summary.errors > 0 ? 'warning' : 'success',
|
type: summary.errors > 0 ? 'warning' : 'success',
|
||||||
message: t('settings.plugins.reloadToast', {
|
message: t('settings.plugins.reloadToast', {
|
||||||
@ -247,7 +247,7 @@ export function PluginDetail() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={isApplying && actionKey === 'disable'}
|
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')}
|
{t('settings.plugins.disable')}
|
||||||
</Button>
|
</Button>
|
||||||
@ -255,7 +255,7 @@ export function PluginDetail() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={isApplying && actionKey === 'enable'}
|
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')}
|
{t('settings.plugins.enable')}
|
||||||
</Button>
|
</Button>
|
||||||
@ -267,7 +267,7 @@ export function PluginDetail() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={isApplying && actionKey === 'update'}
|
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')}
|
{t('settings.plugins.update')}
|
||||||
</Button>
|
</Button>
|
||||||
@ -489,7 +489,7 @@ export function PluginDetail() {
|
|||||||
}}
|
}}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
setShowUninstallDialog(false)
|
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')}
|
title={t('settings.plugins.uninstall')}
|
||||||
body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })}
|
body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export function PluginList() {
|
|||||||
|
|
||||||
const handleReload = async () => {
|
const handleReload = async () => {
|
||||||
try {
|
try {
|
||||||
const reloadSummary = await reloadPlugins(currentWorkDir)
|
const reloadSummary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
|
||||||
addToast({
|
addToast({
|
||||||
type: reloadSummary.errors > 0 ? 'warning' : 'success',
|
type: reloadSummary.errors > 0 ? 'warning' : 'success',
|
||||||
message: t('settings.plugins.reloadToast', {
|
message: t('settings.plugins.reloadToast', {
|
||||||
|
|||||||
@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
@ -98,6 +98,7 @@ import { useSessionStore } from '../stores/sessionStore'
|
|||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
import { usePluginStore } from '../stores/pluginStore'
|
||||||
import type { RepositoryContextResult } from '../api/sessions'
|
import type { RepositoryContextResult } from '../api/sessions'
|
||||||
|
|
||||||
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
||||||
@ -146,6 +147,7 @@ describe('EmptySession', () => {
|
|||||||
const initialTabState = useTabStore.getInitialState()
|
const initialTabState = useTabStore.getInitialState()
|
||||||
const initialRuntimeState = useSessionRuntimeStore.getInitialState()
|
const initialRuntimeState = useSessionRuntimeStore.getInitialState()
|
||||||
const initialUiState = useUIStore.getInitialState()
|
const initialUiState = useUIStore.getInitialState()
|
||||||
|
const initialPluginState = usePluginStore.getInitialState()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -157,6 +159,7 @@ describe('EmptySession', () => {
|
|||||||
useTabStore.setState(initialTabState, true)
|
useTabStore.setState(initialTabState, true)
|
||||||
useSessionRuntimeStore.setState(initialRuntimeState, true)
|
useSessionRuntimeStore.setState(initialRuntimeState, true)
|
||||||
useUIStore.setState(initialUiState, true)
|
useUIStore.setState(initialUiState, true)
|
||||||
|
usePluginStore.setState(initialPluginState, true)
|
||||||
|
|
||||||
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
|
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
|
||||||
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||||
@ -187,6 +190,7 @@ describe('EmptySession', () => {
|
|||||||
useTabStore.setState(initialTabState, true)
|
useTabStore.setState(initialTabState, true)
|
||||||
useSessionRuntimeStore.setState(initialRuntimeState, true)
|
useSessionRuntimeStore.setState(initialRuntimeState, true)
|
||||||
useUIStore.setState(initialUiState, true)
|
useUIStore.setState(initialUiState, true)
|
||||||
|
usePluginStore.setState(initialPluginState, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses compact composer controls on phone-sized H5 browsers', async () => {
|
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')
|
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 () => {
|
it('integrates repository launch controls into the desktop composer panel', async () => {
|
||||||
render(<EmptySession />)
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { skillsApi } from '../api/skills'
|
|||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import { usePluginStore } from '../stores/pluginStore'
|
||||||
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
|
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
@ -103,6 +104,7 @@ export function EmptySession() {
|
|||||||
const setActiveView = useUIStore((state) => state.setActiveView)
|
const setActiveView = useUIStore((state) => state.setActiveView)
|
||||||
const addToast = useUIStore((state) => state.addToast)
|
const addToast = useUIStore((state) => state.addToast)
|
||||||
const currentModel = useSettingsStore((state) => state.currentModel)
|
const currentModel = useSettingsStore((state) => state.currentModel)
|
||||||
|
const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
|
||||||
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
|
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
|
||||||
const draftRuntimeSelectionKey = draftRuntimeSelection
|
const draftRuntimeSelectionKey = draftRuntimeSelection
|
||||||
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
|
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
|
||||||
@ -198,7 +200,7 @@ export function EmptySession() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [workDir])
|
}, [workDir, lastPluginReloadSummary])
|
||||||
|
|
||||||
const allSlashCommands = useMemo(
|
const allSlashCommands = useMemo(
|
||||||
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
|
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
|
||||||
|
|||||||
91
desktop/src/stores/pluginStore.test.ts
Normal file
91
desktop/src/stores/pluginStore.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -20,11 +20,11 @@ type PluginStore = {
|
|||||||
error: string | null
|
error: string | null
|
||||||
fetchPlugins: (cwd?: string) => Promise<void>
|
fetchPlugins: (cwd?: string) => Promise<void>
|
||||||
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>
|
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>
|
||||||
reloadPlugins: (cwd?: string) => Promise<PluginReloadSummary>
|
reloadPlugins: (cwd?: string, sessionId?: string) => Promise<PluginReloadSummary>
|
||||||
enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
|
enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
|
||||||
disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
|
disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
|
||||||
updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
|
updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
|
||||||
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise<string>
|
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise<string>
|
||||||
clearSelection: () => void
|
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 })
|
set({ isApplying: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { summary } = await pluginsApi.reload(cwd)
|
const { summary } = await pluginsApi.reload(cwd, sessionId)
|
||||||
await get().fetchPlugins(cwd)
|
await get().fetchPlugins(cwd)
|
||||||
const selected = get().selectedPlugin
|
const selected = get().selectedPlugin
|
||||||
if (selected) {
|
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(
|
return runAction(
|
||||||
() => pluginsApi.enable({ id, scope }),
|
() => pluginsApi.enable({ id, scope }),
|
||||||
set,
|
set,
|
||||||
get,
|
get,
|
||||||
cwd,
|
cwd,
|
||||||
|
sessionId,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
disablePlugin: async (id, scope, cwd) => {
|
disablePlugin: async (id, scope, cwd, sessionId) => {
|
||||||
return runAction(
|
return runAction(
|
||||||
() => pluginsApi.disable({ id, scope }),
|
() => pluginsApi.disable({ id, scope }),
|
||||||
set,
|
set,
|
||||||
get,
|
get,
|
||||||
cwd,
|
cwd,
|
||||||
|
sessionId,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePlugin: async (id, scope, cwd) => {
|
updatePlugin: async (id, scope, cwd, sessionId) => {
|
||||||
return runAction(
|
return runAction(
|
||||||
() => pluginsApi.update({ id, scope }),
|
() => pluginsApi.update({ id, scope }),
|
||||||
set,
|
set,
|
||||||
get,
|
get,
|
||||||
cwd,
|
cwd,
|
||||||
|
sessionId,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
uninstallPlugin: async (id, scope, keepData = false, cwd) => {
|
uninstallPlugin: async (id, scope, keepData = false, cwd, sessionId) => {
|
||||||
return runAction(
|
return runAction(
|
||||||
() => pluginsApi.uninstall({ id, scope, keepData }),
|
() => pluginsApi.uninstall({ id, scope, keepData }),
|
||||||
set,
|
set,
|
||||||
get,
|
get,
|
||||||
cwd,
|
cwd,
|
||||||
|
sessionId,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -133,11 +137,13 @@ async function runAction(
|
|||||||
set: (updater: Partial<PluginStore>) => void,
|
set: (updater: Partial<PluginStore>) => void,
|
||||||
get: () => PluginStore,
|
get: () => PluginStore,
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
|
sessionId?: string,
|
||||||
clearSelection = false,
|
clearSelection = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
set({ isApplying: true, error: null })
|
set({ isApplying: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { message } = await action()
|
const { message } = await action()
|
||||||
|
const { summary } = await pluginsApi.reload(cwd, sessionId)
|
||||||
await get().fetchPlugins(cwd)
|
await get().fetchPlugins(cwd)
|
||||||
const selected = get().selectedPlugin
|
const selected = get().selectedPlugin
|
||||||
if (clearSelection) {
|
if (clearSelection) {
|
||||||
@ -145,7 +151,7 @@ async function runAction(
|
|||||||
} else if (selected) {
|
} else if (selected) {
|
||||||
await get().fetchPluginDetail(selected.id, cwd)
|
await get().fetchPluginDetail(selected.id, cwd)
|
||||||
}
|
}
|
||||||
set({ isApplying: false })
|
set({ isApplying: false, lastReloadSummary: summary })
|
||||||
return message
|
return message
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@ -99,3 +99,14 @@ export type PluginReloadSummary = {
|
|||||||
lspServers: number
|
lspServers: number
|
||||||
errors: number
|
errors: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginSessionReloadSummary = {
|
||||||
|
applied: boolean
|
||||||
|
reason?: 'not_running' | 'failed'
|
||||||
|
commands: number
|
||||||
|
agents: number
|
||||||
|
plugins: number
|
||||||
|
mcpServers: number
|
||||||
|
errors: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|||||||
@ -7,9 +7,13 @@ import { clearInstalledPluginsCache } from '../../utils/plugins/installedPlugins
|
|||||||
import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
|
import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
|
||||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||||
import { handlePluginsApi } from '../api/plugins.js'
|
import { handlePluginsApi } from '../api/plugins.js'
|
||||||
|
import { conversationService } from '../services/conversationService.js'
|
||||||
|
import { __resetWebSocketHandlerStateForTests, getSlashCommands } from '../ws/handler.js'
|
||||||
|
|
||||||
let tmpDir: string
|
let tmpDir: string
|
||||||
let originalConfigDir: string | undefined
|
let originalConfigDir: string | undefined
|
||||||
|
let originalHasSession: typeof conversationService.hasSession
|
||||||
|
let originalRequestControl: typeof conversationService.requestControl
|
||||||
|
|
||||||
function makeRequest(
|
function makeRequest(
|
||||||
method: string,
|
method: string,
|
||||||
@ -38,9 +42,15 @@ describe('Plugins API', () => {
|
|||||||
clearInstalledPluginsCache()
|
clearInstalledPluginsCache()
|
||||||
clearPluginCache('plugins-api-test-setup')
|
clearPluginCache('plugins-api-test-setup')
|
||||||
resetSettingsCache()
|
resetSettingsCache()
|
||||||
|
__resetWebSocketHandlerStateForTests()
|
||||||
|
originalHasSession = conversationService.hasSession.bind(conversationService)
|
||||||
|
originalRequestControl = conversationService.requestControl.bind(conversationService)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
conversationService.hasSession = originalHasSession
|
||||||
|
conversationService.requestControl = originalRequestControl
|
||||||
|
__resetWebSocketHandlerStateForTests()
|
||||||
clearInstalledPluginsCache()
|
clearInstalledPluginsCache()
|
||||||
clearPluginCache('plugins-api-test-teardown')
|
clearPluginCache('plugins-api-test-teardown')
|
||||||
resetSettingsCache()
|
resetSettingsCache()
|
||||||
@ -166,4 +176,69 @@ describe('Plugins API', () => {
|
|||||||
expect(typeof body.summary.skills).toBe('number')
|
expect(typeof body.summary.skills).toBe('number')
|
||||||
expect(typeof body.summary.errors).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>',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,22 @@
|
|||||||
import type { PluginScope } from '../../utils/plugins/schemas.js'
|
import type { PluginScope } from '../../utils/plugins/schemas.js'
|
||||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
import { PluginService } from '../services/pluginService.js'
|
import { PluginService } from '../services/pluginService.js'
|
||||||
|
import { conversationService } from '../services/conversationService.js'
|
||||||
|
import { updateSessionSlashCommands } from '../ws/handler.js'
|
||||||
|
|
||||||
const pluginService = new PluginService()
|
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(
|
export async function handlePluginsApi(
|
||||||
req: Request,
|
req: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
@ -29,7 +42,16 @@ export async function handlePluginsApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'POST' && sub === 'reload') {
|
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) {
|
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>> {
|
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
return (await req.json()) as Record<string, unknown>
|
return (await req.json()) as Record<string, unknown>
|
||||||
|
|||||||
@ -32,7 +32,13 @@ const providerService = new ProviderService()
|
|||||||
/**
|
/**
|
||||||
* Cache slash commands from CLI init messages, keyed by sessionId.
|
* 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.
|
* 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) || []
|
return sessionSlashCommands.get(sessionId) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -785,10 +791,7 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
|
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
|
||||||
sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
|
updateSessionSlashCommands(sessionId, cliMsg.slash_commands, { notifyClient: false })
|
||||||
name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
|
|
||||||
description: typeof cmd === 'string' ? '' : (cmd.description || ''),
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1575,6 +1578,55 @@ export function sendToSession(sessionId: string, message: ServerMessage): boolea
|
|||||||
return true
|
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 {
|
export function closeSessionConnection(sessionId: string, reason = 'session closed'): boolean {
|
||||||
const cleanupTimer = sessionCleanupTimers.get(sessionId)
|
const cleanupTimer = sessionCleanupTimers.get(sessionId)
|
||||||
if (cleanupTimer) {
|
if (cleanupTimer) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user