diff --git a/docs/plans/2026-07-01-extension-runtime-refresh-implementation.md b/docs/plans/2026-07-01-extension-runtime-refresh-implementation.md new file mode 100644 index 00000000000..fa82f9d17dc --- /dev/null +++ b/docs/plans/2026-07-01-extension-runtime-refresh-implementation.md @@ -0,0 +1,320 @@ +# Extension Runtime Refresh Implementation Plan + +Tracking issue: https://github.com/QwenLM/qwen-code/issues/3696 + +Working branch: `feat/extension-runtime-refresh` + +## Summary + +Qwen Code should keep the existing user experience where extension UI +mutations take effect automatically. Installing, updating, enabling, disabling, +or uninstalling an extension should continue to refresh runtime state without +requiring the user to run a manual command. + +The remaining work is to make that automatic path complete, cheaper, and more +visible to the model. A separate `/reload-plugins` path should be added later +for external file changes that Qwen Code did not initiate, such as editing +installed extension files by hand. + +## Current Behavior + +`ExtensionManager` already refreshes part of the runtime after extension +mutations: + +```text +enable/disable/install/update/uninstall + -> ExtensionManager.refreshTools() + -> refreshMemory() + -> ToolRegistry.restartMcpServers() + -> SkillManager.refreshCache() + -> SubagentManager.refreshCache() + -> refreshHierarchicalMemory() +``` + +This is useful but incomplete: + +- MCP refresh is currently too expensive because it restarts all MCP servers. +- Commands are rebuilt indirectly through UI-layer command reload behavior. +- Hooks are not part of the extension runtime refresh orchestration. +- LSP server configuration from extensions is not reinitialized at runtime. +- Model-facing availability notices are asymmetric: + - skills and model-invocable commands announce additions, but not removals; + - MCP tools announce additions, but not removals; + - agents update the Agent tool schema/description, but do not send an + explicit added/removed agent reminder. + +## Design Direction + +### Keep Automatic Mutation Refresh + +Extension UI mutations should remain automatic. This differs from Claude Code's +plugin flow, where plugin mutations primarily set a stale flag and ask the user +to run `/reload-plugins`. + +For Qwen Code, automatic mutation refresh is the better default because it +preserves the current UX: when a user toggles an extension, the extension should +actually become usable or unusable in the running session. + +### Add Manual Reload Only for External Changes + +`/reload-plugins` should be a repair/resync path for file changes outside Qwen +Code's mutation APIs: + +- a user edits files under an installed extension directory; +- an external process updates extension contents; +- a marketplace/local extension source changes on disk; +- a watcher sees a change but should not auto-refresh during noisy file writes. + +This command should reuse the same runtime refresh orchestration as automatic +mutations. + +### Separate Runtime Refresh from Model Notification + +Refreshing runtime state is not enough. The model also needs an accurate view of +which capabilities are available. + +Runtime refresh updates what can actually execute. Model notification updates +what the model believes it can call. These are related but separate concerns. + +## Subsystem Behavior + +### Skills + +Extension skills are loaded by `SkillManager.refreshCache()` from active +extensions. `SkillTool.refreshSkills()` updates the in-memory runtime sets. + +Problem: the model-facing `` path is currently add-only after +startup. Disabling an extension removes the skill from runtime state, but the +old listing may remain in conversation history. + +Planned behavior: + +- keep runtime refresh through `SkillManager.refreshCache()`; +- after extension runtime refresh, provide a way to send the current complete + available skills/commands list, or an equivalent clear delta; +- avoid relying only on "new skills became available" reminders for extension + mutation changes. + +### Commands + +Extension commands are user-visible slash commands by default. Some are also +model-visible when `modelInvocable === true`; those enter the Skill tool's +available-skill list and can be invoked by name through `SkillTool`. + +Problem: there is no core command manager. Command reload is a CLI/UI-layer +operation, and extension mutations do not have a clear command refresh contract. + +Planned behavior: + +- make command reload an explicit part of extension runtime refresh where the + CLI/TUI layer is available; +- ensure model-invocable extension commands update the Skill tool provider + before model-facing skill reminders are emitted; +- keep this minimal and avoid moving the entire command system into core. + +### MCP + +Extension MCP server config is merged through `Config.getMergedMcpServers()`. + +Problem: extension mutations currently call `restartMcpServers()`, which +restarts and rediscovers all MCP servers. + +Planned behavior: + +- reuse or extend the existing incremental MCP reconcile path; +- after extension cache state is updated, reconcile against the latest merged + MCP map; +- connect added servers, disconnect removed servers, and restart only changed + servers; +- keep existing added MCP tool reminders; +- add a removed MCP tool reminder so disabling an extension does not leave the + model relying on stale tool availability context. + +### Agents + +Extension agents are loaded by `SubagentManager.refreshCache()`. `AgentTool` +listens for subagent changes and updates its description and `subagent_type` +schema enum, then calls `geminiClient.setTools()`. + +Problem: this updates runtime/tool schema, but there is no explicit +conversation reminder for added or removed agent types. In practice, the model +may not notice new extension agents unless prompted. + +Planned behavior: + +- keep `SubagentManager.refreshCache()` and `AgentTool` schema refresh; +- add a model-facing added/removed agent-type reminder, similar in intent to + Claude Code's `agent_listing_delta`; +- do not make each agent a separate tool. Agents remain selected through the + `Agent` tool's `subagent_type` parameter. + +### LSP + +Extension `lspServers` can be read by the LSP config loader. + +Problem: LSP startup currently initializes the native LSP service once. Runtime +extension changes do not reinitialize LSP server configuration. + +Planned behavior: + +- add an optional LSP reinitialization hook to extension runtime refresh; +- call it when LSP is enabled and the API is available; +- do not add a model-facing reminder for LSP initially, because LSP is exposed + as a fixed `lsp` tool and server state is better surfaced through status and + tool results. + +### Hooks + +Extension hooks should be treated as runtime extension components. + +Problem: extension mutation refresh does not currently have a clear hook reload +step. + +Planned behavior: + +- add hook reload to the extension runtime refresh orchestration; +- disabling/uninstalling an extension should remove its hooks; +- enabling/installing/updating an extension should register its current hooks. + +## Proposed PR Sequence + +### PR 1: Shared Extension Runtime Refresh Orchestrator + +Create a small shared orchestration function used by extension mutations. + +Scope: + +- preserve current behavior; +- keep automatic extension mutation refresh; +- move the current refresh sequence behind one function; +- do not add watcher support; +- do not add `/reload-plugins`; +- do not change model reminders; +- MCP may still use the existing full restart in this PR. + +Expected value: + +- gives later PRs one place to attach commands, hooks, LSP, MCP reconcile, and + model notifications; +- keeps review focused on structure, not behavior changes. + +### PR 2: Complete Automatic Mutation Refresh + +Extend the orchestrator to refresh all runtime components that should update +after extension UI mutations. + +Scope: + +- commands reload when the CLI/TUI command service is available; +- hooks reload; +- optional LSP reinitialization; +- keep skills, agents, and memory refresh; +- keep MCP behavior unchanged unless a minimal no-risk integration is already + available. + +Expected value: + +- extension enable/disable/install/update/uninstall becomes more complete + without changing the user-facing automatic-refresh model. + +### PR 3: Incremental MCP Refresh for Extension Mutations + +Replace full MCP restart with incremental reconcile for extension-driven +changes. + +Scope: + +- reconcile against the latest merged MCP config after extension cache changes; +- avoid restarting unrelated MCP servers; +- keep existing event/update behavior so `setTools()` still runs through the + MCP update path. + +Expected value: + +- reduces side effects and latency when toggling extensions. + +### PR 4: Model-Facing Availability Notifications + +Fix stale model-visible capability lists. + +Scope: + +- send a current complete skills/model-invocable commands listing, or equivalent + clear delta, after extension refresh changes that affect skills/commands; +- add removed MCP tool reminders; +- add agent added/removed reminders; +- avoid adding an LSP reminder initially. + +Expected value: + +- the model's conversation context matches runtime availability after extension + changes. + +### PR 5: `/reload-plugins` + +Add a manual reload command for external extension file changes. + +Scope: + +- reuse the shared extension runtime refresh orchestrator; +- report a concise refresh summary; +- do not replace automatic mutation refresh; +- clear any stale state introduced by the later watcher PR. + +Expected value: + +- users have a manual repair path when file watching detects changes or when + auto-detection misses a change. + +### PR 6: Extension File Watcher and Stale Notification + +Detect external extension file changes and notify the user. + +Scope: + +- watch a conservative allowlist of extension-related files: + - `qwen-extension.json`; + - `.qwen-extension-install.json`; + - extension enablement, preferences, and source metadata; + - `commands/**`; + - `skills/**`; + - `agents/**`; + - `hooks/**`; + - referenced LSP config files; + - extension context files such as `GEMINI.md`; +- mark extension runtime as stale; +- show a UI notification suggesting `/reload-plugins`; +- do not automatically reload on every file event. + +Expected value: + +- external file changes are discoverable without making runtime refresh noisy or + fragile. + +## Non-Goals + +- Do not replace Qwen Code's automatic extension mutation behavior with a + mandatory manual reload flow. +- Do not introduce a broad generic refresh framework before concrete call sites + need it. +- Do not make each agent type a separate model tool. +- Do not make LSP availability reminders part of the first implementation. +- Do not automatically reload every extension file watcher event. + +## Open Questions + +- What is the smallest stable API for command reload from extension runtime + refresh without over-coupling core and CLI? +- Should skills/commands use a full current listing after extension refresh, or + an added/removed delta? Full listing is simpler and closer to the current + `` model. +- Should removed MCP tool reminders include all removed tools, or group by + extension/server to reduce noise? +- Which existing hook reload function should become the public orchestration + point? +- How should `/reload-plugins` behave in non-interactive mode, if at all? + +## Tracking Notes + +Use this document to keep PR scope narrow. Each PR should update this file when +its part of the plan is completed or intentionally changed. diff --git a/packages/core/src/extension/extension-runtime-refresh.test.ts b/packages/core/src/extension/extension-runtime-refresh.test.ts new file mode 100644 index 00000000000..ea31cf7c15a --- /dev/null +++ b/packages/core/src/extension/extension-runtime-refresh.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + refreshExtensionRuntime, + type ExtensionRuntimeRefreshConfig, +} from './extension-runtime-refresh.js'; + +describe('refreshExtensionRuntime', () => { + it('returns early without config', async () => { + await expect(refreshExtensionRuntime(undefined)).resolves.not.toThrow(); + }); + + it('refreshes existing extension runtime components', async () => { + const restartMcpServers = vi.fn(); + 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 refreshExtensionRuntime(config); + + expect(restartMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).toHaveBeenCalledOnce(); + expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); + }); + + it('continues when a refreshCache leg rejects', async () => { + const restartMcpServers = vi.fn(); + const refreshSkills = vi.fn().mockRejectedValue(new Error('skills failed')); + 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)).resolves.toBeUndefined(); + + expect(restartMcpServers).toHaveBeenCalledOnce(); + expect(refreshSkills).toHaveBeenCalledOnce(); + expect(refreshSubagents).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).toHaveBeenCalledOnce(); + }); + + it('resolves when refreshHierarchicalMemory throws', async () => { + const restartMcpServers = vi.fn(); + const refreshSkills = vi.fn(); + const refreshSubagents = vi.fn(); + + const config = { + getToolRegistry: () => ({ restartMcpServers }), + getSkillManager: () => ({ refreshCache: refreshSkills }), + getSubagentManager: () => ({ refreshCache: refreshSubagents }), + refreshHierarchicalMemory: vi + .fn() + .mockRejectedValue(new Error('memory failed')), + } as unknown as ExtensionRuntimeRefreshConfig; + + await expect(refreshExtensionRuntime(config)).resolves.toBeUndefined(); + + expect(restartMcpServers).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(); + }); +}); diff --git a/packages/core/src/extension/extension-runtime-refresh.ts b/packages/core/src/extension/extension-runtime-refresh.ts new file mode 100644 index 00000000000..99f62e689ab --- /dev/null +++ b/packages/core/src/extension/extension-runtime-refresh.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { Config } from '../config/config.js'; + +const debugLogger = createDebugLogger('EXTENSION_RUNTIME_REFRESH'); + +export type ExtensionRuntimeRefreshConfig = Pick< + Config, + | 'getToolRegistry' + | 'getSkillManager' + | 'getSubagentManager' + | 'refreshHierarchicalMemory' +>; + +export async function refreshExtensionRuntime( + config: ExtensionRuntimeRefreshConfig | undefined, +): Promise { + if (!config) return; + + // Error-handling contract: + // Tier 1 (fatal) — restartMcpServers failure propagates. MCP is a + // prerequisite for tool discovery; callers must know + // if it failed so they can surface the error. + // Tier 2 (swallow) — refreshCache failures (skills, subagents) are + // logged via warn but swallowed by allSettled. + // Tier 3 (swallow) — refreshHierarchicalMemory failure is logged via + // error but swallowed by try/catch. + // When adding new refresh steps, decide explicitly which tier applies. + await config.getToolRegistry().restartMcpServers(); + + // Refresh skills + subagents in parallel. Both `refreshCache` calls now + // resolve only after their async change-listener chain settles — for skills, + // that includes `SkillTool.refreshSkills()` rebuilding the model-facing tool + // description and updating `geminiClient`'s tool list. Use allSettled (rather + // than Promise.all) so a rejection from one leg does not cascade — the other + // leg's result is still applied, refreshHierarchicalMemory below still runs, + // and callers (`enableExtension`, etc.) don't unwind because of an unrelated + // transient failure. + const skillManager = config.getSkillManager(); + const settled = await Promise.allSettled([ + skillManager?.refreshCache(), + config.getSubagentManager().refreshCache(), + ]); + + for (const result of settled) { + if (result.status === 'rejected') { + debugLogger.warn( + 'refreshExtensionRuntime: a refreshCache leg failed:', + result.reason, + ); + } + } + + // 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. + try { + await config.refreshHierarchicalMemory(); + } catch (err) { + debugLogger.error( + 'refreshExtensionRuntime: refreshHierarchicalMemory failed:', + err, + ); + } +} diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index dbcc86d7cc2..7628e821194 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -98,6 +98,7 @@ import { import { loadSkillsFromDir } from '../skills/skill-load.js'; import { loadSubagentFromDir } from '../subagents/subagent-manager.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { refreshExtensionRuntime } from './extension-runtime-refresh.js'; const debugLogger = createDebugLogger('EXTENSIONS'); @@ -1570,47 +1571,7 @@ export class ExtensionManager { } async refreshMemory(): Promise { - if (!this.config) return; - // refresh mcp servers - await this.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. 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 the - // `refreshTools` callers (`enableExtension`, etc.) don't unwind - // because of an unrelated transient failure. - const skillManager = this.config.getSkillManager(); - const settled = await Promise.allSettled([ - skillManager?.refreshCache(), - this.config.getSubagentManager().refreshCache(), - ]); - for (const result of settled) { - if (result.status === 'rejected') { - debugLogger.warn( - 'refreshMemory: a refreshCache leg failed:', - result.reason, - ); - } - } - // Hierarchical memory refresh is now awaited too — the previous - // fire-and-forget defeated the rest of the function's "wait until - // refresh is done" contract. 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 refreshMemory is - // invoked. A failed memory refresh leaves stale memory but should - // not back out the surrounding extension transition. - try { - await this.config.refreshHierarchicalMemory(); - } catch (err) { - debugLogger.error( - 'refreshMemory: refreshHierarchicalMemory failed:', - err, - ); - } + await refreshExtensionRuntime(this.config); } async refreshTools(): Promise {