From 304c4b6e362b23ce792c7aa3557c4145f41b49ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Tue, 30 Jun 2026 20:56:22 +0800 Subject: [PATCH 1/2] feat(cli): add /reload-plugins command and plugin stale notification Add a /reload-plugins slash command and a plugin-stale notification flow for extensions, aligning with Claude Code's plugin reload model. When a user installs, uninstalls, updates, enables, disables, or changes the scope of an extension, the runtime no longer rebuilds tool registrations inline on every operation. Each mutation marks plugin runtime state as stale and surfaces one deduplicated in-chat notice directing the user to run /reload-plugins. The command performs one coordinated refresh - extension cache, tools, plugin-provided LSP servers, and slash commands - and reports a summary of what was reloaded. A failed reload surfaces a friendly error and keeps the stale flag set so the user can retry. To let mutations defer the expensive tool refresh, ExtensionManager.enableExtension / disableExtension / installExtension / uninstallExtension / updateExtension gain an optional { refreshTools?: boolean } option (default true, preserving existing behavior). All extension UI entry points pass refreshTools: false so the refresh happens once inside /reload-plugins instead of being interleaved with every mutation. Model-visible skills and agents are auto-refreshed after every mutation via clearPluginCaches(), which rebuilds SkillManager and SubagentManager caches so the next model turn sees the updated active-extension set. This matches Claude Code's clearAllCaches() for model-facing memoization. Commands and hooks are not yet covered by automatic refresh. Model-visible commands flow through CommandService, which has no independent cache-invalidation primitive. Disabled-plugin hook pruning requires a pruneRemovedPluginHooks equivalent. Both are tracked for a follow-up PR. Progress on #3696. --- packages/cli/src/config/hot-reload.test.ts | 109 +++++++++++++++++- packages/cli/src/config/hot-reload.ts | 86 ++++++++++++++ .../src/config/plugin-refresh-state.test.ts | 43 +++++++ .../cli/src/config/plugin-refresh-state.ts | 30 +++++ packages/cli/src/i18n/locales/en.js | 6 + packages/cli/src/i18n/locales/zh-TW.js | 6 + packages/cli/src/i18n/locales/zh.js | 6 + .../src/services/BuiltinCommandLoader.test.ts | 14 +++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 22 +++- .../src/ui/commands/extensionsCommand.test.ts | 8 +- .../cli/src/ui/commands/extensionsCommand.ts | 17 ++- .../commands/reload-plugins-command.test.ts | 102 ++++++++++++++++ .../src/ui/commands/reload-plugins-command.ts | 81 +++++++++++++ .../ExtensionsManagerDialog.test.tsx | 31 +++++ .../extensions/tabs/DiscoverTab.tsx | 28 ++++- .../extensions/tabs/InstalledTab.tsx | 19 ++- .../components/extensions/tabs/SourcesTab.tsx | 15 ++- .../extensions/views/ExtensionActionsView.tsx | 57 +++++++-- .../src/ui/hooks/useExtensionUpdates.test.ts | 2 + .../cli/src/ui/hooks/useExtensionUpdates.ts | 26 ++++- packages/cli/src/utils/events.ts | 1 + .../src/extension/extensionManager.test.ts | 78 ++++++++++++- .../core/src/extension/extensionManager.ts | 27 ++++- 24 files changed, 786 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/config/plugin-refresh-state.test.ts create mode 100644 packages/cli/src/config/plugin-refresh-state.ts create mode 100644 packages/cli/src/ui/commands/reload-plugins-command.test.ts create mode 100644 packages/cli/src/ui/commands/reload-plugins-command.ts diff --git a/packages/cli/src/config/hot-reload.test.ts b/packages/cli/src/config/hot-reload.test.ts index bc388bc8132..7267e4307f2 100644 --- a/packages/cli/src/config/hot-reload.test.ts +++ b/packages/cli/src/config/hot-reload.test.ts @@ -16,7 +16,11 @@ import { import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; -import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core'; +import type { + Config, + Extension, + MCPServerConfig, +} from '@qwen-code/qwen-code-core'; import type { LoadedSettings, Settings } from './settings.js'; import type { SettingsWatcher, @@ -26,6 +30,7 @@ import { registerMcpHotReload, mcpServersEqual, mcpGatingEqual, + reloadPluginsRuntime, } from './hot-reload.js'; import { loadMcpApprovals, @@ -99,6 +104,108 @@ describe('mcpGatingEqual', () => { }); }); +describe('reloadPluginsRuntime', () => { + it('refreshes extension runtime and reloads slash commands', async () => { + const refreshCache = vi.fn(async () => {}); + const refreshTools = vi.fn(async () => {}); + const reinitializeLsp = vi.fn(async () => {}); + const reloadCommands = vi.fn(async () => {}); + const activeExtensions = [ + { + name: 'alpha', + commands: ['alpha:run', 'alpha:check'], + skills: [{ name: 'skill-a' }], + hooks: { PreToolUse: [{ command: 'echo ok' }] }, + mcpServers: { alpha: { command: 'node' } }, + path: '/extensions/alpha', + config: { + name: 'alpha', + version: '1.0.0', + lspServers: { + typescript: { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, + }, + }, + { + name: 'beta', + commands: ['beta:run'], + skills: [{ name: 'skill-b' }, { name: 'skill-c' }], + hooks: { PostToolUse: [{ command: 'echo done' }] }, + mcpServers: { + beta: { command: 'node' }, + gamma: { command: 'node' }, + }, + path: '/extensions/beta', + config: { name: 'beta', version: '1.0.0' }, + }, + ] as unknown as Extension[]; + const config = { + getExtensionManager: () => ({ + refreshCache, + refreshTools, + }), + getActiveExtensions: () => activeExtensions, + getTargetDir: () => '/workspace', + reinitializeLsp, + } as unknown as Config; + + const summary = await reloadPluginsRuntime({ config, reloadCommands }); + + expect(refreshCache).toHaveBeenCalledOnce(); + expect(refreshTools).toHaveBeenCalledOnce(); + expect(reinitializeLsp).toHaveBeenCalledOnce(); + expect(reloadCommands).toHaveBeenCalledOnce(); + expect(refreshCache.mock.invocationCallOrder[0]).toBeLessThan( + refreshTools.mock.invocationCallOrder[0], + ); + expect(refreshTools.mock.invocationCallOrder[0]).toBeLessThan( + reinitializeLsp.mock.invocationCallOrder[0], + ); + expect(reinitializeLsp.mock.invocationCallOrder[0]).toBeLessThan( + reloadCommands.mock.invocationCallOrder[0], + ); + expect(summary).toEqual({ + extensionCount: 2, + commandCount: 3, + skillCount: 3, + hookCount: 2, + mcpServerCount: 3, + lspServerCount: 1, + }); + }); + + it('does not require an LSP reload hook', async () => { + const refreshCache = vi.fn(async () => {}); + const refreshTools = vi.fn(async () => {}); + const reloadCommands = vi.fn(async () => {}); + const config = { + getExtensionManager: () => ({ + refreshCache, + refreshTools, + }), + getActiveExtensions: () => [], + getTargetDir: () => '/workspace', + } as unknown as Config; + + const summary = await reloadPluginsRuntime({ config, reloadCommands }); + + expect(refreshCache).toHaveBeenCalledOnce(); + expect(refreshTools).toHaveBeenCalledOnce(); + expect(reloadCommands).toHaveBeenCalledOnce(); + expect(summary).toEqual({ + extensionCount: 0, + commandCount: 0, + skillCount: 0, + hookCount: 0, + mcpServerCount: 0, + lspServerCount: 0, + }); + }); +}); + // ── Subscriber gate branches ────────────────────────────────────────── interface FakeConfigState { diff --git a/packages/cli/src/config/hot-reload.ts b/packages/cli/src/config/hot-reload.ts index 8b17aec3400..e7530e42f30 100644 --- a/packages/cli/src/config/hot-reload.ts +++ b/packages/cli/src/config/hot-reload.ts @@ -9,6 +9,7 @@ import { createDebugLogger, type Config, getMCPServerStatus, + LspConfigLoader, type MCPServerConfig, } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from './settings.js'; @@ -22,6 +23,91 @@ import { appEvents, AppEvent } from '../utils/events.js'; const debugLogger = createDebugLogger('MCP_HOT_RELOAD'); +type ConfigWithOptionalLspReload = Config & { + reinitializeLsp?: () => void | Promise; +}; + +/** + * Clear the in-memory caches that serve model-visible skills and agents. + * Called immediately after every extension mutation so the next model turn + * sees the updated active-extension set without waiting for /reload-plugins. + * This is the qwen-code equivalent of Claude Code's clearAllCaches() — it + * clears memoization so downstream consumers re-read from getActiveExtensions(), + * but does NOT restart MCP servers or reload slash commands. + */ +export async function clearPluginCaches(config: Config): Promise { + // Guard against null/incomplete config (e.g. in tests or non-interactive + // contexts where skill/subagent managers were never initialised). + if (!config?.getSkillManager && !config?.getSubagentManager) return; + const settled = await Promise.allSettled([ + config.getSkillManager()?.refreshCache(), + config.getSubagentManager()?.refreshCache(), + ]); + for (const result of settled) { + if (result.status === 'rejected') { + debugLogger.warn( + 'clearPluginCaches: a refreshCache leg failed:', + result.reason, + ); + } + } +} + +export interface ReloadPluginsRuntimeOptions { + config: Config; + reloadCommands?: () => void | Promise; +} + +export interface ReloadPluginsSummary { + extensionCount: number; + commandCount: number; + skillCount: number; + hookCount: number; + mcpServerCount: number; + lspServerCount: number; +} + +export async function reloadPluginsRuntime({ + config, + reloadCommands, +}: ReloadPluginsRuntimeOptions): Promise { + const extensionManager = config.getExtensionManager(); + await extensionManager.refreshCache(); + const activeExtensions = config.getActiveExtensions(); + const lspConfigs = await new LspConfigLoader( + config.getTargetDir(), + ).loadExtensionConfigs(activeExtensions); + await extensionManager.refreshTools(); + await (config as ConfigWithOptionalLspReload).reinitializeLsp?.(); + await reloadCommands?.(); + + return { + extensionCount: activeExtensions.length, + commandCount: activeExtensions.reduce( + (sum, extension) => sum + (extension.commands?.length ?? 0), + 0, + ), + skillCount: activeExtensions.reduce( + (sum, extension) => sum + (extension.skills?.length ?? 0), + 0, + ), + hookCount: activeExtensions.reduce( + (sum, extension) => + sum + + Object.values(extension.hooks ?? {}).reduce( + (hookSum, hooks) => hookSum + hooks.length, + 0, + ), + 0, + ), + mcpServerCount: activeExtensions.reduce( + (sum, extension) => sum + Object.keys(extension.mcpServers ?? {}).length, + 0, + ), + lspServerCount: lspConfigs.length, + }; +} + /** * The three connection-admission lists discovery consults to decide whether a * given MCP server may connect. Distinct from the `mcpServers` config map: diff --git a/packages/cli/src/config/plugin-refresh-state.test.ts b/packages/cli/src/config/plugin-refresh-state.test.ts new file mode 100644 index 00000000000..95226e42afd --- /dev/null +++ b/packages/cli/src/config/plugin-refresh-state.test.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearPluginsChanged, + markPluginsChanged, + needsPluginRefresh, + resetPluginRefreshStateForTesting, +} from './plugin-refresh-state.js'; +import { appEvents, AppEvent } from '../utils/events.js'; + +describe('plugin refresh state', () => { + beforeEach(() => { + resetPluginRefreshStateForTesting(); + }); + + it('deduplicates plugin refresh notifications until cleared', () => { + const listener = vi.fn(); + appEvents.on(AppEvent.PluginRefreshNeeded, listener); + + try { + expect(markPluginsChanged('extension installed')).toBe(true); + expect(needsPluginRefresh()).toBe(true); + expect(listener).toHaveBeenCalledWith('extension installed'); + + expect(markPluginsChanged('extension updated')).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + clearPluginsChanged(); + expect(needsPluginRefresh()).toBe(false); + + expect(markPluginsChanged('extension updated')).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith('extension updated'); + } finally { + appEvents.off(AppEvent.PluginRefreshNeeded, listener); + } + }); +}); diff --git a/packages/cli/src/config/plugin-refresh-state.ts b/packages/cli/src/config/plugin-refresh-state.ts new file mode 100644 index 00000000000..99ff510c62c --- /dev/null +++ b/packages/cli/src/config/plugin-refresh-state.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { appEvents, AppEvent } from '../utils/events.js'; + +let pluginRefreshNeeded = false; + +export function markPluginsChanged(reason?: string): boolean { + if (pluginRefreshNeeded) { + return false; + } + pluginRefreshNeeded = true; + appEvents.emit(AppEvent.PluginRefreshNeeded, reason); + return true; +} + +export function clearPluginsChanged(): void { + pluginRefreshNeeded = false; +} + +export function needsPluginRefresh(): boolean { + return pluginRefreshNeeded; +} + +export function resetPluginRefreshStateForTesting(): void { + pluginRefreshNeeded = false; +} diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c5c2d6a7751..2f9df3adaa3 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -116,6 +116,10 @@ export default { 'Open homepage': 'Open homepage', 'Project (Workspace)': 'Project (Workspace)', 'Refreshed {{count}} extension(s).': 'Refreshed {{count}} extension(s).', + 'Reload extension runtime changes.': 'Reload extension runtime changes.', + 'Reload failed.': 'Reload failed.', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reloaded: {{summary}}': 'Reloaded: {{summary}}', 'Remove from Favorites': 'Remove from Favorites', 'Remove marketplace': 'Remove marketplace', 'Remove marketplace "{{name}}"?': 'Remove marketplace "{{name}}"?', @@ -957,6 +961,8 @@ export default { 'No plugins available in this marketplace.', 'Select a plugin to install from marketplace "{{name}}":': 'Select a plugin to install from marketplace "{{name}}":', + 'Plugin changes detected. Run `/reload-plugins` to apply them.': + 'Plugin changes detected. Run `/reload-plugins` to apply them.', 'Plugin selection cancelled.': 'Plugin selection cancelled.', 'Select a plugin from "{{name}}"': 'Select a plugin from "{{name}}"', 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 9e283642598..f3031e61e93 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -111,6 +111,10 @@ export default { 'Open homepage': '開啟主頁', 'Project (Workspace)': '專案(工作區)', 'Refreshed {{count}} extension(s).': '已刷新 {{count}} 個擴充。', + 'Reload extension runtime changes.': '重新載入擴展執行時變更。', + 'Reload failed.': '重新載入失敗。', + 'Reload failed: {{message}}': '重新載入失敗:{{message}}', + 'Reloaded: {{summary}}': '已重新載入:{{summary}}', 'Remove from Favorites': '從收藏中移除', 'Remove marketplace': '移除市場來源', 'Remove marketplace "{{name}}"?': '移除市場來源 "{{name}}"?', @@ -887,6 +891,8 @@ export default { 'No plugins available in this marketplace.': '此市場中沒有可用的插件。', 'Select a plugin to install from marketplace "{{name}}":': '從市場 "{{name}}" 中選擇要安裝的插件:', + 'Plugin changes detected. Run `/reload-plugins` to apply them.': + '偵測到插件變更。執行 `/reload-plugins` 以套用它們。', 'Plugin selection cancelled.': '插件選擇已取消。', 'Select a plugin from "{{name}}"': '從 "{{name}}" 中選擇插件', 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 9e0200576f0..cd8defd9dac 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -109,6 +109,10 @@ export default { 'Open homepage': '打开主页', 'Project (Workspace)': '项目(工作区)', 'Refreshed {{count}} extension(s).': '已刷新 {{count}} 个扩展。', + 'Reload extension runtime changes.': '重新加载扩展运行时变更。', + 'Reload failed.': '重新加载失败。', + 'Reload failed: {{message}}': '重新加载失败:{{message}}', + 'Reloaded: {{summary}}': '已重新加载:{{summary}}', 'Remove from Favorites': '从收藏中移除', 'Remove marketplace': '移除市场源', 'Remove marketplace "{{name}}"?': '移除市场源 "{{name}}"?', @@ -934,6 +938,8 @@ export default { 'No plugins available in this marketplace.': '此市场中没有可用的插件。', 'Select a plugin to install from marketplace "{{name}}":': '从市场 "{{name}}" 中选择要安装的插件:', + 'Plugin changes detected. Run `/reload-plugins` to apply them.': + '检测到插件变更。执行 `/reload-plugins` 以应用它们。', 'Plugin selection cancelled.': '插件选择已取消。', 'Select a plugin from "{{name}}"': '从 "{{name}}" 中选择插件', 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index fa8a118dfbc..83cfcc1a33a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -65,6 +65,17 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => { }; }); +vi.mock('../ui/commands/reload-plugins-command.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + reloadPluginsCommand: { + name: 'reload-plugins', + description: 'Reload plugins command', + kind: CommandKind.BUILT_IN, + }, + }; +}); + vi.mock('../ui/commands/hooksCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { @@ -201,6 +212,9 @@ describe('BuiltinCommandLoader', () => { const modelCmd = commands.find((c) => c.name === 'model'); expect(modelCmd).toBeDefined(); + + const reloadPluginsCmd = commands.find((c) => c.name === 'reload-plugins'); + expect(reloadPluginsCmd).toBeDefined(); }); it('should include trust command when folder trust is enabled', async () => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index a66d71e0abe..b09343c96bc 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -54,6 +54,7 @@ import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { recapCommand } from '../ui/commands/recapCommand.js'; import { renameCommand } from '../ui/commands/renameCommand.js'; +import { reloadPluginsCommand } from '../ui/commands/reload-plugins-command.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; @@ -154,6 +155,7 @@ export class BuiltinCommandLoader implements ICommandLoader { quitCommand, recapCommand, renameCommand, + reloadPluginsCommand, restoreCommand(this.config), resumeCommand, rewindCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 93eb067b61b..c8a2c410aea 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -87,7 +87,7 @@ import { profileCheckpoint, finalizeStartupProfile, } from '../utils/startupProfiler.js'; -import { appEvents } from '../utils/events.js'; +import { appEvents, AppEvent } from '../utils/events.js'; import process from 'node:process'; /** @@ -452,6 +452,7 @@ export const AppContainer = (props: AppContainerProps) => { extensionManager, historyManager.addItem, config.getWorkingDir(), + config, ); const { providerUpdateRequest, dismissProviderUpdate } = useProviderUpdates( @@ -889,6 +890,25 @@ export const AppContainer = (props: AppContainerProps) => { return () => handler?.cleanup(); }, [historyManager.addItem]); + useEffect(() => { + const addItem = historyManager.addItem; + const onPluginRefreshNeeded = () => { + addItem( + { + type: MessageType.INFO, + text: t( + 'Plugin changes detected. Run `/reload-plugins` to apply them.', + ), + }, + Date.now(), + ); + }; + appEvents.on(AppEvent.PluginRefreshNeeded, onPluginRefreshNeeded); + return () => { + appEvents.off(AppEvent.PluginRefreshNeeded, onPluginRefreshNeeded); + }; + }, [historyManager.addItem]); + // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 7d66743047c..cbb9ff188e9 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -8,6 +8,10 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import { MessageType } from '../types.js'; import { extensionsCommand } from './extensionsCommand.js'; import { type CommandContext } from './types.js'; +import { + needsPluginRefresh, + resetPluginRefreshStateForTesting, +} from '../../config/plugin-refresh-state.js'; import { describe, it, @@ -49,6 +53,7 @@ describe('extensionsCommand', () => { beforeEach(() => { vi.resetAllMocks(); + resetPluginRefreshStateForTesting(); mockOpenBrowserSecurely.mockResolvedValue(undefined); mockExtensionManager = createMockExtensionManager(); mockGetExtensions.mockReturnValue([]); @@ -236,7 +241,8 @@ describe('extensionsCommand', () => { }, expect.any(Number), ); - expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + expect(mockContext.ui.reloadCommands).not.toHaveBeenCalled(); + expect(needsPluginRefresh()).toBe(true); }); it('should redact URL credentials in install progress messages', async () => { diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index a10a6a999c5..b7e5d393f79 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -5,6 +5,8 @@ */ import { getErrorMessage } from '../../utils/errors.js'; +import { clearPluginCaches } from '../../config/hot-reload.js'; +import { markPluginsChanged } from '../../config/plugin-refresh-state.js'; import { MessageType } from '../types.js'; import { type CommandContext, @@ -231,7 +233,14 @@ async function installAction(context: CommandContext, args: string) { }, Date.now(), ); - const extension = await extensionManager.installExtension(installMetadata); + const extension = await extensionManager.installExtension( + installMetadata, + undefined, + undefined, + undefined, + undefined, + { refreshTools: false }, + ); context.ui.addItem( { type: MessageType.INFO, @@ -241,8 +250,10 @@ async function installAction(context: CommandContext, args: string) { }, Date.now(), ); - // FIXME: refresh command controlled by ui for now, cannot be auto refreshed by extensionManager - context.ui.reloadCommands(); + if (context.services.config) { + await clearPluginCaches(context.services.config); + } + markPluginsChanged('extension installed'); } catch (error) { context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/reload-plugins-command.test.ts b/packages/cli/src/ui/commands/reload-plugins-command.test.ts new file mode 100644 index 00000000000..40ad35330c4 --- /dev/null +++ b/packages/cli/src/ui/commands/reload-plugins-command.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { reloadPluginsCommand } from './reload-plugins-command.js'; +import type { CommandContext } from './types.js'; +import { reloadPluginsRuntime } from '../../config/hot-reload.js'; +import { clearPluginsChanged } from '../../config/plugin-refresh-state.js'; + +vi.mock('../../config/hot-reload.js', () => ({ + reloadPluginsRuntime: vi.fn(async () => ({ + extensionCount: 1, + commandCount: 2, + skillCount: 3, + hookCount: 4, + mcpServerCount: 5, + lspServerCount: 6, + })), +})); + +vi.mock('../../config/plugin-refresh-state.js', () => ({ + clearPluginsChanged: vi.fn(), +})); + +const reloadPluginsRuntimeMock = vi.mocked(reloadPluginsRuntime); +const clearPluginsChangedMock = vi.mocked(clearPluginsChanged); + +describe('reloadPluginsCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an error when config is missing', async () => { + const context = { + services: { config: null }, + ui: { reloadCommands: vi.fn() }, + } as unknown as CommandContext; + + const result = await reloadPluginsCommand.action?.(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + expect(reloadPluginsRuntimeMock).not.toHaveBeenCalled(); + expect(clearPluginsChangedMock).not.toHaveBeenCalled(); + }); + + it('reloads plugin runtime', async () => { + const config = {} as Config; + const reloadCommands = vi.fn(); + const context = { + services: { config }, + ui: { reloadCommands }, + } as unknown as CommandContext; + + const result = await reloadPluginsCommand.action?.(context, ''); + + expect(reloadPluginsRuntimeMock).toHaveBeenCalledWith({ + config, + reloadCommands, + }); + expect(clearPluginsChangedMock).toHaveBeenCalledOnce(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Reloaded: 1 plugin · 2 commands · 3 skills · 4 hooks · 5 plugin MCP servers · 6 plugin LSP servers', + }); + }); + + it('surfaces reload failures without clearing the refresh flag', async () => { + // A failed reload must not clear the pending-refresh flag — the runtime is + // still stale, so the user should be able to retry /reload-plugins. + reloadPluginsRuntimeMock.mockRejectedValueOnce(new Error('boom')); + + const config = {} as Config; + const reloadCommands = vi.fn(); + const context = { + services: { config }, + ui: { reloadCommands }, + } as unknown as CommandContext; + + const result = await reloadPluginsCommand.action?.(context, ''); + + expect(reloadPluginsRuntimeMock).toHaveBeenCalledWith({ + config, + reloadCommands, + }); + expect(clearPluginsChangedMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Reload failed: boom', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/reload-plugins-command.ts b/packages/cli/src/ui/commands/reload-plugins-command.ts new file mode 100644 index 00000000000..d0400e825bd --- /dev/null +++ b/packages/cli/src/ui/commands/reload-plugins-command.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + reloadPluginsRuntime, + type ReloadPluginsSummary, +} from '../../config/hot-reload.js'; +import { clearPluginsChanged } from '../../config/plugin-refresh-state.js'; +import { t } from '../../i18n/index.js'; +import { + CommandKind, + type CommandContext, + type MessageActionReturn, + type SlashCommand, +} from './types.js'; + +function countLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function formatReloadPluginsSummary(summary: ReloadPluginsSummary) { + return [ + countLabel(summary.extensionCount, 'plugin'), + countLabel(summary.commandCount, 'command'), + countLabel(summary.skillCount, 'skill'), + countLabel(summary.hookCount, 'hook'), + countLabel(summary.mcpServerCount, 'plugin MCP server'), + countLabel(summary.lspServerCount, 'plugin LSP server'), + ].join(' · '); +} + +export const reloadPluginsCommand: SlashCommand = { + name: 'reload-plugins', + get description() { + return t('Reload extension runtime changes.'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + action: async (context: CommandContext): Promise => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + try { + const summary = await reloadPluginsRuntime({ + config, + reloadCommands: context.ui.reloadCommands, + }); + // Only clear the pending-refresh flag on a successful reload — a failed + // reload leaves the runtime stale, so the user should be able to retry + // /reload-plugins (and still see the "changes detected" notice if they + // haven't). + clearPluginsChanged(); + + return { + type: 'message', + messageType: 'info', + content: t('Reloaded: {{summary}}', { + summary: formatReloadPluginsSummary(summary), + }), + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: + error instanceof Error + ? t('Reload failed: {{message}}', { message: error.message }) + : t('Reload failed.'), + }; + } + }, +}; diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 97174aa4a9e..4d29cb2b70e 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -16,6 +16,10 @@ import { KeypressProvider } from '../../contexts/KeypressContext.js'; import { SettingsContext } from '../../contexts/SettingsContext.js'; import { ShellFocusContext } from '../../contexts/ShellFocusContext.js'; import { LoadedSettings } from '../../../config/settings.js'; +import { + needsPluginRefresh, + resetPluginRefreshStateForTesting, +} from '../../../config/plugin-refresh-state.js'; import type { UIState } from '../../contexts/UIStateContext.js'; import type { Config, @@ -98,6 +102,8 @@ const createConfig = ( ({ getExtensionManager: () => manager, getMcpServers: () => overrides.mcpServers ?? {}, + getSkillManager: () => undefined, + getSubagentManager: () => undefined, getToolRegistry: () => undefined, getPromptRegistry: () => undefined, getResourceRegistry: () => undefined, @@ -203,6 +209,7 @@ const renderWide = (config: Config, columns: number) => { describe('ExtensionsManagerDialog (tabbed)', () => { beforeEach(() => { vi.clearAllMocks(); + resetPluginRefreshStateForTesting(); }); it('renders the tab bar with all three tabs', () => { @@ -423,6 +430,30 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Extension v1.0.0'); }); + it('marks plugins stale when toggling an installed plugin', async () => { + const manager = createManager({ + extensions: [mockExtension('alpha', true)], + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + stdin.write(' '); + + await waitFor(() => { + expect(manager.disableExtension).toHaveBeenCalledWith( + 'alpha', + expect.any(String), + undefined, + { refreshTools: false }, + ); + }); + expect(needsPluginRefresh()).toBe(true); + }); + it('nests extension-bundled MCP servers under their extension on the Installed tab', async () => { const ext = mockExtension('alpha', true); (ext as unknown as { mcpServers: Record }).mcpServers = { diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 779fbbcf04d..b427208a998 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -22,6 +22,8 @@ import { createDebugLogger, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; +import { clearPluginCaches } from '../../../../config/hot-reload.js'; +import { markPluginsChanged } from '../../../../config/plugin-refresh-state.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; const debugLogger = createDebugLogger('DISCOVER_TAB'); @@ -219,7 +221,14 @@ export const DiscoverTab = ({ let ext; try { const metadata = await parseInstallSource(plugin.installSource); - ext = await extensionManager.installExtension(metadata); + ext = await extensionManager.installExtension( + metadata, + undefined, + undefined, + undefined, + undefined, + { refreshTools: false }, + ); } catch (error) { errors.push( `${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`, @@ -238,11 +247,15 @@ export const DiscoverTab = ({ await extensionManager.disableExtension( ext.name, SettingScope.User, + undefined, + { refreshTools: false }, ); try { await extensionManager.enableExtension( ext.name, SettingScope.Workspace, + undefined, + { refreshTools: false }, ); } catch (enableError) { // The User-scope disable already landed; roll it back so a failed @@ -253,6 +266,8 @@ export const DiscoverTab = ({ await extensionManager.enableExtension( ext.name, SettingScope.User, + undefined, + { refreshTools: false }, ); } catch (rollbackError) { // Rollback failed: the extension is now disabled at every scope. @@ -286,6 +301,15 @@ export const DiscoverTab = ({ } setInstalling(false); setSelectedKeys(new Set()); + if (installed > 0) { + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins; + // don't suppress the install-success UI for a cache error. + } + markPluginsChanged('extension installed'); + } if (errors.length === 0) { onStatus({ type: 'success', @@ -320,7 +344,7 @@ export const DiscoverTab = ({ goToList(); } }, - [extensionManager, onStatus, load, onInstalled, goToList, onLockChange], + [extensionManager, config, onStatus, load, onInstalled, goToList, onLockChange], ); const installWithScope = useCallback( diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 4a4db387859..ec18a883e47 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -33,6 +33,8 @@ import { SettingScope as CliSettingScope, } from '../../../../config/settings.js'; import { getErrorMessage } from '../../../../utils/errors.js'; +import { clearPluginCaches } from '../../../../config/hot-reload.js'; +import { markPluginsChanged } from '../../../../config/plugin-refresh-state.js'; import type { InstalledItem, InstalledGroup, @@ -462,10 +464,21 @@ export const InstalledTab = ({ }); try { if (item.isActive) { - await extensionManager.disableExtension(item.name, scope); + await extensionManager.disableExtension(item.name, scope, undefined, { + refreshTools: false, + }); } else { - await extensionManager.enableExtension(item.name, scope); + await extensionManager.enableExtension(item.name, scope, undefined, { + refreshTools: false, + }); } + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins; + // don't convert a successful toggle into a user-facing error. + } + markPluginsChanged('extension enablement changed'); onStatus({ type: 'success', text: t('"{{name}}" {{state}}.', { @@ -480,7 +493,7 @@ export const InstalledTab = ({ mutatingRef.current = false; } }, - [extensionManager, load, onStatus], + [extensionManager, config, load, onStatus], ); const toggleMcp = useCallback( diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index 88da343fd0b..cb1db3802c1 100644 --- a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -22,6 +22,8 @@ import { createDebugLogger, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; +import { clearPluginCaches } from '../../../../config/hot-reload.js'; +import { markPluginsChanged } from '../../../../config/plugin-refresh-state.js'; import { stripUnsafeCharacters } from '../../../utils/textUtils.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; @@ -202,11 +204,20 @@ export const SourcesTab = ({ setBusy(true); try { const metadata = await parseInstallSource(input.trim()); - const ext = await extensionManager.installExtension(metadata); + const ext = await extensionManager.installExtension( + metadata, + undefined, + undefined, + undefined, + undefined, + { refreshTools: false }, + ); onStatus({ type: 'success', text: t('Installed extension "{{name}}".', { name: ext.name }), }); + await clearPluginCaches(config); + markPluginsChanged('extension installed'); await load(); onChanged(); goToList(); @@ -218,7 +229,7 @@ export const SourcesTab = ({ } finally { setBusy(false); } - }, [extensionManager, input, onStatus, load, onChanged, goToList]); + }, [extensionManager, config, input, onStatus, load, onChanged, goToList]); const openSourceDetail = useCallback( async (source: ExtensionSource) => { diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index ce00eff15db..7acb4244b86 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -18,6 +18,8 @@ import { checkForExtensionUpdate, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; +import { markPluginsChanged } from '../../../../config/plugin-refresh-state.js'; +import { clearPluginCaches } from '../../../../config/hot-reload.js'; import { ExtensionUpdateState } from '../../../state/extensions.js'; import { PluginDetailView, @@ -114,11 +116,23 @@ export const ExtensionActionsView = ({ switch (action) { case 'toggle': if (enabled) { - await manager.disableExtension(name, settingScopeFor(scope)); + await manager.disableExtension( + name, + settingScopeFor(scope), + undefined, + { refreshTools: false }, + ); } else { - await manager.enableExtension(name, settingScopeFor(scope)); + await manager.enableExtension( + name, + settingScopeFor(scope), + undefined, + { refreshTools: false }, + ); } setEnabled(!enabled); + await clearPluginCaches(config); + markPluginsChanged('extension enablement changed'); onStatus({ type: 'success', text: t('"{{name}}" {{state}}.', { @@ -194,7 +208,10 @@ export const ExtensionActionsView = ({ extension, ExtensionUpdateState.UPDATE_AVAILABLE, () => {}, + false, ); + await clearPluginCaches(config); + markPluginsChanged('extension updated'); onStatus({ type: 'success', text: t('Updated "{{name}}".', { name }), @@ -211,7 +228,7 @@ export const ExtensionActionsView = ({ onStatus({ type: 'error', text: getErrorMessage(error) }); } }, - [manager, extension, enabled, scope, onStatus, onReload], + [manager, config, extension, enabled, scope, onStatus, onReload], ); const handleScope = useCallback( @@ -225,17 +242,31 @@ export const ExtensionActionsView = ({ // failed enable can't leave the prefs pointing at a scope the extension // isn't actually enabled at. if (newScope === 'user') { - await manager.enableExtension(name, SettingScope.User); + await manager.enableExtension(name, SettingScope.User, undefined, { + refreshTools: false, + }); } else { - await manager.disableExtension(name, SettingScope.User); + await manager.disableExtension(name, SettingScope.User, undefined, { + refreshTools: false, + }); try { - await manager.enableExtension(name, SettingScope.Workspace); + await manager.enableExtension( + name, + SettingScope.Workspace, + undefined, + { refreshTools: false }, + ); } catch (enableError) { // The User-scope disable already landed; if the Workspace enable // fails the extension would be disabled everywhere. Roll the User // enable back so it isn't silently dead. try { - await manager.enableExtension(name, SettingScope.User); + await manager.enableExtension( + name, + SettingScope.User, + undefined, + { refreshTools: false }, + ); } catch (rollbackError) { // Rollback also failed: the extension is now disabled at every // scope. Surface that explicitly — the bare enable error wouldn't @@ -253,6 +284,8 @@ export const ExtensionActionsView = ({ manager.setExtensionScope(name, newScope); setScope(newScope); setEnabled(true); + await clearPluginCaches(config); + markPluginsChanged('extension scope changed'); onStatus({ type: 'success', text: t('Set "{{name}}" scope to {{scope}}.', { @@ -267,7 +300,7 @@ export const ExtensionActionsView = ({ setScopeBusy(false); setSub('detail'); }, - [manager, extension, onStatus, onReload], + [manager, config, extension, onStatus, onReload], ); const handleUninstall = useCallback( @@ -275,7 +308,11 @@ export const ExtensionActionsView = ({ if (!manager) return; setUninstallBusy(true); try { - await manager.uninstallExtension(ext.name, false); + await manager.uninstallExtension(ext.name, false, undefined, { + refreshTools: false, + }); + await clearPluginCaches(config); + markPluginsChanged('extension uninstalled'); onStatus({ type: 'success', text: t('Uninstalled "{{name}}".', { name: ext.name }), @@ -288,7 +325,7 @@ export const ExtensionActionsView = ({ } onExit(); }, - [manager, onStatus, onReload, onExit], + [manager, config, onStatus, onReload, onExit], ); // Escape: from the detail leaves; from a sub-view returns to the detail. diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index bc0906aa334..6bb2f152440 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -8,6 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { resetPluginRefreshStateForTesting } from '../../config/plugin-refresh-state.js'; import { useExtensionUpdates, @@ -277,6 +278,7 @@ describe('useExtensionUpdates', () => { let userExtensionsDir: string; beforeEach(() => { + resetPluginRefreshStateForTesting(); tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-cli-test-home-')); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions'); diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.ts index 11db90a0b23..a78c3580d41 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.ts @@ -5,11 +5,14 @@ */ import { + type Config, type ExtensionManager, getExtensionDisplayName, } from '@qwen-code/qwen-code-core'; import { getCurrentLanguage } from '../../i18n/index.js'; import { getErrorMessage } from '../../utils/errors.js'; +import { clearPluginCaches } from '../../config/hot-reload.js'; +import { markPluginsChanged } from '../../config/plugin-refresh-state.js'; import { ExtensionUpdateState, extensionUpdatesReducer, @@ -218,6 +221,7 @@ export const useExtensionUpdates = ( extensionManager: ExtensionManager, addItem: UseHistoryManagerReturn['addItem'], cwd: string, + config?: Config, ) => { const [extensionsUpdateState, dispatchExtensionStateUpdate] = useReducer( extensionUpdatesReducer, @@ -250,6 +254,7 @@ export const useExtensionUpdates = ( }, [ extensions, extensionManager, + config, extensionsUpdateState.extensionStatuses, dispatchExtensionStateUpdate, ]); @@ -289,9 +294,19 @@ export const useExtensionUpdates = ( payload: { name: extensionName, state }, }); }, + false, ) - .then((result) => { + .then(async (result) => { if (!result) return; + if (config) { + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins; + // don't convert a successful update into a user-facing error. + } + } + markPluginsChanged('extension updated'); addItem( { type: MessageType.INFO, @@ -323,7 +338,14 @@ export const useExtensionUpdates = ( Date.now(), ); } - }, [extensions, extensionManager, extensionsUpdateState, addItem, cwd]); + }, [ + extensions, + extensionManager, + config, + extensionsUpdateState, + addItem, + cwd, + ]); const extensionsUpdateStateComputed = useMemo(() => { const result = new Map(); diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index 5a6275f6a81..cc5e1b3950b 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -9,6 +9,7 @@ import { EventEmitter } from 'node:events'; export enum AppEvent { OpenDebugConsole = 'open-debug-console', LogError = 'log-error', + PluginRefreshNeeded = 'plugin-refresh-needed', OauthDisplayMessage = 'oauth-display-message', OauthAuthUrl = 'oauth-auth-url', /** diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 7979b56683b..96c4e9a66ef 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -23,7 +23,11 @@ import { hashValue, type ExtensionConfig, } from './extensionManager.js'; -import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; +import type { + Config, + MCPServerConfig, + ExtensionInstallMetadata, +} from '../index.js'; const mockGit = { clone: vi.fn(), @@ -787,6 +791,78 @@ describe('extension tests', () => { await manager.enableExtension('ext1', SettingScope.Workspace); expect(manager.isEnabled('ext1', tempWorkspaceDir)).toBe(true); }); + + it('should skip tool refresh when requested', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + const mockRestartMcpServers = vi.fn(); + const mockRefreshCache = vi.fn(); + const mockRefreshHierarchicalMemory = vi.fn(); + const manager = createExtensionManager(); + manager.setConfig({ + getToolRegistry: () => ({ + restartMcpServers: mockRestartMcpServers, + }), + getSkillManager: () => ({ + refreshCache: mockRefreshCache, + }), + getSubagentManager: () => ({ + refreshCache: mockRefreshCache, + }), + refreshHierarchicalMemory: mockRefreshHierarchicalMemory, + } as unknown as Config); + await manager.refreshCache(); + + await manager.disableExtension('ext1', SettingScope.User, undefined, { + refreshTools: false, + }); + expect(manager.isEnabled('ext1')).toBe(false); + expect( + manager.getLoadedExtensions().find((ext) => ext.name === 'ext1') + ?.isActive, + ).toBe(false); + + await manager.enableExtension('ext1', SettingScope.User, undefined, { + refreshTools: false, + }); + + expect(mockRestartMcpServers).not.toHaveBeenCalled(); + expect(mockRefreshCache).not.toHaveBeenCalled(); + expect(mockRefreshHierarchicalMemory).not.toHaveBeenCalled(); + expect(manager.isEnabled('ext1')).toBe(true); + expect( + manager.getLoadedExtensions().find((ext) => ext.name === 'ext1') + ?.isActive, + ).toBe(true); + }); + + it('should refresh tools by default when disabling (no options)', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + const mockRestartMcpServers = vi.fn(); + const manager = createExtensionManager(); + manager.setConfig({ + getToolRegistry: () => ({ restartMcpServers: mockRestartMcpServers }), + getSkillManager: () => ({ refreshCache: vi.fn() }), + getSubagentManager: () => ({ refreshCache: vi.fn() }), + } as unknown as Config); + await manager.refreshCache(); + + // Disable without passing options — refreshTools should still run. + await manager.disableExtension('ext2', SettingScope.User); + + expect(manager.isEnabled('ext2')).toBe(false); + // Default refreshTools: true means restartMcpServers IS called (unlike + // the explicit { refreshTools: false } test above that asserts the + // opposite). + expect(mockRestartMcpServers).toHaveBeenCalled(); + }); }); describe('validateExtensionOverrides', () => { diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index dbcc86d7cc2..d3eb8a763ee 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -112,6 +112,10 @@ export enum SettingScope { SystemDefaults = 'SystemDefaults', } +interface ExtensionRuntimeRefreshOptions { + refreshTools?: boolean; +} + export interface ExtensionChannelConfig { /** Relative path to JS entry point (must export `plugin: ChannelPlugin`) */ entry: string; @@ -435,6 +439,7 @@ export class ExtensionManager { name: string, scope: SettingScope, cwd?: string, + options?: ExtensionRuntimeRefreshOptions, ): Promise { const currentDir = cwd ?? this.workspaceDir; if ( @@ -455,7 +460,9 @@ export class ExtensionManager { const config = getTelemetryConfig(currentDir, this.telemetrySettings); logExtensionEnable(config, new ExtensionEnableEvent(name, scope)); extension.isActive = true; - await this.refreshTools(); + if (options?.refreshTools ?? true) { + await this.refreshTools(); + } } /** @@ -465,6 +472,7 @@ export class ExtensionManager { name: string, scope: SettingScope, cwd?: string, + options?: ExtensionRuntimeRefreshOptions, ): Promise { const currentDir = cwd ?? this.workspaceDir; const config = getTelemetryConfig(currentDir, this.telemetrySettings); @@ -485,7 +493,9 @@ export class ExtensionManager { this.disableByPath(name, true, scopePath); logExtensionDisable(config, new ExtensionDisableEvent(name, scope)); extension.isActive = false; - await this.refreshTools(); + if (options?.refreshTools ?? true) { + await this.refreshTools(); + } } /** @@ -1018,6 +1028,7 @@ export class ExtensionManager { requestSetting?: (setting: ExtensionSetting) => Promise, cwd?: string, previousExtensionConfig?: ExtensionConfig, + options?: ExtensionRuntimeRefreshOptions, ): Promise { const currentDir = cwd ?? this.workspaceDir; const telemetryConfig = getTelemetryConfig( @@ -1287,7 +1298,9 @@ export class ExtensionManager { 'success', ), ); - await this.refreshTools(); + if (options?.refreshTools ?? true) { + await this.refreshTools(); + } } else { logExtensionInstallEvent( telemetryConfig, @@ -1301,6 +1314,8 @@ export class ExtensionManager { await this.enableExtension( newExtensionConfig.name, SettingScope.User, + undefined, + options, ); } } finally { @@ -1385,6 +1400,7 @@ export class ExtensionManager { extensionIdentifier: string, isUpdate: boolean, cwd?: string, + options?: ExtensionRuntimeRefreshOptions, ): Promise { const currentDir = cwd ?? this.workspaceDir; const telemetryConfig = getTelemetryConfig( @@ -1421,7 +1437,9 @@ export class ExtensionManager { this.removeEnablementConfig(extension.name); this.preferencesStore.clear(extension.name); - await this.refreshTools(); + if (options?.refreshTools ?? true) { + await this.refreshTools(); + } logExtensionUninstall( telemetryConfig, @@ -1512,6 +1530,7 @@ export class ExtensionManager { undefined, undefined, previousExtensionConfig, + { refreshTools: enableExtensionReloading }, ); } catch (e) { callback(extension.name, ExtensionUpdateState.ERROR); From 48ece8deba3990d4f89a4e1ca19ad0642e3825f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Wed, 1 Jul 2026 12:42:13 +0800 Subject: [PATCH 2/2] fix: wrap remaining clearPluginCaches calls in their own try/catch ExtensionActionsView (4 calls), SourcesTab, and extensionsCommand all had clearPluginCaches inside outer try/catch blocks where a cache refresh failure would be misattributed to the wrapping mutation's error path. Wrap each call individually so cache errors don't cascade. Also export ExtensionRuntimeRefreshOptions so external TypeScript consumers can reference the type used in public method signatures. --- .../cli/src/ui/commands/extensionsCommand.ts | 6 ++++- .../components/extensions/tabs/SourcesTab.tsx | 6 ++++- .../extensions/views/ExtensionActionsView.tsx | 24 +++++++++++++++---- .../core/src/extension/extensionManager.ts | 2 +- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index b7e5d393f79..1433a0de88b 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -251,7 +251,11 @@ async function installAction(context: CommandContext, args: string) { Date.now(), ); if (context.services.config) { - await clearPluginCaches(context.services.config); + try { + await clearPluginCaches(context.services.config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins. + } } markPluginsChanged('extension installed'); } catch (error) { diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index cb1db3802c1..11e8a88eb7a 100644 --- a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -216,7 +216,11 @@ export const SourcesTab = ({ type: 'success', text: t('Installed extension "{{name}}".', { name: ext.name }), }); - await clearPluginCaches(config); + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins. + } markPluginsChanged('extension installed'); await load(); onChanged(); diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index 7acb4244b86..77d498492df 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -131,7 +131,11 @@ export const ExtensionActionsView = ({ ); } setEnabled(!enabled); - await clearPluginCaches(config); + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins. + } markPluginsChanged('extension enablement changed'); onStatus({ type: 'success', @@ -210,7 +214,11 @@ export const ExtensionActionsView = ({ () => {}, false, ); - await clearPluginCaches(config); + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins. + } markPluginsChanged('extension updated'); onStatus({ type: 'success', @@ -284,7 +292,11 @@ export const ExtensionActionsView = ({ manager.setExtensionScope(name, newScope); setScope(newScope); setEnabled(true); - await clearPluginCaches(config); + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins. + } markPluginsChanged('extension scope changed'); onStatus({ type: 'success', @@ -311,7 +323,11 @@ export const ExtensionActionsView = ({ await manager.uninstallExtension(ext.name, false, undefined, { refreshTools: false, }); - await clearPluginCaches(config); + try { + await clearPluginCaches(config); + } catch { + // Cache refresh failure is recoverable via /reload-plugins. + } markPluginsChanged('extension uninstalled'); onStatus({ type: 'success', diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index d3eb8a763ee..81af372ee74 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -112,7 +112,7 @@ export enum SettingScope { SystemDefaults = 'SystemDefaults', } -interface ExtensionRuntimeRefreshOptions { +export interface ExtensionRuntimeRefreshOptions { refreshTools?: boolean; }