From bca3024eabbe0110a23bf5e82c8aa2235d5668e2 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 29 May 2026 20:06:32 +0800 Subject: [PATCH] feat(plugins): hot-apply plugin changes to the current session on /plugins reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/plugins reload` now hot-loads newly installed or enabled plugin skills and connects newly enabled MCP servers into the running session — no /new required. - agent-core: add PluginManager.runtimeSnapshot() and Session.applyPluginRuntimeSnapshot(); re-render the main agent's system prompt and refresh builtin tools so new skills (and the Skill tool) appear; add McpConnectionManager.connect() for incremental, non-disruptive connects; make reloadPlugins session-scoped and keep apply failures from poisoning the plugin-load error state; report only servers that actually connected. - node-sdk: reloadPlugins() returns the applied result (PluginReloadResult). - kimi-code: /plugins reload reports what was applied and refreshes skill slash-commands; call-to-action points to a highlighted /plugins reload instead of /new. - Disable/remove/update/sessionStart are not torn down live; reload flags this via needsNewSession. - Tests, docs (en+zh), and a changeset. --- .changeset/plugin-reload-hot-apply.md | 9 + apps/kimi-code/src/tui/commands/dispatch.ts | 2 + apps/kimi-code/src/tui/commands/plugins.ts | 47 +++- .../dialogs/plugins-selector.test.ts | 4 +- .../test/tui/kimi-tui-message-flow.test.ts | 41 +++- docs/en/customization/mcp.md | 2 +- docs/en/customization/plugins.md | 14 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/customization/mcp.md | 2 +- docs/zh/customization/plugins.md | 14 +- docs/zh/reference/slash-commands.md | 2 +- packages/agent-core/src/agent/index.ts | 18 ++ .../agent-core/src/mcp/connection-manager.ts | 17 +- packages/agent-core/src/plugin/manager.ts | 14 ++ packages/agent-core/src/plugin/types.ts | 35 +++ packages/agent-core/src/rpc/core-api.ts | 10 +- packages/agent-core/src/rpc/core-impl.ts | 19 +- packages/agent-core/src/session/index.ts | 128 +++++++++- .../harness/plugin-reload-session.test.ts | 222 ++++++++++++++++++ .../test/mcp/connection-manager.test.ts | 24 ++ .../agent-core/test/plugin/manager.test.ts | 37 +++ .../agent-core/test/rpc/plugins-rpc.test.ts | 2 +- packages/node-sdk/src/rpc.ts | 6 +- packages/node-sdk/src/session.ts | 6 +- packages/node-sdk/src/types.ts | 2 + 25 files changed, 634 insertions(+), 45 deletions(-) create mode 100644 .changeset/plugin-reload-hot-apply.md create mode 100644 packages/agent-core/test/harness/plugin-reload-session.test.ts diff --git a/.changeset/plugin-reload-hot-apply.md b/.changeset/plugin-reload-hot-apply.md new file mode 100644 index 000000000..77b2ce465 --- /dev/null +++ b/.changeset/plugin-reload-hot-apply.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +`/plugins reload` now hot-applies plugin changes to the current session — no `/new` required. Newly installed or enabled plugin skills load immediately (the main agent's skill list and `Skill` tool are refreshed) and newly enabled plugin MCP servers are connected. Disable, remove, update, and `sessionStart` changes are not torn down in a running session; reload reports when a new session is still needed to fully apply them. + +Adds `PluginManager.runtimeSnapshot()` and `Session.applyPluginRuntimeSnapshot()` in `agent-core`; the SDK's `reloadPlugins()` now returns the applied result (`PluginReloadResult` / `PluginRuntimeApplyResult`). diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 28ccf8bc0..a424f79ca 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -35,6 +35,7 @@ import { } from './config'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; import { handlePluginsCommand } from './plugins'; +import type { SkillListSession } from './skills'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -121,6 +122,7 @@ export interface SlashCommandHost { showSessionPicker(): Promise; sendNormalUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + refreshSkillCommands(session?: SkillListSession): Promise; readonly skillCommandMap: Map; // Controller refs diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 929afce93..6cfebf063 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -1,6 +1,8 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; +import chalk from 'chalk'; + import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { @@ -87,7 +89,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } await session.setPluginMcpServerEnabled(id, server, action === 'enable'); host.showStatus( - `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run ${reloadCommandText(host.state.theme.colors)} to apply to this session.`, ); return; } @@ -264,7 +266,9 @@ async function applyPluginEnabled( ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` : ''; if (showStatus) { - host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); + host.showStatus( + `${enabled ? 'Enabled' : 'Disabled'} ${id}. Run ${reloadCommandText(host.state.theme.colors)} to apply to this session.${mcpHint}`, + ); } const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; return `${pluginInlineChangeHint()}${inlineMcpHint}`; @@ -393,22 +397,39 @@ async function installPluginFromSource( ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed'; + const reloadCmd = reloadCommandText(host.state.theme.colors); host.showStatus( - `${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, + `${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run ${reloadCmd} to apply to this session.`, ); if (options?.successNotice === 'marketplace') { host.showNotice( `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`, + `Marketplace install or update succeeded for ${summary.id}. Run ${reloadCmd} to apply to this session.`, ); } } async function reloadPlugins(host: SlashCommandHost): Promise { - const summary = await host.requireSession().reloadPlugins(); - const line = `Reload: +${summary.added.length} -${summary.removed.length}` + - (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); + const session = host.requireSession(); + const summary = await session.reloadPlugins(); + const applied = summary.applied; + const parts = [`+${summary.added.length} plugins`]; + if (applied !== undefined) { + parts.push( + `${applied.addedSkills.length} skills`, + `${applied.addedMcpServers.length} MCP servers`, + ); + } + let line = `Reload: ${parts.join(', ')} now active`; + if (summary.errors.length > 0) { + line += ` (${summary.errors.length} errors)`; + } + if (summary.removed.length > 0 || (applied?.needsNewSession ?? false)) { + line += '. Removals, updates, and sessionStart changes need a new session to fully apply.'; + } host.showStatus(line); + // New skills may add slash commands; refresh the palette/autocomplete. + await host.refreshSkillCommands(session); } function resolvePluginInstallSource(source: string, workDir: string): string { @@ -420,5 +441,15 @@ function resolvePluginInstallSource(source: string, workDir: string): string { } function pluginInlineChangeHint(): string { - return 'pending /new'; + return 'pending reload'; +} + +/** + * Render a `/plugins reload` reference with the theme accent + bold so the + * call to action stands out against the dimmed status line. chalk restores the + * surrounding status color after the highlighted span, so it composes with the + * status component's own coloring. + */ +function reloadCommandText(colors: { readonly accent: string }): string { + return chalk.hex(colors.accent).bold('/plugins reload'); } diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 7e3a81060..8149dbf65 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -300,7 +300,7 @@ describe('plugins selector dialogs', () => { }, ], selectedId: 'kimi-datasource', - pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, + pluginHint: { id: 'kimi-datasource', text: 'pending reload' }, colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), @@ -308,7 +308,7 @@ describe('plugins selector dialogs', () => { const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain('? Kimi Datasource enabled pending /new'); + expect(out).toContain('? Kimi Datasource enabled pending reload'); }); it('defaults plugin removal confirmation to cancel', () => { 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 40ab4e651..59fe3c8ff 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 @@ -1486,9 +1486,11 @@ describe('KimiTUI message flow', () => { ); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ Demo disabled pending /new'); + expect(out).toContain('❯ Demo disabled pending reload'); expect(out).not.toContain('Space enable'); - expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Disabled demo. Run /plugins reload to apply to this session.', + ); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { @@ -1579,12 +1581,43 @@ describe('KimiTUI message flow', () => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ data disabled pending /new'); + expect(out).toContain('❯ data disabled pending reload'); expect(stripSgr(renderTranscript(driver))).not.toContain( - 'Disabled MCP server data for kimi-datasource. Run /new to apply.', + 'Disabled MCP server data for kimi-datasource. Run /plugins reload to apply to this session.', ); }); + it('reports the reload summary and refreshes skill commands on /plugins reload', async () => { + const session = makeSession({ + reloadPlugins: vi.fn(async () => ({ + added: [], + removed: [], + errors: [], + applied: { + addedSkills: ['hot-skill'], + addedMcpServers: ['plugin-demo:data'], + needsNewSession: true, + }, + })), + }); + const { driver } = await makeDriver(session); + const refreshSpy = vi.spyOn( + driver as unknown as { refreshSkillCommands: (...args: unknown[]) => Promise }, + 'refreshSkillCommands', + ); + + driver.handleUserInput('/plugins reload'); + + // The status line wraps at the terminal width, so collapse whitespace. + const transcript = () => stripSgr(renderTranscript(driver)).replaceAll(/\s+/g, ' '); + await vi.waitFor(() => { + expect(transcript()).toContain('Reload: +0 plugins, 1 skills, 1 MCP servers now active'); + }); + expect(transcript()).toContain('need a new session to fully apply'); + expect(session.reloadPlugins).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + }); + it('requires confirmation before /plugins remove removes a plugin', async () => { const session = makeSession(); const { driver } = await makeDriver(session); diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 718c103c7..a73444c2c 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -17,7 +17,7 @@ Project entries override user-level entries with the same name. The easiest entry point is running `/mcp-config` in the TUI, which guides you through adding, editing, or removing servers. To check connection status, run `/mcp`. -Plugins can also declare MCP servers in `kimi.plugin.json` or `.kimi-plugin/plugin.json`. Plugin-declared servers are enabled by default but only start in new sessions; disable or re-enable them from `/plugins` or with `/plugins mcp disable|enable `, then start a new session. See [Plugins](./plugins.md) for details. +Plugins can also declare MCP servers in `kimi.plugin.json` or `.kimi-plugin/plugin.json`. Plugin-declared servers are enabled by default; a newly installed or enabled one comes online in the current session after `/plugins reload` (or in a new session). Disable or re-enable them from `/plugins` or with `/plugins mcp disable|enable ` — enabling applies on `/plugins reload`, while disabling needs a new session to fully take effect. See [Plugins](./plugins.md) for details. The top-level shape of `mcp.json` is: diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 9a0944d8f..11e3231cd 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -30,7 +30,7 @@ Most users only need the interactive manager. You can also use these slash comma | `/plugins enable ` | Enable a plugin; opens the manager when `` is omitted. | | `/plugins disable ` | Disable a plugin; opens the manager when `` is omitted. | | `/plugins remove ` | Remove a plugin; requires confirmation. | -| `/plugins reload` | Reload `installed.json` and each plugin manifest. | +| `/plugins reload` | Reload `installed.json` and each plugin manifest, and hot-apply newly added skills and newly enabled MCP servers to the current session. | | `/plugins mcp enable ` | Enable an MCP server declared by a plugin. | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin. | @@ -38,9 +38,9 @@ For general slash command behavior, see [Slash commands](../reference/slash-comm Kimi Code CLI currently installs plugins per user. Records are stored under `$KIMI_CODE_HOME/plugins/` and apply across all projects. Project-local, repository-shared, admin-managed, and `--scope` installs are not supported yet. -Plugin changes apply to new sessions only. After installing, enabling, disabling, removing, or reloading a plugin, or changing an MCP server toggle, start a fresh session with `/new`. The current session is not updated; new skills, session-start behavior, and MCP servers load only in new sessions. +After installing or enabling a plugin (or enabling one of its MCP servers), run `/plugins reload` to apply the change to the current session — no `/new` required. Reload hot-loads newly added skills (the main agent's skill list and the `Skill` tool are refreshed) and connects newly enabled MCP servers; their tools become available on the next turn. Additive changes only: disabling or removing a plugin, updating one, and `sessionStart` injections are not torn down in a running session. When any of those are pending, `/plugins reload` reports that a new session (`/new`) is still required to fully apply them. -Local installs are copied into `$KIMI_CODE_HOME/plugins/managed//`, and Kimi Code CLI always runs from that managed copy. Editing the original source directory after install has no effect until you reinstall — `/plugins reload` re-reads install records and manifests, not the original source. Removing a plugin deletes only its install record; the managed copy and the original source files are left on disk. +Local installs are copied into `$KIMI_CODE_HOME/plugins/managed//`, and Kimi Code CLI always runs from that managed copy. Editing the original source directory after install has no effect until you reinstall — `/plugins reload` re-reads install records and manifests (not the original source) and applies the additive changes above. Removing a plugin deletes only its install record; the managed copy and the original source files are left on disk. ## Plugin manifest @@ -134,14 +134,16 @@ HTTP server: For stdio servers, `command` may be a command on `PATH` or a `./` path inside the plugin root. If `cwd` is set, it must also start with `./` and stay inside the plugin root; other values are rejected and the server is omitted. Plugin MCP servers inherit the current process environment; values under `env` are literal overrides. -Plugin MCP servers start only in new sessions. To disable or re-enable one, run `/plugins`, select the plugin, and press `M`. Shortcut commands are also available: +Newly enabled plugin MCP servers come online when you run `/plugins reload` (or in a new session). To disable or re-enable one, run `/plugins`, select the plugin, and press `M`. Shortcut commands are also available: ```sh +# Disabling is not torn down live — a new session fully applies it: /plugins mcp disable kimi-finance finance /new +# Enabling takes effect in the current session after a reload: /plugins mcp enable kimi-finance finance -/new +/plugins reload ``` ## Security model @@ -151,5 +153,5 @@ Plugins expose a limited loading surface: - Install and session startup read only plugin manifests and Markdown skill files. - All paths must stay inside the plugin root after symlinks are resolved. - Command-backed plugin tools, hooks, and legacy tool runtimes are not executed by the plugin loader. -- MCP servers declared by enabled plugins start only in new sessions and can be disabled from `/plugins`. +- MCP servers declared by enabled plugins come online via `/plugins reload` (or a new session) and can be disabled from `/plugins`. - Bad manifests or unsafe paths produce diagnostics in `/plugins info ` without crashing unrelated sessions. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 26968e257..ee67eab56 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -56,7 +56,7 @@ Some commands are only available in the idle state. Running them while the sessi | `/usage` | — | Show token usage, context consumption, and quota information. | Yes | | `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes | | `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes | -| `/plugins` | — | Open the interactive plugin manager for user/global installs: install, inspect, enable, disable, confirm removal, reload, browse the official marketplace, and toggle plugin MCP servers. Shortcut subcommands remain available. | Yes | +| `/plugins` | — | Open the interactive plugin manager for user/global installs: install, inspect, enable, disable, confirm removal, reload (hot-applies new skills and MCP servers to the current session), browse the official marketplace, and toggle plugin MCP servers. Shortcut subcommands remain available. | Yes | | `/version` | — | Show the Kimi Code CLI version number. | Yes | | `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes | diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index a75bcf6d2..d0c671904 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -17,7 +17,7 @@ MCP server 配置写在 `mcp.json` 中,分为两层: 最方便的入口是在 TUI 中运行 `/mcp-config`,它会引导你新增、编辑或删除 server。要查看当前连接状态,可运行 `/mcp`。 -Plugins 也可以在 `kimi.plugin.json` 或 `.kimi-plugin/plugin.json` 中声明 MCP servers。Plugin 声明的 servers 默认启用,但只会在新会话中启动;可以在 `/plugins` 中禁用或重新启用,也可以使用 `/plugins mcp disable|enable `,然后开启新会话。详见 [Plugins](./plugins.md)。 +Plugins 也可以在 `kimi.plugin.json` 或 `.kimi-plugin/plugin.json` 中声明 MCP servers。Plugin 声明的 servers 默认启用;新安装或新启用的 server 在运行 `/plugins reload`(或新会话)后即在当前会话上线。可以在 `/plugins` 中禁用或重新启用,也可以使用 `/plugins mcp disable|enable `——启用在 `/plugins reload` 后生效,禁用则需开启新会话才能完全生效。详见 [Plugins](./plugins.md)。 `mcp.json` 的顶层结构如下: diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 3145d71f4..eb50517ea 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -30,7 +30,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl | `/plugins enable ` | 启用 plugin;省略 `` 时打开管理器。 | | `/plugins disable ` | 禁用 plugin;省略 `` 时打开管理器。 | | `/plugins remove ` | 移除 plugin,需二次确认。 | -| `/plugins reload` | 重载 `installed.json` 和各 plugin manifest。 | +| `/plugins reload` | 重载 `installed.json` 和各 plugin manifest,并把新增的 Skills 和新启用的 MCP servers 热加载到当前会话。 | | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server。 | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server。 | @@ -38,9 +38,9 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl Kimi Code CLI 目前按用户安装 plugins,记录在 `$KIMI_CODE_HOME/plugins/` 下,对所有项目生效。暂不支持项目级、仓库级、管理员分发,以及带 `--scope` 的安装方式。 -Plugin 变更只对新会话生效。安装、启用/禁用、移除、重载 plugin,或修改 MCP server 开关后,需要通过 `/new` 开启新会话;当前会话不会更新,新的 Skills、会话启动行为和 MCP servers 只会在新会话中加载。 +安装或启用 plugin(或启用其某个 MCP server)后,运行 `/plugins reload` 即可把变更应用到当前会话,无需 `/new`。重载会热加载新增的 Skills(刷新 main agent 的 Skill 列表和 `Skill` 工具),并连接新启用的 MCP servers,其工具在下一个回合可用。仅作用于「新增」:禁用或移除 plugin、更新 plugin,以及 `sessionStart` 注入,不会在运行中的会话里被回收;存在这类待处理变更时,`/plugins reload` 会提示仍需开启新会话(`/new`)才能完全生效。 -本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,Kimi Code CLI 始终从这份托管副本运行。安装后再编辑原始源目录不会生效,需要重新安装——`/plugins reload` 只会重读安装记录和 manifest,不会重读原始源。移除 plugin 只会删除其安装记录,托管副本和原始源文件仍保留在磁盘上。 +本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,Kimi Code CLI 始终从这份托管副本运行。安装后再编辑原始源目录不会生效,需要重新安装——`/plugins reload` 只重读安装记录和 manifest(不会重读原始源),并应用上述「新增」变更。移除 plugin 只会删除其安装记录,托管副本和原始源文件仍保留在磁盘上。 ## Plugin manifest @@ -134,14 +134,16 @@ HTTP server: 对于 stdio servers,`command` 可以是 `PATH` 上的命令,也可以是 plugin 根目录内以 `./` 开头的路径。如果设置了 `cwd`,它也必须以 `./` 开头并位于 plugin 根目录内;其他取值会被拒绝,该 server 会被忽略。Plugin MCP servers 会继承当前进程的环境变量;`env` 中的值会按字面量覆盖。 -Plugin MCP servers 只会在新会话中启动。要禁用或重新启用某个 server,运行 `/plugins`,选中 plugin 后按 `M`。也可以使用快捷命令: +新启用的 plugin MCP servers 会在运行 `/plugins reload`(或新会话)时上线。要禁用或重新启用某个 server,运行 `/plugins`,选中 plugin 后按 `M`。也可以使用快捷命令: ```sh +# 禁用不会在运行中的会话里回收——需新会话才能完全生效: /plugins mcp disable kimi-finance finance /new +# 启用在当前会话 reload 后即生效: /plugins mcp enable kimi-finance finance -/new +/plugins reload ``` ## 安全模型 @@ -151,5 +153,5 @@ Plugins 的加载范围有限: - 安装和会话启动时,仅读取 plugin manifests 和 Markdown Skill 文件。 - 所有路径在解析符号链接后仍必须位于 plugin 根目录内。 - 命令型 plugin tools、hooks 和旧式工具运行时不会由 plugin loader 执行。 -- 已启用 plugin 声明的 MCP servers 只会在新会话中启动,并且可以从 `/plugins` 中禁用。 +- 已启用 plugin 声明的 MCP servers 会通过 `/plugins reload`(或新会话)上线,并且可以从 `/plugins` 中禁用。 - 损坏的 manifest 或不安全路径会显示在 `/plugins info ` 的 diagnostics 中,不会让无关会话崩溃。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 887128805..a8ec84452 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -56,7 +56,7 @@ | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | -| `/plugins` | — | 打开面向 user/global(用户全局)安装的交互式 plugin 管理器,用于安装、查看、启用、禁用、确认移除、重载、浏览官方 marketplace,以及启用或禁用 plugin MCP servers;快捷子命令仍可使用。 | 是 | +| `/plugins` | — | 打开面向 user/global(用户全局)安装的交互式 plugin 管理器,用于安装、查看、启用、禁用、确认移除、重载(把新增 Skills 和 MCP servers 热加载到当前会话)、浏览官方 marketplace,以及启用或禁用 plugin MCP servers;快捷子命令仍可使用。 | 是 | | `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 | | `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 | diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index d7c412470..417319853 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -266,6 +266,24 @@ export class Agent { this.tools.setActiveTools(profile.tools); } + /** + * Re-render only the system prompt from a profile, leaving the active tool + * set untouched. Used after plugin skills are hot-loaded so the model sees + * the new skills (the prompt's skill listing is rendered live from the + * registry). Unlike {@link useProfile}, this does not reset active tools, + * which can be mutated at runtime and must survive a reload. + */ + rerenderSystemPrompt(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void { + const systemPrompt = profile.systemPrompt({ + osEnv: this.kaos.osEnv, + cwd: this.config.cwd, + skills: this.skills?.registry, + cwdListing: context?.cwdListing, + agentsMd: context?.agentsMd, + }); + this.config.update({ systemPrompt }); + } + async resume(): Promise<{ warning?: string }> { const result = await this.records.replay(); await this.background.loadFromDisk(); diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index fb3b861f2..1e63960ae 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -167,7 +167,22 @@ export class McpConnectionManager { return Math.max(0, endedAt - this.initialLoadStartedAt); } + /** + * Connect additional MCP servers into an already-running manager without + * disturbing existing entries or resetting the initial-load timing metrics. + * Used to hot-add plugin servers when plugins are reloaded mid-session. Tool + * registration happens automatically via the {@link onStatusChange} + * subscription, so agents pick the new tools up on their next turn. + */ + async connect(configs: Record): Promise { + await Promise.allSettled(this.spawnEntries(configs)); + } + private async connectAllNow(configs: Record): Promise { + await Promise.allSettled(this.spawnEntries(configs)); + } + + private spawnEntries(configs: Record): Promise[] { const tasks: Promise[] = []; for (const [name, config] of Object.entries(configs)) { const disabled = config.enabled === false; @@ -183,7 +198,7 @@ export class McpConnectionManager { tasks.push(this.connectOne(entry, this.beginConnectAttempt(entry))); } } - await Promise.allSettled(tasks); + return tasks; } async reconnect(name: string): Promise { diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index e42eb7317..d87a84885 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -14,6 +14,7 @@ import { type PluginInfo, type PluginMcpServerInfo, type PluginRecord, + type PluginRuntimeSnapshot, type PluginSource, type PluginSummary, type ReloadSummary, @@ -206,6 +207,19 @@ export class PluginManager { return out; } + /** + * Bundle the currently-enabled plugins' skill roots, MCP servers, and + * sessionStart skills into a single immutable snapshot. Used to hot-apply + * plugin changes to a live session via `Session.applyPluginRuntimeSnapshot`. + */ + runtimeSnapshot(): PluginRuntimeSnapshot { + return { + pluginSkillRoots: this.pluginSkillRoots(), + mcpServers: this.enabledMcpServers(), + sessionStarts: this.enabledSessionStarts(), + }; + } + enabledMcpServers(): Record { const out: Record = {}; for (const record of this.records.values()) { diff --git a/packages/agent-core/src/plugin/types.ts b/packages/agent-core/src/plugin/types.ts index f6d3942ae..d25759d2a 100644 --- a/packages/agent-core/src/plugin/types.ts +++ b/packages/agent-core/src/plugin/types.ts @@ -1,4 +1,5 @@ import type { McpServerConfig } from '../config/schema'; +import type { SkillRoot } from '../skill'; export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info'; @@ -118,6 +119,40 @@ export interface ReloadSummary { readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>; } +/** + * An immutable description of what the currently-enabled plugins want the + * runtime to look like: skill roots to load, MCP servers to run, and + * sessionStart skills to auto-inject. Produced by `PluginManager` and applied + * to a live session by `Session.applyPluginRuntimeSnapshot`. + */ +export interface PluginRuntimeSnapshot { + readonly pluginSkillRoots: readonly SkillRoot[]; + readonly mcpServers: Record; + readonly sessionStarts: readonly EnabledPluginSessionStart[]; +} + +/** + * What `Session.applyPluginRuntimeSnapshot` was actually able to hot-load into + * the current session. Only additive capabilities take effect live; anything + * that would require tearing down existing state sets `needsNewSession`. + */ +export interface PluginRuntimeApplyResult { + readonly addedSkills: readonly string[]; + readonly addedMcpServers: readonly string[]; + /** + * True when the live session still differs from the snapshot in a way that + * only a new session can reconcile: a disabled/removed plugin MCP server is + * still connected, or the set of sessionStart injections drifted (new ones + * cannot be injected mid-conversation, old ones cannot be retracted). + */ + readonly needsNewSession: boolean; +} + +/** Result of `/plugins reload`: the manager-level diff plus what was applied. */ +export interface PluginReloadResult extends ReloadSummary { + readonly applied?: PluginRuntimeApplyResult; +} + export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/; export function normalizePluginId(name: string): string { diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 218bf6667..4a16a2fe0 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -9,7 +9,7 @@ import type { SessionMeta } from '#/session'; import type { BackgroundTaskInfo } from '#/tools/builtin'; import type { ContentPart } from '@moonshot-ai/kosong'; -import type { PluginInfo, PluginSummary, ReloadSummary } from '#/plugin'; +import type { PluginInfo, PluginReloadResult, PluginSummary } from '#/plugin'; import type { UsageStatus } from './events'; import type { WithAgentId, WithSessionId } from './types'; @@ -238,9 +238,13 @@ export interface GetPluginInfoPayload { readonly id: string; } -export type ReloadPluginsResult = ReloadSummary; +export type ReloadPluginsResult = PluginReloadResult; export type { PluginSummary, PluginInfo }; +export interface ReloadPluginsPayload { + readonly sessionId: string; +} + export interface RenameSessionPayload { readonly title: string; } @@ -320,6 +324,6 @@ export interface CoreAPI extends SessionAPIWithId { setPluginEnabled: (payload: SetPluginEnabledPayload) => void; setPluginMcpServerEnabled: (payload: SetPluginMcpServerEnabledPayload) => void; removePlugin: (payload: RemovePluginPayload) => void; - reloadPlugins: (payload: EmptyPayload) => ReloadPluginsResult; + reloadPlugins: (payload: ReloadPluginsPayload) => ReloadPluginsResult; getPluginInfo: (payload: GetPluginInfoPayload) => PluginInfo; } diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index c0aa30dd7..761ae0168 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -60,6 +60,7 @@ import type { PromptPayload, ReconnectMcpServerPayload, RegisterToolPayload, + ReloadPluginsPayload, ReloadPluginsResult, RemoveKimiProviderPayload, RemovePluginPayload, @@ -602,11 +603,11 @@ export class KimiCore implements PromisableMethods { await this.plugins.remove(id); } - async reloadPlugins(_: EmptyPayload): Promise { + async reloadPlugins({ sessionId }: ReloadPluginsPayload): Promise { + let summary: ReloadPluginsResult; try { - const summary = await this.plugins.reload(); + summary = await this.plugins.reload(); this.pluginsLoadError = undefined; - return summary; } catch (error) { this.pluginsLoadError = error instanceof Error ? error : new Error(String(error)); throw new KimiError( @@ -615,6 +616,18 @@ export class KimiCore implements PromisableMethods { { cause: error, details: { kimiHomeDir: this.homeDir } }, ); } + + // Hot-apply the refreshed plugin set to the session that asked for the + // reload. The manager is shared across sessions, but applying is + // per-session — other live sessions reconcile on their own reload. A + // failure here must NOT poison `pluginsLoadError`: the manager reloaded + // fine, so plugin management stays usable; the apply error surfaces on its + // own without the misleading "fix installed.json" guidance. + const session = this.sessions.get(sessionId); + const applied = session + ? await session.applyPluginRuntimeSnapshot(this.plugins.runtimeSnapshot()) + : undefined; + return { ...summary, applied }; } async getPluginInfo({ id }: GetPluginInfoPayload): Promise { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 7192cc614..8bc0d7607 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -19,7 +19,11 @@ import { type McpServerEntry, type SessionMcpConfig, } from '../mcp'; -import type { EnabledPluginSessionStart } from '../plugin'; +import type { + EnabledPluginSessionStart, + PluginRuntimeApplyResult, + PluginRuntimeSnapshot, +} from '../plugin'; import { DEFAULT_AGENT_PROFILES, DEFAULT_INIT_PROMPT, @@ -98,6 +102,11 @@ export class Session { readonly hookEngine: HookEngine; private agentIdCounter = 0; private readonly skillsReady: Promise; + // Plugin ids whose skills have been loaded into the registry (initial load + + // every reload). The registry is additive and has no unload path, so a plugin + // id still in this set but absent from a later snapshot means its skills are + // now stale and only a new session can drop them. + private readonly loadedPluginSkillIds = new Set(); metadata: SessionMeta = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -321,6 +330,116 @@ export class Session { return this.skills.listSkills().map(summarizeSkill); } + /** + * Hot-apply a plugin runtime snapshot to this live session. Additive only: + * newly enabled plugin skills are loaded and the main agent's system prompt + * is re-rendered so the model sees them; the `Skill` builtin tool is + * refreshed so it appears when skills first become available; and newly + * enabled plugin MCP servers are connected (their tools register via the MCP + * status subscription and are picked up on the next turn). Disabled/removed + * capabilities are NOT torn down and sessionStart skills are NOT injected + * mid-conversation — those are reported via `needsNewSession`. + */ + async applyPluginRuntimeSnapshot( + snapshot: PluginRuntimeSnapshot, + ): Promise { + await this.skillsReady; + + // Skills: re-resolve roots with the snapshot's plugin roots and merge them + // in. loadRoots skips already-loaded roots, so only new ones are scanned. + const before = new Set(this.skills.listSkills().map((skill) => skill.name)); + const roots = await resolveSkillRoots({ + paths: { + userHomeDir: this.options.skills?.userHomeDir ?? homedir(), + workDir: this.options.kaos.getcwd(), + }, + explicitDirs: this.options.skills?.explicitDirs, + extraDirs: this.options.skills?.extraDirs, + pluginSkillRoots: snapshot.pluginSkillRoots, + mergeAllAvailableSkills: this.options.skills?.mergeAllAvailableSkills, + builtinDir: this.options.skills?.builtinDir, + }); + await this.skills.loadRoots(roots); + this.rememberLoadedPluginSkills(snapshot.pluginSkillRoots); + const addedSkills = this.skills + .listSkills() + .map((skill) => skill.name) + .filter((name) => !before.has(name)); + + // Builtin tools: rebuild so the `Skill` tool appears once skills exist. + this.refreshAgentBuiltinTools(); + + // System prompt: re-render the main agent's prompt so the model sees the + // new skills. Active tools are intentionally left untouched. + const main = this.agents.get('main'); + const profile = DEFAULT_AGENT_PROFILES['agent']; + if (main !== undefined && profile !== undefined) { + const context = await prepareSystemPromptContext(main.kaos); + main.rerenderSystemPrompt(profile, context); + } + + // MCP: connect only servers not already present; never reconnect existing. + const existingServers = new Set(this.mcp.list().map((entry) => entry.name)); + const newServers = Object.fromEntries( + Object.entries(snapshot.mcpServers).filter(([name]) => !existingServers.has(name)), + ); + const newServerNames = Object.keys(newServers); + if (newServerNames.length > 0) { + await this.mcp.connect(newServers); + } + // Report only servers that actually came online. connect() awaits each + // spawn, so by now every entry has settled; a failed / needs-auth server + // must not be announced as "now active". + const addedMcpServers = newServerNames.filter( + (name) => this.mcp.get(name)?.status === 'connected', + ); + + return { + addedSkills, + addedMcpServers, + needsNewSession: this.pluginRuntimeNeedsNewSession(snapshot, main), + }; + } + + /** + * Whether the live session still differs from the snapshot in a way only a + * new session can reconcile: skills from a now-disabled/removed plugin still + * loaded in the registry, a plugin MCP server still connected but no longer + * enabled (disable/remove can't be cleanly torn down), or a drift between the + * desired and currently-active sessionStart injections. + */ + private pluginRuntimeNeedsNewSession( + snapshot: PluginRuntimeSnapshot, + main: Agent | undefined, + ): boolean { + // Skills: loadRoots is additive, so a plugin whose skills are still in the + // registry but is no longer in the snapshot leaves stale skills behind. + const desiredSkillPlugins = new Set( + snapshot.pluginSkillRoots + .map((root) => root.plugin?.id) + .filter((id): id is string => id !== undefined), + ); + const stalePluginSkills = [...this.loadedPluginSkillIds].some( + (id) => !desiredSkillPlugins.has(id), + ); + if (stalePluginSkills) return true; + + const desiredServers = new Set(Object.keys(snapshot.mcpServers)); + const stalePluginServer = this.mcp + .list() + .some((entry) => entry.name.startsWith('plugin-') && !desiredServers.has(entry.name)); + if (stalePluginServer) return true; + + const activeStarts = new Set( + (main?.pluginSessionStarts ?? []).map((start) => `${start.pluginId}:${start.skillName}`), + ); + const desiredStarts = snapshot.sessionStarts.map( + (start) => `${start.pluginId}:${start.skillName}`, + ); + if (desiredStarts.length !== activeStarts.size) return true; + return desiredStarts.some((key) => !activeStarts.has(key)); + } + private async loadSkills(): Promise { const roots = await resolveSkillRoots({ paths: { @@ -335,6 +454,13 @@ export class Session { }); await this.skills.loadRoots(roots); registerBuiltinSkills(this.skills); + this.rememberLoadedPluginSkills(this.options.skills?.pluginSkillRoots); + } + + private rememberLoadedPluginSkills(roots: readonly SkillRoot[] | undefined): void { + for (const root of roots ?? []) { + if (root.plugin?.id !== undefined) this.loadedPluginSkillIds.add(root.plugin.id); + } } private async loadMcpServers(): Promise { diff --git a/packages/agent-core/test/harness/plugin-reload-session.test.ts b/packages/agent-core/test/harness/plugin-reload-session.test.ts new file mode 100644 index 000000000..0bb6bab14 --- /dev/null +++ b/packages/agent-core/test/harness/plugin-reload-session.test.ts @@ -0,0 +1,222 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createRPC, + KimiCore, + type ApprovalResponse, + type CoreAPI, + type CoreRPC, + type Event, + type SDKAPI, +} from '../../src'; + +// A provider + model so a session created WITH a model has a real provider +// (and therefore initializes builtin tools, incl. the gated Skill tool). No +// default_model: sessions created without a model stay provider-less, so the +// other tests' behavior is unchanged. +const CONFIG = ` +[providers."test-provider"] +type = "kimi" +api_key = "test-key" +base_url = "https://api.example/v1" + +[models."test/model"] +provider = "test-provider" +model = "test-model" +max_context_size = 1000000 +`; + +describe('plugin reload hot-apply to a live session', () => { + let tmp: string; + let homeDir: string; + let workDir: string; + let configPath: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-plugin-reload-')); + homeDir = join(tmp, 'home'); + workDir = join(tmp, 'work'); + configPath = join(tmp, 'config.toml'); + await mkdir(workDir, { recursive: true }); + await writeFile(configPath, CONFIG); + // Hermetic OS home so the developer's real ~/.kimi-code skills don't leak + // into the session and pre-populate invocable skills (which would defeat + // the "Skill tool appears only after hot-load" assertion). + const osHome = join(tmp, 'os-home'); + await mkdir(osHome, { recursive: true }); + vi.stubEnv('HOME', osHome); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('makes a newly installed plugin skill available and visible to the model after reload', async () => { + const { core, rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_reload_skill', workDir }); + + // The skill does not exist before the plugin is installed. + const before = await rpc.listSkills({ sessionId: created.id }); + expect(before.some((skill) => skill.name === 'hotpack-review')).toBe(false); + + const pluginRoot = await makePlugin('hotpack', { skillNames: ['hotpack-review'] }); + await rpc.installPlugin({ source: pluginRoot }); + + // Installing alone does not touch the already-running session. + const afterInstall = await rpc.listSkills({ sessionId: created.id }); + expect(afterInstall.some((skill) => skill.name === 'hotpack-review')).toBe(false); + + // Reload hot-applies the plugin to this session. + const result = await rpc.reloadPlugins({ sessionId: created.id }); + expect(result.applied?.addedSkills).toContain('hotpack-review'); + + // The skill is now listed... + const afterReload = await rpc.listSkills({ sessionId: created.id }); + expect(afterReload.some((skill) => skill.name === 'hotpack-review')).toBe(true); + + // ...and the main agent's system prompt was re-rendered so the model knows. + const main = core.sessions.get(created.id)?.agents.get('main'); + expect(main?.config.systemPrompt).toContain('hotpack-review'); + }); + + it('exposes the Skill builtin tool to the model only after a skill is hot-loaded', async () => { + const { rpc } = await createTestRpc(); + // A model gives the main agent a provider, so builtin tools initialize and + // the gated Skill tool can appear once an invocable skill exists. + const created = await rpc.createSession({ id: 'ses_reload_skilltool', workDir, model: 'test/model' }); + + // With zero invocable skills, the Skill tool is gated out of the tool set. + const before = await rpc.getTools({ sessionId: created.id, agentId: 'main' }); + expect(before.some((tool) => tool.name === 'Skill')).toBe(false); + + const pluginRoot = await makePlugin('toolpack', { skillNames: ['toolpack-do'] }); + await rpc.installPlugin({ source: pluginRoot }); + await rpc.reloadPlugins({ sessionId: created.id }); + + // The hot-loaded skill makes the Skill tool available so the model can call it. + const after = await rpc.getTools({ sessionId: created.id, agentId: 'main' }); + expect(after.some((tool) => tool.name === 'Skill')).toBe(true); + }); + + it('does not report a plugin MCP server that failed to connect as added/active', async () => { + const { core, rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_reload_mcp', workDir }); + + const pluginRoot = await makePlugin('datapack', { + mcpServers: { data: { command: 'kimi-nonexistent-mcp-binary' } }, + }); + await rpc.installPlugin({ source: pluginRoot }); + + const first = await rpc.reloadPlugins({ sessionId: created.id }); + // The bogus command fails fast: the server entry is registered on the + // session but must NOT be reported as "now active". + expect(first.applied?.addedMcpServers).toEqual([]); + const entry = core.sessions.get(created.id)?.mcp.list().find((e) => e.name === 'plugin-datapack:data'); + expect(entry?.status).toBe('failed'); + expect(first.applied?.needsNewSession).toBe(false); + }); + + it('flags needsNewSession when a plugin MCP server is disabled but still registered', async () => { + const { rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_reload_mcp_disable', workDir }); + + const pluginRoot = await makePlugin('datapack', { + mcpServers: { data: { command: 'kimi-nonexistent-mcp-binary' } }, + }); + await rpc.installPlugin({ source: pluginRoot }); + await rpc.reloadPlugins({ sessionId: created.id }); + + // Disabling the plugin and reloading leaves the registered server stale — + // the live session can no longer be reconciled without a new session. + await rpc.setPluginEnabled({ id: 'datapack', enabled: false }); + const second = await rpc.reloadPlugins({ sessionId: created.id }); + expect(second.applied?.addedMcpServers).toEqual([]); + expect(second.applied?.needsNewSession).toBe(true); + }); + + it('flags needsNewSession when a skills-only plugin is disabled (skills stay loaded)', async () => { + const { rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_reload_skill_disable', workDir }); + + const pluginRoot = await makePlugin('skillpack', { skillNames: ['skillpack-task'] }); + await rpc.installPlugin({ source: pluginRoot }); + + const first = await rpc.reloadPlugins({ sessionId: created.id }); + expect(first.applied?.addedSkills).toContain('skillpack-task'); + expect(first.applied?.needsNewSession).toBe(false); + + // Disabling the plugin and reloading: loadRoots is additive so the skill + // stays in the registry. The user must be told a new session is required. + await rpc.setPluginEnabled({ id: 'skillpack', enabled: false }); + const second = await rpc.reloadPlugins({ sessionId: created.id }); + expect(second.applied?.needsNewSession).toBe(true); + // The stale skill is still listed (not torn down) — exactly why /new is needed. + const skills = await rpc.listSkills({ sessionId: created.id }); + expect(skills.some((s) => s.name === 'skillpack-task')).toBe(true); + }); + + it('flags needsNewSession when a plugin adds a sessionStart not active in this session', async () => { + const { rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_reload_sessionstart', workDir }); + + const pluginRoot = await makePlugin('startpack', { + skillNames: ['startpack-intro'], + sessionStartSkill: 'startpack-intro', + }); + await rpc.installPlugin({ source: pluginRoot }); + + // The sessionStart was not present when this session's main agent was + // created, so it cannot be injected mid-conversation — reload must flag it. + const result = await rpc.reloadPlugins({ sessionId: created.id }); + expect(result.applied?.needsNewSession).toBe(true); + }); + + async function makePlugin( + name: string, + options: { + readonly skillNames?: readonly string[]; + readonly mcpServers?: Record; + readonly sessionStartSkill?: string; + } = {}, + ): Promise { + const root = await mkdtemp(join(tmpdir(), `plugin-${name}-`)); + const manifest: Record = { name }; + for (const skillName of options.skillNames ?? []) { + manifest['skills'] = './skills/'; + await mkdir(join(root, 'skills', skillName), { recursive: true }); + await writeFile( + join(root, 'skills', skillName, 'SKILL.md'), + `---\nname: ${skillName}\ndescription: A hot-loaded skill\n---\nbody`, + 'utf8', + ); + } + if (options.mcpServers !== undefined) { + manifest['mcpServers'] = options.mcpServers; + } + if (options.sessionStartSkill !== undefined) { + manifest['sessionStart'] = { skill: options.sessionStartSkill }; + } + await writeFile(join(root, 'kimi.plugin.json'), JSON.stringify(manifest), 'utf8'); + return realpath(root); + } + + async function createTestRpc(): Promise<{ core: KimiCore; events: Event[]; rpc: CoreRPC }> { + const [coreRpc, sdkRpc] = createRPC(); + const events: Event[] = []; + const core = new KimiCore(coreRpc, { homeDir, configPath }); + const rpc = await sdkRpc({ + emitEvent: (event) => { + events.push(event); + }, + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + return { core, events, rpc }; + } +}); diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 21c967390..e5cac4f07 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -82,6 +82,30 @@ describe('McpConnectionManager', () => { } }, 20000); + it('connect() adds new servers without reconnecting existing ones or resetting initial-load timing', async () => { + const cm = new McpConnectionManager(); + try { + await cm.connectAll({ alpha: stdioConfig() }); + expect(cm.get('alpha')?.status).toBe('connected'); + const initialDuration = cm.initialLoadDurationMs(); + + const events: McpServerEntry[] = []; + cm.onStatusChange((entry) => events.push(entry)); + + await cm.connect({ beta: stdioConfig() }); + + expect(cm.list().map((e) => e.name).toSorted()).toEqual(['alpha', 'beta']); + expect(cm.get('beta')?.status).toBe('connected'); + // The incremental connect must not touch the already-connected server... + expect(events.some((entry) => entry.name === 'alpha')).toBe(false); + expect(events.some((entry) => entry.name === 'beta')).toBe(true); + // ...nor reset the initial-load timing metrics. + expect(cm.initialLoadDurationMs()).toBe(initialDuration); + } finally { + await cm.shutdown(); + } + }, 20000); + it('isolates failures: a bad server is marked failed without blocking the rest', async () => { const cm = new McpConnectionManager(); try { diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index 0f3ca0389..a65f06602 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -78,6 +78,43 @@ describe('PluginManager', () => { expect(manager.get('demo')?.originalSource).toBe(pluginRoot); }); + it('runtimeSnapshot() bundles an enabled plugin\'s skills, MCP servers, and sessionStarts', async () => { + const home = await makeKimiHome(); + const pluginRoot = await makePlugin('demo', { + skillNames: ['demo-skill'], + sessionStartSkill: 'demo-skill', + mcpServers: { finance: { command: 'finance-mcp' } }, + }); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(pluginRoot); + + const snapshot = manager.runtimeSnapshot(); + expect(snapshot.pluginSkillRoots.some((root) => root.plugin?.id === 'demo')).toBe(true); + expect(snapshot.mcpServers).toHaveProperty('plugin-demo:finance'); + expect(snapshot.sessionStarts).toContainEqual({ pluginId: 'demo', skillName: 'demo-skill' }); + }); + + it('runtimeSnapshot() omits a disabled plugin', async () => { + const home = await makeKimiHome(); + const pluginRoot = await makePlugin('demo', { + skillNames: ['demo-skill'], + sessionStartSkill: 'demo-skill', + mcpServers: { finance: { command: 'finance-mcp' } }, + }); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(pluginRoot); + await manager.setEnabled('demo', false); + + const snapshot = manager.runtimeSnapshot(); + expect(snapshot.pluginSkillRoots).toEqual([]); + expect(snapshot.mcpServers).toEqual({}); + expect(snapshot.sessionStarts).toEqual([]); + }); + it('install() accepts a .kimi-plugin manifest', async () => { const home = await makeKimiHome(); const root = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-')); diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index 4b019e58b..d8f443008 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -92,7 +92,7 @@ describe('KimiCore plugin RPCs', () => { JSON.stringify({ version: 1, plugins: [] }), 'utf8', ); - await core.reloadPlugins({}); + await core.reloadPlugins({ sessionId: 'no-session' }); await expect(core.listPlugins({})).resolves.toEqual([]); }); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index a2041af5c..9c8672333 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -35,8 +35,8 @@ import type { McpStartupMetrics, PermissionMode, PluginInfo, + PluginReloadResult, PluginSummary, - ReloadSummary, CompactOptions, SessionPlan, SessionStatus, @@ -464,9 +464,9 @@ export class SDKRpcClient { return rpc.removePlugin({ id }); } - async reloadPlugins(): Promise { + async reloadPlugins(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); - return rpc.reloadPlugins({}); + return rpc.reloadPlugins({ sessionId: input.sessionId }); } async getPluginInfo(id: string): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 6dc395ef7..03bc7af0d 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -8,9 +8,9 @@ import type { McpStartupMetrics, PermissionMode, PluginInfo, + PluginReloadResult, PluginSummary, PromptInput, - ReloadSummary, ResumedSessionState, SessionPlan, SessionStatus, @@ -312,9 +312,9 @@ export class Session { await this.rpc.removePlugin(id); } - async reloadPlugins(): Promise { + async reloadPlugins(): Promise { this.ensureOpen(); - return this.rpc.reloadPlugins(); + return this.rpc.reloadPlugins({ sessionId: this.id }); } async getPluginInfo(id: string): Promise { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index cc031ed8c..d962b2252 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -33,6 +33,8 @@ export type { OAuthRef, PluginInfo, PluginMcpServerInfo, + PluginReloadResult, + PluginRuntimeApplyResult, PluginSummary, PromptOrigin, ProviderConfig,