From 70dc9ab11120109b9b58bdc7cdf509b60990cd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 20:56:30 +0800 Subject: [PATCH] fix: refresh plugin settings before slash reload Desktop plugin enable and disable actions are written by the server process, while an already-running CLI owns its own settings cache. Reloading only the plugin caches left enabledPlugins stale after a disable-then-enable cycle, so the slash command list stayed empty until a new conversation spawned a fresh CLI. Reset the settings cache at the active plugin refresh boundary before resolving enabled plugins. The regression test simulates an external desktop enable toggle after a cached disabled read and verifies the plugin slash command is restored in the same refresh. Constraint: Desktop server and active CLI are separate processes with independent in-memory settings caches Rejected: Restart the CLI on every plugin toggle | heavier user-visible lifecycle change and unnecessary once the reload handoff invalidates settings Confidence: high Scope-risk: narrow Directive: Keep reload_plugins as the handoff that rereads settings before plugin command discovery Tested: bun test src/server/__tests__/plugins.test.ts Tested: bun run check:server --- src/server/__tests__/plugins.test.ts | 114 +++++++++++++++++++++++++++ src/utils/plugins/refresh.ts | 7 +- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/plugins.test.ts b/src/server/__tests__/plugins.test.ts index 31694ce3..2288dd6e 100644 --- a/src/server/__tests__/plugins.test.ts +++ b/src/server/__tests__/plugins.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' +import type { AppState } from '../../state/AppStateStore.js' import { isEnabledPluginSettingValue } from '../../utils/plugins/dependencyResolver.js' import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js' import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' +import { refreshActivePlugins } from '../../utils/plugins/refresh.js' import { resetSettingsCache } from '../../utils/settings/settingsCache.js' import { handlePluginsApi } from '../api/plugins.js' import { conversationService } from '../services/conversationService.js' @@ -241,4 +243,116 @@ describe('Plugins API', () => { }, ]) }) + + it('refreshActivePlugins rereads settings after an external enable toggle', async () => { + const marketplaceRoot = path.join(tmpDir, 'marketplace-root') + const pluginRoot = path.join(marketplaceRoot, 'plugins', 'draw') + const pluginsDir = path.join(tmpDir, 'plugins') + const marketplaceFile = path.join( + marketplaceRoot, + '.claude-plugin', + 'marketplace.json', + ) + + await fs.mkdir(path.join(pluginRoot, '.claude-plugin'), { recursive: true }) + await fs.mkdir(path.join(pluginRoot, 'commands'), { recursive: true }) + await fs.mkdir(path.dirname(marketplaceFile), { recursive: true }) + await fs.mkdir(pluginsDir, { recursive: true }) + + await fs.writeFile( + path.join(pluginRoot, '.claude-plugin', 'plugin.json'), + JSON.stringify({ + name: 'draw', + version: '1.0.0', + description: 'Drawing plugin', + }), + 'utf-8', + ) + await fs.writeFile( + path.join(pluginRoot, 'commands', 'render.md'), + '---\ndescription: Render a drawing.\n---\nRender this drawing.', + 'utf-8', + ) + await fs.writeFile( + marketplaceFile, + JSON.stringify({ + name: 'test-market', + owner: { name: 'Test' }, + plugins: [ + { + name: 'draw', + source: './plugins/draw', + version: '1.0.0', + }, + ], + }), + 'utf-8', + ) + await fs.writeFile( + path.join(pluginsDir, 'known_marketplaces.json'), + JSON.stringify({ + 'test-market': { + source: { source: 'directory', path: marketplaceRoot }, + installLocation: marketplaceRoot, + lastUpdated: new Date(0).toISOString(), + }, + }), + 'utf-8', + ) + + const settingsPath = path.join(tmpDir, 'settings.json') + await fs.writeFile( + settingsPath, + JSON.stringify({ + enabledPlugins: { + 'draw@test-market': false, + }, + }), + 'utf-8', + ) + + const disabledResult = await loadAllPluginsCacheOnly() + expect(disabledResult.enabled).toEqual([]) + expect(disabledResult.disabled).toContainEqual( + expect.objectContaining({ source: 'draw@test-market', enabled: false }), + ) + + await fs.writeFile( + settingsPath, + JSON.stringify({ + enabledPlugins: { + 'draw@test-market': true, + }, + }), + 'utf-8', + ) + + let appState = { + plugins: { + enabled: [], + disabled: disabledResult.disabled, + commands: [], + errors: [], + needsRefresh: true, + }, + mcp: { pluginReconnectKey: 0 }, + agentDefinitions: { allAgents: [], errors: [] }, + } as unknown as AppState + + const result = await refreshActivePlugins(updater => { + appState = updater(appState) + }) + + expect(result.enabled_count).toBe(1) + expect(result.command_count).toBe(1) + expect(result.pluginCommands).toContainEqual( + expect.objectContaining({ + name: 'draw:render', + description: 'Render a drawing.', + }), + ) + expect(appState.plugins.commands).toContainEqual( + expect.objectContaining({ name: 'draw:render' }), + ) + }) }) diff --git a/src/utils/plugins/refresh.ts b/src/utils/plugins/refresh.ts index c31e8118..d02e1642 100644 --- a/src/utils/plugins/refresh.ts +++ b/src/utils/plugins/refresh.ts @@ -27,6 +27,7 @@ import type { PluginError } from '../../types/plugin.js' import { logForDebugging } from '../debug.js' import { errorMessage } from '../errors.js' import { logError } from '../log.js' +import { resetSettingsCache } from '../settings/settingsCache.js' import { clearAllCaches } from './cacheUtils.js' import { getPluginCommands } from './loadPluginCommands.js' import { loadPluginHooks } from './loadPluginHooks.js' @@ -72,7 +73,11 @@ export type RefreshActivePluginsResult = { export async function refreshActivePlugins( setAppState: SetAppState, ): Promise { - logForDebugging('refreshActivePlugins: clearing all plugin caches') + logForDebugging('refreshActivePlugins: clearing settings and plugin caches') + // Desktop plugin toggles are written by the server process while the active + // CLI keeps its own settings cache. /reload-plugins is the explicit handoff + // point, so re-read settings before resolving enabledPlugins. + resetSettingsCache() clearAllCaches() // Orphan exclusions are session-frozen by default, but /reload-plugins is // an explicit "disk changed, re-read it" signal — recompute them too.