From bca3024eabbe0110a23bf5e82c8aa2235d5668e2 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 29 May 2026 20:06:32 +0800 Subject: [PATCH 1/2] 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 00000000..77b2ce46 --- /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 28ccf8bc..a424f79c 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 929afce9..6cfebf06 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 7e3a8106..8149dbf6 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 40ab4e65..59fe3c8f 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,10 +1581,41 @@ 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 () => { diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 718c103c..a73444c2 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 9a0944d8..11e3231c 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 26968e25..ee67eab5 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 a75bcf6d..d0c67190 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 3145d71f..eb50517e 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 88712880..a8ec8445 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 d7c41247..41731985 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 fb3b861f..1e63960a 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 e42eb731..d87a8488 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 f6d3942a..d25759d2 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 218bf666..4a16a2fe 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 c0aa30dd..761ae016 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 7192cc61..8bc0d760 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 00000000..0bb6bab1 --- /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 21c96739..e5cac4f0 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 0f3ca038..a65f0660 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 4b019e58..d8f44300 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 a2041af5..9c867233 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 6dc395ef..03bc7af0 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 cc031ed8..d962b225 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, From 717018bb6b4d46db843a9b071d97050a8b7fd2e9 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 29 May 2026 21:18:32 +0800 Subject: [PATCH 2/2] refactor(plugins): surface reloaded skills via injection and detect in-place plugin updates Two review fixes on top of the /plugins reload hot-apply: 1. Don't rewrite the main agent's system prompt on reload. Rewriting it busts the prompt-cache prefix for the whole conversation, breaks the "system prompt set once" invariant, and silently refreshed cwd/AGENTS.md too. Instead add a SkillRefreshInjector that appends the current skill listing as a system reminder when it drifts from the prompt baseline; the listing's "DISREGARD any earlier skill listings" header supersedes the stale one, and the base injector re-injects after compaction. Resume seeds the baseline so a post-resume reload still surfaces new skills (native resume skips useProfile). 2. needsNewSession now detects capabilities CHANGED IN PLACE by a plugin update, not just added/removed. Track a config digest per connected plugin MCP server and compare loaded plugin skills (additive registry) against the plugins' current declaration: a same-named MCP server whose command/args/env/cwd changed, or a removed/renamed skill, now flags needsNewSession. New skills / servers remain additive (needsNewSession stays false). Removes Agent.rerenderSystemPrompt. Adds tests for the injector and for the in-place-update and resume-baseline cases. --- packages/agent-core/src/agent/index.ts | 28 ++-- .../agent-core/src/agent/injection/manager.ts | 2 + .../src/agent/injection/skill-refresh.ts | 43 ++++++ packages/agent-core/src/session/index.ts | 137 ++++++++++++------ .../agent/injection/skill-refresh.test.ts | 75 ++++++++++ .../harness/plugin-reload-session.test.ts | 73 +++++++++- 6 files changed, 296 insertions(+), 62 deletions(-) create mode 100644 packages/agent-core/src/agent/injection/skill-refresh.ts create mode 100644 packages/agent-core/test/agent/injection/skill-refresh.test.ts diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 41731985..1194bf3a 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -109,6 +109,13 @@ export class Agent { readonly planMode: PlanMode; readonly usage: UsageRecorder; readonly skills: SkillManager | null; + /** + * The skill listing baked into the current system prompt, captured when the + * profile is applied. The {@link SkillRefreshInjector} compares the live + * listing against this to decide whether to surface an updated one after + * plugins are hot-loaded — without rewriting the (cache-prefix) system prompt. + */ + systemPromptSkillListing: string | undefined; readonly tools: ToolManager; readonly background: BackgroundManager; readonly cron: CronManager | null; @@ -264,24 +271,9 @@ export class Agent { }); this.config.update({ profileName: profile.name, systemPrompt }); 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 }); + // Remember the skill listing baked into this prompt so the + // SkillRefreshInjector can detect when a hot-reload makes it stale. + this.systemPromptSkillListing = this.skills?.registry.getModelSkillListing(); } async resume(): Promise<{ warning?: string }> { diff --git a/packages/agent-core/src/agent/injection/manager.ts b/packages/agent-core/src/agent/injection/manager.ts index edda42c4..748bb801 100644 --- a/packages/agent-core/src/agent/injection/manager.ts +++ b/packages/agent-core/src/agent/injection/manager.ts @@ -3,6 +3,7 @@ import type { DynamicInjector } from './injector'; import { PermissionModeInjector } from './permission-mode'; import { PluginSessionStartInjector } from './plugin-session-start'; import { PlanModeInjector } from './plan-mode'; +import { SkillRefreshInjector } from './skill-refresh'; export class InjectionManager { private readonly injectors: DynamicInjector[]; @@ -10,6 +11,7 @@ export class InjectionManager { constructor(protected readonly agent: Agent) { this.injectors = [ new PluginSessionStartInjector(agent), + new SkillRefreshInjector(agent), new PlanModeInjector(agent), new PermissionModeInjector(agent), ]; diff --git a/packages/agent-core/src/agent/injection/skill-refresh.ts b/packages/agent-core/src/agent/injection/skill-refresh.ts new file mode 100644 index 00000000..7c5dbe0f --- /dev/null +++ b/packages/agent-core/src/agent/injection/skill-refresh.ts @@ -0,0 +1,43 @@ +import { DynamicInjector } from './injector'; + +/** + * Surfaces an up-to-date skill listing when plugins are hot-loaded mid-session. + * + * The base system prompt bakes in the skill listing once at bootstrap and is + * intentionally NOT rewritten on `/plugins reload` — rewriting it would bust the + * prompt-cache prefix for the whole conversation and reset runtime state. Instead + * this injector appends the current listing as a system reminder. The listing's + * "DISREGARD any earlier skill listings" header makes it supersede the stale one + * still sitting in the prompt. The base class re-injects after compaction scrolls + * the reminder out, so the model never loses the up-to-date listing. + */ +export class SkillRefreshInjector extends DynamicInjector { + protected override readonly injectionVariant = 'skills_reloaded'; + private surfaced: string | undefined; + + override onContextClear(): void { + super.onContextClear(); + this.surfaced = undefined; + } + + override getInjection(): string | undefined { + const registry = this.agent.skills?.registry; + if (registry === undefined) return undefined; + // No baseline captured means the agent never rendered a profile prompt with + // a skill listing (e.g. a resumed agent replaying its prompt, or a bare test + // agent). Without a baseline we cannot tell what the model already sees, so + // surface nothing rather than risk a spurious reminder. + const baseline = this.agent.systemPromptSkillListing; + if (baseline === undefined) return undefined; + const current = registry.getModelSkillListing(); + // While the live listing still matches the one baked into the system + // prompt, there is nothing extra to surface. + if (current === baseline) return undefined; + // The listing drifted from the prompt baseline. Surface it once; only + // re-surface if it changed again or scrolled out of context via compaction + // (the base class nulls `injectedAt` in that case). + if (this.injectedAt !== null && this.surfaced === current) return undefined; + this.surfaced = current; + return current; + } +} diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 8bc0d760..8cdd8191 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -34,6 +34,7 @@ import { import type { ProviderManager } from './provider-manager'; import type { ToolServices } from '../runtime-types'; import { + discoverSkills, registerBuiltinSkills, resolveSkillRoots, SkillRegistry, @@ -102,11 +103,13 @@ 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(); + // Config digest of every plugin MCP server actually connected into this + // session, keyed by runtime name (`plugin-:`). Entries are only + // ever ADDED (on first connect), never updated, because a running server's + // config cannot be hot-swapped. A later snapshot whose config differs from the + // recorded digest — or that drops a recorded server — means the live session + // is stale and only a new session can reconcile it. + private readonly loadedPluginMcpDigests = new Map(); metadata: SessionMeta = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -187,6 +190,14 @@ export class Session { if (main !== undefined && profile !== undefined && main.config.systemPrompt === '') { await this.bootstrapAgentProfile(main, profile); } + // Native resume replays the system prompt from the wire without calling + // useProfile, so the skill-listing baseline is never captured. Seed it now + // (it matches the replayed prompt) so the SkillRefreshInjector can detect + // when a later /plugins reload makes the listing stale. + const skillListing = this.skills.getModelSkillListing(); + for (const agent of this.agents.values()) { + agent.systemPromptSkillListing ??= skillListing; + } await this.triggerSessionStart('resume'); return { warning: resumeWarning }; } @@ -360,7 +371,6 @@ export class Session { builtinDir: this.options.skills?.builtinDir, }); await this.skills.loadRoots(roots); - this.rememberLoadedPluginSkills(snapshot.pluginSkillRoots); const addedSkills = this.skills .listSkills() .map((skill) => skill.name) @@ -369,14 +379,11 @@ export class Session { // 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. + // The model learns about the new skills via the SkillRefreshInjector, which + // surfaces an updated skill listing as a system reminder on the next turn. + // We deliberately do NOT rewrite the base system prompt (that would bust the + // prompt-cache prefix and reset runtime state). 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)); @@ -386,6 +393,12 @@ export class Session { const newServerNames = Object.keys(newServers); if (newServerNames.length > 0) { await this.mcp.connect(newServers); + // Record the config we just connected so a later in-place config change + // to the same server name is detected as stale. Existing servers are + // never re-recorded — their running config is whatever we first connected. + for (const [name, config] of Object.entries(newServers)) { + this.loadedPluginMcpDigests.set(name, mcpConfigDigest(config)); + } } // Report only servers that actually came online. connect() awaits each // spawn, so by now every entry has settled; a failed / needs-auth server @@ -394,41 +407,64 @@ export class Session { (name) => this.mcp.get(name)?.status === 'connected', ); + // The skill names the plugins currently declare (re-discovered from the + // snapshot roots). Compared against the additive registry, this detects a + // skill that was removed or renamed by a plugin update. + const desiredSkillKeys = await this.desiredPluginSkillKeys(snapshot); + return { addedSkills, addedMcpServers, - needsNewSession: this.pluginRuntimeNeedsNewSession(snapshot, main), + needsNewSession: this.pluginRuntimeNeedsNewSession(snapshot, main, desiredSkillKeys), }; } + /** `${pluginId}\0${skillName}` for every skill the snapshot's plugins currently declare. */ + private async desiredPluginSkillKeys(snapshot: PluginRuntimeSnapshot): Promise> { + if (snapshot.pluginSkillRoots.length === 0) return new Set(); + const discovered = await discoverSkills({ roots: snapshot.pluginSkillRoots }); + const keys = new Set(); + for (const skill of discovered) { + if (skill.plugin?.id !== undefined) keys.add(pluginSkillKey(skill.plugin.id, skill.name)); + } + return keys; + } + /** * 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. + * new session can reconcile. Additive changes (new skills / new MCP servers) + * are hot-loaded and do NOT count; this detects capabilities that were + * removed or CHANGED IN PLACE by a plugin update, which the running session + * cannot tear down or hot-swap: + * - a loaded plugin skill (in the additive registry) that the plugin no + * longer declares — i.e. the plugin was disabled/removed, or it renamed + * or dropped that skill; + * - a connected plugin MCP server that is gone, or whose config (command / + * args / env / cwd / …) changed since we connected it; + * - a drift between the desired and currently-active sessionStart injections. */ private pluginRuntimeNeedsNewSession( snapshot: PluginRuntimeSnapshot, main: Agent | undefined, + desiredSkillKeys: ReadonlySet, ): 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; + // Skills: the registry is additive (no unload), so any loaded plugin skill + // the plugins no longer declare is stale — covers disable/remove of a plugin + // and remove/rename of an individual skill. New skills are simply absent + // from the registry until loaded, so they never trip this. + const staleSkill = this.skills.listSkills().some((skill) => { + const pluginId = skill.plugin?.id; + return pluginId !== undefined && !desiredSkillKeys.has(pluginSkillKey(pluginId, skill.name)); + }); + if (staleSkill) 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; + // MCP: a server we connected is stale if it's no longer enabled, or if its + // config changed in place (same runtime name, different command/args/env/…). + const staleMcp = [...this.loadedPluginMcpDigests].some(([name, digest]) => { + const desired = snapshot.mcpServers[name]; + return desired === undefined || mcpConfigDigest(desired) !== digest; + }); + if (staleMcp) return true; const activeStarts = new Set( (main?.pluginSessionStarts ?? []).map((start) => `${start.pluginId}:${start.skillName}`), @@ -454,18 +490,16 @@ 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 { const servers = this.options.mcpConfig?.servers; if (servers === undefined || Object.keys(servers).length === 0) return; + // Record the config of each plugin server we're about to connect so a later + // reload can detect an in-place config change (see loadedPluginMcpDigests). + for (const [name, config] of Object.entries(servers)) { + if (name.startsWith('plugin-')) this.loadedPluginMcpDigests.set(name, mcpConfigDigest(config)); + } await this.mcp.connectAll(servers); const entries = this.mcp.list().filter((entry) => entry.status !== 'disabled'); const totalCount = entries.length; @@ -635,6 +669,27 @@ export class Session { export * from './subagent-host'; +function pluginSkillKey(pluginId: string, skillName: string): string { + return `${pluginId}${skillName}`; +} + +/** + * A stable, order-independent digest of an MCP server config, used to detect an + * in-place config change (command / args / env / cwd / …) across a reload. + */ +function mcpConfigDigest(config: unknown): string { + return stableStringify(config); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`; +} + function initCompletionReminder(agentsMd: string): string { const latest = agentsMd.trim().length === 0 diff --git a/packages/agent-core/test/agent/injection/skill-refresh.test.ts b/packages/agent-core/test/agent/injection/skill-refresh.test.ts new file mode 100644 index 00000000..e5cc90e1 --- /dev/null +++ b/packages/agent-core/test/agent/injection/skill-refresh.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Agent } from '../../../src/agent'; +import { SkillRefreshInjector } from '../../../src/agent/injection/skill-refresh'; + +const VARIANT = { kind: 'injection', variant: 'skills_reloaded' }; + +function makeAgent(initialListing: string, systemPromptSkillListing: string | undefined) { + const state = { listing: initialListing }; + const history: unknown[] = []; + const appendSystemReminder = vi.fn(() => { + history.push({}); + }); + const agent = { + skills: { registry: { getModelSkillListing: () => state.listing } }, + systemPromptSkillListing, + context: { history, appendSystemReminder }, + } as unknown as Agent; + return { agent, state, appendSystemReminder }; +} + +describe('SkillRefreshInjector', () => { + it('does not inject while the live listing matches the system prompt baseline', async () => { + const { agent, appendSystemReminder } = makeAgent('DISREGARD ... skill-a', 'DISREGARD ... skill-a'); + const injector = new SkillRefreshInjector(agent); + + await injector.inject(); + + expect(appendSystemReminder).not.toHaveBeenCalled(); + }); + + it('surfaces the updated listing once after skills change, then stays quiet', async () => { + const { agent, state, appendSystemReminder } = makeAgent('base', 'base'); + const injector = new SkillRefreshInjector(agent); + + state.listing = 'DISREGARD ... skill-a, skill-b'; + await injector.inject(); + expect(appendSystemReminder).toHaveBeenCalledTimes(1); + expect(appendSystemReminder).toHaveBeenCalledWith( + expect.stringContaining('skill-b'), + VARIANT, + ); + + // No further change → no repeat injection. + await injector.inject(); + expect(appendSystemReminder).toHaveBeenCalledTimes(1); + }); + + it('re-injects when the reminder is compacted out of context', async () => { + const { agent, state, appendSystemReminder } = makeAgent('base', 'base'); + const injector = new SkillRefreshInjector(agent); + + state.listing = 'updated'; + await injector.inject(); + expect(appendSystemReminder).toHaveBeenCalledTimes(1); + + // Compaction drops everything up to and including the reminder. + injector.onContextCompacted(1000); + await injector.inject(); + expect(appendSystemReminder).toHaveBeenCalledTimes(2); + }); + + it('surfaces a freshly changed listing even while a prior one is still present', async () => { + const { agent, state, appendSystemReminder } = makeAgent('base', 'base'); + const injector = new SkillRefreshInjector(agent); + + state.listing = 'v1'; + await injector.inject(); + state.listing = 'v2'; + await injector.inject(); + + expect(appendSystemReminder).toHaveBeenCalledTimes(2); + expect(appendSystemReminder).toHaveBeenLastCalledWith(expect.stringContaining('v2'), VARIANT); + }); +}); diff --git a/packages/agent-core/test/harness/plugin-reload-session.test.ts b/packages/agent-core/test/harness/plugin-reload-session.test.ts index 0bb6bab1..c0d171bc 100644 --- a/packages/agent-core/test/harness/plugin-reload-session.test.ts +++ b/packages/agent-core/test/harness/plugin-reload-session.test.ts @@ -56,7 +56,7 @@ describe('plugin reload hot-apply to a live session', () => { vi.unstubAllEnvs(); }); - it('makes a newly installed plugin skill available and visible to the model after reload', async () => { + it('makes a newly installed plugin skill available after reload', async () => { const { core, rpc } = await createTestRpc(); const created = await rpc.createSession({ id: 'ses_reload_skill', workDir }); @@ -79,9 +79,11 @@ describe('plugin reload hot-apply to a live session', () => { 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. + // ...but the base system prompt is intentionally NOT rewritten — the model + // learns the new skill via the SkillRefreshInjector on its next turn (see + // skill-refresh.test.ts), which keeps the prompt-cache prefix stable. const main = core.sessions.get(created.id)?.agents.get('main'); - expect(main?.config.systemPrompt).toContain('hotpack-review'); + expect(main?.config.systemPrompt).not.toContain('hotpack-review'); }); it('exposes the Skill builtin tool to the model only after a skill is hot-loaded', async () => { @@ -176,6 +178,71 @@ describe('plugin reload hot-apply to a live session', () => { expect(result.applied?.needsNewSession).toBe(true); }); + it('flags needsNewSession when a reinstalled plugin changes an existing MCP server config', async () => { + const { rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_mcp_cfg_change', workDir }); + + await rpc.installPlugin({ + source: await makePlugin('cfgpack', { mcpServers: { data: { command: 'kimi-fake-mcp', args: ['v1'] } } }), + }); + const first = await rpc.reloadPlugins({ sessionId: created.id }); + expect(first.applied?.needsNewSession).toBe(false); + + // Reinstall the same id with changed args for the same server name. Its + // runtime name is unchanged, so the live session keeps the old process. + await rpc.installPlugin({ + source: await makePlugin('cfgpack', { mcpServers: { data: { command: 'kimi-fake-mcp', args: ['v2'] } } }), + }); + const second = await rpc.reloadPlugins({ sessionId: created.id }); + expect(second.applied?.needsNewSession).toBe(true); + }); + + it('flags needsNewSession when a reinstalled plugin renames a skill', async () => { + const { rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_skill_rename', workDir }); + + await rpc.installPlugin({ source: await makePlugin('renamepack', { skillNames: ['old-skill'] }) }); + const first = await rpc.reloadPlugins({ sessionId: created.id }); + expect(first.applied?.needsNewSession).toBe(false); + + // Reinstall with the skill renamed: old-skill removed on disk, new-skill added. + await rpc.installPlugin({ source: await makePlugin('renamepack', { skillNames: ['new-skill'] }) }); + const second = await rpc.reloadPlugins({ sessionId: created.id }); + expect(second.applied?.needsNewSession).toBe(true); + + // new-skill is hot-loaded; old-skill lingers in the additive registry — which + // is exactly why a new session is required to drop it. + const skills = await rpc.listSkills({ sessionId: created.id }); + expect(skills.some((s) => s.name === 'new-skill')).toBe(true); + expect(skills.some((s) => s.name === 'old-skill')).toBe(true); + }); + + it('keeps needsNewSession false when a reinstalled plugin only ADDS a skill', async () => { + const { rpc } = await createTestRpc(); + const created = await rpc.createSession({ id: 'ses_skill_add', workDir }); + + await rpc.installPlugin({ source: await makePlugin('addpack', { skillNames: ['skill-a'] }) }); + await rpc.reloadPlugins({ sessionId: created.id }); + + await rpc.installPlugin({ source: await makePlugin('addpack', { skillNames: ['skill-a', 'skill-b'] }) }); + const second = await rpc.reloadPlugins({ sessionId: created.id }); + expect(second.applied?.addedSkills).toContain('skill-b'); + expect(second.applied?.needsNewSession).toBe(false); + }); + + it('captures the skill-listing baseline on resume so a later reload can still surface skills', async () => { + const first = await createTestRpc(); + const created = await first.rpc.createSession({ id: 'ses_resume_base', workDir }); + await first.core.sessions.get(created.id)?.flushMetadata(); + + // A fresh process resumes the session: useProfile is NOT called, but the + // baseline must still be seeded (otherwise SkillRefreshInjector goes silent). + const second = await createTestRpc(); + await second.rpc.resumeSession({ sessionId: created.id }); + const main = second.core.sessions.get(created.id)?.agents.get('main'); + expect(main?.systemPromptSkillListing).toBeDefined(); + }); + async function makePlugin( name: string, options: {