Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion packages/cli/src/config/hot-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +30,7 @@ import {
registerMcpHotReload,
mcpServersEqual,
mcpGatingEqual,
reloadPluginsRuntime,
} from './hot-reload.js';
import {
loadMcpApprovals,
Expand Down Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/config/hot-reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createDebugLogger,
type Config,
getMCPServerStatus,
LspConfigLoader,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './settings.js';
Expand All @@ -22,6 +23,91 @@ import { appEvents, AppEvent } from '../utils/events.js';

const debugLogger = createDebugLogger('MCP_HOT_RELOAD');

type ConfigWithOptionalLspReload = Config & {
reinitializeLsp?: () => void | Promise<void>;
};

/**
* 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<void> {
Comment thread
ZijianZhang989 marked this conversation as resolved.
// 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(),
Comment thread
ZijianZhang989 marked this conversation as resolved.
]);
for (const result of settled) {
if (result.status === 'rejected') {
debugLogger.warn(
'clearPluginCaches: a refreshCache leg failed:',
Comment thread
ZijianZhang989 marked this conversation as resolved.
result.reason,
);
}
}
}

export interface ReloadPluginsRuntimeOptions {
config: Config;
reloadCommands?: () => void | Promise<void>;
}

export interface ReloadPluginsSummary {
extensionCount: number;
commandCount: number;
skillCount: number;
hookCount: number;
mcpServerCount: number;
lspServerCount: number;
}

export async function reloadPluginsRuntime({
config,
reloadCommands,
}: ReloadPluginsRuntimeOptions): Promise<ReloadPluginsSummary> {
Comment thread
ZijianZhang989 marked this conversation as resolved.
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?.();

Comment thread
ZijianZhang989 marked this conversation as resolved.
return {
Comment thread
ZijianZhang989 marked this conversation as resolved.
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,
Comment thread
ZijianZhang989 marked this conversation as resolved.
0,
),
0,
),
mcpServerCount: activeExtensions.reduce(
(sum, extension) => sum + Object.keys(extension.mcpServers ?? {}).length,
0,
),
lspServerCount: lspConfigs.length,
Comment thread
ZijianZhang989 marked this conversation as resolved.
};
}

/**
* The three connection-admission lists discovery consults to decide whether a
* given MCP server may connect. Distinct from the `mcpServers` config map:
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/config/plugin-refresh-state.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
30 changes: 30 additions & 0 deletions packages/cli/src/config/plugin-refresh-state.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"?',
Expand Down Expand Up @@ -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':
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"?',
Expand Down Expand Up @@ -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':
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"?',
Expand Down Expand Up @@ -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':
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading