diff --git a/docs/design/extension-file-reload.md b/docs/design/extension-file-reload.md new file mode 100644 index 0000000000..64de2904cb --- /dev/null +++ b/docs/design/extension-file-reload.md @@ -0,0 +1,607 @@ +# Extension File Reload Design + +## Background + +Extension changes currently enter the runtime from two different directions. +User-initiated UI mutations, such as enable, disable, install, uninstall, and +update, already go through `ExtensionManager` and can refresh runtime state +directly. Out-of-band filesystem changes, such as editing an installed +extension's `skills/`, `commands/`, `hooks/`, or `qwen-extension.json`, are not +owned by a single UI action and therefore need a watcher-driven path. + +This design adds that missing watcher path while preserving the direct mutation +path. It follows the same layering used by the MCP and LSP hot-reload designs: + +- the CLI decides when filesystem changes should trigger a reload or a user + notification; +- Core owns how extension runtime state is refreshed; +- UI components consume a small event/state object instead of polling extension + files directly. + +The key constraint is that not every extension file can be safely hot-applied in +the same way. Content-like capability files can be refreshed automatically, but +package-level changes should ask the user to run `/reload-plugins` so the +extension cache, runtime tools, hooks, context files, and slash command list are +rebuilt from one coherent snapshot. + +## Current Code Assessment + +- `ExtensionManager` already loads extension manifests, convention directories, + install metadata, enablement state, marketplace source state, commands, + skills, agents, hooks, MCP declarations, and LSP declarations. +- UI extension operations already call `ExtensionManager.refreshTools()` after + changing runtime-relevant state. That path refreshes MCP, skills, subagents, + hooks, and hierarchical memory through Core. +- Slash command completion is built by `CommandService.create()` from loaders. + Extension commands and skill-backed slash commands do not automatically + appear unless `reloadCommands()` rebuilds that command service. +- Skill and subagent managers have cache refresh APIs, but those caches are + separate from slash command completion. +- Hooks are owned by `HookSystem` and `HookRegistry`. Recreating the whole hook + system would lose agent-scoped temporary hooks, so reload must target + configured hooks only. +- `SettingsWatcher` and existing MCP/LSP watchers do not cover installed + extension package content. Extension-specific files need their own watcher. +- Linked extensions can live outside the user extension directory, so watching + only `~/.qwen/extensions` misses active development workflows. + +## Goals + +Make extension changes take effect in the current interactive session without a +full CLI restart: + +- keep UI extension mutations immediately effective; +- detect manual extension edits, additions, and removals under the user + extension directory; +- detect edits in linked extension source directories; +- auto-refresh content-level capability files under `commands/`, `skills/`, + and `agents/`; +- prompt the user to run `/reload-plugins` for package-level changes; +- refresh hooks as part of runtime reload without losing agent-scoped hooks; +- keep slash command completion in sync with command and skill changes; +- suppress watcher notifications for changes written by Qwen's own extension + mutations; +- surface MCP and hook reload failures instead of reporting a misleading + successful reload summary. + +## Non-goals + +- Do not make hook file edits content-auto-refreshable. Hook behavior can affect + command execution and security-sensitive workflows, so hook edits are treated + as package-level changes. +- Do not hot-reload arbitrary extension files. Unknown files are ignored unless + they are resolved context files. +- Do not add per-extension incremental MCP restart. This design continues to use + the existing MCP reinitialization entry point. +- Do not change extension discovery, conversion, installation source parsing, or + marketplace semantics. +- Do not support runtime toggling of bare mode. The watcher is simply not + started in bare mode. + +## Code Structure + +The implementation is intentionally split by layer. + +```text +packages/core/src/extension/ + extensionManager.ts + Extension mutation lifecycle events. + UI mutation methods still own direct runtime refresh. + + extension-runtime-refresh.ts + Core runtime refresh contract for extension mutations. + +packages/core/src/hooks/ + hookRegistry.ts + Reload configured hooks while preserving agent-scoped hooks. + + hookSystem.ts + Public hook reload facade used by extension runtime refresh. + +packages/cli/src/config/ + extension-refresh-state.ts + Shared event/state object for watcher, slash processor, and reload command. + + extension-file-watcher.ts + Filesystem watcher and path classifier. + + extension-runtime-reload.ts + CLI reload helpers for /reload-plugins and content auto-refresh. + +packages/cli/src/ui/commands/ + reload-plugins-command.ts + Interactive slash command for package-level extension reload. + +packages/cli/src/ui/hooks/ + slashCommandProcessor.ts + Event consumers for stale notifications and content auto-refresh. + +packages/cli/src/ + gemini.tsx + ui/AppContainer.tsx + ui/startInteractiveUI.tsx + Startup and dependency injection for ExtensionRefreshState and watcher. +``` + +## Design + +### 1. Classify Filesystem Changes + +`ExtensionFileWatcher` maps a chokidar event to one of three outcomes: + +```ts +type RefreshAction = 'auto' | 'stale' | false; +``` + +The classification is deliberately conservative. + +| Path class | Action | Reason | +| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | +| `commands/**` | `auto` | Slash command loaders can rebuild from the existing extension cache. | +| `skills/**` | `auto` | Skill cache and slash command loaders can rebuild without changing package identity. | +| `agents/**` | `auto` | Subagent cache can rebuild without changing package identity. | +| `hooks/**` | `stale` | Hook execution behavior should be reloaded from a coherent package snapshot. | +| `qwen-extension.json` | `stale` | Manifest can change commands, skills, agents, hooks, MCP, LSP, context file names, and metadata. | +| `.qwen-extension-install.json` | `stale` | Install metadata affects linked source roots and package identity. | +| configured context files | `stale` | Model context can change and should be reloaded explicitly. | +| extension directory add/remove | `stale` | Installed extension topology changed. | +| top-level extension config files | `stale` | Enablement, preferences, or marketplaces changed outside UI mutation path. | +| unknown files | ignored | Avoid refreshing for build artifacts or unrelated data. | + +The same classifier is used for user-installed extensions and linked extension +source roots. For linked roots, the watcher first finds the owning linked +extension and then classifies the path relative to that source root. + +### 2. Watch User and Linked Extension Roots + +`ExtensionFileWatcher.startWatching()` builds watch roots from: + +1. `Storage.getUserExtensionsDir()`, when it exists; +2. active linked extension source paths from install metadata; +3. the parent of the user extension directory, only when the extension + directory does not exist yet. + +The parent bootstrap watcher covers first extension installation or manual +creation of the extension directory after startup. When the directory appears, +the watcher marks extension state stale and schedules `restartWatching()` in a +microtask. Scheduling the restart avoids closing the bootstrap watcher while +chokidar is still dispatching the event. + +Watcher options: + +```ts +watchFs(roots, { + ignoreInitial: true, + followSymlinks: false, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 50, + }, + ignored: (filePath) => this.isIgnored(filePath), +}); +``` + +`followSymlinks: false` keeps an extension from causing Qwen to watch arbitrary +external paths through symlinks. The ignore filter skips `node_modules`, `.git`, +common editor backup files, swap files, temporary files, and `.DS_Store`. + +### 3. Share Reload State Through ExtensionRefreshState + +`ExtensionRefreshState` is the small event/state primitive shared by the +watcher, the slash command processor, and `/reload-plugins`. + +Key methods: + +```ts +markExtensionsChanged(reason?: string): boolean; +markExtensionContentChanged(reason?: string): boolean; +clearExtensionsChanged(): void; +notifyExtensionsReloadStarted(): void; +needsExtensionRefresh(): boolean; +beginSuppression(onSettle?: () => void): () => void; +suppressNotifications(fn: () => T, onSettle?: () => void): T; +``` + +Events: + +| Event | Producer | Consumer | Meaning | +| ------------------------- | --------------------------------------- | --------------------------- | -------------------------------------------------------------------- | +| `ExtensionContentChanged` | `ExtensionFileWatcher` | `useSlashCommandProcessor` | Content-level files changed; schedule auto-refresh. | +| `ExtensionRefreshNeeded` | `ExtensionFileWatcher` | `useSlashCommandProcessor` | Package-level state changed; tell the user to run `/reload-plugins`. | +| `ExtensionsReloadStarted` | `/reload-plugins` | `useSlashCommandProcessor` | Cancel pending content refresh timers before manual reload. | +| `ExtensionsReloaded` | `/reload-plugins`, watcher restart path | watcher and slash processor | Clear stale flags and restart/cancel pending work. | + +`markExtensionsChanged()` deduplicates stale notifications until the state is +cleared. Content-change notifications are not deduplicated by this state object, +because the slash command processor owns debounce and serialization. + +### 4. Suppress Watcher Noise During Programmatic Mutations + +`ExtensionManager` exposes: + +```ts +interface ExtensionMutationEvent { + id: number; + phase: 'start' | 'end'; + operation: string; +} + +addMutationListener(listener: ExtensionMutationListener): () => void; +``` + +Runtime-relevant mutation methods call `beginMutation()` and always emit a +matching end event in `finally`. + +Methods that emit mutation events: + +- `enableExtension()` +- `disableExtension()` +- `installExtension()` +- `uninstallExtension()` +- `updateExtension()` +- `addSource()` +- `removeSource()` +- `setExtensionScope()` +- `setMcpServerDisabled()` + +Methods that do not emit mutation events: + +- `toggleFavorite()` +- `markSourceUpdated()` + +The watcher keeps `mutation id -> end suppression callback` in a `Map`. This is +important because install can trigger enable internally, and separate mutations +can overlap. Pairing by id avoids relying on stack order. + +When the outer suppression depth reaches zero, the watcher restarts. That +refreshes linked source roots, context file names, and active extension +metadata after the mutation has settled. + +### 5. Refresh Runtime State From Core + +`refreshExtensionRuntime()` is the Core-side runtime refresh entry point used by +extension UI mutations. + +It refreshes in this order: + +1. `config.reinitializeMcpServers(config.getSettingsMcpServers())` +2. `config.getSkillManager()?.refreshCache()` +3. `config.getSubagentManager().refreshCache()` +4. `config.getHookSystem()?.reload()` +5. `config.refreshHierarchicalMemory()` + +MCP reinitialization runs first because skill and subagent tool descriptions can +depend on the updated MCP tool list. + +Skills, subagents, and hooks run through `Promise.allSettled()` so one rejected +leg does not prevent the others from applying. Hook reload failure is stored and +rethrown after hierarchical memory has had a chance to refresh. This keeps hook +failures visible while still applying best-effort cache refreshes. + +Failure contract: + +- MCP failure propagates immediately and later runtime legs do not run. +- Hook reload failure propagates after parallel refresh legs and memory refresh + settle. +- Skill refresh failure is logged and best-effort. +- Subagent refresh failure is logged and best-effort. +- Hierarchical memory refresh failure is logged and best-effort. + +### 6. Reload Package-Level Changes With /reload-plugins + +`reloadPluginsRuntime()` is the CLI-side runtime reload helper used by the +slash command: + +```ts +async function reloadPluginsRuntime(options: { + config: Config; + reloadCommands?: () => void | Promise; +}): Promise; +``` + +Flow: + +1. `config.getExtensionManager().refreshCache()` +2. `config.getExtensionManager().refreshTools()` +3. `reloadCommands()` +4. summarize active extension capabilities + +The summary counts active extension declarations for: + +- extensions; +- commands; +- skills; +- agents; +- hooks; +- extension MCP servers; +- extension LSP servers. + +`/reload-plugins` owns the user-facing command behavior: + +1. require `config`; +2. emit `ExtensionsReloadStarted`; +3. call `reloadPluginsRuntime()`; +4. call `clearExtensionsChanged()` on success or failure; +5. return either a localized info summary or an error message. + +Clearing stale state on failure is intentional. If a failed reload left +`extensionRefreshNeeded = true`, future file watcher notifications would be +deduplicated away and content auto-refresh would keep bypassing itself. + +### 7. Auto-Refresh Content-Level Changes + +`refreshExtensionContentRuntime()` is used for content-only filesystem changes. + +Flow: + +1. refresh extension cache; +2. refresh skill cache; +3. refresh subagent cache; +4. reload slash commands; +5. aggregate errors and throw a single message if any leg failed. + +The slash command processor listens for `ExtensionContentChanged` and debounces +the refresh by 250 ms. It serializes refreshes with: + +```ts +extensionContentRefreshRunningRef; +extensionContentRefreshPendingRef; +``` + +If a content event arrives while a refresh is running, the processor marks +another pass as pending and runs that pass after the current one finishes. A +small upper bound prevents a noisy editor or build process from keeping the +same refresh task alive indefinitely. + +If `ExtensionRefreshState.needsExtensionRefresh()` is true, content +auto-refresh exits early. The package-level reload must run first so command, +skill, agent, hook, MCP, LSP, and context state are rebuilt from one extension +cache snapshot. + +### 8. Reload Hooks Without Dropping Agent-Scoped Hooks + +`HookRegistry.reloadConfiguredHooks()` replaces only configured hook entries. +It preserves entries with `agentScope !== undefined`, because those are +temporary hooks registered for subagent execution. + +Flow: + +1. save `previousEntries`; +2. keep `agentEntries`; +3. set registry entries to `agentEntries`; +4. run `processHooksFromConfig()`; +5. on failure, restore `previousEntries` and rethrow. + +`HookSystem.reload()` is a narrow facade that delegates to +`hookRegistry.reloadConfiguredHooks()`. Runtime reload therefore does not need +to recreate the whole hook system. + +This reload path does not re-read user or project settings files from disk. +`processHooksFromConfig()` re-processes the current `Config` values for +user/project hooks and the refreshed extension config values. Settings file +reload remains owned by the settings reload path; `/reload-plugins` is scoped to +extension runtime state. + +### 9. Wire State Into Interactive UI + +Interactive startup creates one shared `ExtensionRefreshState`: + +```ts +const extensionRefreshState = new ExtensionRefreshState(); +const extensionFileWatcher = isBareMode(argv.bare) + ? undefined + : new ExtensionFileWatcher(config, undefined, extensionRefreshState); +``` + +That state is passed through: + +```text +gemini.tsx + -> startInteractiveUI(...) + -> AppContainer + -> useSlashCommandProcessor + -> CommandContext.services.extensionRefreshState +``` + +`AppContainer` creates a fallback `ExtensionRefreshState` only when one was not +provided. This keeps tests and alternate UI entry points simple while the main +interactive path shares state between watcher and slash command processing. + +Cleanup unregisters the reload listener and stops the watcher. + +## Event Flows + +### Content File Edit + +```text +edit extension commands/skills/agents file + -> ExtensionFileWatcher classifies as auto + -> ExtensionRefreshState.markExtensionContentChanged() + -> useSlashCommandProcessor schedules debounced refresh + -> refreshExtensionContentRuntime() + -> ExtensionManager.refreshCache() + -> SkillManager.refreshCache() + -> SubagentManager.refreshCache() + -> reloadCommands() +``` + +### Package-Level File Edit + +```text +edit qwen-extension.json/hooks/context/install metadata/topology + -> ExtensionFileWatcher classifies as stale + -> ExtensionRefreshState.markExtensionsChanged() + -> useSlashCommandProcessor prints: + "Extensions changed on disk. Run /reload-plugins to apply updates." + -> user runs /reload-plugins + -> reloadPluginsRuntime() + -> ExtensionManager.refreshCache() + -> ExtensionManager.refreshTools() + -> reloadCommands() +``` + +### UI Mutation + +```text +user enables/disables/installs/uninstalls/updates extension + -> ExtensionManager emits mutation start + -> ExtensionRefreshState begins suppression + -> ExtensionManager writes disk/runtime state + -> ExtensionManager.refreshTools() + -> refreshExtensionRuntime() + -> ExtensionManager emits mutation end + -> suppression settles + -> ExtensionFileWatcher restarts with fresh roots/context files +``` + +## Concurrency and Ordering + +- Watcher restarts are generation-guarded. Events from an old watcher instance + are ignored after `watchGeneration` changes. +- Mutation suppression is paired by mutation id, not stack order. +- `stopWatching()` ends all pending suppressions before dropping watcher + references, so suppression depth cannot leak when the watcher is stopped + while a mutation is in flight. +- Content auto-refresh is serialized in the slash command processor. Concurrent + events coalesce into at most one pending rerun. +- `/reload-plugins` emits `ExtensionsReloadStarted` and `ExtensionsReloaded` so + pending content refresh timers are canceled around manual reload. +- Package-level stale state wins over content auto-refresh. If a stale reload is + needed, content auto-refresh exits and waits for `/reload-plugins`. + +## Failure Semantics + +| Path | Behavior | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| MCP reinitialization in mutation or `/reload-plugins` | Propagates. A success message would be misleading because extension MCP tools may be unavailable. | +| Hook reload in mutation or `/reload-plugins` | Propagates after other parallel refresh legs settle. A success summary would be misleading because configured hooks may not be registered. | +| Skill cache refresh during mutation | Logged and best-effort. | +| Subagent cache refresh during mutation | Logged and best-effort. | +| Hierarchical memory refresh during mutation | Logged and best-effort. It should not roll back already-written extension state. | +| Content auto-refresh failure | Aggregated and shown in the UI with a `/reload-plugins` fallback. | +| `/reload-plugins` failure | Returns an error message and clears stale state so future watcher notifications can fire again. | +| Hook registry reload failure | Restores previous hook entries and rethrows. | +| Watcher error | Logged through debug logger; the session continues. | + +## Tests + +### Core Tests + +`packages/core/src/extension/extension-runtime-refresh.test.ts` + +- returns early without config; +- refreshes MCP before skills/subagents/hooks/memory; +- propagates MCP reconcile failures; +- keeps skill refresh failure best-effort; +- propagates hook reload failures after other refresh legs settle; +- keeps hierarchical memory failure best-effort. + +`packages/core/src/extension/extensionManager.test.ts` + +- emits mutation start/end around disable; +- emits mutation end when disable fails; +- emits mutation start/end around install, including nested enable events; +- emits mutation start/end around uninstall; +- emits mutation start/end around update temp directory failure; +- does not emit mutation events for favorite changes or source timestamp + updates; +- preserves existing extension loading, command discovery, hook loading, and + refreshTools coverage. + +`packages/core/src/hooks/hookRegistry.test.ts` + +- reloads configured hooks; +- preserves agent-scoped hooks during reload; +- restores previous entries when configured hook reload fails. + +`packages/core/src/hooks/hookSystem.test.ts` + +- delegates reload to the hook registry. + +### CLI Tests + +`packages/cli/src/config/extension-refresh-state.test.ts` + +- emits stale refresh events once until cleared; +- emits content refresh events; +- suppresses notifications during mutation suppression; +- clears stale state and suppression windows correctly. + +`packages/cli/src/config/extension-file-watcher.test.ts` + +- classifies commands, skills, and agents as auto-refresh; +- classifies manifests, install metadata, hooks, context files, and extension + topology changes as stale; +- ignores unknown files and ignored directories; +- watches linked extension sources; +- suppresses notifications during programmatic mutation; +- restarts watching after mutation settlement; +- handles late creation of the extension directory. + +`packages/cli/src/config/extension-runtime-reload.test.ts` + +- reloads extension cache, runtime tools, and slash commands for + `/reload-plugins`; +- summarizes active extension capabilities; +- refreshes content runtime components; +- aggregates content auto-refresh failures. + +`packages/cli/src/ui/commands/reload-plugins-command.test.ts` + +- registers the command as interactive-only behavior; +- returns an error when config is missing; +- reloads runtime and clears stale state on success; +- clears stale state on failure and returns an error. + +`packages/cli/src/services/BuiltinCommandLoader.test.ts` + +- includes `/reload-plugins` in built-in command loading. + +### Manual Verification + +Manual verification should cover: + +1. Enable an extension from the UI and confirm commands, skills, agents, MCP, + hooks, and context are refreshed without restarting. +2. Disable the same extension and confirm runtime capabilities are removed or no + longer offered. +3. Edit a command file under `commands/` and confirm slash command completion + updates automatically. +4. Edit a skill file under `skills/` and confirm skill-backed slash command + completion updates automatically. +5. Edit an agent file under `agents/` and confirm agent cache behavior reflects + the change. +6. Edit `hooks/hooks.json`, `qwen-extension.json`, install metadata, context + files, or extension directory topology and confirm the UI asks for + `/reload-plugins`. +7. Run `/reload-plugins` and confirm the summary reports extensions, commands, + skills, agents, hooks, extension MCP servers, and extension LSP servers. +8. Force a reload failure and confirm the UI reports the error, then a later + filesystem change can still trigger another notification. + +## Tradeoffs + +- Hooks are treated as package-level stale changes even though a configured hook + reload API exists. This avoids silently changing hook execution behavior from + a background filesystem event. +- MCP refresh remains full runtime reinitialization. Per-extension incremental + MCP restart would reduce cost but would expand this PR into MCP ownership and + reconciliation logic. +- The watcher classifies unknown files as ignored instead of stale. This reduces + noise for build artifacts but means extension authors must put runtime + capability files in the supported convention directories. +- Linked extension roots are watched directly. This improves authoring + ergonomics but can increase watcher count for users with many linked + extensions. + +## Future Work + +- Add per-extension incremental MCP reconciliation. +- Add user-visible diagnostics for fatal watcher errors such as `ENOSPC` or + `EMFILE`. +- Consider a typed reload result from `refreshExtensionRuntime()` if callers + need partial-success summaries. +- Optimize linked extension source lookup with a precomputed root map if many + linked extensions become common. +- Revisit hook content auto-refresh only if hook reload can be made explicit, + observable, and safe enough for background application. diff --git a/packages/cli/src/config/extension-file-watcher.test.ts b/packages/cli/src/config/extension-file-watcher.test.ts new file mode 100644 index 0000000000..de881f8dd4 --- /dev/null +++ b/packages/cli/src/config/extension-file-watcher.test.ts @@ -0,0 +1,524 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'node:path'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { ExtensionFileWatcher } from './extension-file-watcher.js'; +import type { ExtensionRefreshState } from './extension-refresh-state.js'; + +type EventHandler = (...args: unknown[]) => void; + +interface MockWatcherEntry { + target: string | string[]; + options: Record; + handlers: Record; + instance: { + on: ReturnType; + close: ReturnType; + }; +} + +const { mockWatchers, mockWatch, mockExistsSync } = vi.hoisted(() => { + const mockWatchers: MockWatcherEntry[] = []; + const mockExistsSync = vi.fn().mockReturnValue(true); + const mockWatch = vi + .fn() + .mockImplementation( + (target: string | string[], options: Record) => { + const handlers: Record = {}; + const instance = { + on: vi + .fn() + .mockImplementation((event: string, handler: EventHandler) => { + handlers[event] = handler; + return instance; + }), + close: vi.fn().mockResolvedValue(undefined), + }; + mockWatchers.push({ target, options, handlers, instance }); + return instance; + }, + ); + return { mockWatchers, mockWatch, mockExistsSync }; +}); + +vi.mock('chokidar', () => ({ + watch: mockWatch, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: mockExistsSync, + }; +}); + +function configWithExtensions(extensions: unknown[]): Config { + return { + getExtensions: () => extensions, + getActiveExtensions: () => extensions, + getExtensionManager: () => ({ + addMutationListener: vi.fn(() => vi.fn()), + }), + } as unknown as Config; +} + +function createRefreshState(): ExtensionRefreshState { + return { + markExtensionContentChanged: vi.fn(), + markExtensionsChanged: vi.fn(), + beginSuppression: vi.fn((onSettle?: () => void) => () => onSettle?.()), + } as unknown as ExtensionRefreshState; +} + +function fireAllEvent( + watcherIndex: number, + event: string, + changedPath: string, +) { + mockWatchers[watcherIndex].handlers['all']?.(event, changedPath); +} + +describe('ExtensionFileWatcher', () => { + const extensionsDir = '/home/user/.qwen/extensions'; + + beforeEach(() => { + vi.clearAllMocks(); + mockWatchers.length = 0; + mockExistsSync.mockReturnValue(true); + }); + + it('watches the extensions directory and linked extension sources', () => { + const linkedSource = path.resolve('relative-linked-extension'); + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([ + { + path: linkedSource, + config: {}, + installMetadata: { + type: 'link', + source: 'relative-linked-extension', + }, + contextFiles: [], + }, + ]), + extensionsDir, + refreshState, + ); + + watcher.startWatching(); + + expect(mockWatch).toHaveBeenCalledOnce(); + expect(mockWatchers[0].target).toEqual([extensionsDir, linkedSource]); + expect(mockWatchers[0].options).toEqual( + expect.objectContaining({ + ignoreInitial: true, + followSymlinks: false, + }), + ); + }); + + it('marks refresh needed for manual extension add and remove events', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'addDir', `${extensionsDir}/new-extension`); + fireAllEvent(0, 'unlinkDir', `${extensionsDir}/old-extension`); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2); + }); + + it('marks stale refresh needed for inventory and hook files', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([ + { + path: `${extensionsDir}/alpha`, + config: {}, + installMetadata: undefined, + contextFiles: [], + }, + ]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'change', `${extensionsDir}/alpha/qwen-extension.json`); + fireAllEvent(0, 'change', `${extensionsDir}/alpha/hooks/hooks.json`); + fireAllEvent(0, 'change', `${extensionsDir}/extension-enablement.json`); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(3); + expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled(); + }); + + it('does not mark preferences or marketplace metadata as stale', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'change', `${extensionsDir}/extension-preferences.json`); + fireAllEvent(0, 'change', `${extensionsDir}/marketplaces.json`); + + expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled(); + expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled(); + }); + + it('auto-refreshes command, skill, and agent content changes', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'add', `${extensionsDir}/alpha/commands/run.toml`); + fireAllEvent(0, 'unlink', `${extensionsDir}/alpha/skills/demo/SKILL.md`); + fireAllEvent(0, 'change', `${extensionsDir}/alpha/agents/reviewer.md`); + + expect(refreshState.markExtensionContentChanged).toHaveBeenCalledTimes(3); + expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled(); + }); + + it('treats content events as stale when the extension manifest is gone', () => { + mockExistsSync.mockImplementation( + (filePath: string) => !filePath.endsWith('/alpha/qwen-extension.json'), + ); + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'unlink', `${extensionsDir}/alpha/commands/run.toml`); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledOnce(); + expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled(); + }); + + it('marks refresh needed for linked source changes and context files', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([ + { + path: '/tmp/linked-extension', + config: {}, + installMetadata: { type: 'link', source: '/tmp/linked-extension' }, + contextFiles: ['/tmp/linked-extension/GEMINI.md'], + }, + ]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'change', '/tmp/linked-extension/qwen-extension.json'); + fireAllEvent(0, 'change', '/tmp/linked-extension/GEMINI.md'); + fireAllEvent(0, 'change', '/tmp/linked-extension/commands/run.toml'); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2); + expect(refreshState.markExtensionContentChanged).toHaveBeenCalledOnce(); + }); + + it('marks refresh needed for file-backed hook and LSP config changes', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([ + { + path: `${extensionsDir}/alpha`, + config: { + hooks: 'config/hooks.json', + lspServers: '/tmp/alpha-lsp.json', + }, + installMetadata: undefined, + contextFiles: [], + }, + ]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'change', `${extensionsDir}/alpha/config/hooks.json`); + fireAllEvent(0, 'change', '/tmp/alpha-lsp.json'); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2); + expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled(); + }); + + it('does not watch inactive linked extension sources or context files', () => { + const activeSource = '/tmp/active-linked-extension'; + const inactiveSource = '/tmp/inactive-linked-extension'; + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + { + getExtensions: () => [ + { + path: activeSource, + config: {}, + installMetadata: { type: 'link', source: activeSource }, + contextFiles: [`${activeSource}/QWEN.md`], + }, + { + path: inactiveSource, + config: {}, + installMetadata: { type: 'link', source: inactiveSource }, + contextFiles: [`${inactiveSource}/QWEN.md`], + }, + ], + getActiveExtensions: () => [ + { + path: activeSource, + config: {}, + installMetadata: { type: 'link', source: activeSource }, + contextFiles: [`${activeSource}/QWEN.md`], + }, + ], + getExtensionManager: () => ({ + addMutationListener: vi.fn(() => vi.fn()), + }), + } as unknown as Config, + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + expect(mockWatchers[0].target).toEqual([extensionsDir, activeSource]); + + fireAllEvent(0, 'change', `${inactiveSource}/QWEN.md`); + fireAllEvent(0, 'change', `${inactiveSource}/commands/run.toml`); + + expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled(); + expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled(); + }); + + it('ignores unrelated files', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent(0, 'change', `${extensionsDir}/alpha/README.md`); + fireAllEvent(0, 'change', `${extensionsDir}/alpha/node_modules/pkg/x.js`); + fireAllEvent(0, 'change', `${extensionsDir}/alpha/.DS_Store`); + + expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled(); + + const ignored = mockWatchers[0].options['ignored'] as ( + filePath: string, + ) => boolean; + expect(ignored('C:/Users/me/.qwen/extensions/alpha/.git/config')).toBe( + true, + ); + expect( + ignored('C:/Users/me/.qwen/extensions/alpha/node_modules/pkg/index.js'), + ).toBe(true); + }); + + it('bootstraps on the parent when the extensions directory does not exist', () => { + mockExistsSync.mockReturnValue(false); + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + + watcher.startWatching(); + + expect(mockWatch).toHaveBeenCalledOnce(); + expect(mockWatchers[0].target).toBe('/home/user/.qwen'); + expect(mockWatchers[0].options).toEqual( + expect.objectContaining({ + ignoreInitial: true, + followSymlinks: false, + depth: 0, + }), + ); + }); + + it('restarts watching after extension manager mutations settle', () => { + const refreshState = createRefreshState(); + let mutationListener: + | ((event: { + id: number; + phase: 'start' | 'end'; + operation: string; + }) => void) + | undefined; + const manager = { + addMutationListener: vi.fn( + ( + listener: (event: { + id: number; + phase: 'start' | 'end'; + operation: string; + }) => void, + ) => { + mutationListener = listener; + return vi.fn(); + }, + ), + }; + const watcher = new ExtensionFileWatcher( + { + getExtensions: () => [], + getActiveExtensions: () => [], + getExtensionManager: () => manager, + } as unknown as Config, + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + mutationListener?.({ + id: 1, + phase: 'start', + operation: 'installExtension', + }); + mutationListener?.({ + id: 1, + phase: 'end', + operation: 'installExtension', + }); + + expect(mockWatch).toHaveBeenCalledTimes(2); + expect(mockWatchers[0].instance.close).toHaveBeenCalledOnce(); + }); + + it('ignores buffered events from stopped watchers', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + const oldWatcherIndex = 0; + watcher.restartWatching(); + + fireAllEvent(oldWatcherIndex, 'addDir', `${extensionsDir}/old-buffered`); + + expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled(); + }); + + it('pairs overlapping extension manager mutations by id', () => { + const refreshState = createRefreshState(); + let mutationListener: + | ((event: { + id: number; + phase: 'start' | 'end'; + operation: string; + }) => void) + | undefined; + const endSuppressions = new Map void>(); + vi.mocked(refreshState.beginSuppression).mockImplementation((onSettle) => { + const endSuppression = vi.fn(() => onSettle?.()); + endSuppressions.set(endSuppressions.size + 1, endSuppression); + return endSuppression; + }); + const manager = { + addMutationListener: vi.fn( + ( + listener: (event: { + id: number; + phase: 'start' | 'end'; + operation: string; + }) => void, + ) => { + mutationListener = listener; + return vi.fn(); + }, + ), + }; + const watcher = new ExtensionFileWatcher( + { + getExtensions: () => [], + getActiveExtensions: () => [], + getExtensionManager: () => manager, + } as unknown as Config, + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + mutationListener?.({ id: 1, phase: 'start', operation: 'enableExtension' }); + mutationListener?.({ + id: 2, + phase: 'start', + operation: 'disableExtension', + }); + mutationListener?.({ id: 1, phase: 'end', operation: 'enableExtension' }); + mutationListener?.({ id: 2, phase: 'end', operation: 'disableExtension' }); + + expect(endSuppressions.get(1)).toHaveBeenCalledOnce(); + expect(endSuppressions.get(2)).toHaveBeenCalledOnce(); + }); + + it('ends pending mutation suppressions when watching stops', () => { + const refreshState = createRefreshState(); + const endSuppression = vi.fn(); + vi.mocked(refreshState.beginSuppression).mockReturnValue(endSuppression); + let mutationListener: + | ((event: { + id: number; + phase: 'start' | 'end'; + operation: string; + }) => void) + | undefined; + const manager = { + addMutationListener: vi.fn( + ( + listener: (event: { + id: number; + phase: 'start' | 'end'; + operation: string; + }) => void, + ) => { + mutationListener = listener; + return vi.fn(); + }, + ), + }; + const watcher = new ExtensionFileWatcher( + { + getExtensions: () => [], + getActiveExtensions: () => [], + getExtensionManager: () => manager, + } as unknown as Config, + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + mutationListener?.({ + id: 1, + phase: 'start', + operation: 'installExtension', + }); + watcher.stopWatching(); + + expect(endSuppression).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/config/extension-file-watcher.ts b/packages/cli/src/config/extension-file-watcher.ts new file mode 100644 index 0000000000..13ab68e02b --- /dev/null +++ b/packages/cli/src/config/extension-file-watcher.ts @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { watch as watchFs, type FSWatcher } from 'chokidar'; +import { + createDebugLogger, + isSubpath, + Storage, + type Config, + type ExtensionMutationEvent, +} from '@qwen-code/qwen-code-core'; +import { ExtensionRefreshState } from './extension-refresh-state.js'; + +const debugLogger = createDebugLogger('EXTENSION_FILE_WATCHER'); + +const TOP_LEVEL_FILES = new Set(['extension-enablement.json']); + +const EXTENSION_FILES = new Set([ + 'qwen-extension.json', + '.qwen-extension-install.json', +]); + +// Keep these sets in sync with extension directory conventions. New runtime +// directories must be classified here as either content-auto-refreshable or +// package-stale. +const AUTO_REFRESH_DIRS = new Set(['commands', 'skills', 'agents']); +const STALE_DIRS = new Set(['hooks']); + +type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; +type RefreshAction = 'auto' | 'stale'; + +export class ExtensionFileWatcher { + private watcher?: FSWatcher; + private bootstrapWatcher?: FSWatcher; + private mutationListenerDisposer?: () => void; + private mutationSuppressionEnds = new Map void>(); + private staleFiles = new Set(); + private watching = false; + private watchGeneration = 0; + + constructor( + private readonly config: Config, + private readonly extensionsDir = Storage.getUserExtensionsDir(), + private readonly refreshState = new ExtensionRefreshState(), + ) {} + + startWatching(): void { + this.stopWatching(); + this.watching = true; + const generation = ++this.watchGeneration; + this.subscribeExtensionManagerMutations(); + this.staleFiles = this.getStaleFiles(); + const roots = this.getWatchRoots(); + + if (roots.length > 0) { + this.watcher = watchFs(roots, { + ignoreInitial: true, + followSymlinks: false, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 50, + }, + ignored: (filePath: string) => this.isIgnored(filePath), + }) + .on('all', (event: string, changedPath: string) => { + if (generation !== this.watchGeneration) return; + const resolvedPath = path.resolve(changedPath); + const action = this.getRefreshAction( + event as WatchEvent, + resolvedPath, + ); + let marked = false; + if (action === 'auto') { + marked = this.refreshState.markExtensionContentChanged( + 'extension content files changed', + ); + } else if (action === 'stale') { + marked = this.refreshState.markExtensionsChanged( + 'extension files changed', + ); + } + debugLogger.debug('Extension file event classified', { + event, + path: resolvedPath, + action, + marked, + }); + }) + .on('error', (error: unknown) => { + debugLogger.warn('Extension file watcher error:', error); + }); + } + + if (!fs.existsSync(this.extensionsDir)) { + this.watchExtensionsParent(); + } + } + + stopWatching(): void { + const watcher = this.watcher; + const bootstrapWatcher = this.bootstrapWatcher; + this.watcher = undefined; + this.bootstrapWatcher = undefined; + this.watching = false; + this.watchGeneration++; + this.mutationListenerDisposer?.(); + this.mutationListenerDisposer = undefined; + this.endPendingMutationSuppressions(); + watcher?.close().catch((error: unknown) => { + debugLogger.warn('Extension file watcher close error:', error); + }); + bootstrapWatcher?.close().catch((error: unknown) => { + debugLogger.warn('Extension bootstrap watcher close error:', error); + }); + } + + restartWatching(): void { + this.startWatching(); + } + + private getWatchRoots(): string[] { + const roots = new Set(); + if (fs.existsSync(this.extensionsDir)) { + roots.add(this.extensionsDir); + } + for (const extension of this.config.getActiveExtensions()) { + if (extension.installMetadata?.type === 'link') { + const rawSource = extension.installMetadata.source; + const source = rawSource ? path.resolve(rawSource) : undefined; + if (source && fs.existsSync(source)) { + roots.add(source); + } + } + } + return [...roots]; + } + + private getStaleFiles(): Set { + const files = new Set(); + for (const extension of this.config.getActiveExtensions()) { + for (const filePath of extension.contextFiles) { + files.add(path.resolve(filePath)); + } + const configured = extension.config.contextFileName; + const names = + configured === undefined + ? ['QWEN.md'] + : Array.isArray(configured) + ? configured + : [configured]; + for (const name of names) { + files.add(path.resolve(extension.path, name)); + } + this.addManifestFileReference( + files, + extension.path, + extension.config.hooks, + ); + this.addManifestFileReference( + files, + extension.path, + extension.config.lspServers, + ); + } + return files; + } + + private addManifestFileReference( + files: Set, + extensionPath: string, + value: unknown, + ): void { + if (typeof value !== 'string') return; + files.add( + path.isAbsolute(value) + ? path.resolve(value) + : path.resolve(extensionPath, value), + ); + } + + private watchExtensionsParent(): void { + this.closeBootstrapWatcher(); + const parentDir = path.dirname(this.extensionsDir); + const dirBasename = path.basename(this.extensionsDir); + const generation = this.watchGeneration; + this.bootstrapWatcher = watchFs(parentDir, { + ignoreInitial: true, + followSymlinks: false, + depth: 0, + ignored: (filePath: string) => + filePath !== parentDir && path.basename(filePath) !== dirBasename, + }) + .on('all', (_event: string, changedPath: string) => { + if (generation !== this.watchGeneration) return; + if (path.basename(changedPath) !== dirBasename) return; + if (!fs.existsSync(this.extensionsDir)) return; + this.refreshState.markExtensionsChanged('extension directory created'); + queueMicrotask(() => { + if (this.watching) { + this.restartWatching(); + } + }); + }) + .on('error', (error: unknown) => { + debugLogger.warn('Extension bootstrap watcher error:', error); + }); + } + + private getRefreshAction( + event: WatchEvent, + changedPath: string, + ): RefreshAction | false { + if (this.staleFiles.has(changedPath)) { + return 'stale'; + } + if (changedPath === path.resolve(this.extensionsDir)) { + if (event === 'unlinkDir') { + this.watchExtensionsParent(); + return 'stale'; + } + return false; + } + if (isSubpath(this.extensionsDir, changedPath)) { + return this.getUserExtensionRefreshAction(event, changedPath); + } + return this.getLinkedExtensionRefreshAction(changedPath); + } + + private getUserExtensionRefreshAction( + event: WatchEvent, + changedPath: string, + ): RefreshAction | false { + const relative = path.relative(this.extensionsDir, changedPath); + const parts = relative.split(path.sep).filter(Boolean); + if (parts.length === 1) { + if (TOP_LEVEL_FILES.has(parts[0])) return 'stale'; + if (event === 'addDir' || event === 'unlinkDir') return 'stale'; + return false; + } + if ( + !fs.existsSync( + path.join(this.extensionsDir, parts[0], 'qwen-extension.json'), + ) + ) { + return 'stale'; + } + const runtimePath = parts.slice(1); + return this.getRuntimePathRefreshAction(runtimePath); + } + + private getLinkedExtensionRefreshAction( + changedPath: string, + ): RefreshAction | false { + for (const extension of this.config.getActiveExtensions()) { + if (extension.installMetadata?.type !== 'link') continue; + const rawSource = extension.installMetadata.source; + const source = rawSource ? path.resolve(rawSource) : undefined; + if (!source || !isSubpath(source, changedPath)) continue; + const relative = path.relative(source, changedPath); + const parts = relative.split(path.sep).filter(Boolean); + return parts.length === 0 + ? 'stale' + : this.getRuntimePathRefreshAction(parts); + } + return false; + } + + private getRuntimePathRefreshAction(parts: string[]): RefreshAction | false { + if (EXTENSION_FILES.has(parts[0]) || STALE_DIRS.has(parts[0])) { + return 'stale'; + } + if (AUTO_REFRESH_DIRS.has(parts[0])) { + return 'auto'; + } + return false; + } + + private isIgnored(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + const searchablePath = `/${normalized}/`; + if ( + searchablePath.includes('/node_modules/') || + searchablePath.includes('/.git/') + ) { + return true; + } + const basename = normalized.split('/').pop() ?? ''; + return ( + basename === '.DS_Store' || + basename.endsWith('~') || + basename.endsWith('.swp') || + basename.endsWith('.tmp') + ); + } + + private subscribeExtensionManagerMutations(): void { + const manager = this.config.getExtensionManager(); + this.mutationListenerDisposer = manager.addMutationListener( + (event: ExtensionMutationEvent) => { + if (event.phase === 'start') { + this.mutationSuppressionEnds.set( + event.id, + this.refreshState.beginSuppression(() => + this.restartAfterMutation(), + ), + ); + return; + } + const endSuppression = this.mutationSuppressionEnds.get(event.id); + if (!endSuppression) { + return; + } + this.mutationSuppressionEnds.delete(event.id); + endSuppression(); + }, + ); + } + + private endPendingMutationSuppressions(): void { + const endSuppressions = [...this.mutationSuppressionEnds.values()]; + this.mutationSuppressionEnds.clear(); + for (const endSuppression of endSuppressions) { + endSuppression(); + } + } + + private restartAfterMutation(): void { + if (this.watching) { + this.restartWatching(); + } + } + + private closeBootstrapWatcher(): void { + const bootstrapWatcher = this.bootstrapWatcher; + this.bootstrapWatcher = undefined; + bootstrapWatcher?.close().catch((error: unknown) => { + debugLogger.warn('Extension bootstrap watcher close error:', error); + }); + } +} diff --git a/packages/cli/src/config/extension-refresh-state.test.ts b/packages/cli/src/config/extension-refresh-state.test.ts new file mode 100644 index 0000000000..9eacc9a9fd --- /dev/null +++ b/packages/cli/src/config/extension-refresh-state.test.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExtensionRefreshState } from './extension-refresh-state.js'; +import { AppEvent } from '../utils/events.js'; + +describe('extension refresh state', () => { + let refreshState: ExtensionRefreshState; + + beforeEach(() => { + refreshState = new ExtensionRefreshState(); + }); + + it('deduplicates refresh notifications until cleared', () => { + const listener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, listener); + + try { + expect( + refreshState.markExtensionsChanged('extension files changed'), + ).toBe(true); + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(listener).toHaveBeenCalledWith('extension files changed'); + + expect( + refreshState.markExtensionsChanged('extension files changed again'), + ).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + refreshState.clearExtensionsChanged(); + expect(refreshState.needsExtensionRefresh()).toBe(false); + + expect( + refreshState.markExtensionsChanged('extension files changed again'), + ).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, listener); + } + }); + + it('suppresses watcher notifications during known mutations', async () => { + const staleListener = vi.fn(); + const contentListener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.on(AppEvent.ExtensionContentChanged, contentListener); + + try { + await refreshState.suppressNotifications(async () => { + expect( + refreshState.markExtensionsChanged('extension files changed'), + ).toBe(false); + expect( + refreshState.markExtensionContentChanged('extension content changed'), + ).toBe(false); + }); + + expect(refreshState.needsExtensionRefresh()).toBe(false); + expect(staleListener).not.toHaveBeenCalled(); + expect(contentListener).not.toHaveBeenCalled(); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.off(AppEvent.ExtensionContentChanged, contentListener); + } + }); + + it('clears the post-mutation suppression window after reload', async () => { + const listener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, listener); + + try { + await refreshState.suppressNotifications(async () => {}); + expect(refreshState.markExtensionsChanged('during suppress window')).toBe( + false, + ); + + refreshState.clearExtensionsChanged(); + + expect(refreshState.markExtensionsChanged('after reload')).toBe(true); + expect(listener).toHaveBeenCalledWith('after reload'); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, listener); + } + }); + + it('preserves stale state for changes that arrive during reload', () => { + const listener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, listener); + + try { + expect(refreshState.markExtensionsChanged('before reload')).toBe(true); + refreshState.notifyExtensionsReloadStarted(); + + expect(refreshState.markExtensionsChanged('during reload')).toBe(false); + refreshState.clearExtensionsChanged(); + + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith( + 'extension files changed during reload', + ); + + refreshState.clearExtensionsChanged(); + expect(refreshState.needsExtensionRefresh()).toBe(false); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, listener); + } + }); + + it('defers content changes that arrive during reload', () => { + const contentListener = vi.fn(); + refreshState.on(AppEvent.ExtensionContentChanged, contentListener); + + try { + refreshState.notifyExtensionsReloadStarted(); + + expect( + refreshState.markExtensionContentChanged( + 'content changed during reload', + ), + ).toBe(false); + expect(contentListener).not.toHaveBeenCalled(); + + refreshState.clearExtensionsChanged(); + + expect(refreshState.needsExtensionRefresh()).toBe(false); + expect(contentListener).toHaveBeenCalledOnce(); + expect(contentListener).toHaveBeenCalledWith( + 'extension content files changed during reload', + ); + } finally { + refreshState.off(AppEvent.ExtensionContentChanged, contentListener); + } + }); + + it('lets stale changes during reload take priority over content changes', () => { + const staleListener = vi.fn(); + const contentListener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.on(AppEvent.ExtensionContentChanged, contentListener); + + try { + refreshState.notifyExtensionsReloadStarted(); + + expect(refreshState.markExtensionContentChanged('content changed')).toBe( + false, + ); + expect(refreshState.markExtensionsChanged('manifest changed')).toBe( + false, + ); + refreshState.clearExtensionsChanged(); + + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(staleListener).toHaveBeenCalledOnce(); + expect(staleListener).toHaveBeenCalledWith( + 'extension files changed during reload', + ); + expect(contentListener).not.toHaveBeenCalled(); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.off(AppEvent.ExtensionContentChanged, contentListener); + } + }); + + it('does not emit content changes while stale refresh is needed', () => { + const contentListener = vi.fn(); + refreshState.on(AppEvent.ExtensionContentChanged, contentListener); + + try { + expect(refreshState.markExtensionsChanged('manifest changed')).toBe(true); + expect(refreshState.markExtensionContentChanged('content changed')).toBe( + false, + ); + expect(contentListener).not.toHaveBeenCalled(); + } finally { + refreshState.off(AppEvent.ExtensionContentChanged, contentListener); + } + }); + + it('keeps refresh needed when reload fails', () => { + const staleListener = vi.fn(); + const reloadedListener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.on(AppEvent.ExtensionsReloaded, reloadedListener); + + try { + refreshState.notifyExtensionsReloadStarted(); + refreshState.markExtensionsReloadFailed('reload failed'); + + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(reloadedListener).toHaveBeenCalledOnce(); + expect(staleListener).toHaveBeenCalledWith('reload failed'); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.off(AppEvent.ExtensionsReloaded, reloadedListener); + } + }); + + it('preserves changes that arrive before a reload failure', () => { + const staleListener = vi.fn(); + const contentListener = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.on(AppEvent.ExtensionContentChanged, contentListener); + + try { + refreshState.notifyExtensionsReloadStarted(); + expect(refreshState.isReloadInProgress()).toBe(true); + expect(refreshState.markExtensionContentChanged('content changed')).toBe( + false, + ); + expect(refreshState.markExtensionsChanged('manifest changed')).toBe( + false, + ); + + refreshState.markExtensionsReloadFailed('reload failed'); + expect(refreshState.isReloadInProgress()).toBe(false); + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(staleListener).toHaveBeenCalledWith('reload failed'); + + refreshState.clearExtensionsChanged(); + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(staleListener).toHaveBeenCalledWith( + 'extension files changed during reload', + ); + expect(contentListener).not.toHaveBeenCalled(); + } finally { + refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener); + refreshState.off(AppEvent.ExtensionContentChanged, contentListener); + } + }); + + it('settles only after all overlapping suppressions end', () => { + const onSettle = vi.fn(); + const endFirst = refreshState.beginSuppression(onSettle); + const endSecond = refreshState.beginSuppression(onSettle); + + endFirst(); + expect(onSettle).not.toHaveBeenCalled(); + + endSecond(); + expect(onSettle).toHaveBeenCalledOnce(); + + endSecond(); + expect(onSettle).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/config/extension-refresh-state.ts b/packages/cli/src/config/extension-refresh-state.ts new file mode 100644 index 0000000000..9340eaca63 --- /dev/null +++ b/packages/cli/src/config/extension-refresh-state.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { AppEvent } from '../utils/events.js'; + +const SUPPRESS_AFTER_MS = 1000; +export const EXTENSION_RELOAD_FAILED_REASON = 'extension reload failed'; + +export class ExtensionRefreshState { + private extensionRefreshNeeded = false; + private reloadInProgress = false; + private changedDuringReload = false; + private contentChangedDuringReload = false; + private suppressionDepth = 0; + private suppressUntil = 0; + + constructor(private readonly events = new EventEmitter()) {} + + on(event: AppEvent, listener: (...args: unknown[]) => void): void { + this.events.on(event, listener); + } + + off(event: AppEvent, listener: (...args: unknown[]) => void): void { + this.events.off(event, listener); + } + + markExtensionsChanged(reason?: string): boolean { + if (this.isSuppressed()) { + return false; + } + if (this.reloadInProgress) { + this.changedDuringReload = true; + return false; + } + if (this.extensionRefreshNeeded) { + return false; + } + this.extensionRefreshNeeded = true; + this.events.emit(AppEvent.ExtensionRefreshNeeded, reason); + return true; + } + + markExtensionContentChanged(reason?: string): boolean { + if (this.isSuppressed()) { + return false; + } + if (this.reloadInProgress) { + this.contentChangedDuringReload = true; + return false; + } + if (this.extensionRefreshNeeded) { + return false; + } + this.events.emit(AppEvent.ExtensionContentChanged, reason); + return true; + } + + clearExtensionsChanged(): void { + const changedDuringReload = this.changedDuringReload; + const contentChangedDuringReload = this.contentChangedDuringReload; + this.extensionRefreshNeeded = changedDuringReload; + this.reloadInProgress = false; + this.changedDuringReload = false; + this.contentChangedDuringReload = false; + this.suppressUntil = 0; + this.events.emit(AppEvent.ExtensionsReloaded); + if (changedDuringReload) { + this.events.emit( + AppEvent.ExtensionRefreshNeeded, + 'extension files changed during reload', + ); + } else if (contentChangedDuringReload) { + this.events.emit( + AppEvent.ExtensionContentChanged, + 'extension content files changed during reload', + ); + } + } + + notifyExtensionsReloadStarted(): void { + this.reloadInProgress = true; + this.changedDuringReload = false; + this.contentChangedDuringReload = false; + this.events.emit(AppEvent.ExtensionsReloadStarted); + } + + markExtensionsReloadFailed(reason = EXTENSION_RELOAD_FAILED_REASON): void { + const changedDuringReload = this.changedDuringReload; + const contentChangedDuringReload = this.contentChangedDuringReload; + this.extensionRefreshNeeded = true; + this.reloadInProgress = false; + this.changedDuringReload = changedDuringReload; + this.contentChangedDuringReload = contentChangedDuringReload; + this.suppressUntil = 0; + this.events.emit(AppEvent.ExtensionsReloaded); + this.events.emit(AppEvent.ExtensionRefreshNeeded, reason); + } + + needsExtensionRefresh(): boolean { + return this.extensionRefreshNeeded; + } + + isReloadInProgress(): boolean { + return this.reloadInProgress; + } + + beginSuppression(onSettle?: () => void): () => void { + this.suppressionDepth++; + let settled = false; + return () => { + if (settled) return; + settled = true; + this.suppressionDepth = Math.max(0, this.suppressionDepth - 1); + this.suppressUntil = Date.now() + SUPPRESS_AFTER_MS; + if (this.suppressionDepth === 0) { + onSettle?.(); + } + }; + } + + suppressNotifications(fn: () => T, onSettle?: () => void): T { + const endSuppression = this.beginSuppression(onSettle); + + try { + const result = fn(); + if (isPromiseLike(result)) { + return Promise.resolve(result).finally(endSuppression) as T; + } + endSuppression(); + return result; + } catch (error) { + endSuppression(); + throw error; + } + } + + resetForTesting(): void { + this.extensionRefreshNeeded = false; + this.reloadInProgress = false; + this.changedDuringReload = false; + this.contentChangedDuringReload = false; + this.suppressionDepth = 0; + this.suppressUntil = 0; + } + + private isSuppressed(): boolean { + return this.suppressionDepth > 0 || Date.now() < this.suppressUntil; + } +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ); +} diff --git a/packages/cli/src/config/extension-runtime-reload.test.ts b/packages/cli/src/config/extension-runtime-reload.test.ts new file mode 100644 index 0000000000..d42864a6f1 --- /dev/null +++ b/packages/cli/src/config/extension-runtime-reload.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + refreshExtensionContentRuntime, + reloadPluginsRuntime, +} from './extension-runtime-reload.js'; + +describe('reloadPluginsRuntime', () => { + it('refreshes extension runtime and returns active extension counts', async () => { + const refreshCache = vi.fn(); + const refreshTools = vi.fn(); + const reloadCommands = vi.fn(); + const config = { + isSafeMode: () => false, + getExtensionManager: () => ({ + refreshCache, + refreshTools, + }), + getActiveExtensions: () => [ + { + commands: ['a', 'b'], + skills: [{ name: 's1' }], + agents: [{ name: 'a1' }, { name: 'a2' }], + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { type: 'command', command: 'echo one' }, + { type: 'command', command: 'echo two' }, + ], + }, + ], + }, + mcpServers: { + one: { command: 'node' }, + }, + config: { + lspServers: { + ts: {}, + }, + }, + }, + ], + } as unknown as Config; + + const summary = await reloadPluginsRuntime({ config, reloadCommands }); + + expect(refreshCache).toHaveBeenCalledOnce(); + expect(refreshTools).toHaveBeenCalledOnce(); + expect(reloadCommands).toHaveBeenCalledOnce(); + expect(summary).toEqual({ + extensionCount: 1, + commandCount: 2, + skillCount: 1, + agentCount: 2, + hookCount: 2, + mcpServerCount: 1, + lspServerCount: 1, + }); + }); + + it('rejects without refreshing extensions in safe mode', async () => { + const refreshCache = vi.fn(); + const refreshTools = vi.fn(); + const reloadCommands = vi.fn(); + const config = { + isSafeMode: () => true, + getExtensionManager: () => ({ + refreshCache, + refreshTools, + }), + } as unknown as Config; + + await expect( + reloadPluginsRuntime({ config, reloadCommands }), + ).rejects.toThrow('Extension reload is disabled in safe mode.'); + + expect(refreshCache).not.toHaveBeenCalled(); + expect(refreshTools).not.toHaveBeenCalled(); + expect(reloadCommands).not.toHaveBeenCalled(); + }); +}); + +describe('refreshExtensionContentRuntime', () => { + it('refreshes extension cache, skills, agents, and slash commands', async () => { + const refreshCache = vi.fn(); + const refreshSkillCache = vi.fn(); + const refreshSubagentCache = vi.fn(); + const reloadCommands = vi.fn(); + const config = { + isSafeMode: () => false, + getExtensionManager: () => ({ refreshCache }), + getSkillManager: () => ({ refreshCache: refreshSkillCache }), + getSubagentManager: () => ({ refreshCache: refreshSubagentCache }), + } as unknown as Config; + + await refreshExtensionContentRuntime({ config, reloadCommands }); + + expect(refreshCache).toHaveBeenCalledOnce(); + expect(refreshSkillCache).toHaveBeenCalledOnce(); + expect(refreshSubagentCache).toHaveBeenCalledOnce(); + expect(reloadCommands).toHaveBeenCalledOnce(); + }); + + it('continues refreshing content before reporting refresh failures', async () => { + const refreshCache = vi.fn().mockRejectedValue(new Error('cache failed')); + const refreshSkillCache = vi + .fn() + .mockRejectedValue(new Error('skills failed')); + const refreshSubagentCache = vi.fn(); + const reloadCommands = vi.fn(); + const config = { + isSafeMode: () => false, + getExtensionManager: () => ({ refreshCache }), + getSkillManager: () => ({ refreshCache: refreshSkillCache }), + getSubagentManager: () => ({ refreshCache: refreshSubagentCache }), + } as unknown as Config; + + await expect( + refreshExtensionContentRuntime({ config, reloadCommands }), + ).rejects.toThrow('cache failed; skills failed'); + + expect(refreshCache).toHaveBeenCalledOnce(); + expect(refreshSkillCache).toHaveBeenCalledOnce(); + expect(refreshSubagentCache).toHaveBeenCalledOnce(); + expect(reloadCommands).toHaveBeenCalledOnce(); + }); + + it('continues refreshing content when subagent manager is not initialized', async () => { + const refreshCache = vi.fn(); + const refreshSkillCache = vi.fn(); + const reloadCommands = vi.fn(); + const config = { + isSafeMode: () => false, + getExtensionManager: () => ({ refreshCache }), + getSkillManager: () => ({ refreshCache: refreshSkillCache }), + getSubagentManager: () => undefined, + } as unknown as Config; + + await expect( + refreshExtensionContentRuntime({ config, reloadCommands }), + ).resolves.toBeUndefined(); + + expect(refreshCache).toHaveBeenCalledOnce(); + expect(refreshSkillCache).toHaveBeenCalledOnce(); + expect(reloadCommands).toHaveBeenCalledOnce(); + }); + + it('skips content refresh in safe mode', async () => { + const refreshCache = vi.fn(); + const reloadCommands = vi.fn(); + const config = { + isSafeMode: () => true, + getExtensionManager: () => ({ refreshCache }), + } as unknown as Config; + + await expect( + refreshExtensionContentRuntime({ config, reloadCommands }), + ).resolves.toBeUndefined(); + + expect(refreshCache).not.toHaveBeenCalled(); + expect(reloadCommands).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/config/extension-runtime-reload.ts b/packages/cli/src/config/extension-runtime-reload.ts new file mode 100644 index 0000000000..32cdeea40e --- /dev/null +++ b/packages/cli/src/config/extension-runtime-reload.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createDebugLogger, + type Config, + type Extension, +} from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('EXTENSION_RUNTIME_RELOAD'); + +export interface ReloadPluginsSummary { + extensionCount: number; + commandCount: number; + skillCount: number; + agentCount: number; + hookCount: number; + mcpServerCount: number; + lspServerCount: number; +} + +export async function reloadPluginsRuntime(options: { + config: Config; + reloadCommands?: () => void | Promise; +}): Promise { + if (options.config.isSafeMode()) { + throw new Error('Extension reload is disabled in safe mode.'); + } + const manager = options.config.getExtensionManager(); + await manager.refreshCache(); + await manager.refreshTools(); + await options.reloadCommands?.(); + return summarizeExtensions(options.config.getActiveExtensions()); +} + +export async function refreshExtensionContentRuntime(options: { + config: Config; + reloadCommands?: () => void | Promise; +}): Promise { + if (options.config.isSafeMode()) return; + + const manager = options.config.getExtensionManager(); + const errors: unknown[] = []; + try { + await manager.refreshCache(); + } catch (error) { + errors.push(error); + debugLogger.warn( + 'refreshExtensionContentRuntime: refreshCache failed:', + error, + ); + } + + const settled = await Promise.allSettled([ + options.config.getSkillManager()?.refreshCache(), + options.config.getSubagentManager()?.refreshCache(), + options.reloadCommands?.(), + ]); + + for (const result of settled) { + if (result.status === 'rejected') { + errors.push(result.reason); + debugLogger.warn( + 'refreshExtensionContentRuntime: a refresh leg failed:', + result.reason, + ); + } + } + + if (errors.length > 0) { + throw new Error( + errors + .map((error) => + error instanceof Error ? error.message : String(error), + ) + .join('; '), + ); + } +} + +function summarizeExtensions(extensions: Extension[]): ReloadPluginsSummary { + return extensions.reduce( + (summary, extension) => { + summary.extensionCount++; + summary.commandCount += extension.commands?.length ?? 0; + summary.skillCount += extension.skills?.length ?? 0; + summary.agentCount += extension.agents?.length ?? 0; + summary.hookCount += countHooks(extension); + summary.mcpServerCount += Object.keys(extension.mcpServers ?? {}).length; + summary.lspServerCount += countLspServers(extension); + return summary; + }, + { + extensionCount: 0, + commandCount: 0, + skillCount: 0, + agentCount: 0, + hookCount: 0, + mcpServerCount: 0, + lspServerCount: 0, + }, + ); +} + +function countHooks(extension: Extension): number { + return Object.values(extension.hooks ?? {}).reduce( + (sum, definitions) => + sum + + (definitions ?? []).reduce( + (innerSum, definition) => innerSum + (definition.hooks?.length ?? 0), + 0, + ), + 0, + ); +} + +function countLspServers(extension: Extension): number { + const lspServers = extension.config.lspServers; + if (!lspServers) return 0; + if (typeof lspServers === 'string') return 1; + return Object.keys(lspServers).length; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c778bafec6..88176b9059 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -201,6 +201,14 @@ vi.mock('./config/lsp-config-watcher.js', () => ({ }, })); +vi.mock('./config/extension-file-watcher.js', () => ({ + ExtensionFileWatcher: class { + startWatching() {} + restartWatching() {} + stopWatching() {} + }, +})); + function withLspDisabledConfig( config: T, ): T & { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7025d33d0a..08fe05ace4 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -42,6 +42,8 @@ import { import { SettingsWatcher } from './config/settingsWatcher.js'; import { registerMcpHotReload } from './config/hot-reload.js'; import { LspConfigWatcher } from './config/lsp-config-watcher.js'; +import { ExtensionFileWatcher } from './config/extension-file-watcher.js'; +import { ExtensionRefreshState } from './config/extension-refresh-state.js'; import { initializeI18n, resolveLanguageSetting } from './i18n/index.js'; import { setupStartupWorktree, @@ -78,6 +80,25 @@ import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; const debugLogger = createDebugLogger('STARTUP'); +interface RuntimeLspReinitializeResult { + reconcile: { + added: string[]; + removed: string[]; + restarted: string[]; + unchanged: string[]; + failed: string[]; + }; + skipped: Array<{ name: string }>; +} + +interface RuntimeLspClient { + reinitialize?: () => Promise; +} + +interface RuntimeLspConfig { + reinitializeLsp?: () => Promise; +} + function clearCorruptionEnvVars(): void { delete process.env[ENV_CORRUPTED_PATH]; delete process.env[ENV_WAS_RECOVERED]; @@ -615,6 +636,28 @@ export async function main() { registerLspHotReload(config, registerCleanup); + const extensionRefreshState = new ExtensionRefreshState(); + const extensionFileWatcher = + isBareMode(argv.bare) || config.isSafeMode() + ? undefined + : new ExtensionFileWatcher(config, undefined, extensionRefreshState); + extensionFileWatcher?.startWatching(); + if (extensionFileWatcher) { + const restartExtensionWatcher = () => + extensionFileWatcher.restartWatching(); + extensionRefreshState.on( + AppEvent.ExtensionsReloaded, + restartExtensionWatcher, + ); + registerCleanup(() => { + extensionRefreshState.off( + AppEvent.ExtensionsReloaded, + restartExtensionWatcher, + ); + extensionFileWatcher.stopWatching(); + }); + } + // Phase D-1: persist the WorktreeSession sidecar so Phase C's restore // machinery on a subsequent `--resume` picks the worktree back up, and // capture any override of a previously-resumed session's worktree so @@ -850,6 +893,7 @@ export async function main() { initializationResult!, { postRenderConnectIde: deferIdeConnection, + extensionRefreshState, }, ); // Clean up corruption env vars so subsequent relaunch children @@ -1047,9 +1091,15 @@ export function registerLspHotReload( config: Config, registerCleanup: (fn: () => void | Promise) => void, ): void { + const lspClient = config.getLspClient?.() as + | (ReturnType & RuntimeLspClient) + | undefined; + const runtimeConfig = config as Config & RuntimeLspConfig; + const reinitializeLsp = runtimeConfig.reinitializeLsp; if ( config.isLspEnabled?.() !== true || - !config.getLspClient?.()?.reinitialize + !lspClient?.reinitialize || + !reinitializeLsp ) { return; } @@ -1068,7 +1118,7 @@ export function registerLspHotReload( ); let errorReported = false; try { - const result = await config.reinitializeLsp(); + const result = await reinitializeLsp(); if (result) { const failedServers = getRuntimeReloadFailedNames(result.reconcile); debugLogger.info( diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 9e6f45e4d3..cc1b6f5325 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2060,4 +2060,34 @@ export default { 'Ús: /history collapse-on-resume|expand-on-resume|expand-now', 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': 'Història reduïda: {{n}} missatges ocults. Utilitzeu /history expand-now per mostrar.', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 3f7b10d332..137c8ed96c 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2105,4 +2105,34 @@ export default { in: 'ein', out: 'aus', 'In/Out': 'Ein/Aus', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 61ede7a27d..22ad72f628 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2552,4 +2552,34 @@ export default { reqs: 'reqs', in: 'in', out: 'out', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 9306775b8d..564eea679b 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2107,4 +2107,34 @@ export default { in: 'ent.', out: 'sort.', 'In/Out': 'Ent/Sort', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 6e9e9b6cab..aa6b9c3820 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1869,4 +1869,34 @@ export default { in: '入力', out: '出力', 'In/Out': '入力/出力', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 6bf522d209..b1bd2d7593 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2091,4 +2091,34 @@ export default { in: 'ent.', out: 'saída', 'In/Out': 'Ent/Saída', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ceb05b235f..acfe059665 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2079,4 +2079,34 @@ export default { in: 'вх.', out: 'вых.', 'In/Out': 'Вх/Вых', + + // ============================================================================ + // reload-plugins command + // ============================================================================ + '{{count}} extension': '{{count}} extension', + '{{count}} extensions': '{{count}} extensions', + '{{count}} command': '{{count}} command', + '{{count}} commands': '{{count}} commands', + '{{count}} skill': '{{count}} skill', + '{{count}} skills': '{{count}} skills', + '{{count}} agent': '{{count}} agent', + '{{count}} agents': '{{count}} agents', + '{{count}} hook': '{{count}} hook', + '{{count}} hooks': '{{count}} hooks', + '{{count}} extension MCP server': '{{count}} extension MCP server', + '{{count}} extension MCP servers': '{{count}} extension MCP servers', + '{{count}} extension LSP server': '{{count}} extension LSP server', + '{{count}} extension LSP servers': '{{count}} extension LSP servers', + 'Reload extension changes from disk': 'Reload extension changes from disk', + 'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}', + 'Reload failed: {{message}}': 'Reload failed: {{message}}', + 'Reload failed.': 'Reload failed.', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + 'Extension reload did not complete. Run /reload-plugins to try again.': + 'Extension reload did not complete. Run /reload-plugins to try again.', }; diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index f59a257e29..4f15dfbb5c 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2145,4 +2145,34 @@ export default { ' (not in model registry)': '(不在模型註冊表中)', 'start server': '啟動伺服器', 'No compression needed.': '無需壓縮。', + + // ============================================================================ + // reload-plugins 命令 + // ============================================================================ + '{{count}} extension': '{{count}} 個擴充', + '{{count}} extensions': '{{count}} 個擴充', + '{{count}} command': '{{count}} 個指令', + '{{count}} commands': '{{count}} 個指令', + '{{count}} skill': '{{count}} 個技能', + '{{count}} skills': '{{count}} 個技能', + '{{count}} agent': '{{count}} 個代理', + '{{count}} agents': '{{count}} 個代理', + '{{count}} hook': '{{count}} 個鉤子', + '{{count}} hooks': '{{count}} 個鉤子', + '{{count}} extension MCP server': '{{count}} 個擴充 MCP 伺服器', + '{{count}} extension MCP servers': '{{count}} 個擴充 MCP 伺服器', + '{{count}} extension LSP server': '{{count}} 個擴充 LSP 伺服器', + '{{count}} extension LSP servers': '{{count}} 個擴充 LSP 伺服器', + 'Reload extension changes from disk': '從磁碟重新載入擴充變更', + 'Reloaded extensions: {{summary}}': '已重新載入擴充:{{summary}}', + 'Reload failed: {{message}}': '重新載入失敗:{{message}}', + 'Reload failed.': '重新載入失敗。', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + '磁碟上的擴充已變更。執行 /reload-plugins 來套用更新。', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + '擴充內容重新整理失敗:{{message}}。執行 /reload-plugins 來套用更新。', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + '擴充內容重新整理失敗。執行 /reload-plugins 來套用更新。', + 'Extension reload did not complete. Run /reload-plugins to try again.': + '擴充重新載入未完成。執行 /reload-plugins 重試。', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 8fca65b984..6243ab5f1a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2347,4 +2347,34 @@ export default { '中国 (China) - 阿里云百炼': '中国 - 阿里云百炼', '阿里云百炼 (aliyun.com)': '阿里云百炼(aliyun.com)', 'No compression needed.': '无需压缩。', + + // ============================================================================ + // reload-plugins 命令 + // ============================================================================ + '{{count}} extension': '{{count}} 个扩展', + '{{count}} extensions': '{{count}} 个扩展', + '{{count}} command': '{{count}} 个命令', + '{{count}} commands': '{{count}} 个命令', + '{{count}} skill': '{{count}} 个技能', + '{{count}} skills': '{{count}} 个技能', + '{{count}} agent': '{{count}} 个代理', + '{{count}} agents': '{{count}} 个代理', + '{{count}} hook': '{{count}} 个钩子', + '{{count}} hooks': '{{count}} 个钩子', + '{{count}} extension MCP server': '{{count}} 个扩展 MCP 服务器', + '{{count}} extension MCP servers': '{{count}} 个扩展 MCP 服务器', + '{{count}} extension LSP server': '{{count}} 个扩展 LSP 服务器', + '{{count}} extension LSP servers': '{{count}} 个扩展 LSP 服务器', + 'Reload extension changes from disk': '从磁盘重新加载扩展变更', + 'Reloaded extensions: {{summary}}': '已重新加载扩展:{{summary}}', + 'Reload failed: {{message}}': '重新加载失败:{{message}}', + 'Reload failed.': '重新加载失败。', + 'Extensions changed on disk. Run /reload-plugins to apply updates.': + '磁盘上的扩展已变更。运行 /reload-plugins 来应用更新。', + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.': + '扩展内容刷新失败:{{message}}。运行 /reload-plugins 来应用更新。', + 'Failed to refresh extension content. Run /reload-plugins to apply updates.': + '扩展内容刷新失败。运行 /reload-plugins 来应用更新。', + 'Extension reload did not complete. Run /reload-plugins to try again.': + '扩展重新加载未完成。运行 /reload-plugins 重试。', }; diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index fa8a118dfb..76330872ef 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -292,4 +292,14 @@ describe('BuiltinCommandLoader', () => { const hooksCmd2 = commands2.find((c) => c.name === 'hooks'); expect(hooksCmd2).toBeDefined(); }); + + it('should register the /reload-plugins command', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + const command = commands.find((c) => c.name === 'reload-plugins'); + + expect(command).toBeDefined(); + expect(command?.kind).toBe(CommandKind.BUILT_IN); + }); }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d4ee0e7a7e..181198de25 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -54,6 +54,7 @@ import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { recapCommand } from '../ui/commands/recapCommand.js'; +import { reloadPluginsCommand } from '../ui/commands/reload-plugins-command.js'; import { renameCommand } from '../ui/commands/renameCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; @@ -155,6 +156,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, recapCommand, + reloadPluginsCommand, renameCommand, restoreCommand(this.config), resumeCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 62168afe49..03b1a51f8e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -166,6 +166,7 @@ import { } from './utils/workflow-keyword.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; +import { ExtensionRefreshState } from '../config/extension-refresh-state.js'; import { useFocus } from './hooks/useFocus.js'; import { useAwaySummary } from './hooks/useAwaySummary.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; @@ -424,6 +425,7 @@ interface AppContainerProps { startupWarnings?: string[]; version: string; initializationResult: InitializationResult; + extensionRefreshState?: ExtensionRefreshState; } /** @@ -440,6 +442,10 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { settings, config, initializationResult } = props; + const extensionRefreshState = useMemo( + () => props.extensionRefreshState ?? new ExtensionRefreshState(), + [props.extensionRefreshState], + ); const historyManager = useHistory(); // `useHistory()` returns a fresh memoized object whenever `history` changes, // so depending on `historyManager` directly inside event-handler callbacks @@ -1505,6 +1511,7 @@ export const AppContainer = (props: AppContainerProps) => { logger, historyManager.updateItem, setSessionName, + extensionRefreshState, ); // onDebugMessage should log to debug logfile, not update footer debugMessage 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 0000000000..ab36aa043b --- /dev/null +++ b/packages/cli/src/ui/commands/reload-plugins-command.test.ts @@ -0,0 +1,106 @@ +/** + * @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/extension-runtime-reload.js'; +import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; + +vi.mock('../../config/extension-runtime-reload.js', () => ({ + reloadPluginsRuntime: vi.fn(async () => ({ + extensionCount: 1, + commandCount: 2, + skillCount: 3, + agentCount: 4, + hookCount: 5, + mcpServerCount: 6, + lspServerCount: 7, + })), +})); + +describe('reloadPluginsCommand', () => { + let extensionRefreshState: ExtensionRefreshState; + + beforeEach(() => { + vi.clearAllMocks(); + extensionRefreshState = { + clearExtensionsChanged: vi.fn(), + markExtensionsReloadFailed: vi.fn(), + notifyExtensionsReloadStarted: vi.fn(), + } as unknown as ExtensionRefreshState; + }); + + it('returns an error when config is missing', async () => { + const context = { + services: { config: null, extensionRefreshState }, + 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(reloadPluginsRuntime).not.toHaveBeenCalled(); + expect(extensionRefreshState.clearExtensionsChanged).not.toHaveBeenCalled(); + expect( + extensionRefreshState.notifyExtensionsReloadStarted, + ).not.toHaveBeenCalled(); + }); + + it('reloads extension runtime and clears stale state', async () => { + const config = {} as Config; + const reloadCommands = vi.fn(); + const context = { + services: { config, extensionRefreshState }, + ui: { reloadCommands }, + } as unknown as CommandContext; + + const result = await reloadPluginsCommand.action?.(context, ''); + + expect( + extensionRefreshState.notifyExtensionsReloadStarted, + ).toHaveBeenCalledOnce(); + expect(reloadPluginsRuntime).toHaveBeenCalledWith({ + config, + reloadCommands, + }); + expect(extensionRefreshState.clearExtensionsChanged).toHaveBeenCalledOnce(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Reloaded extensions: 1 extension · 2 commands · 3 skills · 4 agents · 5 hooks · 6 extension MCP servers · 7 extension LSP servers', + }); + }); + + it('preserves stale state when reload fails', async () => { + vi.mocked(reloadPluginsRuntime).mockRejectedValueOnce(new Error('boom')); + const context = { + services: { config: {} as Config, extensionRefreshState }, + ui: { reloadCommands: vi.fn() }, + } as unknown as CommandContext; + + const result = await reloadPluginsCommand.action?.(context, ''); + + expect( + extensionRefreshState.notifyExtensionsReloadStarted, + ).toHaveBeenCalledOnce(); + expect(extensionRefreshState.clearExtensionsChanged).not.toHaveBeenCalled(); + expect( + extensionRefreshState.markExtensionsReloadFailed, + ).toHaveBeenCalledOnce(); + 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 0000000000..91df64a379 --- /dev/null +++ b/packages/cli/src/ui/commands/reload-plugins-command.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + reloadPluginsRuntime, + type ReloadPluginsSummary, +} from '../../config/extension-runtime-reload.js'; +import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { t } from '../../i18n/index.js'; +import { + CommandKind, + type CommandContext, + type MessageActionReturn, + type SlashCommand, +} from './types.js'; + +function summaryCountTerm( + count: number, + singularKey: string, + pluralKey: string, +): string { + return t(count === 1 ? singularKey : pluralKey, { + count: String(count), + }); +} + +export function formatReloadPluginsSummary(summary: ReloadPluginsSummary) { + return [ + summaryCountTerm( + summary.extensionCount, + '{{count}} extension', + '{{count}} extensions', + ), + summaryCountTerm( + summary.commandCount, + '{{count}} command', + '{{count}} commands', + ), + summaryCountTerm(summary.skillCount, '{{count}} skill', '{{count}} skills'), + summaryCountTerm(summary.agentCount, '{{count}} agent', '{{count}} agents'), + summaryCountTerm(summary.hookCount, '{{count}} hook', '{{count}} hooks'), + summaryCountTerm( + summary.mcpServerCount, + '{{count}} extension MCP server', + '{{count}} extension MCP servers', + ), + summaryCountTerm( + summary.lspServerCount, + '{{count}} extension LSP server', + '{{count}} extension LSP servers', + ), + ].join(' · '); +} + +export const reloadPluginsCommand: SlashCommand = { + name: 'reload-plugins', + get description() { + return t('Reload extension changes from disk'); + }, + 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.'), + }; + } + + const extensionRefreshState = + context.services.extensionRefreshState ?? new ExtensionRefreshState(); + + try { + extensionRefreshState.notifyExtensionsReloadStarted(); + const summary = await reloadPluginsRuntime({ + config, + reloadCommands: context.ui.reloadCommands, + }); + extensionRefreshState.clearExtensionsChanged(); + return { + type: 'message', + messageType: 'info', + content: t('Reloaded extensions: {{summary}}', { + summary: formatReloadPluginsSummary(summary), + }), + }; + } catch (error) { + extensionRefreshState.markExtensionsReloadFailed(); + 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/commands/types.ts b/packages/cli/src/ui/commands/types.ts index d8b402594b..cc13196547 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -24,6 +24,7 @@ import type { ExtensionUpdateAction, ExtensionUpdateStatus, } from '../state/extensions.js'; +import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; // Grouped dependencies for clarity and easier mocking export interface CommandContext { @@ -50,6 +51,7 @@ export interface CommandContext { config: Config | null; settings: LoadedSettings; logger: Logger | null; + extensionRefreshState?: ExtensionRefreshState; }; // UI state and history management ui: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 534de2dbd5..de547b1887 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -23,6 +23,8 @@ import { MessageType } from '../types.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; import { type GeminiClient, SlashCommandStatus, @@ -111,6 +113,10 @@ vi.mock('./useKeypress.js', () => ({ useKeypress: vi.fn(), })); +vi.mock('../../config/extension-runtime-reload.js', () => ({ + refreshExtensionContentRuntime: vi.fn(async () => undefined), +})); + function createTestCommand( overrides: Partial, kind: CommandKind = CommandKind.BUILT_IN, @@ -182,6 +188,7 @@ describe('useSlashCommandProcessor', () => { mockOpenModelDialog.mockClear(); mockOpenMemoryDialog.mockClear(); mockFireUserPromptExpansionEvent.mockResolvedValue(undefined); + vi.mocked(refreshExtensionContentRuntime).mockResolvedValue(undefined); mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false); mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); mockConfig.getHookSystem = vi.fn().mockReturnValue({ @@ -197,6 +204,7 @@ describe('useSlashCommandProcessor', () => { mcpCommands: SlashCommand[] = [], setIsProcessing = vi.fn(), settings: LoadedSettings = mockSettings, + extensionRefreshState?: ExtensionRefreshState, ) => { mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands)); mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); @@ -221,6 +229,8 @@ describe('useSlashCommandProcessor', () => { true, // isConfigInitialized null, // logger mockUpdateItem, + undefined, // setSessionName + extensionRefreshState, ), ); @@ -1699,6 +1709,147 @@ describe('useSlashCommandProcessor', () => { expect(abortSpy).toHaveBeenCalledTimes(1); }); + it('auto-refreshes extension content changes after debounce', async () => { + const extensionRefreshState = new ExtensionRefreshState(); + const result = setupProcessorHook( + [], + [], + [], + vi.fn(), + mockSettings, + extensionRefreshState, + ); + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); + + act(() => { + extensionRefreshState.markExtensionContentChanged('content changed'); + }); + + await waitFor(() => { + expect(refreshExtensionContentRuntime).toHaveBeenCalledOnce(); + }); + expect(refreshExtensionContentRuntime).toHaveBeenCalledWith({ + config: mockConfig, + reloadCommands: expect.any(Function), + }); + }); + + it('shows an error when extension content auto-refresh fails', async () => { + vi.mocked(refreshExtensionContentRuntime).mockRejectedValueOnce( + new Error('refresh failed'), + ); + const extensionRefreshState = new ExtensionRefreshState(); + const result = setupProcessorHook( + [], + [], + [], + vi.fn(), + mockSettings, + extensionRefreshState, + ); + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); + + act(() => { + extensionRefreshState.markExtensionContentChanged('content changed'); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Failed to refresh extension content: refresh failed. Run /reload-plugins to apply updates.', + }, + expect.any(Number), + ); + }); + }); + + it('skips content auto-refresh while package reload is needed', async () => { + const extensionRefreshState = new ExtensionRefreshState(); + const result = setupProcessorHook( + [], + [], + [], + vi.fn(), + mockSettings, + extensionRefreshState, + ); + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); + + act(() => { + extensionRefreshState.markExtensionsChanged('manifest changed'); + extensionRefreshState.markExtensionContentChanged('content changed'); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(refreshExtensionContentRuntime).not.toHaveBeenCalled(); + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Extensions changed on disk. Run /reload-plugins to apply updates.', + }, + expect.any(Number), + ); + }); + + it('shows a retry message when extension reload fails', async () => { + const extensionRefreshState = new ExtensionRefreshState(); + const result = setupProcessorHook( + [], + [], + [], + vi.fn(), + mockSettings, + extensionRefreshState, + ); + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); + + act(() => { + extensionRefreshState.markExtensionsReloadFailed(); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Extension reload did not complete. Run /reload-plugins to try again.', + }, + expect.any(Number), + ); + }); + + it('cancels pending content auto-refresh when manual reload starts', async () => { + const extensionRefreshState = new ExtensionRefreshState(); + const result = setupProcessorHook( + [], + [], + [], + vi.fn(), + mockSettings, + extensionRefreshState, + ); + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); + + act(() => { + extensionRefreshState.markExtensionContentChanged('content changed'); + extensionRefreshState.notifyExtensionsReloadStarted(); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(refreshExtensionContentRuntime).not.toHaveBeenCalled(); + }); + it('should reload commands when SkillManager fires a change event', async () => { const removeListener = vi.fn(); const addChangeListener = vi.fn().mockReturnValue(removeListener); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ec0cc51557..330d024cb4 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -60,6 +60,13 @@ import { parseStackedSlashCommands, MAX_STACKED_SKILLS, } from '../../utils/commands.js'; +import { AppEvent } from '../../utils/events.js'; +import { t } from '../../i18n/index.js'; +import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; +import { + EXTENSION_RELOAD_FAILED_REASON, + ExtensionRefreshState, +} from '../../config/extension-refresh-state.js'; import { hasSlashCommandPathSeparator, isBtwCommand, @@ -109,6 +116,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'btw', 'history', ]); +const MAX_EXTENSION_CONTENT_REFRESH_PASSES = 5; function getSkillCommandName(command: SlashCommand): string { return command.skillDetail?.name ?? command.name; @@ -174,7 +182,17 @@ export const useSlashCommandProcessor = ( logger: Logger | null, updateItem: UseHistoryManagerReturn['updateItem'], setSessionName?: (name: string | null) => void, + extensionRefreshState?: ExtensionRefreshState, ) => { + const fallbackExtensionRefreshStateRef = useRef( + null, + ); + if (!fallbackExtensionRefreshStateRef.current) { + fallbackExtensionRefreshStateRef.current = new ExtensionRefreshState(); + } + const activeExtensionRefreshState = + extensionRefreshState ?? fallbackExtensionRefreshStateRef.current; + // Ref avoids adding `history` to the commandContext useMemo deps, // which would cause a full context rebuild on every history append. const historyRef = useRef(history); @@ -191,6 +209,19 @@ export const useSlashCommandProcessor = ( const commandReloadResolversRef = useRef< Array<{ trigger: number; resolve: () => void }> >([]); + const extensionContentRefreshTimerRef = useRef | null>(null); + const extensionContentRefreshRunningRef = useRef(false); + const extensionContentRefreshPendingRef = useRef(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const resolveCommandReloads = useCallback((completedTrigger: number) => { if (commandReloadResolversRef.current.length === 0) { @@ -224,6 +255,83 @@ export const useSlashCommandProcessor = ( }); }); }, [config]); + + const clearPendingExtensionContentRefresh = useCallback(() => { + if (!extensionContentRefreshTimerRef.current) { + return; + } + clearTimeout(extensionContentRefreshTimerRef.current); + extensionContentRefreshTimerRef.current = null; + }, []); + + const showExtensionContentRefreshError = useCallback( + (error: unknown) => { + if (!mountedRef.current) { + return; + } + addItem( + { + type: MessageType.ERROR, + text: + error instanceof Error + ? t( + 'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.', + { message: error.message }, + ) + : t( + 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + ), + }, + Date.now(), + ); + }, + [addItem], + ); + + const runExtensionContentRefresh = useCallback(async () => { + if (!config) { + return; + } + if (extensionContentRefreshRunningRef.current) { + extensionContentRefreshPendingRef.current = true; + return; + } + extensionContentRefreshRunningRef.current = true; + let refreshPasses = 0; + try { + do { + if (refreshPasses >= MAX_EXTENSION_CONTENT_REFRESH_PASSES) { + extensionContentRefreshPendingRef.current = false; + showExtensionContentRefreshError( + new Error('too many extension content changes are still pending'), + ); + return; + } + refreshPasses++; + extensionContentRefreshPendingRef.current = false; + if (activeExtensionRefreshState.isReloadInProgress()) { + return; + } + if (activeExtensionRefreshState.needsExtensionRefresh()) { + return; + } + await refreshExtensionContentRuntime({ + config, + reloadCommands, + }); + } while (extensionContentRefreshPendingRef.current); + } catch (error: unknown) { + extensionContentRefreshPendingRef.current = false; + showExtensionContentRefreshError(error); + } finally { + extensionContentRefreshRunningRef.current = false; + } + }, [ + activeExtensionRefreshState, + config, + reloadCommands, + showExtensionContentRefreshError, + ]); const [shellConfirmationRequest, setShellConfirmationRequest] = useState { + const listener = (reason?: unknown) => { + clearPendingExtensionContentRefresh(); + addItem( + { + type: MessageType.INFO, + text: + reason === EXTENSION_RELOAD_FAILED_REASON + ? t( + 'Extension reload did not complete. Run /reload-plugins to try again.', + ) + : t( + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + ), + }, + Date.now(), + ); + }; + activeExtensionRefreshState.on(AppEvent.ExtensionRefreshNeeded, listener); + return () => { + activeExtensionRefreshState.off( + AppEvent.ExtensionRefreshNeeded, + listener, + ); + }; + }, [ + activeExtensionRefreshState, + addItem, + clearPendingExtensionContentRefresh, + ]); + + useEffect(() => { + activeExtensionRefreshState.on( + AppEvent.ExtensionsReloadStarted, + clearPendingExtensionContentRefresh, + ); + activeExtensionRefreshState.on( + AppEvent.ExtensionsReloaded, + clearPendingExtensionContentRefresh, + ); + return () => { + activeExtensionRefreshState.off( + AppEvent.ExtensionsReloadStarted, + clearPendingExtensionContentRefresh, + ); + activeExtensionRefreshState.off( + AppEvent.ExtensionsReloaded, + clearPendingExtensionContentRefresh, + ); + }; + }, [activeExtensionRefreshState, clearPendingExtensionContentRefresh]); + + useEffect(() => { + if (!config) { + return; + } + const listener = () => { + clearPendingExtensionContentRefresh(); + extensionContentRefreshTimerRef.current = setTimeout(() => { + extensionContentRefreshTimerRef.current = null; + void runExtensionContentRefresh(); + }, 250); + }; + activeExtensionRefreshState.on(AppEvent.ExtensionContentChanged, listener); + return () => { + activeExtensionRefreshState.off( + AppEvent.ExtensionContentChanged, + listener, + ); + clearPendingExtensionContentRefresh(); + extensionContentRefreshPendingRef.current = false; + }; + }, [ + clearPendingExtensionContentRefresh, + config, + activeExtensionRefreshState, + runExtensionContentRefresh, + ]); + useEffect(() => { const controller = new AbortController(); const load = async () => { diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 9c8a1021a7..868de266d9 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -14,6 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; +import type { ExtensionRefreshState } from '../config/extension-refresh-state.js'; import { DualOutputBridge } from '../dualOutput/DualOutputBridge.js'; import { DualOutputContext } from '../dualOutput/DualOutputContext.js'; import { RemoteInputWatcher } from '../remoteInput/RemoteInputWatcher.js'; @@ -45,6 +46,7 @@ const debugLogger = createDebugLogger('STARTUP'); export interface StartInteractiveUIOptions { postRenderConnectIde?: boolean; postRenderInitializeTelemetry?: boolean; + extensionRefreshState?: ExtensionRefreshState; } export async function startInteractiveUI( @@ -164,6 +166,7 @@ export async function startInteractiveUI( startupWarnings={startupWarnings} version={version} initializationResult={initializationResult} + extensionRefreshState={options.extensionRefreshState} /> diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index 93a355f444..317a2271cd 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -25,6 +25,10 @@ export enum AppEvent { */ McpPendingApprovalChanged = 'mcp-pending-approval-changed', LspStatusChanged = 'lsp-status-changed', + ExtensionContentChanged = 'extension-content-changed', + ExtensionRefreshNeeded = 'extension-refresh-needed', + ExtensionsReloadStarted = 'extensions-reload-started', + ExtensionsReloaded = 'extensions-reloaded', StartupIdeConnectionStatusChanged = 'startup-ide-connection-status-changed', } diff --git a/packages/core/src/extension/extension-runtime-refresh.test.ts b/packages/core/src/extension/extension-runtime-refresh.test.ts index ea31cf7c15..0cd6506a73 100644 --- a/packages/core/src/extension/extension-runtime-refresh.test.ts +++ b/packages/core/src/extension/extension-runtime-refresh.test.ts @@ -16,56 +16,239 @@ describe('refreshExtensionRuntime', () => { }); it('refreshes existing extension runtime components', async () => { - const restartMcpServers = vi.fn(); - const refreshSkills = vi.fn(); - const refreshSubagents = vi.fn(); - const refreshHierarchicalMemory = vi.fn(); + const order: string[] = []; + const reinitializeMcpServers = vi.fn(async () => { + order.push('mcp'); + }); + const reinitializeLsp = vi.fn(async () => { + order.push('lsp'); + return undefined; + }); + const refreshSkills = vi.fn(async () => { + order.push('skills'); + }); + const refreshSubagents = vi.fn(async () => { + order.push('subagents'); + }); + const reloadHooks = vi.fn(async () => { + order.push('hooks'); + }); + const refreshHierarchicalMemory = vi.fn(async () => { + order.push('memory'); + }); + const settingsMcpServers = { server: { command: 'cmd' } }; const config = { - getToolRegistry: () => ({ restartMcpServers }), + getSettingsMcpServers: () => settingsMcpServers, + reinitializeMcpServers, + reinitializeLsp, getSkillManager: () => ({ refreshCache: refreshSkills }), getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), refreshHierarchicalMemory, } as unknown as ExtensionRuntimeRefreshConfig; await refreshExtensionRuntime(config); - expect(restartMcpServers).toHaveBeenCalledOnce(); + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); + expect(reinitializeMcpServers).toHaveBeenCalledWith(settingsMcpServers); + expect(reinitializeLsp).toHaveBeenCalledOnce(); expect(refreshSkills).toHaveBeenCalledOnce(); expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(reloadHooks).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); + // MCP and LSP must settle first, then skills/subagents/hooks in parallel, memory last. + expect(order[0]).toBe('mcp'); + expect(order[1]).toBe('lsp'); + expect(order.slice(2, 5)).toEqual( + expect.arrayContaining(['skills', 'subagents', 'hooks']), + ); + expect(order[5]).toBe('memory'); + }); + + it('propagates MCP reconcile failures', async () => { + const reinitializeMcpServers = vi + .fn() + .mockRejectedValue(new Error('mcp failed')); + const refreshSkills = vi.fn(); + const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn(); + const refreshHierarchicalMemory = vi.fn(); + + const config = { + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, + getSkillManager: () => ({ refreshCache: refreshSkills }), + getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), + refreshHierarchicalMemory, + } as unknown as ExtensionRuntimeRefreshConfig; + + await expect(refreshExtensionRuntime(config)).rejects.toThrow('mcp failed'); + + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).not.toHaveBeenCalled(); + expect(refreshSubagents).not.toHaveBeenCalled(); + expect(reloadHooks).not.toHaveBeenCalled(); + expect(refreshHierarchicalMemory).not.toHaveBeenCalled(); + }); + + it('propagates LSP partial failures', async () => { + const reinitializeMcpServers = vi.fn(); + const refreshSkills = vi.fn(); + const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn(); + const refreshHierarchicalMemory = vi.fn(); + + const config = { + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, + reinitializeLsp: vi.fn().mockResolvedValue({ + reconcile: { + added: [], + removed: [], + restarted: [], + unchanged: [], + failed: ['clangd'], + }, + skipped: [], + }), + getSkillManager: () => ({ refreshCache: refreshSkills }), + getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), + refreshHierarchicalMemory, + } as unknown as ExtensionRuntimeRefreshConfig; + + await expect(refreshExtensionRuntime(config)).rejects.toThrow( + 'LSP reload partially failed: clangd', + ); + + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).toHaveBeenCalledOnce(); + expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(reloadHooks).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); + }); + + it('continues refresh legs when LSP reload throws', async () => { + const reinitializeMcpServers = vi.fn(); + const refreshSkills = vi.fn(); + const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn(); + const refreshHierarchicalMemory = vi.fn(); + + const config = { + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, + reinitializeLsp: vi.fn().mockRejectedValue(new Error('lsp failed')), + getSkillManager: () => ({ refreshCache: refreshSkills }), + getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), + refreshHierarchicalMemory, + } as unknown as ExtensionRuntimeRefreshConfig; + + await expect(refreshExtensionRuntime(config)).rejects.toThrow('lsp failed'); + + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).toHaveBeenCalledOnce(); + expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(reloadHooks).toHaveBeenCalledOnce(); expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); }); it('continues when a refreshCache leg rejects', async () => { - const restartMcpServers = vi.fn(); + const reinitializeMcpServers = vi.fn(); const refreshSkills = vi.fn().mockRejectedValue(new Error('skills failed')); const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn(); const refreshHierarchicalMemory = vi.fn(); const config = { - getToolRegistry: () => ({ restartMcpServers }), + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, getSkillManager: () => ({ refreshCache: refreshSkills }), getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), refreshHierarchicalMemory, } as unknown as ExtensionRuntimeRefreshConfig; await expect(refreshExtensionRuntime(config)).resolves.toBeUndefined(); - expect(restartMcpServers).toHaveBeenCalledOnce(); + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); expect(refreshSkills).toHaveBeenCalledOnce(); expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(reloadHooks).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); + }); + + it('propagates hook reload failures after other refresh legs settle', async () => { + const reinitializeMcpServers = vi.fn(); + const refreshSkills = vi.fn(); + const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn().mockRejectedValue(new Error('hooks failed')); + const refreshHierarchicalMemory = vi.fn(); + + const config = { + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, + getSkillManager: () => ({ refreshCache: refreshSkills }), + getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), + refreshHierarchicalMemory, + } as unknown as ExtensionRuntimeRefreshConfig; + + await expect(refreshExtensionRuntime(config)).rejects.toThrow( + 'hooks failed', + ); + + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).toHaveBeenCalledOnce(); + expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(reloadHooks).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); + }); + + it('aggregates hook and LSP reload failures', async () => { + const reinitializeMcpServers = vi.fn(); + const refreshSkills = vi.fn(); + const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn().mockRejectedValue(new Error('hooks failed')); + const refreshHierarchicalMemory = vi.fn(); + + const config = { + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, + reinitializeLsp: vi.fn().mockRejectedValue(new Error('lsp failed')), + getSkillManager: () => ({ refreshCache: refreshSkills }), + getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), + refreshHierarchicalMemory, + } as unknown as ExtensionRuntimeRefreshConfig; + + await expect(refreshExtensionRuntime(config)).rejects.toMatchObject({ + message: 'Extension runtime refresh had multiple failures', + errors: [expect.any(Error), expect.any(Error)], + }); + + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).toHaveBeenCalledOnce(); + expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(reloadHooks).toHaveBeenCalledOnce(); expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); }); it('resolves when refreshHierarchicalMemory throws', async () => { - const restartMcpServers = vi.fn(); + const reinitializeMcpServers = vi.fn(); const refreshSkills = vi.fn(); const refreshSubagents = vi.fn(); + const reloadHooks = vi.fn(); const config = { - getToolRegistry: () => ({ restartMcpServers }), + getSettingsMcpServers: () => undefined, + reinitializeMcpServers, getSkillManager: () => ({ refreshCache: refreshSkills }), getSubagentManager: () => ({ refreshCache: refreshSubagents }), + getHookSystem: () => ({ reload: reloadHooks }), refreshHierarchicalMemory: vi .fn() .mockRejectedValue(new Error('memory failed')), @@ -73,31 +256,9 @@ describe('refreshExtensionRuntime', () => { await expect(refreshExtensionRuntime(config)).resolves.toBeUndefined(); - expect(restartMcpServers).toHaveBeenCalledOnce(); + expect(reinitializeMcpServers).toHaveBeenCalledOnce(); expect(refreshSkills).toHaveBeenCalledOnce(); expect(refreshSubagents).toHaveBeenCalledOnce(); - }); - - it('rejects when restartMcpServers fails', async () => { - const restartMcpServers = vi - .fn() - .mockRejectedValue(new Error('mcp failed')); - const refreshSkills = vi.fn(); - const refreshSubagents = vi.fn(); - const refreshHierarchicalMemory = vi.fn(); - - const config = { - getToolRegistry: () => ({ restartMcpServers }), - getSkillManager: () => ({ refreshCache: refreshSkills }), - getSubagentManager: () => ({ refreshCache: refreshSubagents }), - refreshHierarchicalMemory, - } as unknown as ExtensionRuntimeRefreshConfig; - - await expect(refreshExtensionRuntime(config)).rejects.toThrow('mcp failed'); - - expect(restartMcpServers).toHaveBeenCalledOnce(); - expect(refreshSkills).not.toHaveBeenCalled(); - expect(refreshSubagents).not.toHaveBeenCalled(); - expect(refreshHierarchicalMemory).not.toHaveBeenCalled(); + expect(reloadHooks).toHaveBeenCalledOnce(); }); }); diff --git a/packages/core/src/extension/extension-runtime-refresh.ts b/packages/core/src/extension/extension-runtime-refresh.ts index 99f62e689a..a1f7d273e0 100644 --- a/packages/core/src/extension/extension-runtime-refresh.ts +++ b/packages/core/src/extension/extension-runtime-refresh.ts @@ -11,63 +11,96 @@ const debugLogger = createDebugLogger('EXTENSION_RUNTIME_REFRESH'); export type ExtensionRuntimeRefreshConfig = Pick< Config, - | 'getToolRegistry' + | 'getSettingsMcpServers' + | 'reinitializeMcpServers' | 'getSkillManager' | 'getSubagentManager' + | 'getHookSystem' | 'refreshHierarchicalMemory' ->; +> & { + reinitializeLsp?: Config['reinitializeLsp']; +}; export async function refreshExtensionRuntime( config: ExtensionRuntimeRefreshConfig | undefined, ): Promise { if (!config) return; - // Error-handling contract: - // Tier 1 (fatal) — restartMcpServers failure propagates. MCP is a - // prerequisite for tool discovery; callers must know - // if it failed so they can surface the error. - // Tier 2 (swallow) — refreshCache failures (skills, subagents) are - // logged via warn but swallowed by allSettled. - // Tier 3 (swallow) — refreshHierarchicalMemory failure is logged via - // error but swallowed by try/catch. - // When adding new refresh steps, decide explicitly which tier applies. - await config.getToolRegistry().restartMcpServers(); - - // Refresh skills + subagents in parallel. Both `refreshCache` calls now - // resolve only after their async change-listener chain settles — for skills, - // that includes `SkillTool.refreshSkills()` rebuilding the model-facing tool - // description and updating `geminiClient`'s tool list. Use allSettled (rather - // than Promise.all) so a rejection from one leg does not cascade — the other - // leg's result is still applied, refreshHierarchicalMemory below still runs, - // and callers (`enableExtension`, etc.) don't unwind because of an unrelated - // transient failure. - const skillManager = config.getSkillManager(); - const settled = await Promise.allSettled([ - skillManager?.refreshCache(), - config.getSubagentManager().refreshCache(), - ]); - - for (const result of settled) { - if (result.status === 'rejected') { - debugLogger.warn( - 'refreshExtensionRuntime: a refreshCache leg failed:', - result.reason, + // MCP servers must settle first — skills and subagents may depend on the + // updated MCP tool list for their own refresh (e.g. SkillTool.refreshSkills() + // rebuilds the model-facing tool description and updates geminiClient's tool + // list). A failure here is user-visible because extension MCP tools will be + // unavailable, so let callers surface it. + await config.reinitializeMcpServers(config.getSettingsMcpServers()); + let lspReloadError: unknown; + try { + const lspResult = await config.reinitializeLsp?.(); + const failedLspServers = lspResult?.reconcile.failed ?? []; + if (failedLspServers.length > 0) { + lspReloadError = new Error( + `LSP reload partially failed: ${failedLspServers.join(', ')}`, ); } + } catch (err) { + debugLogger.warn('refreshExtensionRuntime: reinitializeLsp failed:', err); + lspReloadError = err; } + // Skills, subagents, and hooks refresh in parallel. Use allSettled (rather + // than Promise.all) so a rejection from one leg does not prevent the other + // legs from applying or skip refreshHierarchicalMemory below. Hook reload + // failures are surfaced after these best-effort legs settle. + const skillManager = config.getSkillManager(); + const refreshLegs = [ + { name: 'skills', promise: skillManager?.refreshCache() }, + { name: 'subagents', promise: config.getSubagentManager().refreshCache() }, + { name: 'hooks', promise: config.getHookSystem()?.reload() }, + ] as const; + const settled = await Promise.allSettled( + refreshLegs.map((leg) => leg.promise), + ); + let hookReloadError: unknown; + + settled.forEach((result, index) => { + if (result.status === 'rejected') { + debugLogger.warn( + `refreshExtensionRuntime: ${refreshLegs[index].name} failed:`, + result.reason, + ); + if (refreshLegs[index].name === 'hooks') { + hookReloadError = result.reason; + } + } + }); + // Await hierarchical memory refresh so callers only continue after the // extension refresh has settled. Wrap in try/catch so a transient failure // doesn't propagate up to `enableExtension` / `installExtension` callers, // which have already mutated their `isActive`/`installed` flags by the time // this function is invoked — a failed memory refresh leaves stale memory - // but should not back out the surrounding extension transition. + // but should not back out the surrounding extension transition. At this + // point enable/disable and install/uninstall callers may already have + // updated isActive state, extension cache entries, or on-disk enablement + // metadata. try { await config.refreshHierarchicalMemory(); } catch (err) { - debugLogger.error( + debugLogger.warn( 'refreshExtensionRuntime: refreshHierarchicalMemory failed:', err, ); } + + const surfacedErrors = [hookReloadError, lspReloadError].filter( + (error): error is unknown => error !== undefined, + ); + if (surfacedErrors.length === 1) { + throw surfacedErrors[0]; + } + if (surfacedErrors.length > 1) { + throw new AggregateError( + surfacedErrors, + 'Extension runtime refresh had multiple failures', + ); + } } diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 7979b56683..0466c34771 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -16,12 +16,15 @@ import { ExtensionStorage } from './storage.js'; import { QWEN_DIR } from '../config/storage.js'; import { ExtensionManager, + ExtensionUpdateState, SettingScope, type ExtensionManagerOptions, + type Extension, validateName, getExtensionId, hashValue, type ExtensionConfig, + type ExtensionMutationEvent, } from './extensionManager.js'; import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; @@ -198,6 +201,36 @@ describe('extension tests', () => { }); }); + it('should emit mutation lifecycle events around install', async () => { + const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip'); + fs.writeFileSync(archivePath, 'not used by mocked extractor'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'local-archive-extension'); + }, + ); + + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + + await manager.installExtension( + { + source: archivePath, + type: 'local', + }, + async () => {}, + ); + + expect(events).toEqual([ + { id: 1, phase: 'start', operation: 'installExtension' }, + { id: 2, phase: 'start', operation: 'enableExtension' }, + { id: 2, phase: 'end', operation: 'enableExtension' }, + { id: 1, phase: 'end', operation: 'installExtension' }, + ]); + }); + it('should clean up converted temp dir for local archive installs', async () => { const archivePath = path.join(tempWorkspaceDir, 'gemini-extension.zip'); fs.writeFileSync(archivePath, 'not used by mocked extractor'); @@ -337,6 +370,33 @@ describe('extension tests', () => { }); }); + describe('uninstallExtension', () => { + it('should emit mutation lifecycle events around uninstall', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempWorkspaceDir, + originSource: 'QwenCode', + }, + }); + + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + + await manager.uninstallExtension('my-extension', false); + + expect(events).toEqual([ + { id: 1, phase: 'start', operation: 'uninstallExtension' }, + { id: 1, phase: 'end', operation: 'uninstallExtension' }, + ]); + }); + }); + describe('loadExtension', () => { it('should include extension path in loaded extension', async () => { const extensionDir = path.join(userExtensionsDir, 'test-extension'); @@ -518,6 +578,32 @@ describe('extension tests', () => { expect(extensions[0].name).toBe('ext2'); }); + it('keeps the previous cache when refreshCache fails before replacement', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'stable-ext', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + expect(manager.getLoadedExtensions().map((ext) => ext.name)).toEqual([ + 'stable-ext', + ]); + + const internals = manager as unknown as { + loadExtensionsFromExtensionsDir: () => Promise; + }; + internals.loadExtensionsFromExtensionsDir = vi + .fn() + .mockRejectedValue(new Error('refresh failed')); + + await expect(manager.refreshCache()).rejects.toThrow('refresh failed'); + expect(manager.getLoadedExtensions().map((ext) => ext.name)).toEqual([ + 'stable-ext', + ]); + }); + describe('command discovery', () => { it('should discover .md command files', async () => { const extDir = createExtension({ @@ -690,6 +776,50 @@ describe('extension tests', () => { }); describe('enableExtension / disableExtension', () => { + it('should emit mutation lifecycle events around extension changes', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + + await manager.disableExtension('my-extension', SettingScope.User); + + expect(events).toEqual([ + { id: 1, phase: 'start', operation: 'disableExtension' }, + { id: 1, phase: 'end', operation: 'disableExtension' }, + ]); + }); + + it('should not emit mutation lifecycle events when validation fails', async () => { + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + + await expect( + manager.disableExtension('missing-extension', SettingScope.User), + ).rejects.toThrow( + 'Extension with name missing-extension does not exist.', + ); + + await expect( + manager.enableExtension('missing-extension', SettingScope.User), + ).rejects.toThrow( + 'Extension with name missing-extension does not exist.', + ); + + await expect(manager.addSource(' ')).rejects.toThrow( + 'Marketplace source cannot be empty.', + ); + + expect(events).toEqual([]); + }); + it('should disable an extension at the user scope', async () => { createExtension({ extensionsDir: userExtensionsDir, @@ -789,6 +919,75 @@ describe('extension tests', () => { }); }); + describe('preference-only operations', () => { + it('should not emit mutation lifecycle events for preference changes', () => { + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + + expect(manager.toggleFavorite('my-extension')).toBe(true); + fs.writeFileSync( + path.join(userExtensionsDir, 'marketplaces.json'), + JSON.stringify([ + { + name: 'marketplace', + source: 'owner/repo', + type: 'github', + addedAt: '2026-01-01T00:00:00.000Z', + }, + ]), + ); + expect(manager.markSourceUpdated('marketplace')).toMatchObject({ + name: 'marketplace', + }); + + expect(events).toEqual([]); + }); + }); + + describe('updateExtension', () => { + it('should end mutation lifecycle events when temp directory creation fails', async () => { + const extensionPath = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempWorkspaceDir, + originSource: 'QwenCode', + }, + }); + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + const callback = vi.fn(); + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + const extension = manager + .getLoadedExtensions() + .find((entry) => entry.path === extensionPath); + vi.spyOn(ExtensionStorage, 'createTmpDir').mockRejectedValueOnce( + new Error('disk full'), + ); + + await expect( + manager.updateExtension( + extension!, + ExtensionUpdateState.UPDATE_AVAILABLE, + callback, + ), + ).rejects.toThrow('disk full'); + + expect(events).toEqual([ + { id: 1, phase: 'start', operation: 'updateExtension' }, + { id: 1, phase: 'end', operation: 'updateExtension' }, + ]); + expect(callback).toHaveBeenCalledWith( + 'my-extension', + ExtensionUpdateState.ERROR, + ); + }); + }); + describe('validateExtensionOverrides', () => { it('should mark all extensions as active if no enabled extensions are provided', async () => { createExtension({ @@ -980,32 +1179,36 @@ describe('extension tests', () => { expect(serverConfig.env!['MISSING_VAR']).toBe('$UNDEFINED_ENV_VAR'); expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); - describe('refreshTools and refreshMemory', () => { + describe('refreshTools', () => { it('refreshTools should return early if config is not set', async () => { const manager = createExtensionManager(); // Should not throw when config is undefined await expect(manager.refreshTools()).resolves.not.toThrow(); }); - it('refreshTools should always call refreshMemory', async () => { + it('refreshTools should call all refresh methods', async () => { const mockRefreshCache = vi.fn(); - const mockRestartMcpServers = vi.fn(); + const mockReinitializeMcpServers = vi.fn(); + const mockReloadHooks = vi.fn(); const mockRefreshHierarchicalMemory = vi.fn(); + const mockSettingsMcpServers = { server: { command: 'cmd' } }; const mockConfig = { getGeminiClient: () => ({ isInitialized: () => false, setTools: vi.fn(), }), - getToolRegistry: () => ({ - restartMcpServers: mockRestartMcpServers, - }), + getSettingsMcpServers: () => mockSettingsMcpServers, + reinitializeMcpServers: mockReinitializeMcpServers, getSkillManager: () => ({ refreshCache: mockRefreshCache, }), getSubagentManager: () => ({ refreshCache: mockRefreshCache, }), + getHookSystem: () => ({ + reload: mockReloadHooks, + }), refreshHierarchicalMemory: mockRefreshHierarchicalMemory, }; @@ -1015,47 +1218,12 @@ describe('extension tests', () => { await manager.refreshTools(); - // refreshMemory should be called which includes these - expect(mockRestartMcpServers).toHaveBeenCalledOnce(); + expect(mockReinitializeMcpServers).toHaveBeenCalledOnce(); + expect(mockReinitializeMcpServers).toHaveBeenCalledWith( + mockSettingsMcpServers, + ); expect(mockRefreshCache).toHaveBeenCalledTimes(2); // skillManager and subagentManager - expect(mockRefreshHierarchicalMemory).toHaveBeenCalledOnce(); - }); - - it('refreshMemory should return early if config is not set', async () => { - const manager = createExtensionManager(); - - // Should not throw when config is undefined - await expect(manager.refreshMemory()).resolves.not.toThrow(); - }); - - it('refreshMemory should call all refresh methods', async () => { - const mockSkillRefreshCache = vi.fn(); - const mockSubagentRefreshCache = vi.fn(); - const mockRestartMcpServers = vi.fn(); - const mockRefreshHierarchicalMemory = vi.fn(); - - const mockConfig = { - getToolRegistry: () => ({ - restartMcpServers: mockRestartMcpServers, - }), - getSkillManager: () => ({ - refreshCache: mockSkillRefreshCache, - }), - getSubagentManager: () => ({ - refreshCache: mockSubagentRefreshCache, - }), - refreshHierarchicalMemory: mockRefreshHierarchicalMemory, - }; - - const manager = createExtensionManager(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (manager as any).config = mockConfig; - - await manager.refreshMemory(); - - expect(mockRestartMcpServers).toHaveBeenCalledOnce(); - expect(mockSkillRefreshCache).toHaveBeenCalledOnce(); - expect(mockSubagentRefreshCache).toHaveBeenCalledOnce(); + expect(mockReloadHooks).toHaveBeenCalledOnce(); expect(mockRefreshHierarchicalMemory).toHaveBeenCalledOnce(); }); }); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 7628e82119..b690064f88 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -216,6 +216,14 @@ export interface ExtensionManagerOptions { ) => Promise; } +export interface ExtensionMutationEvent { + id: number; + phase: 'start' | 'end'; + operation: string; +} + +export type ExtensionMutationListener = (event: ExtensionMutationEvent) => void; + // ============================================================================ // Helper Functions // ============================================================================ @@ -302,6 +310,8 @@ async function loadCommandsFromDir(dir: string): Promise { export class ExtensionManager { private extensionCache: Map | null = null; + private readonly mutationListeners = new Set(); + private nextMutationId = 0; // Enablement configuration (directly implemented) private readonly configDir: string; @@ -374,6 +384,34 @@ export class ExtensionManager { this.requestChoicePlugin = requestChoicePlugin; } + addMutationListener(listener: ExtensionMutationListener): () => void { + this.mutationListeners.add(listener); + return () => { + this.mutationListeners.delete(listener); + }; + } + + private beginMutation(operation: string): () => void { + const id = ++this.nextMutationId; + this.emitMutation({ id, phase: 'start', operation }); + let ended = false; + return () => { + if (ended) return; + ended = true; + this.emitMutation({ id, phase: 'end', operation }); + }; + } + + private emitMutation(event: ExtensionMutationEvent): void { + for (const listener of this.mutationListeners) { + try { + listener(event); + } catch (error) { + debugLogger.warn('Extension mutation listener failed:', error); + } + } + } + // ========================================================================== // Enablement functionality (directly implemented) // ========================================================================== @@ -450,13 +488,19 @@ export class ExtensionManager { if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } - const scopePath = - scope === SettingScope.Workspace ? currentDir : os.homedir(); - this.enableByPath(name, true, scopePath); - const config = getTelemetryConfig(currentDir, this.telemetrySettings); - logExtensionEnable(config, new ExtensionEnableEvent(name, scope)); - extension.isActive = true; - await this.refreshTools(); + + const endMutation = this.beginMutation('enableExtension'); + try { + const scopePath = + scope === SettingScope.Workspace ? currentDir : os.homedir(); + this.enableByPath(name, true, scopePath); + const config = getTelemetryConfig(currentDir, this.telemetrySettings); + logExtensionEnable(config, new ExtensionEnableEvent(name, scope)); + extension.isActive = true; + await this.refreshTools(); + } finally { + endMutation(); + } } /** @@ -481,12 +525,18 @@ export class ExtensionManager { if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } - const scopePath = - scope === SettingScope.Workspace ? currentDir : os.homedir(); - this.disableByPath(name, true, scopePath); - logExtensionDisable(config, new ExtensionDisableEvent(name, scope)); - extension.isActive = false; - await this.refreshTools(); + + const endMutation = this.beginMutation('disableExtension'); + try { + const scopePath = + scope === SettingScope.Workspace ? currentDir : os.homedir(); + this.disableByPath(name, true, scopePath); + logExtensionDisable(config, new ExtensionDisableEvent(name, scope)); + extension.isActive = false; + await this.refreshTools(); + } finally { + endMutation(); + } } /** @@ -526,7 +576,12 @@ export class ExtensionManager { } setExtensionScope(name: string, scope: ExtensionScope): void { - this.preferencesStore.setScope(name, scope); + const endMutation = this.beginMutation('setExtensionScope'); + try { + this.preferencesStore.setScope(name, scope); + } finally { + endMutation(); + } } /** MCP servers individually disabled inside the given extension. */ @@ -539,11 +594,16 @@ export class ExtensionManager { serverName: string, disabled: boolean, ): void { - this.preferencesStore.setMcpServerDisabled( - extensionName, - serverName, - disabled, - ); + const endMutation = this.beginMutation('setMcpServerDisabled'); + try { + this.preferencesStore.setMcpServerDisabled( + extensionName, + serverName, + disabled, + ); + } finally { + endMutation(); + } } // ========================================================================== @@ -588,25 +648,36 @@ export class ExtensionManager { `Expected a .claude-plugin/marketplace.json.`, ); } - const now = new Date().toISOString(); - const entry: ExtensionSource = { - name: config.name || trimmed, - source: trimmed, - type: parseExtensionSourceType(trimmed), - addedAt: now, - lastUpdatedAt: now, - }; - this.sourceRegistryStore.add(entry); - this.discoverCache = null; // sources changed -> refetch on next discover - return entry; + + const endMutation = this.beginMutation('addSource'); + try { + const now = new Date().toISOString(); + const entry: ExtensionSource = { + name: config.name || trimmed, + source: trimmed, + type: parseExtensionSourceType(trimmed), + addedAt: now, + lastUpdatedAt: now, + }; + this.sourceRegistryStore.add(entry); + this.discoverCache = null; // sources changed -> refetch on next discover + return entry; + } finally { + endMutation(); + } } removeSource(name: string): boolean { - const removed = this.sourceRegistryStore.remove(name); - if (removed) { - this.discoverCache = null; + const endMutation = this.beginMutation('removeSource'); + try { + const removed = this.sourceRegistryStore.remove(name); + if (removed) { + this.discoverCache = null; + } + return removed; + } finally { + endMutation(); } - return removed; } /** @@ -713,7 +784,6 @@ export class ExtensionManager { * Refreshes the extension cache from disk. */ async refreshCache(options?: { names?: string[] }): Promise { - this.extensionCache = new Map(); const requestedNames = options?.names?.filter(Boolean) ?? []; let extensions: Extension[]; if (requestedNames.length > 0) { @@ -729,9 +799,11 @@ export class ExtensionManager { this.workspaceDir, ); } + const nextCache = new Map(); extensions.forEach((extension) => { - this.extensionCache!.set(extension.name, extension); + nextCache.set(extension.name, extension); }); + this.extensionCache = nextCache; } getLoadedExtensions(): Extension[] { @@ -1034,6 +1106,7 @@ export class ExtensionManager { let tempDir: string | undefined; let convertedSourcePath: string | undefined; + const endMutation = this.beginMutation('installExtension'); try { if (!this.isWorkspaceTrusted) { throw new Error( @@ -1376,6 +1449,8 @@ export class ExtensionManager { ); } throw error; + } finally { + endMutation(); } } @@ -1387,47 +1462,52 @@ export class ExtensionManager { isUpdate: boolean, cwd?: string, ): Promise { - const currentDir = cwd ?? this.workspaceDir; - const telemetryConfig = getTelemetryConfig( - currentDir, - this.telemetrySettings, - ); - const installedExtensions = this.getLoadedExtensions(); - const extension = installedExtensions.find( - (installed) => - installed.config.name.toLowerCase() === - extensionIdentifier.toLowerCase() || - installed.installMetadata?.source.toLowerCase() === - extensionIdentifier.toLowerCase(), - ); - if (!extension) { - throw new Error(`Extension not found.`); + const endMutation = this.beginMutation('uninstallExtension'); + try { + const currentDir = cwd ?? this.workspaceDir; + const telemetryConfig = getTelemetryConfig( + currentDir, + this.telemetrySettings, + ); + const installedExtensions = this.getLoadedExtensions(); + const extension = installedExtensions.find( + (installed) => + installed.config.name.toLowerCase() === + extensionIdentifier.toLowerCase() || + installed.installMetadata?.source.toLowerCase() === + extensionIdentifier.toLowerCase(), + ); + if (!extension) { + throw new Error(`Extension not found.`); + } + const storage = new ExtensionStorage( + extension.installMetadata?.type === 'link' + ? extension.name + : path.basename(extension.path), + ); + + await fs.promises.rm(storage.getExtensionDir(), { + recursive: true, + force: true, + }); + + if (this.extensionCache) { + this.extensionCache.delete(extension.name); + } + + if (isUpdate) return; + + this.removeEnablementConfig(extension.name); + this.preferencesStore.clear(extension.name); + await this.refreshTools(); + + logExtensionUninstall( + telemetryConfig, + new ExtensionUninstallEvent(extension.name, 'success'), + ); + } finally { + endMutation(); } - const storage = new ExtensionStorage( - extension.installMetadata?.type === 'link' - ? extension.name - : path.basename(extension.path), - ); - - await fs.promises.rm(storage.getExtensionDir(), { - recursive: true, - force: true, - }); - - if (this.extensionCache) { - this.extensionCache.delete(extension.name); - } - - if (isUpdate) return; - - this.removeEnablementConfig(extension.name); - this.preferencesStore.clear(extension.name); - await this.refreshTools(); - - logExtensionUninstall( - telemetryConfig, - new ExtensionUninstallEvent(extension.name, 'success'), - ); } async performWorkspaceExtensionMigration( @@ -1498,10 +1578,12 @@ export class ExtensionManager { callback(extension.name, ExtensionUpdateState.UP_TO_DATE); throw new Error(`Extension is linked so does not need to be updated`); } + const endMutation = this.beginMutation('updateExtension'); const originalVersion = extension.version; + let tempDir: string | undefined; - const tempDir = await ExtensionStorage.createTmpDir(); try { + tempDir = await ExtensionStorage.createTmpDir(); const previousExtensionConfig = this.loadExtensionConfig({ extensionDir: extension.path, }); @@ -1537,10 +1619,15 @@ export class ExtensionManager { `Error updating extension, rolling back. ${getErrorMessage(e)}`, ); callback(extension.name, ExtensionUpdateState.ERROR); - await copyExtension(tempDir, extension.path); + if (tempDir) { + await copyExtension(tempDir, extension.path); + } throw e; } finally { - await fs.promises.rm(tempDir, { recursive: true, force: true }); + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + endMutation(); } } @@ -1570,14 +1657,8 @@ export class ExtensionManager { ).filter((updateInfo) => !!updateInfo); } - async refreshMemory(): Promise { - await refreshExtensionRuntime(this.config); - } - async refreshTools(): Promise { - if (!this.config) return; - // FIXME: restart all mcp servers now, this can be optimized by only restarting changed ones at here - await this.refreshMemory(); + await refreshExtensionRuntime(this.config); } } diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts index ac4854dfde..279eb72c06 100644 --- a/packages/core/src/hooks/hookRegistry.test.ts +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -974,6 +974,136 @@ describe('HookRegistry', () => { expect(registry.getAllHooks()).toHaveLength(0); }); + it('preserves agent-scoped hooks when configured hooks reload', async () => { + const userHooks = { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + registry.addAgentHooks( + { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo agent', + name: 'agent-hook', + }, + ], + }, + ], + }, + 'agent:test:reload', + ); + + mockConfig.getUserHooks = vi.fn().mockReturnValue(undefined); + await registry.reloadConfiguredHooks(); + + const after = registry.getAllHooks(); + expect(after).toHaveLength(1); + expect(after[0].source).toBe(HooksConfigSource.Session); + expect(after[0].agentScope).toBe('agent:test:reload'); + }); + + it('preserves configured hook enabled state when hooks reload', async () => { + const userHooks = { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + registry.setHookEnabled('user-hook', false); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + + await registry.reloadConfiguredHooks(); + + const after = registry.getAllHooks(); + expect(after).toHaveLength(1); + expect(after[0].enabled).toBe(false); + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + }); + + it('restores all previous hooks when configured hooks reload fails', async () => { + const userHooks = { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + registry.addAgentHooks( + { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo agent', + name: 'agent-hook', + }, + ], + }, + ], + }, + 'agent:test:reload-failure', + ); + + const before = registry.getAllHooks(); + mockConfig.getUserHooks = vi.fn(() => { + throw new Error('reload failed'); + }); + + await expect(registry.reloadConfiguredHooks()).rejects.toThrow( + 'reload failed', + ); + + expect(registry.getAllHooks()).toEqual(before); + }); + it('silently keeps entries when the hooks payload is empty', async () => { const registry = new HookRegistry(mockConfig); await registry.initialize(); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts index bb74fda06d..18162589c4 100644 --- a/packages/core/src/hooks/hookRegistry.ts +++ b/packages/core/src/hooks/hookRegistry.ts @@ -85,6 +85,37 @@ export class HookRegistry { ); } + async reloadConfiguredHooks(): Promise { + const previousEntries = this.entries; + const enabledSnapshot = new Map( + previousEntries.map((entry) => [ + this.getHookStateKey(entry), + entry.enabled, + ]), + ); + const agentEntries = this.entries.filter( + (entry) => entry.agentScope !== undefined, + ); + + try { + this.entries = [...agentEntries]; + this.processHooksFromConfig(); + for (const entry of this.entries) { + const enabled = enabledSnapshot.get(this.getHookStateKey(entry)); + if (enabled !== undefined) { + entry.enabled = enabled; + } + } + } catch (err) { + this.entries = previousEntries; + throw err; + } + + debugLogger.debug( + `Hook registry reloaded with ${this.entries.length} hook entries`, + ); + } + /** * Get all hook entries for a specific event */ @@ -206,6 +237,17 @@ export class HookRegistry { return identity; } + private getHookStateKey(entry: HookRegistryEntry): string { + return JSON.stringify([ + entry.eventName, + entry.source, + entry.agentScope ?? null, + this.getHookIdentity(entry), + entry.matcher ?? null, + entry.sequential ?? null, + ]); + } + /** * Process hooks from the config that was already loaded by the CLI */ diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts index 0ccfb09e82..ee82115dee 100644 --- a/packages/core/src/hooks/hookSystem.test.ts +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -67,6 +67,7 @@ describe('HookSystem', () => { mockHookRegistry = { initialize: vi.fn().mockResolvedValue(undefined), + reloadConfiguredHooks: vi.fn().mockResolvedValue(undefined), setHookEnabled: vi.fn(), getAllHooks: vi.fn().mockReturnValue([]), getHooksForEvent: vi.fn().mockReturnValue([]), @@ -140,6 +141,14 @@ describe('HookSystem', () => { }); }); + describe('reload', () => { + it('should reload configured hooks', async () => { + await hookSystem.reload(); + + expect(mockHookRegistry.reloadConfiguredHooks).toHaveBeenCalled(); + }); + }); + describe('getEventHandler', () => { it('should return the hook event handler', () => { const eventHandler = hookSystem.getEventHandler(); diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index dbf4e5940c..8145288b72 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -90,6 +90,11 @@ export class HookSystem { debugLogger.debug('Hook system initialized successfully'); } + async reload(): Promise { + await this.hookRegistry.reloadConfiguredHooks(); + debugLogger.debug('Hook system reloaded successfully'); + } + /** * Set the messages provider for automatic conversation history passing * to function hooks during execution