mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-28 10:37:28 +00:00
feat(cli): add plugin quota and update notices (#2147)
* feat(cli): add plugin quota and update notices - Show "Note: This plugin consumes your quota." after installing quota-consuming official plugins (currently Kimi Datasource). - Show a one-time update notice after invoking an outdated plugin (a plugin MCP tool call or a /<plugin>:<command> turn); the last notified version is persisted so each new marketplace version reminds once. - Skip the third-party trust prompt for loopback sources that mirror the official plugin CDN path, so the dev plugin marketplace no longer prompts when installing official plugins. * feat(cli): report plugin update notices at turn end Buffer plugin MCP tool usage during the turn and report it together with plugin command usage when the turn's output has fully ended, instead of firing the check mid-turn at tool result time. Cancelled turns no longer trigger the notice. * fix(cli): refresh plugin MCP map on miss and serialize notice writes Address review findings on the plugin update notifier: - The memoized MCP server-to-plugin map is reused across /reload, /new, and session switches, so plugins installed or enabled later in the same app run never resolved. Refresh the map once on a lookup miss, and never pin an empty map when there is no session. - Concurrent notices (a turn that used two outdated plugins) raced on the read-modify-write cycle of the notice state file and could drop each other's entries. Serialize checks through a promise queue so each notified version is persisted exactly once. * fix(cli): gate plugin notices on official provenance and survive tool-name truncation Address review findings: - The quota note and the update notice keyed on the plugin id alone, so a local/GitHub fork reusing a billed plugin's manifest id was treated as the official build. Both now require official provenance (a zip install from the official CDN plugin path or its loopback dev mirror) via a shared isOfficialPluginInstall check. - Resolving plugin MCP tools by splitting on the '__' separator broke for qualified names core truncates to 64 chars, which can cut the separator. Match known server names by longest prefix with a name boundary instead, which survives truncation as long as the server part itself is intact. * revert(cli): drop the dev marketplace trust relaxation The loopback carve-out let any local service bypass the third-party trust prompt by serving a zip under the official path shape, which does not prove official provenance (review P1). Revert to the single rule — only https://code.kimi.com/kimi-code/plugins/official/* is a trusted official source — and restore the stock dev marketplace server. Installing official plugins from the dev marketplace shows the trust prompt again. * fix(cli): restrict update notices to the official catalog and settle tests - Skip the update check when the loaded marketplace is not the default official catalog, so a custom KIMI_CODE_PLUGIN_MARKETPLACE_URL can no longer produce a notice that claims to come from the Official Marketplace. - Return a never-rejecting promise from the notifier entry points so tests await the serialized queue directly instead of relying on zero-delay timers for ordering.
This commit is contained in:
parent
a77ee03829
commit
29783e471a
15 changed files with 972 additions and 10 deletions
5
.changeset/plugin-quota-note.md
Normal file
5
.changeset/plugin-quota-note.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Show a quota consumption note after installing official plugins that bill against plan quota (such as Kimi Datasource).
|
||||
5
.changeset/plugin-update-notice.md
Normal file
5
.changeset/plugin-update-notice.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Show an update notice when a turn that used an outdated plugin ends and the Official Marketplace has a newer version; each new version is announced once. Run /plugins to install the latest version.
|
||||
|
|
@ -48,6 +48,7 @@ export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
|
|||
export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json';
|
||||
export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock';
|
||||
export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log';
|
||||
export const KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json';
|
||||
export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
|
||||
export const KIMI_CODE_BANNER_DIR_NAME = 'banner';
|
||||
export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json';
|
||||
|
|
@ -79,6 +80,9 @@ export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`
|
|||
export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json';
|
||||
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
|
||||
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
|
||||
// Official plugins whose usage bills against the user's plan quota. Installing
|
||||
// one of these shows a quota note after the install result.
|
||||
export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource'];
|
||||
export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`;
|
||||
export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`;
|
||||
// Official download page, referenced by prompt copy that steers users away
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ import {
|
|||
} from '../components/messages/plugins-status-panel';
|
||||
import { UsagePanelComponent } from '../components/messages/usage-panel';
|
||||
import { formatErrorMessage } from '../utils/event-payload';
|
||||
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
|
||||
import {
|
||||
formatPluginSourceLabel,
|
||||
isOfficialPluginInstall,
|
||||
isOfficialPluginSource,
|
||||
} from '../utils/plugin-source-label';
|
||||
import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app';
|
||||
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
|
@ -492,6 +497,8 @@ async function installPluginFromSource(
|
|||
|
||||
const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.';
|
||||
|
||||
const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.';
|
||||
|
||||
function showPluginInstallResult(
|
||||
host: SlashCommandHost,
|
||||
beforeList: readonly PluginSummary[],
|
||||
|
|
@ -506,6 +513,11 @@ function showPluginInstallResult(
|
|||
const action = describeInstallAction(previous, summary);
|
||||
host.showStatus(`${action} (${summary.id}).${mcpHint}`);
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
// Gate on provenance, not just the id: a local/GitHub fork whose manifest
|
||||
// reuses a billed plugin's id is not the official quota-consuming build.
|
||||
if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) {
|
||||
host.showStatus(PLUGIN_QUOTA_NOTE, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function describeInstallAction(
|
||||
|
|
|
|||
208
apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts
Normal file
208
apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
|
||||
import {
|
||||
computeUpdateStatus,
|
||||
loadPluginMarketplace,
|
||||
type PluginMarketplace,
|
||||
} from '#/utils/plugin-marketplace';
|
||||
import {
|
||||
readPluginUpdateNoticeState,
|
||||
writePluginUpdateNoticeState,
|
||||
} from '#/utils/plugin-update-notice-state';
|
||||
import { isOfficialPluginInstall } from '../utils/plugin-source-label';
|
||||
|
||||
/**
|
||||
* The slice of the SDK session the notifier reads. Structurally satisfied by
|
||||
* the full SDK `Session`, and easy to fake in tests.
|
||||
*/
|
||||
export interface PluginUpdateNotifierSession {
|
||||
listMcpServers(): Promise<readonly { name: string }[]>;
|
||||
listPlugins(): Promise<readonly PluginSummary[]>;
|
||||
}
|
||||
|
||||
export interface PluginUpdateNotifierDeps {
|
||||
readonly getSession: () => PluginUpdateNotifierSession | undefined;
|
||||
readonly workDir: string;
|
||||
readonly notify: (message: string) => void;
|
||||
/** Overridable for tests; defaults to the shared marketplace loader. */
|
||||
readonly loadMarketplace?: () => Promise<PluginMarketplace>;
|
||||
/** Overridable for tests; defaults to the updates dir under the data dir. */
|
||||
readonly stateFile?: string;
|
||||
}
|
||||
|
||||
const MCP_TOOL_NAME_PREFIX = 'mcp__';
|
||||
const PLUGIN_MCP_TOOL_NAME_PREFIX = `${MCP_TOOL_NAME_PREFIX}plugin-`;
|
||||
// Plugin MCP servers run under the runtime name `plugin-<id>:<server>`
|
||||
// (pluginMcpRuntimeName in packages/agent-core/src/plugin/manager.ts).
|
||||
const PLUGIN_MCP_RUNTIME_NAME = /^plugin-([a-z0-9][a-z0-9_-]{0,63}):/;
|
||||
|
||||
/** Cheap name check for plugin-provided MCP tools (`mcp__plugin-…`). */
|
||||
export function isPluginMcpToolName(toolName: string): boolean {
|
||||
return toolName.startsWith(PLUGIN_MCP_TOOL_NAME_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of sanitizeMcpNamePart in packages/agent-core/src/mcp/tool-naming.ts.
|
||||
* MCP tool names on the wire carry the sanitized server name; the collapse
|
||||
* step guarantees the `__` separator never appears inside a name part.
|
||||
*/
|
||||
function sanitizeMcpServerName(name: string): string {
|
||||
return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the plugin behind a qualified MCP tool name by longest-prefix match
|
||||
* against known server names. Prefix matching (rather than splitting on the
|
||||
* `__` separator) survives core's 64-char truncation, which can cut the
|
||||
* separator before appending the hash suffix; the boundary check keeps a
|
||||
* shorter server name from matching another server's name, and longest match
|
||||
* wins when one server name is a prefix of another. A name truncated inside
|
||||
* the server part itself cannot be attributed reliably and stays unresolved.
|
||||
*/
|
||||
function matchPluginByToolName(
|
||||
toolName: string,
|
||||
serverPluginIds: Map<string, string>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
let bestLength = 0;
|
||||
for (const [serverName, pluginId] of serverPluginIds) {
|
||||
const prefix = `${MCP_TOOL_NAME_PREFIX}${serverName}`;
|
||||
if (!toolName.startsWith(prefix)) continue;
|
||||
const boundary = toolName.charAt(prefix.length);
|
||||
if (boundary !== '' && boundary !== '_') continue;
|
||||
if (prefix.length > bestLength) {
|
||||
best = pluginId;
|
||||
bestLength = prefix.length;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a one-time "update detected" notice for outdated plugins. Callers
|
||||
* report completed plugin usage (a plugin MCP tool name, or the plugin id of
|
||||
* a `/<plugin>:<command>` turn — both reported once the turn's output has
|
||||
* ended); the notifier checks the marketplace and persists the last notified
|
||||
* version, so a plugin is re-notified only when the marketplace advertises a
|
||||
* newer version than the one already shown.
|
||||
*
|
||||
* Entry points are fire-and-forget in production (never reject — the notice
|
||||
* is a background nicety and any failure, e.g. an offline marketplace, is
|
||||
* swallowed) and return an awaitable promise so tests can settle the queue
|
||||
* deterministically.
|
||||
*/
|
||||
export class PluginUpdateNotifier {
|
||||
private marketplacePromise: Promise<PluginMarketplace> | undefined;
|
||||
private mcpServerPluginIds: Map<string, string> | undefined;
|
||||
private readonly inFlight = new Set<string>();
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly deps: PluginUpdateNotifierDeps) {}
|
||||
|
||||
handleMcpToolCompleted(toolName: string): Promise<void> {
|
||||
// Cheap bail before touching the RPC layer — most tools are not MCP tools,
|
||||
// let alone plugin ones.
|
||||
if (!isPluginMcpToolName(toolName)) return Promise.resolve();
|
||||
return this.resolvePluginId(toolName)
|
||||
.then((pluginId) => {
|
||||
if (pluginId !== undefined) return this.enqueue(pluginId);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
handlePluginCommandCompleted(pluginId: string): Promise<void> {
|
||||
return this.enqueue(pluginId);
|
||||
}
|
||||
|
||||
private enqueue(pluginId: string): Promise<void> {
|
||||
// Serialize the read-modify-write cycle on the notice state file: two
|
||||
// concurrent checks (e.g. a turn that used two outdated plugins) would
|
||||
// otherwise read the same snapshot and the last write would drop the
|
||||
// other plugin's entry.
|
||||
this.queue = this.queue.then(() => this.checkAndNotify(pluginId)).catch(() => {});
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
private async resolvePluginId(toolName: string): Promise<string | undefined> {
|
||||
const hit = matchPluginByToolName(toolName, await this.getMcpServerPluginIds());
|
||||
if (hit !== undefined) return hit;
|
||||
// The map is memoized, but this notifier is reused across /reload, /new,
|
||||
// and session switches, so plugins installed or enabled later in the same
|
||||
// app run are missing from it. Refresh once on a miss before giving up.
|
||||
return matchPluginByToolName(toolName, await this.loadMcpServerPluginIds());
|
||||
}
|
||||
|
||||
private async getMcpServerPluginIds(): Promise<Map<string, string>> {
|
||||
if (this.mcpServerPluginIds !== undefined) return this.mcpServerPluginIds;
|
||||
return this.loadMcpServerPluginIds();
|
||||
}
|
||||
|
||||
private async loadMcpServerPluginIds(): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
const session = this.deps.getSession();
|
||||
// Without a session there is nothing to list; leave the cache unset so
|
||||
// the next lookup retries instead of pinning an empty map.
|
||||
if (session === undefined) return map;
|
||||
const servers = await session.listMcpServers();
|
||||
for (const server of servers) {
|
||||
const match = PLUGIN_MCP_RUNTIME_NAME.exec(server.name);
|
||||
if (match?.[1] !== undefined) {
|
||||
map.set(sanitizeMcpServerName(server.name), match[1]);
|
||||
}
|
||||
}
|
||||
this.mcpServerPluginIds = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
private async checkAndNotify(pluginId: string): Promise<void> {
|
||||
if (this.inFlight.has(pluginId)) return;
|
||||
this.inFlight.add(pluginId);
|
||||
try {
|
||||
const session = this.deps.getSession();
|
||||
if (session === undefined) return;
|
||||
const marketplace = await this.loadCatalog();
|
||||
// Only the default official catalog can back an "Official Marketplace"
|
||||
// notice — a custom catalog (KIMI_CODE_PLUGIN_MARKETPLACE_URL) may
|
||||
// advertise anything under any id.
|
||||
if (marketplace.source !== KIMI_CODE_PLUGIN_MARKETPLACE_URL) return;
|
||||
const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId);
|
||||
if (entry === undefined) return;
|
||||
const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId);
|
||||
if (installed === undefined) return;
|
||||
// Only official installs are tracked against the Official Marketplace —
|
||||
// a local/GitHub fork that happens to share a catalog id is not it.
|
||||
if (!isOfficialPluginInstall(installed)) return;
|
||||
const status = computeUpdateStatus(entry.version, installed.version, true);
|
||||
if (status.kind !== 'update') return;
|
||||
const state = await readPluginUpdateNoticeState(this.deps.stateFile);
|
||||
if (state.notified[pluginId] === status.latest) return;
|
||||
this.deps.notify(
|
||||
`Update detected: ${installed.displayName} ${status.latest} is available. ` +
|
||||
'Run /plugins to install the latest version from the Official Marketplace.',
|
||||
);
|
||||
await writePluginUpdateNoticeState(
|
||||
{ ...state, notified: { ...state.notified, [pluginId]: status.latest } },
|
||||
this.deps.stateFile,
|
||||
);
|
||||
} finally {
|
||||
this.inFlight.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
private loadCatalog(): Promise<PluginMarketplace> {
|
||||
// Cached for the app run; a failed fetch is retried on the next invocation.
|
||||
this.marketplacePromise ??= this.loadMarketplace().catch((error: unknown) => {
|
||||
this.marketplacePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.marketplacePromise;
|
||||
}
|
||||
|
||||
private loadMarketplace(): Promise<PluginMarketplace> {
|
||||
const load = this.deps.loadMarketplace;
|
||||
if (load !== undefined) return load();
|
||||
return loadPluginMarketplace({ workDir: this.deps.workDir });
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ import { errorReportHintLine } from '../constant/feedback';
|
|||
import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
|
||||
import { nextTranscriptId } from '../utils/transcript-id';
|
||||
import type { BtwPanelController } from './btw-panel';
|
||||
import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier';
|
||||
import type { StreamingUIController } from './streaming-ui';
|
||||
import type { TasksBrowserController } from './tasks-browser';
|
||||
import { SubAgentEventHandler } from './subagent-event-handler';
|
||||
|
|
@ -119,8 +120,12 @@ export interface SessionEventHost {
|
|||
|
||||
export class SessionEventHandler {
|
||||
readonly subAgentEventHandler: SubAgentEventHandler;
|
||||
private readonly pluginUpdateNotifier: PluginUpdateNotifier;
|
||||
|
||||
constructor(private readonly host: SessionEventHost) {
|
||||
constructor(
|
||||
private readonly host: SessionEventHost,
|
||||
pluginUpdateNotifier?: PluginUpdateNotifier,
|
||||
) {
|
||||
this.subAgentEventHandler = new SubAgentEventHandler(host, {
|
||||
backgroundTasks: this.backgroundTasks,
|
||||
backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal,
|
||||
|
|
@ -128,6 +133,15 @@ export class SessionEventHandler {
|
|||
this.syncBackgroundTaskBadge();
|
||||
},
|
||||
});
|
||||
this.pluginUpdateNotifier =
|
||||
pluginUpdateNotifier ??
|
||||
new PluginUpdateNotifier({
|
||||
getSession: () => this.host.session,
|
||||
workDir: host.state.appState.workDir,
|
||||
notify: (message) => {
|
||||
this.host.showStatus(message, 'warning');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Runtime state – owned by this handler, reset between sessions.
|
||||
|
|
@ -142,6 +156,8 @@ export class SessionEventHandler {
|
|||
private goalCompletionAwaitingClear = false;
|
||||
private goalCompletionTurnEnded = false;
|
||||
private currentTurnHasAssistantText = false;
|
||||
private pluginCommandTurns: Map<string, string> = new Map();
|
||||
private pluginMcpToolsUsedInTurn: Set<string> = new Set();
|
||||
private pendingModelBlockedFallback: GoalChange | undefined;
|
||||
private queuedGoalPromotionPending = false;
|
||||
private queuedGoalPromotionInFlight = false;
|
||||
|
|
@ -158,6 +174,8 @@ export class SessionEventHandler {
|
|||
this.goalCompletionAwaitingClear = false;
|
||||
this.goalCompletionTurnEnded = false;
|
||||
this.currentTurnHasAssistantText = false;
|
||||
this.pluginCommandTurns.clear();
|
||||
this.pluginMcpToolsUsedInTurn.clear();
|
||||
this.pendingModelBlockedFallback = undefined;
|
||||
this.queuedGoalPromotionPending = false;
|
||||
this.queuedGoalPromotionInFlight = false;
|
||||
|
|
@ -293,9 +311,11 @@ export class SessionEventHandler {
|
|||
// Private handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private handleTurnBegin(_event: TurnStartedEvent): void {
|
||||
void _event;
|
||||
private handleTurnBegin(event: TurnStartedEvent): void {
|
||||
this.currentTurnHasAssistantText = false;
|
||||
if (event.origin?.kind === 'plugin_command') {
|
||||
this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId);
|
||||
}
|
||||
this.clearAgentSwarmProgress();
|
||||
this.host.streamingUI.resetToolUi();
|
||||
this.host.streamingUI.setStep(0);
|
||||
|
|
@ -348,6 +368,22 @@ export class SessionEventHandler {
|
|||
this.renderPendingModelBlockedFallback();
|
||||
this.currentTurnHasAssistantText = false;
|
||||
this.goalCompletionTurnEnded = true;
|
||||
// Plugin usage is reported once the whole turn's output has ended — but a
|
||||
// cancelled turn cut the output short, so skip the notice there.
|
||||
const reportPluginUsage = event.reason !== 'cancelled';
|
||||
const pluginCommandPluginId = this.pluginCommandTurns.get(String(event.turnId));
|
||||
if (pluginCommandPluginId !== undefined) {
|
||||
this.pluginCommandTurns.delete(String(event.turnId));
|
||||
if (reportPluginUsage) {
|
||||
void this.pluginUpdateNotifier.handlePluginCommandCompleted(pluginCommandPluginId);
|
||||
}
|
||||
}
|
||||
if (reportPluginUsage) {
|
||||
for (const toolName of this.pluginMcpToolsUsedInTurn) {
|
||||
void this.pluginUpdateNotifier.handleMcpToolCompleted(toolName);
|
||||
}
|
||||
}
|
||||
this.pluginMcpToolsUsedInTurn.clear();
|
||||
this.scheduleQueuedGoalPromotion();
|
||||
}
|
||||
|
||||
|
|
@ -582,6 +618,11 @@ export class SessionEventHandler {
|
|||
synthetic: event.synthetic,
|
||||
};
|
||||
const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData);
|
||||
if (matchedCall !== undefined && isPluginMcpToolName(matchedCall.name)) {
|
||||
// Buffer plugin MCP usage for the turn; the update notice fires once the
|
||||
// whole turn's output has ended (see handleTurnEnd).
|
||||
this.pluginMcpToolsUsedInTurn.add(matchedCall.name);
|
||||
}
|
||||
this.subAgentEventHandler.handleAgentSwarmToolResult(
|
||||
event.toolCallId,
|
||||
resultData,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,20 @@ export function isOfficialPluginSource(source: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an installed plugin provably came from a trusted official
|
||||
* source — a zip download under the official CDN plugin path. Local paths,
|
||||
* GitHub repos, and third-party URLs do not qualify, even when their manifest
|
||||
* id matches an official plugin.
|
||||
*/
|
||||
export function isOfficialPluginInstall(plugin: PluginSummary): boolean {
|
||||
return (
|
||||
plugin.source === 'zip-url' &&
|
||||
plugin.originalSource !== undefined &&
|
||||
isOfficialPluginSource(plugin.originalSource)
|
||||
);
|
||||
}
|
||||
|
||||
function hostFromUrl(raw: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
KIMI_CODE_HOME_ENV,
|
||||
KIMI_CODE_INPUT_HISTORY_DIR_NAME,
|
||||
KIMI_CODE_LOG_DIR_NAME,
|
||||
KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME,
|
||||
KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME,
|
||||
KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME,
|
||||
KIMI_CODE_UPDATE_DIR_NAME,
|
||||
|
|
@ -87,6 +88,17 @@ export function getUpdateRolloutLogFile(): string {
|
|||
return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the plugin update notice state file: `<dataDir>/updates/plugin-notices.json`.
|
||||
*/
|
||||
export function getPluginUpdateNoticeStateFile(): string {
|
||||
return join(
|
||||
getDataDir(),
|
||||
KIMI_CODE_UPDATE_DIR_NAME,
|
||||
KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the banner display state file: `<dataDir>/cache/banner/state.json`.
|
||||
*/
|
||||
|
|
|
|||
67
apps/kimi-code/src/utils/plugin-update-notice-state.ts
Normal file
67
apps/kimi-code/src/utils/plugin-update-notice-state.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getPluginUpdateNoticeStateFile } from '#/utils/paths';
|
||||
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
|
||||
|
||||
/**
|
||||
* Records, per plugin, the newest marketplace version an update notice was
|
||||
* already shown for. A plugin is re-notified only when the marketplace
|
||||
* advertises a version different from the recorded one.
|
||||
*/
|
||||
export type PluginUpdateNoticeState = {
|
||||
version: 1;
|
||||
/** pluginId -> latest marketplace version already notified. */
|
||||
notified: Record<string, string>;
|
||||
};
|
||||
|
||||
const PluginUpdateNoticeStateSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
const notified = (value as { notified?: unknown }).notified;
|
||||
if (typeof notified !== 'object' || notified === null) {
|
||||
return { ...(value as Record<string, unknown>), notified: {} };
|
||||
}
|
||||
|
||||
const normalizedNotified: Record<string, string> = {};
|
||||
for (const [key, record] of Object.entries(notified)) {
|
||||
if (key.length === 0 || typeof record !== 'string' || record.length === 0) continue;
|
||||
normalizedNotified[key] = record;
|
||||
}
|
||||
|
||||
return { ...(value as Record<string, unknown>), notified: normalizedNotified };
|
||||
},
|
||||
z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
notified: z.record(z.string().min(1), z.string().min(1)),
|
||||
})
|
||||
.strict(),
|
||||
);
|
||||
|
||||
export function emptyPluginUpdateNoticeState(): PluginUpdateNoticeState {
|
||||
return {
|
||||
version: 1,
|
||||
notified: {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readPluginUpdateNoticeState(
|
||||
filePath: string = getPluginUpdateNoticeStateFile(),
|
||||
): Promise<PluginUpdateNoticeState> {
|
||||
try {
|
||||
return await readJsonFile(
|
||||
filePath,
|
||||
PluginUpdateNoticeStateSchema,
|
||||
emptyPluginUpdateNoticeState(),
|
||||
);
|
||||
} catch {
|
||||
return emptyPluginUpdateNoticeState();
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePluginUpdateNoticeState(
|
||||
value: PluginUpdateNoticeState,
|
||||
filePath: string = getPluginUpdateNoticeStateFile(),
|
||||
): Promise<void> {
|
||||
await writeJsonFile(filePath, PluginUpdateNoticeStateSchema, value);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '#/tui/components/dialogs/plugins-selector';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import { darkColors, lightColors } from '#/tui/theme/colors';
|
||||
import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label';
|
||||
import { isOfficialPluginInstall, isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label';
|
||||
|
||||
const ANSI_SGR = /\u001B\[[0-9;]*m/g;
|
||||
|
||||
|
|
@ -150,14 +150,51 @@ describe('plugins selector dialogs', () => {
|
|||
})).toBe('third-party');
|
||||
});
|
||||
|
||||
it('recognizes installed plugins by official provenance', () => {
|
||||
const base = {
|
||||
id: 'kimi-datasource',
|
||||
displayName: 'Kimi Datasource',
|
||||
enabled: true,
|
||||
state: 'ok' as const,
|
||||
skillCount: 0,
|
||||
mcpServerCount: 0,
|
||||
enabledMcpServerCount: 0,
|
||||
hookCount: 0,
|
||||
commandCount: 0,
|
||||
hasErrors: false,
|
||||
};
|
||||
// Zip installs from the official CDN path.
|
||||
expect(isOfficialPluginInstall({
|
||||
...base,
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
})).toBe(true);
|
||||
// Same manifest id from a local path, GitHub, a loopback URL, or a
|
||||
// third-party URL is not the official build.
|
||||
expect(isOfficialPluginInstall({ ...base, source: 'local-path' })).toBe(false);
|
||||
expect(isOfficialPluginInstall({ ...base, source: 'github' })).toBe(false);
|
||||
expect(isOfficialPluginInstall({
|
||||
...base,
|
||||
source: 'zip-url',
|
||||
originalSource: 'http://127.0.0.1:58627/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
})).toBe(false);
|
||||
expect(isOfficialPluginInstall({
|
||||
...base,
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://example.test/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('treats only the official Kimi CDN path as a trusted install source', () => {
|
||||
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true);
|
||||
// Curated and other Kimi CDN paths are not "official" for the install gate.
|
||||
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false);
|
||||
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false);
|
||||
// Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial.
|
||||
// Non-Kimi hosts (loopback included), non-https schemes, local paths, and
|
||||
// GitHub sources are unofficial.
|
||||
expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false);
|
||||
expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false);
|
||||
expect(isOfficialPluginSource('http://127.0.0.1:58627/kimi-code/plugins/official/x.zip')).toBe(false);
|
||||
expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false);
|
||||
expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false);
|
||||
expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
|
||||
import {
|
||||
PluginUpdateNotifier,
|
||||
type PluginUpdateNotifierSession,
|
||||
} from '#/tui/controllers/plugin-update-notifier';
|
||||
import type { PluginMarketplace } from '#/utils/plugin-marketplace';
|
||||
import { readPluginUpdateNoticeState } from '#/utils/plugin-update-notice-state';
|
||||
|
||||
function makePluginSummary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: 'kimi-datasource',
|
||||
displayName: 'Kimi Datasource',
|
||||
version: '3.3.0',
|
||||
enabled: true,
|
||||
state: 'ok',
|
||||
skillCount: 0,
|
||||
mcpServerCount: 1,
|
||||
enabledMcpServerCount: 1,
|
||||
hookCount: 0,
|
||||
commandCount: 0,
|
||||
hasErrors: false,
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMarketplaceEntry(
|
||||
id: string,
|
||||
displayName: string,
|
||||
version: string,
|
||||
): PluginMarketplace['plugins'][number] {
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
source: `https://code.kimi.com/kimi-code/plugins/official/${id}.zip`,
|
||||
tier: 'official',
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMarketplace(version = '3.4.0'): PluginMarketplace {
|
||||
return {
|
||||
source: KIMI_CODE_PLUGIN_MARKETPLACE_URL,
|
||||
plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', version)],
|
||||
};
|
||||
}
|
||||
|
||||
interface HarnessOptions {
|
||||
readonly marketplace?: PluginMarketplace;
|
||||
readonly installed?: readonly PluginSummary[];
|
||||
readonly mcpServers?: readonly string[];
|
||||
readonly loadMarketplace?: () => Promise<PluginMarketplace>;
|
||||
}
|
||||
|
||||
function makeHarness(options: HarnessOptions = {}) {
|
||||
const session: PluginUpdateNotifierSession = {
|
||||
listMcpServers: vi.fn(async () =>
|
||||
(options.mcpServers ?? ['plugin-kimi-datasource:data']).map((name) => ({ name })),
|
||||
),
|
||||
listPlugins: vi.fn(async () => options.installed ?? [makePluginSummary()]),
|
||||
};
|
||||
const notify = vi.fn();
|
||||
const loadMarketplace = vi.fn(
|
||||
options.loadMarketplace ?? (async () => options.marketplace ?? makeMarketplace()),
|
||||
);
|
||||
return { session, notify, loadMarketplace };
|
||||
}
|
||||
|
||||
const DATASOURCE_TOOL = 'mcp__plugin-kimi-datasource_data__call_data_source_tool';
|
||||
const EXPECTED_MESSAGE =
|
||||
'Update detected: Kimi Datasource 3.4.0 is available. ' +
|
||||
'Run /plugins to install the latest version from the Official Marketplace.';
|
||||
|
||||
describe('PluginUpdateNotifier', () => {
|
||||
let tempDir: string;
|
||||
let stateFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'plugin-update-notifier-'));
|
||||
stateFile = join(tempDir, 'plugin-notices.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeNotifier(harness: ReturnType<typeof makeHarness>) {
|
||||
return new PluginUpdateNotifier({
|
||||
getSession: () => harness.session,
|
||||
workDir: tempDir,
|
||||
notify: harness.notify,
|
||||
loadMarketplace: harness.loadMarketplace,
|
||||
stateFile,
|
||||
});
|
||||
}
|
||||
|
||||
it('notifies once after a plugin MCP tool completes, then stays silent for that version', async () => {
|
||||
const harness = makeHarness();
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handleMcpToolCompleted(DATASOURCE_TOOL);
|
||||
expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE);
|
||||
|
||||
harness.notify.mockClear();
|
||||
// Follow-up checks run but hit the persisted "already notified" record
|
||||
// instead of notifying again.
|
||||
await notifier.handleMcpToolCompleted(DATASOURCE_TOOL);
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-plugin tool names without touching the session', async () => {
|
||||
const harness = makeHarness();
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handleMcpToolCompleted('Bash');
|
||||
await notifier.handleMcpToolCompleted('mcp__github__create_issue');
|
||||
|
||||
expect(harness.session.listMcpServers).not.toHaveBeenCalled();
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify when the installed version is up to date', async () => {
|
||||
const harness = makeHarness({ installed: [makePluginSummary({ version: '3.4.0' })] });
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handleMcpToolCompleted(DATASOURCE_TOOL);
|
||||
expect(harness.session.listPlugins).toHaveBeenCalled();
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify for plugins absent from the marketplace', async () => {
|
||||
const harness = makeHarness({ installed: [makePluginSummary({ id: 'local-only' })] });
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handlePluginCommandCompleted('local-only');
|
||||
// No marketplace entry — the check bails before even listing plugins.
|
||||
expect(harness.session.listPlugins).not.toHaveBeenCalled();
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify when the catalog is not the official marketplace', async () => {
|
||||
const harness = makeHarness({
|
||||
marketplace: {
|
||||
source: 'https://example.test/custom-marketplace.json',
|
||||
plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0')],
|
||||
},
|
||||
});
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
// A custom catalog may advertise anything under any id — the check bails
|
||||
// before comparing versions.
|
||||
expect(harness.session.listPlugins).not.toHaveBeenCalled();
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify for a same-id fork installed from a local path', async () => {
|
||||
const harness = makeHarness({
|
||||
installed: [
|
||||
makePluginSummary({ source: 'local-path', originalSource: undefined }),
|
||||
],
|
||||
});
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
// Provenance is not official, so the marketplace version is irrelevant.
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves plugin tools whose qualified name core truncated before the separator', async () => {
|
||||
// Server part exactly 50 chars: the 64-char truncation cuts the whole
|
||||
// `__` separator and tool name, leaving `mcp__<server>_<hash>`.
|
||||
const serverName = `plugin-kimi-datasource:${'s'.repeat(27)}`;
|
||||
const sanitized = `plugin-kimi-datasource_${'s'.repeat(27)}`;
|
||||
expect(`mcp__${sanitized}`.length).toBe(55);
|
||||
const truncatedToolName = `mcp__${sanitized}_a1b2c3d4`;
|
||||
|
||||
const harness = makeHarness({ mcpServers: [serverName] });
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handleMcpToolCompleted(truncatedToolName);
|
||||
expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE);
|
||||
});
|
||||
|
||||
it('notifies after a plugin command turn ends', async () => {
|
||||
const harness = makeHarness();
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE);
|
||||
// Plugin commands resolve the plugin id directly — no MCP server lookup.
|
||||
expect(harness.session.listMcpServers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reminds again when the marketplace advertises a newer version', async () => {
|
||||
const first = makeHarness({ marketplace: makeMarketplace('3.4.0') });
|
||||
const notifier = makeNotifier(first);
|
||||
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(first.notify).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A new notifier (fresh app run) against the same state file stays silent
|
||||
// for the already-notified version…
|
||||
const second = makeHarness({ marketplace: makeMarketplace('3.4.0') });
|
||||
const secondNotifier = makeNotifier(second);
|
||||
await secondNotifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(second.notify).not.toHaveBeenCalled();
|
||||
|
||||
// …but reminds once the marketplace moves to a newer version.
|
||||
const third = makeHarness({ marketplace: makeMarketplace('3.5.0') });
|
||||
const thirdNotifier = makeNotifier(third);
|
||||
await thirdNotifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(third.notify).toHaveBeenCalledWith(
|
||||
'Update detected: Kimi Datasource 3.5.0 is available. ' +
|
||||
'Run /plugins to install the latest version from the Official Marketplace.',
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows marketplace failures and retries on the next invocation', async () => {
|
||||
let attempts = 0;
|
||||
const harness = makeHarness({
|
||||
loadMarketplace: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('offline');
|
||||
return makeMarketplace();
|
||||
},
|
||||
});
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(harness.loadMarketplace).toHaveBeenCalledTimes(1);
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
|
||||
await notifier.handlePluginCommandCompleted('kimi-datasource');
|
||||
expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE);
|
||||
});
|
||||
|
||||
it('refreshes the memoized MCP server map when a lookup misses', async () => {
|
||||
const harness = makeHarness({ mcpServers: [] });
|
||||
let servers: readonly string[] = [];
|
||||
harness.session.listMcpServers = vi.fn(async () => servers.map((name) => ({ name })));
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
// The plugin's MCP server is not registered yet (the plugin gets
|
||||
// installed later in the same app run, applied on /reload or /new).
|
||||
await notifier.handleMcpToolCompleted(DATASOURCE_TOOL);
|
||||
expect(harness.session.listMcpServers).toHaveBeenCalled();
|
||||
expect(harness.notify).not.toHaveBeenCalled();
|
||||
|
||||
// After the reload the new server shows up; the next completion must
|
||||
// refresh the memoized map instead of silently staying unresolved.
|
||||
servers = ['plugin-kimi-datasource:data'];
|
||||
await notifier.handleMcpToolCompleted(DATASOURCE_TOOL);
|
||||
expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE);
|
||||
});
|
||||
|
||||
it('keeps every notified plugin when a turn uses two outdated plugins', async () => {
|
||||
const harness = makeHarness({
|
||||
marketplace: {
|
||||
source: KIMI_CODE_PLUGIN_MARKETPLACE_URL,
|
||||
plugins: [
|
||||
makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0'),
|
||||
makeMarketplaceEntry('another-plugin', 'Another Plugin', '2.0.0'),
|
||||
],
|
||||
},
|
||||
installed: [
|
||||
makePluginSummary(),
|
||||
makePluginSummary({
|
||||
id: 'another-plugin',
|
||||
displayName: 'Another Plugin',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const notifier = makeNotifier(harness);
|
||||
|
||||
await Promise.all([
|
||||
notifier.handlePluginCommandCompleted('kimi-datasource'),
|
||||
notifier.handlePluginCommandCompleted('another-plugin'),
|
||||
]);
|
||||
|
||||
expect(harness.notify).toHaveBeenCalledTimes(2);
|
||||
// Both entries must survive in the persisted state — no lost update.
|
||||
const state = await readPluginUpdateNoticeState(stateFile);
|
||||
expect(state.notified).toEqual({
|
||||
'kimi-datasource': '3.4.0',
|
||||
'another-plugin': '2.0.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PluginUpdateNotifier } from '#/tui/controllers/plugin-update-notifier';
|
||||
import { SessionEventHandler } from '#/tui/controllers/session-event-handler';
|
||||
import { getBuiltInPalette } from '#/tui/theme';
|
||||
|
||||
const DATASOURCE_TOOL = 'mcp__plugin-kimi-datasource_data__call_data_source_tool';
|
||||
|
||||
function makeHost() {
|
||||
const streamingUI = {
|
||||
setTurnId: vi.fn(),
|
||||
flushNow: vi.fn(),
|
||||
resetToolUi: vi.fn(),
|
||||
setStep: vi.fn(),
|
||||
finalizeTurn: vi.fn(),
|
||||
getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })),
|
||||
registerToolCall: vi.fn(),
|
||||
completeToolResult: vi.fn(),
|
||||
setTodoList: vi.fn(),
|
||||
};
|
||||
const host = {
|
||||
state: {
|
||||
appState: {
|
||||
sessionId: 's1',
|
||||
streamingPhase: 'waiting',
|
||||
model: 'kimi-model',
|
||||
permissionMode: 'auto',
|
||||
},
|
||||
queuedMessages: [],
|
||||
queuedMessageDispatchPending: false,
|
||||
theme: { palette: getBuiltInPalette('dark') },
|
||||
toolOutputExpanded: false,
|
||||
todoPanel: { getTodos: vi.fn(() => []) },
|
||||
transcriptContainer: { addChild: vi.fn() },
|
||||
ui: { requestRender: vi.fn() },
|
||||
},
|
||||
session: {},
|
||||
aborted: false,
|
||||
sessionEventUnsubscribe: undefined,
|
||||
streamingUI,
|
||||
requireSession: vi.fn(() => ({})),
|
||||
setAppState: vi.fn(),
|
||||
patchLivePane: vi.fn(),
|
||||
resetLivePane: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
showNotice: vi.fn(),
|
||||
updateActivityPane: vi.fn(),
|
||||
track: vi.fn(),
|
||||
mountEditorReplacement: vi.fn(),
|
||||
restoreEditor: vi.fn(),
|
||||
restoreInputText: vi.fn(),
|
||||
appendTranscriptEntry: vi.fn(),
|
||||
sendNormalUserInput: vi.fn(),
|
||||
sendQueuedMessage: vi.fn(),
|
||||
shiftQueuedMessage: vi.fn(),
|
||||
btwPanelController: { routeEvent: vi.fn(() => false) },
|
||||
tasksBrowserController: {},
|
||||
};
|
||||
return { host: host as never, streamingUI };
|
||||
}
|
||||
|
||||
function makeNotifier() {
|
||||
return {
|
||||
handleMcpToolCompleted: vi.fn(),
|
||||
handlePluginCommandCompleted: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallStarted(name: string) {
|
||||
return {
|
||||
type: 'tool.call.started',
|
||||
sessionId: 's1',
|
||||
agentId: 'main',
|
||||
turnId: 1,
|
||||
toolCallId: 't1',
|
||||
name,
|
||||
args: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function toolResult() {
|
||||
return {
|
||||
type: 'tool.result',
|
||||
sessionId: 's1',
|
||||
agentId: 'main',
|
||||
turnId: 1,
|
||||
toolCallId: 't1',
|
||||
output: 'ok',
|
||||
} as never;
|
||||
}
|
||||
|
||||
function turnEnded(reason: string, turnId = 1) {
|
||||
return {
|
||||
type: 'turn.ended',
|
||||
sessionId: 's1',
|
||||
agentId: 'main',
|
||||
turnId,
|
||||
reason,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function pluginCommandTurnStarted() {
|
||||
return {
|
||||
type: 'turn.started',
|
||||
sessionId: 's1',
|
||||
agentId: 'main',
|
||||
turnId: 2,
|
||||
origin: {
|
||||
kind: 'plugin_command',
|
||||
activationId: 'a1',
|
||||
pluginId: 'kimi-datasource',
|
||||
commandName: 'setup',
|
||||
trigger: 'user-slash',
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const sendQueued = (): void => {};
|
||||
|
||||
describe('SessionEventHandler plugin update notices', () => {
|
||||
it('reports plugin MCP usage only when the turn ends', () => {
|
||||
const { host, streamingUI } = makeHost();
|
||||
const notifier = makeNotifier();
|
||||
streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} });
|
||||
const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier);
|
||||
|
||||
handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued);
|
||||
handler.handleEvent(toolResult(), sendQueued);
|
||||
// The tool result alone must not trigger the notice mid-turn.
|
||||
expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled();
|
||||
|
||||
handler.handleEvent(turnEnded('completed'), sendQueued);
|
||||
expect(notifier.handleMcpToolCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(notifier.handleMcpToolCompleted).toHaveBeenCalledWith(DATASOURCE_TOOL);
|
||||
});
|
||||
|
||||
it('skips the notice for a cancelled turn and clears the buffer', () => {
|
||||
const { host, streamingUI } = makeHost();
|
||||
const notifier = makeNotifier();
|
||||
streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} });
|
||||
const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier);
|
||||
|
||||
handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued);
|
||||
handler.handleEvent(toolResult(), sendQueued);
|
||||
handler.handleEvent(turnEnded('cancelled'), sendQueued);
|
||||
expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled();
|
||||
|
||||
// A later completed turn must not replay the cancelled turn's usage.
|
||||
handler.handleEvent(turnEnded('completed', 3), sendQueued);
|
||||
expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-plugin tools', () => {
|
||||
const { host, streamingUI } = makeHost();
|
||||
const notifier = makeNotifier();
|
||||
streamingUI.completeToolResult.mockReturnValue({ name: 'Bash', args: {} });
|
||||
const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier);
|
||||
|
||||
handler.handleEvent(toolCallStarted('Bash'), sendQueued);
|
||||
handler.handleEvent(toolResult(), sendQueued);
|
||||
handler.handleEvent(turnEnded('completed'), sendQueued);
|
||||
expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a finished plugin command turn', () => {
|
||||
const { host } = makeHost();
|
||||
const notifier = makeNotifier();
|
||||
const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier);
|
||||
|
||||
handler.handleEvent(pluginCommandTurnStarted(), sendQueued);
|
||||
handler.handleEvent(turnEnded('completed', 2), sendQueued);
|
||||
expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledWith('kimi-datasource');
|
||||
});
|
||||
|
||||
it('skips a cancelled plugin command turn', () => {
|
||||
const { host } = makeHost();
|
||||
const notifier = makeNotifier();
|
||||
const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier);
|
||||
|
||||
handler.handleEvent(pluginCommandTurnStarted(), sendQueued);
|
||||
handler.handleEvent(turnEnded('cancelled', 2), sendQueued);
|
||||
expect(notifier.handlePluginCommandCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -4194,6 +4194,75 @@ command = "vim"
|
|||
});
|
||||
});
|
||||
|
||||
it('shows a quota note after installing a quota-consuming official plugin', async () => {
|
||||
const session = makeSession({
|
||||
installPlugin: vi.fn(async () => ({
|
||||
id: 'kimi-datasource',
|
||||
displayName: 'Kimi Datasource',
|
||||
version: '3.3.0',
|
||||
enabled: true,
|
||||
state: 'ok',
|
||||
skillCount: 0,
|
||||
mcpServerCount: 1,
|
||||
enabledMcpServerCount: 1,
|
||||
hasErrors: false,
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
})),
|
||||
});
|
||||
const { driver } = await makeDriver(session);
|
||||
|
||||
// Official sources skip the trust prompt, so the install runs immediately.
|
||||
driver.handleUserInput(
|
||||
'/plugins install https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const transcript = stripSgr(renderTranscript(driver));
|
||||
expect(transcript).toContain('Run /new or /reload to apply plugin changes.');
|
||||
expect(transcript).toContain('Note: This plugin consumes your quota.');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the quota note for a same-id fork installed from a local path', async () => {
|
||||
const session = makeSession({
|
||||
installPlugin: vi.fn(async () => ({
|
||||
id: 'kimi-datasource',
|
||||
displayName: 'Kimi Datasource',
|
||||
version: '3.3.0',
|
||||
enabled: true,
|
||||
state: 'ok',
|
||||
skillCount: 0,
|
||||
mcpServerCount: 1,
|
||||
enabledMcpServerCount: 1,
|
||||
hasErrors: false,
|
||||
source: 'local-path',
|
||||
})),
|
||||
});
|
||||
const { driver } = await makeDriver(session);
|
||||
|
||||
driver.handleUserInput('/plugins install ./plugins/kimi-datasource-fork');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
|
||||
PluginInstallTrustConfirmComponent,
|
||||
);
|
||||
});
|
||||
const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent;
|
||||
confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install"
|
||||
confirm.handleInput('\r');
|
||||
|
||||
// The manifest id matches a billed plugin, but a local-path install is
|
||||
// not the official quota-consuming build.
|
||||
await vi.waitFor(() => {
|
||||
const transcript = stripSgr(renderTranscript(driver));
|
||||
expect(transcript).toContain('Installed Kimi Datasource');
|
||||
});
|
||||
expect(stripSgr(renderTranscript(driver))).not.toContain(
|
||||
'Note: This plugin consumes your quota.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not install when the third-party trust prompt is dismissed', async () => {
|
||||
const session = makeSession();
|
||||
const { driver } = await makeDriver(session);
|
||||
|
|
@ -4260,6 +4329,7 @@ command = "vim"
|
|||
const transcript = stripSgr(renderTranscript(driver));
|
||||
expect(transcript).toContain('Installed Demo');
|
||||
expect(transcript).toContain('Run /new or /reload to apply plugin changes.');
|
||||
expect(transcript).not.toContain('Note: This plugin consumes your quota.');
|
||||
});
|
||||
// Installing closes the panel so the success notice / reload tip is visible.
|
||||
await vi.waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ You can also use slash commands directly:
|
|||
| `/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 |
|
||||
|
||||
The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source.
|
||||
The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/<plugin>:<command>` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source.
|
||||
|
||||
### Installing from GitHub
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ You must first complete OAuth login with a Kimi Code account via `/login`. The p
|
|||
2. Find **Kimi Datasource** and press `Enter` to install
|
||||
3. After installation completes, run `/reload` or `/new` to activate the plugin
|
||||
|
||||
The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above.
|
||||
Using Kimi Datasource consumes your Kimi Code plan quota; the install result reminds you of this. The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above.
|
||||
|
||||
### How to use
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以
|
|||
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server |
|
||||
| `/plugins mcp disable <id> <server>` | 禁用 plugin 声明的 MCP server |
|
||||
|
||||
**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。
|
||||
**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/<plugin>:<command>` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。
|
||||
|
||||
### 从 GitHub 安装
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直
|
|||
2. 找到 **Kimi Datasource**,按 `Enter` 安装
|
||||
3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin
|
||||
|
||||
当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。
|
||||
使用 Kimi Datasource 会消耗你的 Kimi Code 套餐额度,安装结果中会提示这一点。当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。
|
||||
|
||||
### 使用方式
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue