From 794714ebef9fbcc74fd65d2b9621eafcc94986c5 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 6 Aug 2026 21:11:03 +0800 Subject: [PATCH] fix(agent-core-v2): gate plugin changes behind session baselines and reminders (#2702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-core-v2): gate plugin changes behind session baselines and reminders - capture a per-session MCP server baseline (ISessionMcpHandle.isBaselineServer) so servers added mid-session (plugin install, mcp.json edit) never register tools in live sessions; they take effect on /new, /reload, or resume, while removed servers stay tombstoned and fail calls with a removal notice - stop rebuilding the system prompt on plugin-source catalog changes: the frozen skill listing and plugin sections cannot move anyway, and the rebuild only churned the ${now} timestamp, invalidating the provider prompt cache - freeze the Agent tool description's catalog profile list once the session catalog has loaded, keeping the tools payload byte-stable across mutations - append a plugin_change system reminder to live sessions on plugin mutations (new IPluginService.onDidMutate; explicit reloadPlugins does not raise it) - revert the TUI hint to "Run /new or /reload to apply plugin changes." and update the plugin/MCP docs and changesets to the corrected contract * fix(agent-core-v2): import LifecycleScope from app/scopes in sessionOutcomeMirror #2666 imported LifecycleScope from #/_base/di/scope, which does not export it (it lives in #/app/scopes), breaking the package build and typecheck on main. * fix(agent-core-v2): close the mutation-driven session-start refresh and overlay baseline leaks Codex review on the PR found two contract leaks: - a plugin mutation re-pulls the plugin skill source, and the existing catalog listener answered with a fresh plugin_session_start reminder — injecting the newly installed plugin's instructions into the live session alongside (and contradicting) the plugin_change notice. The session-start refresh now skips mutation-driven catalog changes (one per mutation, counted; explicit reloads keep the old refresh behavior). - a session created with ephemeral mcpServers kept its MCP baseline open until the overlay connect finished; a workspace server added in that window (plugin install, config edit) leaked into the live session through the merged view. The overlay handle's baseline now freezes on the workspace manager's initial load, with the ephemeral names baseline by construction. * fix(agent-core-v2): drop duplicate LifecycleScope import in sessionOutcomeMirror test --------- Signed-off-by: Haozhe --- .changeset/frozen-plugin-prompt-inputs.md | 5 - .changeset/mcp-removed-tombstone.md | 2 +- apps/kimi-code/src/tui/commands/plugins.ts | 16 +- .../test/tui/kimi-tui-message-flow.test.ts | 34 ---- docs/en/customization/mcp.md | 4 +- docs/en/customization/plugins.md | 14 +- docs/zh/customization/mcp.md | 4 +- docs/zh/customization/plugins.md | 14 +- .../agent-core-v2/src/agent/mcp/mcpService.ts | 10 +- .../src/agent/plugin/agentPluginService.ts | 58 ++++++- .../src/agent/profile/profileService.ts | 23 ++- .../src/agent/tools/agent/agentTool.ts | 27 ++- .../agent-core-v2/src/app/plugin/plugin.ts | 6 + .../src/app/plugin/pluginService.ts | 24 ++- .../agent-core-v2/src/app/plugin/types.ts | 9 + .../src/session/mcp/sessionMcpHandle.ts | 9 +- .../sessionOutcomeMirrorService.ts | 2 +- .../sessionSeed/sessionSeedAdapters.ts | 1 + .../workspaceMcp/workspaceMcpService.ts | 57 +++++- .../agent-core-v2/test/agent/mcp/mcp.test.ts | 47 +++++ .../test/agent/plugin/agentPlugin.test.ts | 162 +++++++++++++++++- .../test/agent/profile/apply-profile.test.ts | 42 +++++ .../test/app/plugin/pluginService.test.ts | 32 +++- packages/agent-core-v2/test/harness/agent.ts | 2 + .../agentLifecycle/agentLifecycle.test.ts | 2 + .../sessionSeed/sessionSeedAdapters.test.ts | 1 + packages/agent-core-v2/test/tool/tool.test.ts | 85 +++++++++ .../sessionLifecycle/sessionLifecycle.test.ts | 2 + .../agentProfileLoader.test.ts | 1 + .../workspaceMcp/workspaceMcp.test.ts | 100 +++++++++++ .../skillCatalog.test.ts | 1 + 31 files changed, 696 insertions(+), 100 deletions(-) delete mode 100644 .changeset/frozen-plugin-prompt-inputs.md diff --git a/.changeset/frozen-plugin-prompt-inputs.md b/.changeset/frozen-plugin-prompt-inputs.md deleted file mode 100644 index b8b629104..000000000 --- a/.changeset/frozen-plugin-prompt-inputs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Stop rewriting the current session's system prompt when plugins are installed, enabled, disabled, removed, or reloaded; the running session keeps its original prompt and the change takes effect in new sessions. diff --git a/.changeset/mcp-removed-tombstone.md b/.changeset/mcp-removed-tombstone.md index 6e915ae67..5796b4ef3 100644 --- a/.changeset/mcp-removed-tombstone.md +++ b/.changeset/mcp-removed-tombstone.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Keep live sessions stable when an MCP server is removed from the workspace config: its tools stay registered and return a removal notice instead of breaking in-flight calls, and the MCP panel shows the removed status. Plugin install, enable, disable, and remove now refresh workspace contributions immediately. +Keep live sessions stable when an MCP server is removed from the workspace config or uninstalled with its plugin: its tools stay registered in open sessions but calls fail with a removal notice, and the MCP panel shows the removed status. Servers added mid-session — by a plugin install or a config edit — are not registered in open sessions; they take effect in new sessions or after `/new` or `/reload`. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 439ff658a..9244c7d25 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -34,7 +34,6 @@ import { isOfficialPluginInstall, isOfficialPluginSource, } from '../utils/plugin-source-label'; -import { isKimiV2Enabled } from '#/cli/experimental-v2'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { openUrl } from '#/utils/open-url'; @@ -560,7 +559,7 @@ async function installCapabilityFromPanel( `${label} installation did not complete. Check the logs and install again from /plugins.`, ); } - host.showStatus(pluginReloadHint(), 'warning'); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); return; } if (entry.id === 'kimi-webbridge') { @@ -573,7 +572,7 @@ async function installCapabilityFromPanel( return; } host.showStatus(`${label} is installed.`); - host.showStatus(pluginReloadHint(), 'warning'); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function installFromPanel( @@ -739,7 +738,7 @@ async function removePlugin(host: SlashCommandHost, id: string): Promise { ); return; } - host.showStatus(pluginReloadHint(), 'warning'); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( @@ -782,13 +781,6 @@ async function installPluginFromSource( const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; -const PLUGIN_RELOAD_HINT_V2 = - 'Plugin changes apply immediately. MCP tools already loaded in open sessions stay visible but fail with a removal notice once uninstalled.'; - -function pluginReloadHint(): string { - return isKimiV2Enabled() ? PLUGIN_RELOAD_HINT_V2 : PLUGIN_RELOAD_HINT; -} - const WEBBRIDGE_POST_INSTALL_MARKDOWN = [ '*Two steps left to use Kimi WebBridge:*', '1. Install the browser extension', @@ -815,7 +807,7 @@ function showPluginInstallResult( : ''; const action = describeInstallAction(previous, summary); host.showStatus(`${action} (${summary.id}).${mcpHint}`); - host.showStatus(pluginReloadHint(), 'warning'); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); // Gate on provenance, not just the id: a local/GitHub fork whose manifest // reuses a billed plugin's id is not the official quota-consuming build. if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index dc2a4deb2..acd25f34a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -460,7 +460,6 @@ afterEach(async () => { } else { process.env['EDITOR'] = originalEditor; } - vi.unstubAllEnvs(); }); describe('KimiTUI message flow', () => { @@ -5290,7 +5289,6 @@ command = "vim" }); it('shows a quota note after installing a quota-consuming official plugin', async () => { - vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); const session = makeSession({ installPlugin: vi.fn(async () => ({ id: 'kimi-datasource', @@ -5320,37 +5318,6 @@ command = "vim" }); }); - it('shows the apply-immediately hint after installing a plugin on the v2 engine', async () => { - vi.stubEnv('KIMI_CODE_LEGACY_FLAG', ''); - const session = makeSession({ - installPlugin: vi.fn(async () => ({ - id: 'demo', - displayName: 'Demo', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 0, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'zip-url', - originalSource: 'https://code.kimi.com/kimi-code/plugins/official/demo.zip', - })), - }); - const { driver } = await makeDriver(session); - - // Official sources skip the trust prompt, so the install runs immediately. - driver.handleUserInput( - '/plugins install https://code.kimi.com/kimi-code/plugins/official/demo.zip', - ); - - await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Plugin changes apply immediately.'); - expect(transcript).not.toContain('Run /new or /reload to apply plugin changes.'); - }); - }); - it('does not show the quota note for a same-id fork installed from a local path', async () => { const session = makeSession({ installPlugin: vi.fn(async () => ({ @@ -5411,7 +5378,6 @@ command = "vim" }); it('loads a local plugin marketplace file and installs from it', async () => { - vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); const marketplaceDir = await makeTempHome(); const marketplacePath = join(marketplaceDir, 'marketplace.json'); await writeFile( diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 795777211..a6533c38f 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -21,7 +21,7 @@ Entries with the same name: the project-level entry takes precedence and overrid Run `/mcp-config` in the TUI to interactively add, edit, or delete servers without manually editing the JSON file. Run `/mcp` to view the connection status of all current servers. -Deleting a server from the configuration does not interrupt open sessions: the server stays listed in `/mcp` as `removed`, its tools remain visible there, and calls to them fail with a removal notice, while new sessions do not register the tools at all. +Deleting a server from the configuration does not interrupt open sessions: the server stays listed in `/mcp` as `removed`, its tools remain visible there, and calls to them fail with a removal notice, while new sessions do not register the tools at all. Conversely, a server added mid-session — by editing `mcp.json` or installing a plugin — is not registered in already-open sessions; it only joins sessions created later. Structure of `mcp.json`: @@ -63,7 +63,7 @@ You do not have to set the connection timeout or the single tool-call timeout pe HTTP and SSE servers support providing static credentials via `headers` or `bearerTokenEnvVar`. When OAuth is needed, run `/mcp-config login ` to complete browser-based authorization. -Plugins can also declare MCP servers in their manifest. Servers declared by a plugin are enabled by default and can be disabled or re-enabled in `/plugins`, and the change applies immediately. See [Plugins](./plugins.md#mcp-servers-in-plugins) for details. +Plugins can also declare MCP servers in their manifest. Servers declared by a plugin are enabled by default and can be disabled or re-enabled in `/plugins`: disabling or removing stops the tools in open sessions — calls fail with a removal notice — while adding or enabling a server takes effect in new sessions or after `/reload`. See [Plugins](./plugins.md#mcp-servers-in-plugins) for details. ::: warning Note stdio entries in a project-level `.kimi-code/mcp.json` execute local commands when a session starts. Only enable these in repositories you trust. diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 5e81363a2..9c3fb7dfc 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -48,7 +48,7 @@ Network requests only go through `github.com` redirects and `codeload.github.com ### Notes -- Plugin changes apply immediately: installing, enabling, disabling, or removing a plugin refreshes its Skills, agents, MCP servers, and hooks in place. The running session keeps the system prompt it started with — new plugin instructions reach only new sessions and newly created agents — and MCP tools already loaded in an open session stay visible but fail with a removal notice once their plugin is uninstalled. +- Plugin changes apply in new sessions or after `/reload`: run `/new` or `/reload` after installing, enabling, disabling, or removing a plugin. A running session never picks up plugin changes — it keeps the system prompt and tools it started with, and receives a system reminder when the plugin set changes. MCP tools from a newly installed plugin are not registered in already-open sessions; tools from a removed plugin stay visible there, but calls to them fail with a removal notice. - Local installations are copied to `$KIMI_CODE_HOME/plugins/managed//`, and the CLI always runs from this managed copy. Editing the original source directory after installation has no effect; you must reinstall. - Removing a plugin only deletes the installation record; the managed copy and original source files remain on disk. - Plugins are currently installed per-user and apply to all projects; project-level installation scope is not yet supported. @@ -80,7 +80,7 @@ You must first complete OAuth login with a Kimi Code account via `/login`. The p 1. Run `/plugins` and select **Official** 2. Find **Kimi Datasource** and press `Enter` to install -3. The plugin activates as soon as installation completes +3. After installation completes, run `/reload` or `/new` to activate the plugin Using Kimi Datasource consumes your Kimi Code plan quota; the install result reminds you of this. The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above. @@ -188,7 +188,7 @@ System-prompt contributions take effect on both agent engines. The interactive T Each field — the inline `systemPrompt` and the `systemPromptPath` file — is limited to 32 KB (UTF-8 bytes): oversized content is ignored and reported in the plugin diagnostics. Across all enabled plugins, one prompt build injects at most 64 KB of instructions; contributions beyond the budget are skipped with a warning, including a single plugin whose inline text and file together exceed that budget. -New sessions and newly created agents read the contributions from the plugins currently enabled. An in-flight request keeps its existing system prompt. Installing, enabling, disabling, or removing a plugin updates the contributions immediately: each agent snapshots the plugin instructions and skill listing when its system prompt is first built, so a running session never picks up plugin changes — even later rebuilds, for example after compaction or a tool-policy change, reuse the snapshot — and `/plugins reload` refreshes the plugin skill list for new agents without rewriting live prompts. A resumed session starts from its persisted prompt, and later rebuilds follow the same snapshot rules. Toggling a plugin's MCP server does not change system-prompt sections. +New sessions and newly created agents read the contributions from the plugins currently enabled. Each agent snapshots the plugin instructions and skill listing when its system prompt is first built, so a running session never picks up plugin changes — installing, enabling, disabling, or removing a plugin never rewrites a live prompt, and even later rebuilds, for example after compaction or a tool-policy change, reuse the snapshot. Run `/new` or `/reload` to start a session that picks up the current contributions. A resumed session starts from its persisted prompt, and later rebuilds follow the same snapshot rules. Toggling a plugin's MCP server does not change system-prompt sections. The built-in agent prompt includes instructions from enabled plugins automatically. A custom `SYSTEM.md` or agent file owns its template, so include `${plugin_sections}` where plugin-contributed instructions should appear. If the custom template includes `${base_prompt}` and that effective default already contains the plugin block, do not add `${plugin_sections}` again. See [Custom agents and SYSTEM.md](./agents.md#overriding-the-main-agent-s-system-prompt-with-system-md) for the complete variable table. @@ -283,7 +283,7 @@ my-plugin/ reviewer.md ``` -Plugin agents rank below every other file source: on a name collision, user-level, extra, project-level, and `--agent-file` agents all win over the plugin-provided one, and replacing a built-in agent still requires an explicit `override: true` in the frontmatter. After installing, enabling, disabling, or removing a plugin, the agent list refreshes immediately. +Plugin agents rank below every other file source: on a name collision, user-level, extra, project-level, and `--agent-file` agents all win over the plugin-provided one, and replacing a built-in agent still requires an explicit `override: true` in the frontmatter. After installing, enabling, disabling, or removing a plugin, the agent list refreshes in a new session or after `/reload`. ## MCP Servers in Plugins @@ -316,12 +316,14 @@ HTTP server (remote service): For stdio servers, `command` can be a command on `PATH` or a path starting with `./` within the plugin root directory. `cwd` likewise must start with `./` and be within the plugin root directory; otherwise the server is ignored. -Plugin MCP servers start or stop as soon as the plugin change is applied. To enable or disable a server: +Plugin MCP servers take effect in new sessions or after `/reload`. To enable or disable a server: ```sh /plugins mcp disable kimi-finance finance +/reload /plugins mcp enable kimi-finance finance +/reload ``` When a plugin's server is removed or disabled, tools it had loaded into an open session stay visible there, but calls to them fail with a removal notice, and new sessions do not register them at all. @@ -357,5 +359,5 @@ Plugins have a limited loading scope. The following operations do not occur duri - Command-type plugin tools and legacy tool runtimes are not executed - All paths must remain within the plugin root directory after symbolic link resolution -- MCP servers of enabled plugins start as soon as the plugin is applied and can be disabled at any time from `/plugins` +- MCP servers of enabled plugins start in new sessions or after `/reload` and can be disabled at any time from `/plugins` - Broken manifests or unsafe paths appear in `/plugins info ` diagnostics and do not affect other sessions diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index b328a90fb..bfc6fd4bb 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -21,7 +21,7 @@ MCP server 配置写在 `mcp.json` 中,分两层: 在 TUI 中运行 `/mcp-config` 可以交互式地新增、编辑或删除 server,无需手动编辑 JSON 文件。运行 `/mcp` 可查看当前所有 server 的连接状态。 -从配置中删除某个 server 不会打断进行中的会话:该 server 在 `/mcp` 中仍显示为 `removed`,其工具在这些会话中保持可见,但调用会失败并返回移除提示;新会话则完全不会注册这些工具。 +从配置中删除某个 server 不会打断进行中的会话:该 server 在 `/mcp` 中仍显示为 `removed`,其工具在这些会话中保持可见,但调用会失败并返回移除提示;新会话则完全不会注册这些工具。反过来,会话进行中新增的 server——无论是编辑 `mcp.json` 还是安装 plugin——都不会注册到已打开的会话中,只会加入之后创建的会话。 `mcp.json` 的结构: @@ -63,7 +63,7 @@ MCP server 配置写在 `mcp.json` 中,分两层: HTTP 与 SSE server 支持通过 `headers` 或 `bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login ` 完成浏览器授权。 -Plugins 也可以在 manifest 中声明 MCP servers。Plugin 声明的 servers 默认启用,可以在 `/plugins` 中禁用或重新启用,变更立即生效。详见 [Plugins](./plugins.md#plugin-中的-mcp-servers)。 +Plugins 也可以在 manifest 中声明 MCP servers。Plugin 声明的 servers 默认启用,可以在 `/plugins` 中禁用或重新启用:禁用或移除后,已打开会话中的工具调用会失败并返回移除提示;新增或启用 server 则在新会话或 `/reload` 后生效。详见 [Plugins](./plugins.md#plugin-中的-mcp-servers)。 ::: warning 注意 项目级 `.kimi-code/mcp.json` 中的 stdio 条目会在会话启动时执行本地命令,只在你信任的仓库里启用。 diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 82ad021e0..a71227ded 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -48,7 +48,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 ### 注意事项 -- Plugin 变更立即生效:安装、启用、禁用或移除 plugin 会就地刷新其 Skill、Agent、MCP server 和 hook。正在运行的会话保持启动时的系统提示词——新的 plugin 指令只会进入新会话和新建 Agent——已加载到进行中的会话中的 MCP 工具仍然可见,但其 plugin 被卸载后调用会失败并返回移除提示。 +- Plugin 变更在新会话或 `/reload` 后生效:安装、启用、禁用或移除 plugin 后,运行 `/new` 或 `/reload` 应用变更。运行中的会话永远不会吸收 plugin 变更——它保持启动时的系统提示词和工具,并会在 plugin 集变化时收到一条 system reminder。新安装 plugin 的 MCP 工具不会注册到已打开的会话中;被移除 plugin 的 MCP 工具在已打开的会话中仍然可见,但调用会失败并返回移除提示。 - 本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,CLI 始终从这份托管副本运行。安装后编辑原始源目录不会生效,需重新安装。 - 移除 plugin 只会删除安装记录,托管副本和原始源文件仍保留在磁盘上。 - Plugin 目前按用户安装,对所有项目生效,暂不支持项目级安装范围。 @@ -80,7 +80,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 1. 运行 `/plugins`,选择 **Official** 2. 找到 **Kimi Datasource**,按 `Enter` 安装 -3. 安装完成后 plugin 立即激活 +3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin 使用 Kimi Datasource 会消耗你的 Kimi Code 套餐额度,安装结果中会提示这一点。当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 @@ -188,7 +188,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以 `systemPrompt` 字段与 `systemPromptPath` 文件各限制为 32 KB(UTF-8 字节):超限内容会被忽略,并显示在 plugin 的 diagnostics 中。一次提示词构建最多注入所有已启用 plugin 合计 64 KB 的指令;超出预算的贡献会被跳过并给出警告——单个 plugin 的内联文本与文件合计超过该预算时同样整体跳过。 -新会话和新建 Agent 会读取当前已启用 plugin 的指令。正在进行的请求会继续使用已有的系统提示词。安装、启用、禁用或移除 plugin 会立即更新各项贡献:每个 Agent 在首次构建系统提示词时快照 plugin 指令和 Skill 列表,因此运行中的会话永远不会吸收 plugin 变更——之后的提示词重建(例如压缩上下文或修改工具策略后)也会复用这份快照——`/plugins reload` 会为新建 Agent 刷新 plugin Skill 列表,但不会改写活跃会话的提示词。从磁盘恢复的会话会先使用持久化的提示词,后续重建遵循相同的快照规则。切换 plugin 的 MCP server 不会改变系统提示词指令。 +新会话和新建 Agent 会读取当前已启用 plugin 的指令。每个 Agent 在首次构建系统提示词时快照 plugin 指令和 Skill 列表,因此运行中的会话永远不会吸收 plugin 变更——安装、启用、禁用或移除 plugin 都不会改写活跃会话的提示词,之后的提示词重建(例如压缩上下文或修改工具策略后)也会复用这份快照。运行 `/new` 或 `/reload` 即可让新会话读取当前的 plugin 指令。从磁盘恢复的会话会先使用持久化的提示词,后续重建遵循相同的快照规则。切换 plugin 的 MCP server 不会改变系统提示词指令。 内置 Agent 提示词会自动包含已启用 plugin 的指令。自定义 `SYSTEM.md` 或 Agent 文件完全拥有自己的模板,因此应在希望出现 plugin 指令的位置加入 `${plugin_sections}`。如果自定义模板包含 `${base_prompt}`,且该有效默认提示词已经包含 plugin 块,就不要再重复加入 `${plugin_sections}`。完整变量表见 [自定义 Agent 与 SYSTEM.md](./agents.md#用-system-md-覆盖主-agent-的系统提示词)。 @@ -283,7 +283,7 @@ my-plugin/ reviewer.md ``` -Plugin Agent 的优先级低于其他文件来源:同名时用户级、额外目录、项目级和 `--agent-file` 的 Agent 都会覆盖 plugin 提供的版本;替换内置 Agent 同样需要在 frontmatter 里显式写 `override: true`。安装、启用、禁用或移除 plugin 后,Agent 列表立即刷新。 +Plugin Agent 的优先级低于其他文件来源:同名时用户级、额外目录、项目级和 `--agent-file` 的 Agent 都会覆盖 plugin 提供的版本;替换内置 Agent 同样需要在 frontmatter 里显式写 `override: true`。安装、启用、禁用或移除 plugin 后,Agent 列表在新会话或 `/reload` 后刷新。 ## Plugin 中的 MCP servers @@ -316,12 +316,14 @@ HTTP server(远程服务): 对于 stdio servers,`command` 可以是 `PATH` 上的命令,也可以是 plugin 根目录内以 `./` 开头的路径。`cwd` 同理,必须以 `./` 开头并位于 plugin 根目录内,否则该 server 会被忽略。 -Plugin MCP servers 随 plugin 变更即时启动或停止。启用或禁用某个 server: +Plugin MCP servers 在新会话或 `/reload` 后生效。启用或禁用某个 server: ```sh /plugins mcp disable kimi-finance finance +/reload /plugins mcp enable kimi-finance finance +/reload ``` plugin 的 server 被移除或禁用后,它已加载到进行中的会话中的工具仍然可见,但调用会失败并返回移除提示;新会话完全不会注册这些工具。 @@ -357,5 +359,5 @@ Plugin 的加载范围有限,以下操作不会在安装或会话启动时发 - 不会执行命令型 plugin tools 或旧式工具运行时 - 所有路径在解析符号链接后仍必须位于 plugin 根目录内 -- 已启用 plugin 的 MCP servers 随 plugin 生效即启动,且可随时从 `/plugins` 禁用 +- 已启用 plugin 的 MCP servers 在新会话或 `/reload` 后启动,且可随时从 `/plugins` 禁用 - 损坏的 manifest 或不安全路径会显示在 `/plugins info ` 的 diagnostics 中,不影响其他会话 diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index 58e8bbba0..8d9d79c41 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -10,7 +10,14 @@ * as `removed`, swaps in the OAuth tool for * `needs-auth` servers, journals tool discoveries on the wire (queued until * restore finishes), and publishes `mcp.server.status` / `tool.list.updated` - * events. Sessions and agents construct without awaiting the manager's + * events. Only the session's baseline servers take part + * (`ISessionMcpHandle.isBaselineServer`, checked on every replayed and + * live status change): a server that appears mid-session — a plugin + * install or a config edit — is ignored here, so its tools, status events, + * and discoveries never reach a live agent; it joins on the next session + * materialization (`/new`, `/reload`, resume), while a tombstoned baseline + * server reconnecting under the same name (a re-enabled plugin) registers + * again. Sessions and agents construct without awaiting the manager's * initial connect; each LLM step instead waits for it through a `loop` * onWillBeginStep hook (a no-op once settled), with the per-execution * `toolExecutor` onWillExecuteTool wait as the backstop. The plain-data state (`mcpToolsByServer`, `discoveryWritesReady`) @@ -219,6 +226,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { } private handleMcpServerStatusChange(entry: McpServerEntry): void { + if (!this.mcpHandle.isBaselineServer(entry.name)) return; this.eventBus.publish({ type: 'mcp.server.status', server: { diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index be043868a..bfc29c438 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -3,7 +3,15 @@ * * Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects * them through `contextInjector` and `systemReminder`, and uses `contextMemory` - * to neutralize stale guidance. Main-agent-only (v1 parity): the service + * to neutralize stale guidance. The session-start refresh on plugin-source + * catalog changes fires only for an explicit plugin reload: a mutation-driven + * reload (install / enable / disable / remove) skips it — the live session + * keeps the guidance it started with — and instead appends a `plugin_change` + * system reminder through `systemReminder` (`plugin` `onDidMutate` — never on + * an explicit reload, whose resumed session would otherwise inherit a stale + * notice), naming the mutated plugin and telling the model the live session + * keeps its original prompt and tool set until `/new` or `/reload`. + * Main-agent-only (v1 parity): the service * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for * every agent, so other agents construct it as a no-op. Resolves * session prompt context through `sessionContext` and reports missing skills @@ -20,7 +28,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; -import type { EnabledPluginSessionStart } from '#/app/plugin/types'; +import type { EnabledPluginSessionStart, PluginMutation } from '#/app/plugin/types'; import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -30,11 +38,36 @@ import { IAgentPluginService } from './agentPlugin'; const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; +const PLUGIN_CHANGE_INJECTION_VARIANT = 'plugin_change'; + +const PLUGIN_CHANGE_VERBS: Record = { + install: 'installed', + enable: 'enabled', + disable: 'disabled', + remove: 'removed', + 'mcp-server': 'updated', +}; + +function renderPluginChangeReminder(mutation: PluginMutation): string { + return ( + `Plugin "${mutation.id}" was ${PLUGIN_CHANGE_VERBS[mutation.kind]}. ` + + 'This session keeps the prompt and tools it started with; ' + + 'run /new or /reload to apply the change, and tell the user if they expect it now.' + ); +} + const MAIN_AGENT_ID = 'main'; export class AgentPluginService extends Service implements IAgentPluginService { declare readonly _serviceBrand: undefined; + // Count of mutation-driven plugin reloads whose catalog change has not + // reached this agent yet. `reloadAndNotify` fires `onDidMutate` + // synchronously within every mutation's `onDidReload`, while the catalog + // re-scan completes asynchronously, so the count is always positive by the + // time a mutation-driven catalog change arrives. + private pendingMutationCatalogChanges = 0; + constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentContextInjectorService injector: IAgentContextInjectorService, @@ -58,9 +91,26 @@ export class AgentPluginService extends Service implements IAgentPluginService { ); this._register( this.skillCatalog.onDidChange((sourceId) => { - if (sourceId === PLUGIN_SKILL_SOURCE_ID) { - void this.appendFreshSessionStartReminder(); + if (sourceId !== PLUGIN_SKILL_SOURCE_ID) return; + if (this.pendingMutationCatalogChanges > 0) { + // Mutation-driven reload: the live session keeps the session-start + // guidance it started with — the plugin_change reminder is the only + // notice it gets. A failed mutation reload produces no catalog + // change, so a later explicit-reload refresh may be skipped once; + // that only keeps the frozen guidance longer, which is safe. + this.pendingMutationCatalogChanges--; + return; } + void this.appendFreshSessionStartReminder(); + }), + ); + this._register( + this.plugins.onDidMutate(({ mutation }) => { + this.pendingMutationCatalogChanges++; + this.reminders.appendSystemReminder(renderPluginChangeReminder(mutation), { + kind: 'injection', + variant: PLUGIN_CHANGE_INJECTION_VARIANT, + }); }), ); } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 649ba8efc..5642e036e 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -44,10 +44,14 @@ * the same keep-live-sessions-stable philosophy as the MCP tombstone. New * agents (new sessions, new subagents) snapshot the then-current state. The * Workspace-scope catalog still re-pulls its plugin source on plugin reload - * (new agents and runtime skill lookups read it) and its change event still - * drives `refreshSystemPrompt`, but the rebuild reuses the frozen values, so - * the prompt only moves when non-plugin inputs change (AGENTS.md, the - * `[tools]` section, session tool policy, compaction). A side effect of the + * (new agents and runtime skill lookups read it), but its change event no + * longer drives `refreshSystemPrompt`: with the plugin-derived inputs + * frozen, such a rebuild could never pick up new content and would only + * churn `${now}`, rewriting the prompt and invalidating the provider's + * prompt cache on every plugin mutation. The prompt only moves when + * non-plugin inputs change (AGENTS.md, the + * `[tools]` section, session tool policy, compaction, the builtin-source + * config toggle). A side effect of the * freeze: skills added mid-session to file-backed sources, and builtin-source * config toggles, no longer ride an unrelated refresh into a live agent's * prompt. `refreshSystemPrompt` never rejects: a @@ -119,10 +123,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { subagentDisplayModel } from '#/session/subagent/configSection'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { - BUILTIN_SKILL_SOURCE_ID, - PLUGIN_SKILL_SOURCE_ID, -} from '#/app/skillCatalog/skillSource'; +import { BUILTIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; @@ -293,7 +294,11 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); this._register( this.skillCatalog.onDidChange((sourceId) => { - if (sourceId === PLUGIN_SKILL_SOURCE_ID || sourceId === BUILTIN_SKILL_SOURCE_ID) { + // Only the builtin source drives a rebuild: plugin-derived prompt + // inputs are frozen for the agent's lifetime, so rebuilding on a + // plugin-source change could never pick up new content — it would + // only churn `${now}` and invalidate the provider's prompt cache. + if (sourceId === BUILTIN_SKILL_SOURCE_ID) { void this.refreshSystemPrompt(); } }), diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index ffefcdebb..330549a1f 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -23,7 +23,13 @@ * pattern used by every agent tool. The per-profile tool listings in the * description read the full contribution table (not the runtime registry, * which only holds tools the caller's own Profile activated), plus any - * dynamically registered tools. Bound at Agent scope. + * dynamically registered tools. The description's catalog profile list is + * snapshotted once the session catalog has loaded and frozen for the agent's + * lifetime: plugin install / enable / disable / remove re-contributes + * profiles mid-session, and a live read would rewrite the tools payload of + * every later request — breaking the provider's prompt cache for a change a + * live agent must not see (new profiles take effect on `/new` or `/reload`). + * Bound at Agent scope. */ import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -121,6 +127,8 @@ export class SubagentTool implements ISubagentTool { private readonly callerAgentId: string; private readonly canRunInBackground: () => boolean; + private catalogReady = false; + private frozenCatalogProfiles: readonly AgentProfile[] | undefined; constructor( @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, @@ -145,6 +153,9 @@ export class SubagentTool implements ISubagentTool { this.toolPolicy.isToolActive('TaskList') && this.toolPolicy.isToolActive('TaskOutput') && this.toolPolicy.isToolActive('TaskStop'); + void this.catalog.ready.then(() => { + this.catalogReady = true; + }); } get description(): string { @@ -153,10 +164,11 @@ export class SubagentTool implements ISubagentTool { : AGENT_BACKGROUND_DISABLED_DESCRIPTION; let description = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; const allowlist = subagentAllowlistFor(this.catalog, this.profile.data()); + const catalogProfiles = this.catalogProfiles(); const profiles = allowlist === undefined - ? this.catalog.list() - : this.catalog.list().filter((profile) => allowlist.includes(profile.name)); + ? catalogProfiles + : catalogProfiles.filter((profile) => allowlist.includes(profile.name)); const typeLines = buildProfileDescriptions( profiles, this.knownToolReferences(), @@ -179,6 +191,15 @@ export class SubagentTool implements ISubagentTool { return description; } + private catalogProfiles(): readonly AgentProfile[] { + if (this.frozenCatalogProfiles !== undefined) return this.frozenCatalogProfiles; + const profiles = this.catalog.list(); + // Freeze only on a loaded catalog — a pre-ready read could pin a partial + // listing for the agent's lifetime. + if (this.catalogReady) this.frozenCatalogProfiles = profiles; + return profiles; + } + private knownToolReferences(): ToolReference[] { const refs = new Map(); for (const contribution of getAgentToolContributions()) { diff --git a/packages/agent-core-v2/src/app/plugin/plugin.ts b/packages/agent-core-v2/src/app/plugin/plugin.ts index 225710646..3cfad9f88 100644 --- a/packages/agent-core-v2/src/app/plugin/plugin.ts +++ b/packages/agent-core-v2/src/app/plugin/plugin.ts @@ -19,6 +19,7 @@ import type { PluginAgentRoot, PluginCommandDef, PluginInfo, + PluginMutationSummary, PluginSummary, PluginUpdateStatus, ReloadSummary, @@ -70,6 +71,11 @@ export interface IPluginService { // empty snapshot from the fallback. hasLoadedSnapshot(): boolean; readonly onDidReload: Event; + // Fires only after a mutation (install / enable / disable / remove) has + // reloaded and notified — unlike `onDidReload`, an explicit + // `reloadPlugins()` does not raise it, so live-session consumers can tell + // "the plugin set changed under you" apart from a deliberate reload. + readonly onDidMutate: Event; } export const IPluginService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 971d7e998..0a8d6901c 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -11,7 +11,9 @@ * fallback instead of rejecting (`hasLoadedSnapshot` exposes the state). * Every mutation (install / enable / disable / remove) re-fires * `onDidReload` so workspace-scoped consumers refresh their contributions - * immediately. Bound at App scope. + * immediately, and additionally fires `onDidMutate` so live-session + * consumers can react to the plugin set changing under them (an explicit + * `reloadPlugins()` raises only `onDidReload`). Bound at App scope. */ import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; @@ -43,6 +45,8 @@ import type { PluginCommandDef, PluginInfo, PluginAgentRoot, + PluginMutation, + PluginMutationSummary, PluginSummary, PluginUpdateStatus, ReloadSummary, @@ -64,8 +68,10 @@ export class PluginService extends Service implements IPluginService { private loadError: Error | undefined; private mutationQueue: Promise = Promise.resolve(); private readonly onDidReloadEmitter = this._register(new Emitter()); + private readonly onDidMutateEmitter = this._register(new Emitter()); readonly onDidReload: Event = this.onDidReloadEmitter.event; + readonly onDidMutate: Event = this.onDidMutateEmitter.event; constructor( @IBootstrapService bootstrap: IBootstrapService, @@ -93,7 +99,7 @@ export class PluginService extends Service implements IPluginService { const info = this.manager.info(record.id); if (info === undefined) throw new BugIndicatingError(`Plugin "${record.id}" missing right after install`); - await this.reloadAndNotify(); + await this.reloadAndNotify({ mutation: { kind: 'install', id: record.id } }); return info; }); } @@ -101,21 +107,23 @@ export class PluginService extends Service implements IPluginService { setPluginEnabled(input: SetPluginEnabledInput): Promise { return this.runSerializedOperation(async () => { await this.manager.setEnabled(input.id, input.enabled); - await this.reloadAndNotify(); + await this.reloadAndNotify({ + mutation: { kind: input.enabled ? 'enable' : 'disable', id: input.id }, + }); }); } setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise { return this.runSerializedOperation(async () => { await this.manager.setMcpServerEnabled(input.id, input.server, input.enabled); - await this.reloadAndNotify(); + await this.reloadAndNotify({ mutation: { kind: 'mcp-server', id: input.id } }); }); } removePlugin(input: RemovePluginInput): Promise { return this.runSerializedOperation(async () => { await this.manager.remove(input.id); - await this.reloadAndNotify(); + await this.reloadAndNotify({ mutation: { kind: 'remove', id: input.id } }); }); } @@ -139,11 +147,15 @@ export class PluginService extends Service implements IPluginService { return reload; } - private async reloadAndNotify(): Promise { + private async reloadAndNotify(options?: { + readonly mutation: PluginMutation; + }): Promise { const summary = await this.manager.reload(); this.snapshotLoaded = true; this.loadError = undefined; this.onDidReloadEmitter.fire(summary); + if (options?.mutation !== undefined) + this.onDidMutateEmitter.fire({ ...summary, mutation: options.mutation }); return summary; } diff --git a/packages/agent-core-v2/src/app/plugin/types.ts b/packages/agent-core-v2/src/app/plugin/types.ts index fa36514c3..ad426972e 100644 --- a/packages/agent-core-v2/src/app/plugin/types.ts +++ b/packages/agent-core-v2/src/app/plugin/types.ts @@ -164,6 +164,15 @@ export interface ReloadSummary { readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>; } +export interface PluginMutation { + readonly kind: 'install' | 'enable' | 'disable' | 'remove' | 'mcp-server'; + readonly id: string; +} + +export interface PluginMutationSummary extends ReloadSummary { + readonly mutation: PluginMutation; +} + export interface PluginUpdateStatus { readonly id: string; readonly source: PluginSource; diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts index 9f613f893..308b539bb 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts @@ -7,7 +7,13 @@ * ordinary sessions, or a `MergedMcpConnectionView` over that manager and a * session-owned overlay manager when the session was created with ephemeral * MCP servers (`CreateSessionOptions.mcpServers`) — consumers never care - * which manager owns a server. The contract carries no IO of its own. + * which manager owns a server. `isBaselineServer` carries the session's + * server baseline: the names captured when the session materialized (open + * to additions until the initial connect settles, then closed). Servers + * that appear later — a plugin install or a config edit — are not part of + * the session, so live agents must not register their tools; a fresh + * baseline is captured on the next session materialization (`/new`, + * `/reload`, resume). The contract carries no IO of its own. * Session-scoped. */ @@ -20,6 +26,7 @@ export interface ISessionMcpHandle { readonly ready: Promise; readonly connectionManager: McpConnectionView; + isBaselineServer(name: string): boolean; } export const ISessionMcpHandle: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts index da72c01a4..475cdd9d0 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts @@ -14,8 +14,8 @@ */ import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentLifecycleService, diff --git a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts index ae37a9da1..f0b15d7fb 100644 --- a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts +++ b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts @@ -165,6 +165,7 @@ export class SessionMcpHandleAdapter extends Service { get connectionManager() { return backing.connectionManager; }, + isBaselineServer: (name) => backing.isBaselineServer(name), }; instantiation.provide(ISessionMcpHandle, handle); } diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts index f0a63dca9..6253a9bf4 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -10,11 +10,22 @@ * with a removal notice, while new sessions never see them), feeds the * manager's global timeout defaults * from the config domain's tunables at each (re)connect, and reports - * connection telemetry for the initial load. It also builds per-session + * connection telemetry for the initial load. Every session handle it hands + * out (`sessionHandle` / `sessionOverlay`) captures a server baseline — the + * names present when the session materializes, open to additions until the + * initial connect settles, then closed — so servers that appear mid-session + * (a plugin install or a config edit, which always land after the initial + * connect via the mutation tail) never reach the live sessions' tool + * registries; the next session materialization (`/new`, `/reload`, resume) + * captures a fresh baseline. It also builds per-session * overlays (`sessionOverlay`): a session-owned manager for a session's - * ephemeral (caller-injected, never persisted) servers, presented through a + * ephemeral (caller-injected, never persisted) servers — baseline members + * by construction — presented through a * `MergedMcpConnectionView` over the shared manager and shut down by the - * session lifecycle when the session scope tears down. + * session lifecycle when the session scope tears down. An overlay handle's + * baseline still freezes on the workspace manager's initial load — never on + * the overlay's own connect — so a slow ephemeral connect cannot reopen the + * window for mid-session workspace additions. * An outright initial-load or change-apply failure is logged (per-server * failures are status entries). The manager (and its stdio child processes, * whose cwd is the handler root) lives as long as the handler — i.e. the @@ -35,7 +46,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { McpConnectionManager, type McpConnectionView } from '#/mcpCore/connection-manager'; import type { McpServerConfig } from '#/mcpCore/config-schema'; import { McpOAuthService } from '#/mcpCore/oauth/service'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; @@ -106,6 +117,7 @@ export class WorkspaceMcpService extends Service implements IWorkspaceMcpService _serviceBrand: undefined, ready: this.ready, connectionManager: this.manager, + isBaselineServer: this.sessionBaseline(this.manager, this.ready), }; } @@ -130,16 +142,51 @@ export class WorkspaceMcpService extends Service implements IWorkspaceMcpService sessionManager, new Set(Object.keys(servers)), ); + const ready = Promise.all([this.ready, connect]).then(() => undefined); return { handle: { _serviceBrand: undefined, - ready: Promise.all([this.ready, connect]).then(() => undefined), + ready, connectionManager: view, + // The baseline's lazy window tracks only the workspace manager's + // initial load: freezing on the combined `ready` would keep it open + // while a slow ephemeral server connects, and a workspace server + // added in that window (plugin install, config edit) would leak into + // the live session through the merged view. Overlay names are known + // at construction, so they need no window at all. + isBaselineServer: this.sessionBaseline(this.manager, this.ready, Object.keys(servers)), }, shutdown: () => sessionManager.shutdown(), }; } + private sessionBaseline( + view: McpConnectionView, + ready: Promise, + extra?: readonly string[], + ): (name: string) => boolean { + const baseline = new Set(extra); + for (const entry of view.list()) { + baseline.add(entry.name); + } + let frozen = false; + void ready.then( + () => { + frozen = true; + }, + () => { + frozen = true; + }, + ); + return (name) => { + if (baseline.has(name)) return true; + if (frozen) return false; + if (view.get(name) === undefined) return false; + baseline.add(name); + return true; + }; + } + private mutate(work: () => Promise): Promise { const tail = this.mutationTail.catch(() => undefined).then(work); this.mutationTail = tail; diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index bd9c7a6d9..5994692f0 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -234,11 +234,13 @@ describe('AgentMcpService', () => { function createService( manager: FakeMcpManager, ready: Promise = Promise.resolve(), + isBaselineServer: (name: string) => boolean = () => true, ): IAgentMcpService { ix.stub(ISessionMcpHandle, { _serviceBrand: undefined, ready, connectionManager: manager as unknown as McpConnectionManager, + isBaselineServer, } satisfies ISessionMcpHandle); ix.stub(ISessionContext, { sessionDir: '/tmp/kimi-code-mcp-test' }); ix.set(IAgentMcpService, new SyncDescriptor(AgentMcpService)); @@ -316,6 +318,51 @@ describe('AgentMcpService', () => { ); }); + it('ignores status changes from servers outside the session baseline', async () => { + const manager = new FakeMcpManager(); + const lateClient = fakeMcpClient(); + manager.setResolved('late server', lateClient, await discoverTools(lateClient)); + const baseClient = fakeMcpClient(); + manager.setResolved('base server', baseClient, await discoverTools(baseClient)); + createService(manager, Promise.resolve(), (name) => name === 'base server'); + + const mcpToolNames = () => + ix + .get(IAgentToolRegistryService) + .list() + .filter((tool) => tool.source === 'mcp') + .map((tool) => tool.name); + + manager.connect('late server'); + + expect(mcpToolNames()).toEqual([]); + expect( + events.filter( + (event) => event.type === 'mcp.server.status' || event.type === 'tool.list.updated', + ), + ).toEqual([]); + + manager.connect('base server'); + + expect(mcpToolNames().toSorted()).toEqual([ + 'mcp__base_server__echo', + 'mcp__base_server__noop', + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'mcp.server.status', + server: expect.objectContaining({ name: 'base server', status: 'connected' }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'tool.list.updated', + reason: 'mcp.connected', + serverName: 'base server', + }), + ); + }); + it('respects the enabledNames filter when registering connected tools', async () => { const manager = new FakeMcpManager(); const client = fakeMcpClient(); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 764a1e530..bc56a5452 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -17,7 +17,11 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInj import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; import { IPluginService } from '#/app/plugin/plugin'; -import type { EnabledPluginSessionStart, ReloadSummary } from '#/app/plugin/types'; +import type { + EnabledPluginSessionStart, + PluginMutationSummary, + ReloadSummary, +} from '#/app/plugin/types'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { summarizeSkill } from '#/app/skillCatalog/types'; import type { SkillDefinition } from '#/app/skillCatalog/types'; @@ -41,13 +45,16 @@ function pluginSkill(): SkillDefinition { interface PluginServiceStubOptions { readonly sessionStarts: readonly EnabledPluginSessionStart[]; readonly reloadEmitter?: Emitter; + readonly mutateEmitter?: Emitter; } function pluginServiceStub(options: PluginServiceStubOptions): IPluginService { const reloadEmitter = options.reloadEmitter; + const mutateEmitter = options.mutateEmitter; return { _serviceBrand: undefined, onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + onDidMutate: mutateEmitter !== undefined ? mutateEmitter.event : () => ({ dispose: () => {} }), listPlugins: async () => [], installPlugin: async () => ({ id: '' }) as never, setPluginEnabled: async () => {}, @@ -279,3 +286,156 @@ describe('AgentPluginService plugin session-start wiring', () => { sinkChange.dispose(); }); }); + +describe('AgentPluginService plugin-change reminder', () => { + let ctx: TestAgentContext | undefined; + + afterEach(async () => { + if (ctx !== undefined) await ctx.dispose(); + ctx = undefined; + }); + + function findPluginChangeMessages(context: TestAgentContext) { + return context.contextData().history.filter( + (message) => + message.origin?.kind === 'injection' && message.origin.variant === 'plugin_change', + ); + } + + it('appends a plugin_change system reminder when the plugin set mutates', async () => { + const mutateEmitter = new Emitter(); + ctx = createTestAgent( + { autoConfigure: true }, + appService(IPluginService, pluginServiceStub({ sessionStarts: [], mutateEmitter })), + skillServices(new InMemorySkillCatalog()), + agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)), + ); + ctx.get(IAgentPluginService); + + mutateEmitter.fire({ + added: [], + removed: [], + errors: [], + mutation: { kind: 'enable', id: 'demo' }, + }); + + const messages = findPluginChangeMessages(ctx); + expect(messages).toHaveLength(1); + expect(messageText(messages[0]!)).toContain('Plugin "demo" was enabled.'); + expect(messageText(messages[0]!)).toContain('run /new or /reload to apply the change'); + mutateEmitter.dispose(); + }); + + it('does not append the plugin_change reminder on an explicit reload', async () => { + const reloadEmitter = new Emitter(); + ctx = createTestAgent( + { autoConfigure: true }, + appService(IPluginService, pluginServiceStub({ sessionStarts: [], reloadEmitter })), + skillServices(new InMemorySkillCatalog()), + agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)), + ); + ctx.get(IAgentPluginService); + + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + + expect(findPluginChangeMessages(ctx)).toHaveLength(0); + reloadEmitter.dispose(); + }); + + function skillCatalogWithChange(catalog: InMemorySkillCatalog, change: Emitter) { + const skillCatalog: ISessionSkillCatalog = { + _serviceBrand: undefined, + catalog, + ready: Promise.resolve(), + onDidChange: change.event, + load: async () => {}, + reload: async () => {}, + list: async () => catalog.listSkills().map(summarizeSkill), + }; + return skillCatalog; + } + + function fireMutation(mutateEmitter: Emitter, id: string): void { + mutateEmitter.fire({ + added: [], + removed: [], + errors: [], + mutation: { kind: 'install', id }, + }); + } + + it('suppresses the session-start refresh for mutation-driven catalog changes', async () => { + const catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + const sinkChange = new Emitter(); + const mutateEmitter = new Emitter(); + ctx = createTestAgent( + { autoConfigure: true }, + appService( + IPluginService, + pluginServiceStub({ + sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], + mutateEmitter, + }), + ), + skillServices(skillCatalogWithChange(catalog, sinkChange)), + agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)), + ); + ctx.get(IAgentPluginService); + await injectRegistered(ctx); + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + + // Production ordering: onDidMutate fires synchronously inside the + // mutation's onDidReload; the catalog change arrives after the async + // re-scan. + fireMutation(mutateEmitter, 'demo'); + sinkChange.fire('plugin'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(findPluginChangeMessages(ctx)).toHaveLength(1); + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + + // An explicit reload (no mutation) still refreshes the guidance. + const appended = waitForPluginSessionStartMessage(ctx); + sinkChange.fire('plugin'); + await appended; + expect(findPluginSessionStartMessages(ctx).length).toBeGreaterThanOrEqual(2); + + sinkChange.dispose(); + mutateEmitter.dispose(); + }); + + it('suppresses one session-start refresh per mutation when mutations arrive back to back', async () => { + const catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + const sinkChange = new Emitter(); + const mutateEmitter = new Emitter(); + ctx = createTestAgent( + { autoConfigure: true }, + appService( + IPluginService, + pluginServiceStub({ + sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], + mutateEmitter, + }), + ), + skillServices(skillCatalogWithChange(catalog, sinkChange)), + agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)), + ); + ctx.get(IAgentPluginService); + await injectRegistered(ctx); + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + + fireMutation(mutateEmitter, 'demo'); + fireMutation(mutateEmitter, 'demo'); + sinkChange.fire('plugin'); + sinkChange.fire('plugin'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(findPluginChangeMessages(ctx)).toHaveLength(2); + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + + sinkChange.dispose(); + mutateEmitter.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts index 18888f326..85457438f 100644 --- a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts @@ -333,6 +333,48 @@ describe('AgentProfileService.applyProfile', () => { change.dispose(); }); + it('does not rebuild the system prompt when the plugin skill source changes', async () => { + let renders = 0; + const countingProfile: ResolvedAgentProfile = normalizeAgentProfile({ + name: 'counting-profile', + systemPrompt: () => `render:${++renders}`, + tools: [], + }); + const change = new Emitter(); + const { profile: svc } = buildContext(skillCatalogWithChange(change)); + await svc.applyProfile(countingProfile); + expect(svc.data().systemPrompt).toBe('render:1'); + + change.fire(PLUGIN_SKILL_SOURCE_ID); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Plugin-derived inputs are frozen for the agent's lifetime, so a plugin + // source change must not trigger a rebuild at all — a rebuild would only + // churn `${now}` and invalidate the provider's prompt cache. + expect(svc.data().systemPrompt).toBe('render:1'); + change.dispose(); + }); + + it('rebuilds the system prompt when the builtin skill source changes', async () => { + let renders = 0; + const countingProfile: ResolvedAgentProfile = normalizeAgentProfile({ + name: 'counting-profile', + systemPrompt: () => `render:${++renders}`, + tools: [], + }); + const change = new Emitter(); + const { profile: svc } = buildContext(skillCatalogWithChange(change)); + await svc.applyProfile(countingProfile); + expect(svc.data().systemPrompt).toBe('render:1'); + + change.fire(BUILTIN_SKILL_SOURCE_ID); + + await vi.waitFor(() => { + expect(svc.data().systemPrompt).toBe('render:2'); + }); + change.dispose(); + }); + it('skips plugin sections beyond the aggregate byte budget and warns once', async () => { const large = 'x'.repeat(48 * 1024); const sections = { diff --git a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts index d548854aa..87e3af155 100644 --- a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts +++ b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts @@ -30,7 +30,7 @@ import { IProviderService, type ProviderConfig } from '#/kosong/provider/provide import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import * as pluginStore from '#/app/plugin/store'; import type { InstalledFile } from '#/app/plugin/store'; -import type { ReloadSummary } from '#/app/plugin/types'; +import type { PluginMutationSummary, ReloadSummary } from '#/app/plugin/types'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubProviderService } from '../provider/stubs'; @@ -284,6 +284,36 @@ describe('PluginService (plugin boundary)', () => { } }); + it('fires onDidMutate after install / enable / disable / remove but not on an explicit reloadPlugins', async () => { + const home = await makeHome(); + await writeValidInstalledFile(home); + const pluginRoot = await makePluginDir('mutate-demo', { description: 'demo plugin' }); + createdDirs.push(pluginRoot); + const host = makeHost(home); + try { + const svc = host.app.accessor.get(IPluginService); + const mutations: PluginMutationSummary[] = []; + svc.onDidMutate((summary) => mutations.push(summary)); + + await svc.installPlugin({ source: pluginRoot }); + await svc.setPluginEnabled({ id: 'mutate-demo', enabled: false }); + await svc.setPluginEnabled({ id: 'mutate-demo', enabled: true }); + await svc.removePlugin({ id: 'mutate-demo' }); + expect(mutations).toHaveLength(4); + expect(mutations.map((summary) => summary.mutation)).toEqual([ + { kind: 'install', id: 'mutate-demo' }, + { kind: 'disable', id: 'mutate-demo' }, + { kind: 'enable', id: 'mutate-demo' }, + { kind: 'remove', id: 'mutate-demo' }, + ]); + + await svc.reloadPlugins(); + expect(mutations).toHaveLength(4); + } finally { + host.dispose(); + } + }); + it('serves enabled plugin system-prompt sections on the consumption plane', async () => { const home = await makeHome(); const pluginRoot = await makePluginDir('prompt-demo', { systemPrompt: 'Always cite sources.' }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 4321092a6..7ea62b7c0 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -695,6 +695,7 @@ export function mcpServices(options: { _serviceBrand: undefined, ready: Promise.resolve(), connectionManager: options.manager!, + isBaselineServer: () => true, } satisfies ISessionMcpHandle); } @@ -1186,6 +1187,7 @@ export class AgentTestContext { _serviceBrand: undefined, ready: Promise.resolve(), connectionManager: new McpConnectionManager(), + isBaselineServer: () => true, } satisfies ISessionMcpHandle); reg.defineInstance(ISessionWorkspaceInfo, { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index e56643151..628b5af27 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -366,6 +366,7 @@ describe('AgentLifecycleService', () => { log: noopLog, oauthService: new McpOAuthService({ store: createMcpOAuthStore(atomicDocsStore) }), }), + isBaselineServer: () => true, } satisfies ISessionMcpHandle); stopAllOnExit = vi.fn(async () => []); ix.stub(IAgentTaskService, { @@ -657,6 +658,7 @@ describe('AgentLifecycleService', () => { _serviceBrand: undefined, ready, connectionManager: new McpConnectionManager({ log: noopLog }), + isBaselineServer: () => true, } satisfies ISessionMcpHandle); const svc = ix.get(IAgentLifecycleService); diff --git a/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts b/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts index c9bfe0b8d..a899aa5f0 100644 --- a/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts +++ b/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts @@ -132,6 +132,7 @@ class WorkspaceMcpStub implements IWorkspaceMcpService { _serviceBrand: undefined, ready: this.ready, connectionManager: this.manager, + isBaselineServer: () => true, }; } diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 2bd1f4e9e..14709c15a 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -686,6 +686,91 @@ describe('Agent tool description', () => { expect(description).not.toContain('- coder: Coder'); }); + it('freezes the subagent type list once the profile catalog is ready', async () => { + const caller: AgentProfile = normalizeAgentProfile({ + name: 'orchestrator', + description: 'Orchestrator', + systemPrompt: () => 'orchestrator', + }); + const coder: AgentProfile = normalizeAgentProfile({ + name: 'coder', + description: 'Coder', + systemPrompt: () => 'coder', + }); + const explore: AgentProfile = normalizeAgentProfile({ + name: 'explore', + description: 'Explorer', + systemPrompt: () => 'explore', + }); + const profiles = [coder]; + const catalog: ISessionAgentProfileCatalog = { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: Event.None as ISessionAgentProfileCatalog['onDidChange'], + get: (name) => [caller, ...profiles].find((profile) => profile.name === name), + getDefault: () => caller, + list: () => [...profiles], + inspect: () => undefined, + load: async () => {}, + reload: async () => {}, + }; + ctx = createTestAgent(sessionService(ISessionAgentProfileCatalog, catalog)); + // Prime the tool, then let catalog.ready settle: from the next read on, + // the description freezes the catalog list. + expect(agentDescription()).toContain('- coder: Coder'); + await Promise.resolve(); + + const frozen = agentDescription(); + expect(frozen).toContain('- coder: Coder'); + + profiles.push(explore); + const after = agentDescription(); + expect(after).toBe(frozen); + expect(after).not.toContain('- explore: Explorer'); + }); + + it('reflects the current catalog list in the description before the catalog is ready', async () => { + const caller: AgentProfile = normalizeAgentProfile({ + name: 'orchestrator', + description: 'Orchestrator', + systemPrompt: () => 'orchestrator', + }); + const coder: AgentProfile = normalizeAgentProfile({ + name: 'coder', + description: 'Coder', + systemPrompt: () => 'coder', + }); + const explore: AgentProfile = normalizeAgentProfile({ + name: 'explore', + description: 'Explorer', + systemPrompt: () => 'explore', + }); + const profiles = [coder]; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const catalog: ISessionAgentProfileCatalog = { + _serviceBrand: undefined, + ready, + onDidChange: Event.None as ISessionAgentProfileCatalog['onDidChange'], + get: (name) => [caller, ...profiles].find((profile) => profile.name === name), + getDefault: () => caller, + list: () => [...profiles], + inspect: () => undefined, + load: async () => {}, + reload: async () => {}, + }; + ctx = createTestAgent(sessionService(ISessionAgentProfileCatalog, catalog)); + + expect(agentDescription()).toContain('- coder: Coder'); + profiles.push(explore); + expect(agentDescription()).toContain('- explore: Explorer'); + + resolveReady(); + await ready; + }); + it('mentions resume preference and result visibility', () => { ctx = createTestAgent(); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 8e90afa0f..95780c60b 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -391,6 +391,7 @@ function workspaceMcpServiceStub(ready: Promise = Promise.resolve()): IWor get connectionManager(): McpConnectionManager { throw new Error('not implemented'); }, + isBaselineServer: () => true, }), sessionOverlay: () => { throw new Error('not implemented'); @@ -1175,6 +1176,7 @@ describe('SessionLifecycleService', () => { _serviceBrand: undefined, ready, connectionManager: {} as unknown as McpConnectionManager, + isBaselineServer: () => true, }; const shutdown = vi.fn(() => Promise.resolve()); const sessionOverlay = vi.fn( diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts index 97b741cfa..63f5ec1d3 100644 --- a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts @@ -194,6 +194,7 @@ function pluginStub( return { _serviceBrand: undefined, onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + onDidMutate: () => ({ dispose: () => {} }), listPlugins: async () => [], installPlugin: async () => ({ id: '' }) as never, setPluginEnabled: async () => {}, diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts index 5ffef3263..0815fdd81 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -173,6 +173,106 @@ describe('WorkspaceMcpService', () => { ); }, 20000); + it('sessionHandle admits servers connecting before ready settles and freezes the baseline after', async () => { + current = { alpha: stdioServer() }; + let settleConnectAll: () => void = () => undefined; + vi.spyOn(McpConnectionManager.prototype, 'connectAll').mockImplementation(function ( + this: McpConnectionManager, + servers: Readonly>, + ) { + for (const [name, config] of Object.entries(servers)) { + void this.connect(name, config); + } + return new Promise((resolve) => { + settleConnectAll = resolve; + }); + }); + + const service = createService(); + manager = service.connectionManager(); + const handle = service.sessionHandle(); + + // 'alpha' appears (connecting) while the initial load is still unsettled: + // admitted into the baseline. A name the view does not know is not. + await vi.waitFor(() => { + expect(manager?.get('alpha')).toBeDefined(); + }); + expect(handle.isBaselineServer('alpha')).toBe(true); + expect(handle.isBaselineServer('ghost')).toBe(false); + + settleConnectAll(); + await service.ready; + + // Once the initial connect settles the baseline is closed: a server that + // connects afterwards (a plugin install or a config edit) stays outside. + await manager?.connect('late', stdioServer()); + expect(handle.isBaselineServer('late')).toBe(false); + expect(handle.isBaselineServer('alpha')).toBe(true); + + // A session materializing now captures a fresh baseline that includes it. + expect(service.sessionHandle().isBaselineServer('late')).toBe(true); + }, 20000); + + it('sessionOverlay marks the ephemeral server names as baseline by construction', async () => { + current = { base: stdioServer() }; + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + const overlay = service.sessionOverlay({ eph: stdioServer() }); + // True even before the overlay's own connect settles. + expect(overlay.handle.isBaselineServer('eph')).toBe(true); + expect(overlay.handle.isBaselineServer('base')).toBe(true); + + await overlay.handle.ready; + expect(overlay.handle.isBaselineServer('eph')).toBe(true); + expect(overlay.handle.isBaselineServer('late')).toBe(false); + + await overlay.shutdown(); + }, 20000); + + it('sessionOverlay freezes the workspace baseline on workspace ready even while the overlay connect is pending', async () => { + current = { base: stdioServer() }; + let settleOverlay: () => void = () => undefined; + vi.spyOn(McpConnectionManager.prototype, 'connectAll').mockImplementation(function ( + this: McpConnectionManager, + servers: Readonly>, + ) { + if ('eph' in servers) { + // Slow ephemeral connect: keeps the overlay's combined readiness open + // long after the workspace initial load has settled. + return new Promise((resolve) => { + settleOverlay = resolve; + }); + } + for (const [name, config] of Object.entries(servers)) { + void this.connect(name, config); + } + return Promise.resolve(); + }); + + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + const overlay = service.sessionOverlay({ eph: stdioServer() }); + expect(overlay.handle.isBaselineServer('eph')).toBe(true); + expect(overlay.handle.isBaselineServer('base')).toBe(true); + + // The overlay connect is still pending, but the workspace portion of the + // baseline closed with the workspace initial load: a workspace server + // added now (plugin install, config edit) must not leak into the session. + await manager?.connect('late', stdioServer()); + expect(overlay.handle.isBaselineServer('late')).toBe(false); + + settleOverlay(); + await overlay.handle.ready; + expect(overlay.handle.isBaselineServer('late')).toBe(false); + expect(overlay.handle.isBaselineServer('eph')).toBe(true); + + await overlay.shutdown(); + }, 20000); + it('sessionOverlay connects ephemeral servers on a session-owned manager, released by shutdown', async () => { current = { base: stdioServer() }; const service = createService(); diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts index a0ee46000..e48103ae9 100644 --- a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -121,6 +121,7 @@ function pluginStub( return { _serviceBrand: undefined, onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + onDidMutate: () => ({ dispose: () => {} }), listPlugins: async () => [], installPlugin: async () => ({ id: '' }) as never, setPluginEnabled: async () => {},