mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat: extension file reload — watch for plugin changes and hot-reload runtime (#6347)
* feat: extension file reload — watch for plugin changes and hot-reload runtime - Extract refreshExtensionRuntime to centralize MCP, skills, subagents, hooks, and memory refresh - Add ExtensionFileWatcher (chokidar) for auto-detecting extension file changes - Add ExtensionRefreshState with per-session scoped instance and mutation suppression - Replace monkey-patching with ExtensionManager native mutation listeners - Add /reload-plugins slash command with i18n-aware summary across all 9 locales - Add auto-refresh of extension content (commands/skills/agents) on file change - Add HookRegistry.reloadConfiguredHooks() with correct error recovery - Fix async mutation pairing via id-based Map instead of LIFO stack - Fix bootstrap watcher close() UB with queueMicrotask deferral - Fix concurrent refresh with runningRef/pendingRef guard - Fix error propagation from refreshExtensionContentRuntime to UI - Fix isIgnored cross-platform path splitting (path.sep → regex) - Fix wrong ExtensionMutationEvent type via import from core - Fix addItem on unmounted component with mountedRef guard - Set followSymlinks: false on chokidar watchers * fix: address extension reload review feedback * docs: expand extension file reload design * fix: harden extension reload watcher state * fix(core): tag extension refresh legs * fix(cli): harden extension reload state handling * fix(cli): clarify extension reload failure state * fix(cli): tighten extension reload boundaries * chore: resolve main conflicts for extension reload * chore: drop unrelated merge formatting changes * fix(core): harden extension refresh edge cases --------- Co-authored-by: 俊良 <zzj542558@alibaba-inc.com> Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
271664b34b
commit
151d269413
36 changed files with 3921 additions and 203 deletions
607
docs/design/extension-file-reload.md
Normal file
607
docs/design/extension-file-reload.md
Normal file
|
|
@ -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<T>(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<void>;
|
||||
}): Promise<ReloadPluginsSummary>;
|
||||
```
|
||||
|
||||
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.
|
||||
524
packages/cli/src/config/extension-file-watcher.test.ts
Normal file
524
packages/cli/src/config/extension-file-watcher.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
handlers: Record<string, EventHandler>;
|
||||
instance: {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => {
|
||||
const handlers: Record<string, EventHandler> = {};
|
||||
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<typeof import('node:fs')>();
|
||||
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<number, () => 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();
|
||||
});
|
||||
});
|
||||
345
packages/cli/src/config/extension-file-watcher.ts
Normal file
345
packages/cli/src/config/extension-file-watcher.ts
Normal file
|
|
@ -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<number, () => void>();
|
||||
private staleFiles = new Set<string>();
|
||||
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<string>();
|
||||
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<string> {
|
||||
const files = new Set<string>();
|
||||
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<string>,
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
250
packages/cli/src/config/extension-refresh-state.test.ts
Normal file
250
packages/cli/src/config/extension-refresh-state.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
162
packages/cli/src/config/extension-refresh-state.ts
Normal file
162
packages/cli/src/config/extension-refresh-state.ts
Normal file
|
|
@ -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<T>(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<unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'then' in value &&
|
||||
typeof value.then === 'function'
|
||||
);
|
||||
}
|
||||
170
packages/cli/src/config/extension-runtime-reload.test.ts
Normal file
170
packages/cli/src/config/extension-runtime-reload.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
125
packages/cli/src/config/extension-runtime-reload.ts
Normal file
125
packages/cli/src/config/extension-runtime-reload.ts
Normal file
|
|
@ -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<void>;
|
||||
}): Promise<ReloadPluginsSummary> {
|
||||
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<void>;
|
||||
}): Promise<void> {
|
||||
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<ReloadPluginsSummary>(
|
||||
(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;
|
||||
}
|
||||
|
|
@ -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<T extends object>(
|
||||
config: T,
|
||||
): T & {
|
||||
|
|
|
|||
|
|
@ -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<RuntimeLspReinitializeResult>;
|
||||
}
|
||||
|
||||
interface RuntimeLspConfig {
|
||||
reinitializeLsp?: () => Promise<RuntimeLspReinitializeResult | undefined>;
|
||||
}
|
||||
|
||||
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,
|
||||
): void {
|
||||
const lspClient = config.getLspClient?.() as
|
||||
| (ReturnType<Config['getLspClient']> & 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(
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 重試。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 重试。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
packages/cli/src/ui/commands/reload-plugins-command.test.ts
Normal file
106
packages/cli/src/ui/commands/reload-plugins-command.test.ts
Normal file
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
104
packages/cli/src/ui/commands/reload-plugins-command.ts
Normal file
104
packages/cli/src/ui/commands/reload-plugins-command.ts
Normal file
|
|
@ -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<MessageActionReturn> => {
|
||||
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.'),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<SlashCommand>,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<ExtensionRefreshState | null>(
|
||||
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<ReturnType<
|
||||
typeof setTimeout
|
||||
> | 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<null | {
|
||||
commands: string[];
|
||||
|
|
@ -357,6 +465,7 @@ export const useSlashCommandProcessor = (
|
|||
config,
|
||||
settings,
|
||||
logger,
|
||||
extensionRefreshState: activeExtensionRefreshState,
|
||||
},
|
||||
ui: {
|
||||
get history() {
|
||||
|
|
@ -419,6 +528,7 @@ export const useSlashCommandProcessor = (
|
|||
setSessionName,
|
||||
extensionsUpdateState,
|
||||
isIdleRef,
|
||||
activeExtensionRefreshState,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -509,6 +619,85 @@ export const useSlashCommandProcessor = (
|
|||
});
|
||||
}, [config, isConfigInitialized, reloadCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</BackgroundTaskViewProvider>
|
||||
</AgentViewProvider>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Extension[]>;
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -216,6 +216,14 @@ export interface ExtensionManagerOptions {
|
|||
) => Promise<string>;
|
||||
}
|
||||
|
||||
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<string[]> {
|
|||
|
||||
export class ExtensionManager {
|
||||
private extensionCache: Map<string, Extension> | null = null;
|
||||
private readonly mutationListeners = new Set<ExtensionMutationListener>();
|
||||
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<void> {
|
||||
this.extensionCache = new Map<string, Extension>();
|
||||
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<string, Extension>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
await refreshExtensionRuntime(this.config);
|
||||
}
|
||||
|
||||
async refreshTools(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -85,6 +85,37 @@ export class HookRegistry {
|
|||
);
|
||||
}
|
||||
|
||||
async reloadConfiguredHooks(): Promise<void> {
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ export class HookSystem {
|
|||
debugLogger.debug('Hook system initialized successfully');
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this.hookRegistry.reloadConfiguredHooks();
|
||||
debugLogger.debug('Hook system reloaded successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the messages provider for automatic conversation history passing
|
||||
* to function hooks during execution
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue