mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
feat(plugins): hot-apply plugin changes to the current session on /plugins reload
`/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.
This commit is contained in:
parent
1873859b0e
commit
bca3024eab
25 changed files with 634 additions and 45 deletions
9
.changeset/plugin-reload-hot-apply.md
Normal file
9
.changeset/plugin-reload-hot-apply.md
Normal file
|
|
@ -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`).
|
||||
|
|
@ -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<void>;
|
||||
sendNormalUserInput(text: string): void;
|
||||
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
|
||||
refreshSkillCommands(session?: SkillListSession): Promise<void>;
|
||||
readonly skillCommandMap: Map<string, string>;
|
||||
|
||||
// Controller refs
|
||||
|
|
|
|||
|
|
@ -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} <server>.`
|
||||
: '';
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<void> },
|
||||
'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);
|
||||
|
|
|
|||
|
|
@ -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 <plugin-id> <server>`, 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 <plugin-id> <server>` — 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Most users only need the interactive manager. You can also use these slash comma
|
|||
| `/plugins enable <id>` | Enable a plugin; opens the manager when `<id>` is omitted. |
|
||||
| `/plugins disable <id>` | Disable a plugin; opens the manager when `<id>` is omitted. |
|
||||
| `/plugins remove <id>` | 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 <id> <server>` | Enable an MCP server declared by a plugin. |
|
||||
| `/plugins mcp disable <id> <server>` | 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/<id>/`, 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/<id>/`, 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 <id>` without crashing unrelated sessions.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <plugin-id> <server>`,然后开启新会话。详见 [Plugins](./plugins.md)。
|
||||
Plugins 也可以在 `kimi.plugin.json` 或 `.kimi-plugin/plugin.json` 中声明 MCP servers。Plugin 声明的 servers 默认启用;新安装或新启用的 server 在运行 `/plugins reload`(或新会话)后即在当前会话上线。可以在 `/plugins` 中禁用或重新启用,也可以使用 `/plugins mcp disable|enable <plugin-id> <server>`——启用在 `/plugins reload` 后生效,禁用则需开启新会话才能完全生效。详见 [Plugins](./plugins.md)。
|
||||
|
||||
`mcp.json` 的顶层结构如下:
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl
|
|||
| `/plugins enable <id>` | 启用 plugin;省略 `<id>` 时打开管理器。 |
|
||||
| `/plugins disable <id>` | 禁用 plugin;省略 `<id>` 时打开管理器。 |
|
||||
| `/plugins remove <id>` | 移除 plugin,需二次确认。 |
|
||||
| `/plugins reload` | 重载 `installed.json` 和各 plugin manifest。 |
|
||||
| `/plugins reload` | 重载 `installed.json` 和各 plugin manifest,并把新增的 Skills 和新启用的 MCP servers 热加载到当前会话。 |
|
||||
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server。 |
|
||||
| `/plugins mcp disable <id> <server>` | 禁用 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/<id>/`,Kimi Code CLI 始终从这份托管副本运行。安装后再编辑原始源目录不会生效,需要重新安装——`/plugins reload` 只会重读安装记录和 manifest,不会重读原始源。移除 plugin 只会删除其安装记录,托管副本和原始源文件仍保留在磁盘上。
|
||||
本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed/<id>/`,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 <id>` 的 diagnostics 中,不会让无关会话崩溃。
|
||||
|
|
|
|||
|
|
@ -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。 | 是 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, McpServerConfig>): Promise<void> {
|
||||
await Promise.allSettled(this.spawnEntries(configs));
|
||||
}
|
||||
|
||||
private async connectAllNow(configs: Record<string, McpServerConfig>): Promise<void> {
|
||||
await Promise.allSettled(this.spawnEntries(configs));
|
||||
}
|
||||
|
||||
private spawnEntries(configs: Record<string, McpServerConfig>): Promise<unknown>[] {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
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<void> {
|
||||
|
|
|
|||
|
|
@ -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<string, McpServerConfig> {
|
||||
const out: Record<string, McpServerConfig> = {};
|
||||
for (const record of this.records.values()) {
|
||||
|
|
|
|||
|
|
@ -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<string, McpServerConfig>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import type {
|
|||
PromptPayload,
|
||||
ReconnectMcpServerPayload,
|
||||
RegisterToolPayload,
|
||||
ReloadPluginsPayload,
|
||||
ReloadPluginsResult,
|
||||
RemoveKimiProviderPayload,
|
||||
RemovePluginPayload,
|
||||
|
|
@ -602,11 +603,11 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
await this.plugins.remove(id);
|
||||
}
|
||||
|
||||
async reloadPlugins(_: EmptyPayload): Promise<ReloadPluginsResult> {
|
||||
async reloadPlugins({ sessionId }: ReloadPluginsPayload): Promise<ReloadPluginsResult> {
|
||||
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<CoreAPI> {
|
|||
{ 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<PluginInfo> {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
// 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<string>();
|
||||
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<PluginRuntimeApplyResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
|
|||
222
packages/agent-core/test/harness/plugin-reload-session.test.ts
Normal file
222
packages/agent-core/test/harness/plugin-reload-session.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
readonly sessionStartSkill?: string;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), `plugin-${name}-`));
|
||||
const manifest: Record<string, unknown> = { 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<CoreAPI, SDKAPI>();
|
||||
const events: Event[] = [];
|
||||
const core = new KimiCore(coreRpc, { homeDir, configPath });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ decision: 'rejected' })),
|
||||
requestQuestion: vi.fn(async () => null),
|
||||
toolCall: vi.fn(async () => ({ output: '' })),
|
||||
});
|
||||
return { core, events, rpc };
|
||||
}
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReloadSummary> {
|
||||
async reloadPlugins(input: SessionIdRpcInput): Promise<PluginReloadResult> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.reloadPlugins({});
|
||||
return rpc.reloadPlugins({ sessionId: input.sessionId });
|
||||
}
|
||||
|
||||
async getPluginInfo(id: string): Promise<PluginInfo> {
|
||||
|
|
|
|||
|
|
@ -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<ReloadSummary> {
|
||||
async reloadPlugins(): Promise<PluginReloadResult> {
|
||||
this.ensureOpen();
|
||||
return this.rpc.reloadPlugins();
|
||||
return this.rpc.reloadPlugins({ sessionId: this.id });
|
||||
}
|
||||
|
||||
async getPluginInfo(id: string): Promise<PluginInfo> {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export type {
|
|||
OAuthRef,
|
||||
PluginInfo,
|
||||
PluginMcpServerInfo,
|
||||
PluginReloadResult,
|
||||
PluginRuntimeApplyResult,
|
||||
PluginSummary,
|
||||
PromptOrigin,
|
||||
ProviderConfig,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue