mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-14 19:25:35 +00:00
Merge origin/feat/web into feat/web
# Conflicts: # .changeset/qualify-sub-skill-names.md # .changeset/replay-compaction-records.md # .changeset/shell-streaming-output.md # apps/kimi-web/src/api/daemon/wire.ts # apps/kimi-web/src/api/types.ts
This commit is contained in:
commit
7ac2dbeb14
126 changed files with 4826 additions and 661 deletions
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI.
|
||||
6
.changeset/quiet-compaction-final-answer.md
Normal file
6
.changeset/quiet-compaction-final-answer.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Clarify that compaction summaries must be emitted in the final answer.
|
||||
6
.changeset/read-media-header-types.md
Normal file
6
.changeset/read-media-header-types.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Read media files using header-detected types before falling back to media extensions.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Show completed and cancelled compaction records correctly when resuming a session.
|
||||
6
.changeset/server-send-cancel-e2e.md
Normal file
6
.changeset/server-send-cancel-e2e.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/services": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Allow aborting queued prompts and handle the SSE transport case in MCP server mapping.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Stream foreground Bash stdout and stderr while commands are still running.
|
||||
8
.changeset/sse-mcp-servers.md
Normal file
8
.changeset/sse-mcp-servers.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": minor
|
||||
"@moonshot-ai/acp-adapter": minor
|
||||
"@moonshot-ai/protocol": minor
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
|
@ -1,5 +1,31 @@
|
|||
# @moonshot-ai/kimi-code
|
||||
|
||||
## 0.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#713](https://github.com/MoonshotAI/kimi-code/pull/713) [`f874251`](https://github.com/MoonshotAI/kimi-code/commit/f874251288927243a9b9d4bfd546e8c17754d566) - Refresh provider model metadata before opening the model picker.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#683](https://github.com/MoonshotAI/kimi-code/pull/683) [`ad239cb`](https://github.com/MoonshotAI/kimi-code/commit/ad239cb1c08266a442c9ca0382fefed87bcb1fd4) - Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session.
|
||||
|
||||
- [#690](https://github.com/MoonshotAI/kimi-code/pull/690) [`7f0dde2`](https://github.com/MoonshotAI/kimi-code/commit/7f0dde2ece3f9a004e934d69258dfd47c954043c) - Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them.
|
||||
|
||||
- [#651](https://github.com/MoonshotAI/kimi-code/pull/651) [`c39c625`](https://github.com/MoonshotAI/kimi-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI.
|
||||
|
||||
- [#617](https://github.com/MoonshotAI/kimi-code/pull/617) [`911e7c3`](https://github.com/MoonshotAI/kimi-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session.
|
||||
|
||||
- [#676](https://github.com/MoonshotAI/kimi-code/pull/676) [`dcf3075`](https://github.com/MoonshotAI/kimi-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running.
|
||||
|
||||
- [#692](https://github.com/MoonshotAI/kimi-code/pull/692) [`7ca9bdf`](https://github.com/MoonshotAI/kimi-code/commit/7ca9bdfed516d148b063229a9686a28f9e29aaef) - Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker.
|
||||
|
||||
- [#675](https://github.com/MoonshotAI/kimi-code/pull/675) [`d1ba145`](https://github.com/MoonshotAI/kimi-code/commit/d1ba14562bafdb6b93c3eec1b5c453186507ed56) - Sync custom registry provider additions, removals, and rotated registry keys during startup refresh.
|
||||
|
||||
- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-code",
|
||||
"version": "0.14.1",
|
||||
"version": "0.14.3",
|
||||
"description": "The Starting Point for Next-Gen Agents",
|
||||
"license": "MIT",
|
||||
"author": "Moonshot AI",
|
||||
|
|
|
|||
|
|
@ -55,14 +55,5 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions {
|
|||
if (opts.yolo && opts.auto) {
|
||||
throw new OptionConflictError('Cannot combine --yolo with --auto.');
|
||||
}
|
||||
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) {
|
||||
throw new OptionConflictError('Cannot combine --yolo with --continue or --session.');
|
||||
}
|
||||
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.auto) {
|
||||
throw new OptionConflictError('Cannot combine --auto with --continue or --session.');
|
||||
}
|
||||
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) {
|
||||
throw new OptionConflictError('Cannot combine --plan with --continue or --session.');
|
||||
}
|
||||
return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ export async function runPrompt(
|
|||
try {
|
||||
await harness.ensureConfigFile();
|
||||
const config = await harness.getConfig();
|
||||
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
|
||||
stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
const { session, resumed, restorePermission, telemetryModel, goalModel } =
|
||||
await resolvePromptSession(
|
||||
harness,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
|
|||
import { CHROME_GUTTER } from '#/tui/constant/rendering';
|
||||
import { KimiTUI } from '#/tui/index';
|
||||
import { currentTheme, getColorPalette } from '#/tui/theme';
|
||||
import { combineStartupNotice } from '#/tui/utils/startup';
|
||||
|
||||
import type { CLIOptions } from './options';
|
||||
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
|
||||
|
|
@ -91,6 +92,9 @@ export async function runShell(
|
|||
return;
|
||||
}
|
||||
const config = await harness.getConfig();
|
||||
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
|
||||
configWarning = combineStartupNotice(configWarning, warning);
|
||||
}
|
||||
const configMs = Date.now() - configStartedAt;
|
||||
const tui = new KimiTUI(harness, {
|
||||
cliOptions: opts,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
*
|
||||
* `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the
|
||||
* TUI does; the next launch's `refreshAllProviderModels`
|
||||
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}`
|
||||
* and re-fetches the model list, so periodic refresh is automatic.
|
||||
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries
|
||||
* available API-key candidates, and re-fetches the model list, so periodic
|
||||
* refresh is automatic.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -410,13 +411,26 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
.command('provider')
|
||||
.description('Manage LLM providers non-interactively.');
|
||||
|
||||
// Last-resort boundary: handlers report expected failures themselves, but
|
||||
// anything that escapes (e.g. a config write rejected because config.toml
|
||||
// is invalid) must end as a one-line error + exit 1, not an unhandled
|
||||
// rejection dumping a stack trace.
|
||||
const runAction = async (resolved: ProviderDeps, run: () => Promise<void>): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
resolved.stderr.write(`${errorMessage(error)}\n`);
|
||||
resolved.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
provider
|
||||
.command('add <url>')
|
||||
.description('Import every provider listed in a custom registry (api.json).')
|
||||
.option('--api-key <key>', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.')
|
||||
.action(async (url: string, options: { apiKey?: string }) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await handleProviderAdd(resolved, url, { apiKey: options.apiKey });
|
||||
await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey }));
|
||||
});
|
||||
|
||||
provider
|
||||
|
|
@ -424,7 +438,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
.description('Remove a provider and every model alias that referenced it.')
|
||||
.action(async (providerId: string) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await handleProviderRemove(resolved, providerId);
|
||||
await runAction(resolved, () => handleProviderRemove(resolved, providerId));
|
||||
});
|
||||
|
||||
provider
|
||||
|
|
@ -433,7 +447,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
.option('--json', 'Emit the raw providers/models config as JSON.', false)
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await handleProviderList(resolved, { json: options.json === true });
|
||||
await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true }));
|
||||
});
|
||||
|
||||
const catalog = provider
|
||||
|
|
@ -452,11 +466,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
options: { filter?: string; url?: string; json?: boolean },
|
||||
) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await handleCatalogList(resolved, providerId, {
|
||||
json: options.json === true,
|
||||
...(options.filter === undefined ? {} : { filter: options.filter }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
});
|
||||
await runAction(resolved, () =>
|
||||
handleCatalogList(resolved, providerId, {
|
||||
json: options.json === true,
|
||||
...(options.filter === undefined ? {} : { filter: options.filter }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -472,11 +488,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
options: { apiKey?: string; defaultModel?: string; url?: string },
|
||||
) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await handleCatalogAdd(resolved, providerId, {
|
||||
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
||||
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
});
|
||||
await runAction(resolved, () =>
|
||||
handleCatalogAdd(resolved, providerId, {
|
||||
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
||||
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import type { SlashCommandHost } from './dispatch';
|
|||
// Plan / Config commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000;
|
||||
|
||||
export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
|
||||
const session = host.session;
|
||||
if (session === undefined) {
|
||||
|
|
@ -196,8 +198,9 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string):
|
|||
await applyThemeChoice(host, theme);
|
||||
}
|
||||
|
||||
export function handleModelCommand(host: SlashCommandHost, args: string): void {
|
||||
export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> {
|
||||
const alias = args.trim();
|
||||
await refreshModelsForPicker(host);
|
||||
if (alias.length === 0) {
|
||||
showModelPicker(host);
|
||||
return;
|
||||
|
|
@ -229,6 +232,37 @@ function showEditorPicker(host: SlashCommandHost): void {
|
|||
);
|
||||
}
|
||||
|
||||
async function refreshModelsForPicker(host: SlashCommandHost): Promise<void> {
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
host.authFlow.refreshOAuthProviderModels(),
|
||||
MODEL_PICKER_REFRESH_TIMEOUT_MS,
|
||||
);
|
||||
if (result === undefined) return;
|
||||
for (const f of result.failed) {
|
||||
host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
resolve(undefined);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> {
|
||||
const previous = host.state.appState.editorCommand ?? '';
|
||||
if (value === previous && value.length > 0) {
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ async function handleBuiltInSlashCommand(
|
|||
await handleThemeCommand(host, args);
|
||||
return;
|
||||
case 'model':
|
||||
handleModelCommand(host, args);
|
||||
await handleModelCommand(host, args);
|
||||
return;
|
||||
case 'provider':
|
||||
await handleProviderCommand(host);
|
||||
|
|
|
|||
|
|
@ -50,10 +50,14 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt
|
|||
providers: host.state.appState.availableProviders,
|
||||
activeProviderId,
|
||||
onAdd: () => {
|
||||
void handleProviderAdd(host);
|
||||
void handleProviderAdd(host).catch((error: unknown) => {
|
||||
host.showError(`Add provider failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onDeleteSource: (providerIds) => {
|
||||
void handleProviderManagerDeleteSource(host, providerIds);
|
||||
void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => {
|
||||
host.showError(`Remove provider failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -233,7 +237,9 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
|
|||
initialTabId: providerId,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void setDefaultModel(host, alias, thinking);
|
||||
void setDefaultModel(host, alias, thinking).catch((error: unknown) => {
|
||||
host.showError(`Set default model failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -269,8 +275,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
|
||||
try {
|
||||
entries = await fetchCustomRegistry(source);
|
||||
} catch (err) {
|
||||
host.showError(`Failed to import registry: ${formatErrorMessage(err)}`);
|
||||
} catch (error) {
|
||||
host.showError(`Failed to import registry: ${formatErrorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -287,8 +293,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
models: config.models,
|
||||
});
|
||||
await host.authFlow.refreshConfigAfterLogin();
|
||||
} catch (err) {
|
||||
host.showError(`Failed to apply registry: ${formatErrorMessage(err)}`);
|
||||
} catch (error) {
|
||||
host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +327,9 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
initialTabId: firstNewProvider,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void setDefaultModel(host, alias, thinking);
|
||||
void setDefaultModel(host, alias, thinking).catch((error: unknown) => {
|
||||
host.showError(`Set default model failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
|
|
|
|||
|
|
@ -539,8 +539,8 @@ function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] {
|
|||
|
||||
function mcpServerDescription(server: PluginMcpServerInfo): string {
|
||||
const action = server.enabled ? 'Enter/Space disable' : 'Enter/Space enable';
|
||||
if (server.transport === 'http') {
|
||||
return `${action} · HTTP · ${server.url ?? server.runtimeName}`;
|
||||
if (server.transport === 'http' || server.transport === 'sse') {
|
||||
return `${action} · ${server.transport.toUpperCase()} · ${server.url ?? server.runtimeName}`;
|
||||
}
|
||||
const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : '';
|
||||
const command = `${server.command ?? ''}${args}`.trim();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
|
|||
import type { SkillListSession } from '../commands';
|
||||
|
||||
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
|
||||
import { refreshAllProviderModels } from '../utils/refresh-providers';
|
||||
import {
|
||||
refreshAllProviderModels,
|
||||
type RefreshProviderScope,
|
||||
type RefreshResult,
|
||||
} from '../utils/refresh-providers';
|
||||
import type { SessionEventHandler } from './session-event-handler';
|
||||
import type { AppState, KimiTUIOptions } from '../types';
|
||||
import type { TUIState } from '../tui-state';
|
||||
|
|
@ -142,26 +146,28 @@ export class AuthFlowController {
|
|||
* config. Runs best-effort: individual provider failures are collected
|
||||
* and returned instead of thrown.
|
||||
*/
|
||||
async refreshProviderModels(): Promise<{
|
||||
readonly changed: ReadonlyArray<{
|
||||
readonly providerId: string;
|
||||
readonly providerName: string;
|
||||
readonly added: number;
|
||||
readonly removed: number;
|
||||
}>;
|
||||
readonly unchanged: readonly string[];
|
||||
readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>;
|
||||
}> {
|
||||
async refreshProviderModels(): Promise<RefreshResult> {
|
||||
return this.refreshProviderModelsWithScope('all');
|
||||
}
|
||||
|
||||
async refreshOAuthProviderModels(): Promise<RefreshResult> {
|
||||
return this.refreshProviderModelsWithScope('oauth');
|
||||
}
|
||||
|
||||
private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> {
|
||||
const { host } = this;
|
||||
const result = await refreshAllProviderModels({
|
||||
getConfig: () => host.harness.getConfig({ reload: true }),
|
||||
removeProvider: (id) => host.harness.removeProvider(id),
|
||||
setConfig: (patch) => host.harness.setConfig(patch),
|
||||
resolveOAuthToken: async (providerName, oauthRef) => {
|
||||
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
|
||||
return tokenProvider.getAccessToken();
|
||||
const result = await refreshAllProviderModels(
|
||||
{
|
||||
getConfig: () => host.harness.getConfig({ reload: true }),
|
||||
removeProvider: (id) => host.harness.removeProvider(id),
|
||||
setConfig: (patch) => host.harness.setConfig(patch),
|
||||
resolveOAuthToken: async (providerName, oauthRef) => {
|
||||
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
|
||||
return tokenProvider.getAccessToken();
|
||||
},
|
||||
},
|
||||
});
|
||||
{ scope },
|
||||
);
|
||||
if (result.changed.length > 0) {
|
||||
await this.refreshAvailableModels();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
getCapabilities,
|
||||
Spacer,
|
||||
} from '@earendil-works/pi-tui';
|
||||
import type { MigrationPlan } from '@moonshot-ai/migration-legacy';
|
||||
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
|
||||
import type {
|
||||
ApprovalRequest,
|
||||
|
|
@ -20,14 +19,17 @@ import type {
|
|||
PromptPart,
|
||||
Session,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { MigrationPlan } from '@moonshot-ai/migration-legacy';
|
||||
import { resolve } from 'pathe';
|
||||
|
||||
import type { CLIOptions } from '#/cli/options';
|
||||
import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index';
|
||||
import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import { getInputHistoryFile } from '#/utils/paths';
|
||||
import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect';
|
||||
|
||||
import { BannerProvider } from './banner/banner-provider';
|
||||
import {
|
||||
BUILTIN_SLASH_COMMANDS,
|
||||
buildSkillSlashCommands,
|
||||
|
|
@ -37,9 +39,10 @@ import {
|
|||
type KimiSlashCommand,
|
||||
type SkillListSession,
|
||||
} from './commands';
|
||||
import * as slashCommands from './commands/dispatch';
|
||||
import { BannerComponent } from './components/chrome/banner';
|
||||
import { DeviceCodeBoxComponent } from './components/chrome/device-code-box';
|
||||
import { GutterContainer } from './components/chrome/gutter-container';
|
||||
import { CHROME_GUTTER } from './constant/rendering';
|
||||
import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader';
|
||||
import { WelcomeComponent } from './components/chrome/welcome';
|
||||
import {
|
||||
|
|
@ -54,15 +57,6 @@ import { CompactionComponent } from './components/dialogs/compaction';
|
|||
import { HelpPanelComponent } from './components/dialogs/help-panel';
|
||||
import { QuestionDialogComponent } from './components/dialogs/question-dialog';
|
||||
import { SessionPickerComponent } from './components/dialogs/session-picker';
|
||||
import { AuthFlowController } from './controllers/auth-flow';
|
||||
import { BtwPanelController } from './controllers/btw-panel';
|
||||
import { EditorKeyboardController } from './controllers/editor-keyboard';
|
||||
import { SessionEventHandler } from './controllers/session-event-handler';
|
||||
import * as slashCommands from './commands/dispatch';
|
||||
import { SessionReplayRenderer } from './controllers/session-replay';
|
||||
import { StreamingUIController } from './controllers/streaming-ui';
|
||||
import { TasksBrowserController } from './controllers/tasks-browser';
|
||||
import { installRainbowDance } from './easter-eggs/dance';
|
||||
import {
|
||||
FileMentionProvider,
|
||||
type SlashAutocompleteCommand,
|
||||
|
|
@ -92,19 +86,26 @@ import {
|
|||
NO_ACTIVE_SESSION_MESSAGE,
|
||||
PRODUCT_NAME,
|
||||
} from './constant/kimi-tui';
|
||||
import { CHROME_GUTTER } from './constant/rendering';
|
||||
import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal';
|
||||
import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
|
||||
import { AuthFlowController } from './controllers/auth-flow';
|
||||
import { BtwPanelController } from './controllers/btw-panel';
|
||||
import { EditorKeyboardController } from './controllers/editor-keyboard';
|
||||
import { SessionEventHandler } from './controllers/session-event-handler';
|
||||
import { SessionReplayRenderer } from './controllers/session-replay';
|
||||
import { StreamingUIController } from './controllers/streaming-ui';
|
||||
import { TasksBrowserController } from './controllers/tasks-browser';
|
||||
import { installRainbowDance } from './easter-eggs/dance';
|
||||
import { adaptPanelResponse } from './reverse-rpc/approval/adapter';
|
||||
import { ApprovalController } from './reverse-rpc/approval/controller';
|
||||
import { createApprovalRequestHandler } from './reverse-rpc/approval/handler';
|
||||
import { BannerProvider } from './banner/banner-provider';
|
||||
import { BannerComponent } from './components/chrome/banner';
|
||||
import { registerReverseRPCHandlers } from './reverse-rpc/index';
|
||||
import { QuestionController } from './reverse-rpc/question/controller';
|
||||
import { createQuestionAskHandler } from './reverse-rpc/question/handler';
|
||||
import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types';
|
||||
import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme';
|
||||
import type { ColorToken, ResolvedTheme, ThemeName } from './theme';
|
||||
import { createTUIState, type TUIState } from './tui-state';
|
||||
import {
|
||||
INITIAL_LIVE_PANE,
|
||||
type AppState,
|
||||
|
|
@ -116,15 +117,14 @@ import {
|
|||
type TUIStartupOptions,
|
||||
type TUIStartupState,
|
||||
} from './types';
|
||||
import { createTUIState, type TUIState } from './tui-state';
|
||||
import { isExpandable } from './utils/component-capabilities';
|
||||
import { isDeadTerminalError } from './utils/dead-terminal';
|
||||
import { formatErrorMessage } from './utils/event-payload';
|
||||
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
|
||||
import { extractMediaAttachments } from './utils/image-placeholder';
|
||||
import { hasPatchChanges } from './utils/object-patch';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import { sessionRowsForPicker } from './utils/session-picker-rows';
|
||||
import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
|
||||
import { installTerminalFocusTracking } from './utils/terminal-focus';
|
||||
import { notifyTerminalOnce } from './utils/terminal-notification';
|
||||
import { installTerminalThemeTracking } from './utils/terminal-theme';
|
||||
|
|
@ -246,10 +246,7 @@ export class KimiTUI {
|
|||
|
||||
public onExit?: (exitCode?: number) => Promise<void>;
|
||||
|
||||
track(
|
||||
event: string,
|
||||
properties?: Parameters<KimiHarness['track']>[1],
|
||||
): void {
|
||||
track(event: string, properties?: Parameters<KimiHarness['track']>[1]): void {
|
||||
this.harness.track(event, properties);
|
||||
}
|
||||
|
||||
|
|
@ -377,8 +374,7 @@ export class KimiTUI {
|
|||
try {
|
||||
const migrationResult = await this.runMigrationScreen(this.migrationPlan);
|
||||
if (this.migrateOnly) {
|
||||
const failed =
|
||||
migrationResult.decision === 'now' && migrationResult.migrated === false;
|
||||
const failed = migrationResult.decision === 'now' && migrationResult.migrated === false;
|
||||
this.disposeTerminalTracking();
|
||||
this.state.ui.stop();
|
||||
await this.onExit?.(failed ? 1 : 0);
|
||||
|
|
@ -424,11 +420,7 @@ export class KimiTUI {
|
|||
if (this.state.appState.banner === null || this.state.appState.banner === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.state.transcriptContainer.children.some(
|
||||
(child) => child instanceof BannerComponent,
|
||||
)
|
||||
) {
|
||||
if (this.state.transcriptContainer.children.some((child) => child instanceof BannerComponent)) {
|
||||
return;
|
||||
}
|
||||
const welcomeIndex = this.state.transcriptContainer.children.findIndex(
|
||||
|
|
@ -489,10 +481,7 @@ export class KimiTUI {
|
|||
);
|
||||
}
|
||||
for (const f of result.failed) {
|
||||
this.showStatus(
|
||||
`Skipped refreshing ${f.provider}: ${f.reason}`,
|
||||
'warning',
|
||||
);
|
||||
this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning');
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: startup must not crash on background refresh failures.
|
||||
|
|
@ -511,6 +500,7 @@ export class KimiTUI {
|
|||
}
|
||||
if (shouldReplayHistory) {
|
||||
await this.sessionReplay.hydrateFromReplay(this.requireSession());
|
||||
this.applyStartupPermissionAndPlanToAppState();
|
||||
}
|
||||
const resumeState = this.session?.getResumeState();
|
||||
if (resumeState?.warning !== undefined) {
|
||||
|
|
@ -568,7 +558,8 @@ export class KimiTUI {
|
|||
if (resolve(target.workDir) !== resolve(workDir)) {
|
||||
this.state.ui.stop();
|
||||
process.stderr.write(
|
||||
`${currentTheme.fg('warning',
|
||||
`${currentTheme.fg(
|
||||
'warning',
|
||||
`Session "${startup.sessionFlag}" was created under a different directory.\n` +
|
||||
` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`,
|
||||
)}\n\n`,
|
||||
|
|
@ -596,8 +587,11 @@ export class KimiTUI {
|
|||
} else {
|
||||
session = await this.harness.createSession(createSessionOptions);
|
||||
}
|
||||
if (session !== undefined && startup.model !== undefined && isResumeStartup) {
|
||||
await session.setModel(startup.model);
|
||||
if (session !== undefined && shouldReplayHistory) {
|
||||
await this.applyStartupModesToResumedSession(session);
|
||||
if (startup.model !== undefined) {
|
||||
await session.setModel(startup.model);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isOAuthLoginRequiredError(error)) throw error;
|
||||
|
|
@ -610,6 +604,7 @@ export class KimiTUI {
|
|||
}
|
||||
await this.setSession(session);
|
||||
await this.syncRuntimeState(session);
|
||||
this.applyStartupPermissionAndPlanToAppState();
|
||||
this.state.startupState = 'ready';
|
||||
return shouldReplayHistory;
|
||||
}
|
||||
|
|
@ -1079,10 +1074,7 @@ export class KimiTUI {
|
|||
}
|
||||
|
||||
async syncRuntimeState(session: Session = this.requireSession()): Promise<void> {
|
||||
const [status, goalResult] = await Promise.all([
|
||||
session.getStatus(),
|
||||
session.getGoal(),
|
||||
]);
|
||||
const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]);
|
||||
this.setAppState({
|
||||
sessionId: session.id,
|
||||
model: status.model ?? '',
|
||||
|
|
@ -1098,6 +1090,40 @@ export class KimiTUI {
|
|||
});
|
||||
}
|
||||
|
||||
// Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed
|
||||
// session may already be in plan mode from its persisted records, and
|
||||
// re-entering plan mode throws, so only enable it when it is not active yet.
|
||||
// setPermission is idempotent and needs no such guard.
|
||||
private async applyStartupModesToResumedSession(session: Session): Promise<void> {
|
||||
const { startup } = this.options;
|
||||
if (startup.auto) {
|
||||
await session.setPermission('auto');
|
||||
} else if (startup.yolo) {
|
||||
await session.setPermission('yolo');
|
||||
}
|
||||
if (startup.plan) {
|
||||
const status = await session.getStatus();
|
||||
if (!status.planMode) {
|
||||
await session.setPlanMode(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply startup flags that the user explicitly passed on the command line.
|
||||
// syncRuntimeState and session-replay hydration can both read stale persisted
|
||||
// values, so this guarantees the footer reflects the CLI intent.
|
||||
private applyStartupPermissionAndPlanToAppState(): void {
|
||||
const { startup } = this.options;
|
||||
if (startup.auto) {
|
||||
this.setAppState({ permissionMode: 'auto' });
|
||||
} else if (startup.yolo) {
|
||||
this.setAppState({ permissionMode: 'yolo' });
|
||||
}
|
||||
if (startup.plan) {
|
||||
this.setAppState({ planMode: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Plan mode is set by createSession — do not re-enter it here.
|
||||
private async activateRuntime(): Promise<void> {
|
||||
const session = this.requireSession();
|
||||
|
|
@ -1298,6 +1324,19 @@ export class KimiTUI {
|
|||
this.sessionEventHandler.startSubscription();
|
||||
this.clearTranscriptAndRedraw();
|
||||
this.showStatus(`Started a new session (${session.id}).`);
|
||||
void this.showConfigWarningsIfAny();
|
||||
}
|
||||
|
||||
/** Surface config.toml load warnings (degraded or kept-previous config) in the status bar. */
|
||||
private async showConfigWarningsIfAny(): Promise<void> {
|
||||
try {
|
||||
const { warnings } = await this.harness.getConfigDiagnostics();
|
||||
for (const warning of warnings) {
|
||||
this.showStatus(warning, 'warning');
|
||||
}
|
||||
} catch {
|
||||
/* diagnostics are best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -1336,10 +1375,7 @@ export class KimiTUI {
|
|||
return new GoalSetMessageComponent();
|
||||
}
|
||||
if (entry.goalData?.kind === 'lifecycle') {
|
||||
return buildGoalMarker(
|
||||
entry.goalData.change,
|
||||
this.state.toolOutputExpanded,
|
||||
);
|
||||
return buildGoalMarker(entry.goalData.change, this.state.toolOutputExpanded);
|
||||
}
|
||||
return null;
|
||||
case 'assistant': {
|
||||
|
|
@ -1396,7 +1432,10 @@ export class KimiTUI {
|
|||
}
|
||||
}
|
||||
|
||||
private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void {
|
||||
private appendApprovalTranscriptEntry(
|
||||
request: ApprovalRequest,
|
||||
response: ApprovalResponse,
|
||||
): void {
|
||||
if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return;
|
||||
const parts: string[] = [];
|
||||
switch (response.decision) {
|
||||
|
|
@ -1425,9 +1464,7 @@ export class KimiTUI {
|
|||
|
||||
private renderWelcome(): void {
|
||||
if (
|
||||
this.state.transcriptContainer.children.some(
|
||||
(child) => child instanceof WelcomeComponent,
|
||||
)
|
||||
this.state.transcriptContainer.children.some((child) => child instanceof WelcomeComponent)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1457,16 +1494,12 @@ export class KimiTUI {
|
|||
}
|
||||
|
||||
showStatus(message: string, color?: ColorToken): void {
|
||||
this.state.transcriptContainer.addChild(
|
||||
new StatusMessageComponent(message, color),
|
||||
);
|
||||
this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color));
|
||||
this.state.ui.requestRender();
|
||||
}
|
||||
|
||||
showNotice(title: string, detail?: string): void {
|
||||
this.state.transcriptContainer.addChild(
|
||||
new NoticeMessageComponent(title, detail),
|
||||
);
|
||||
this.state.transcriptContainer.addChild(new NoticeMessageComponent(title, detail));
|
||||
this.state.ui.requestRender();
|
||||
}
|
||||
|
||||
|
|
@ -1641,9 +1674,7 @@ export class KimiTUI {
|
|||
}
|
||||
|
||||
async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise<void> {
|
||||
const palette = await getColorPalette(
|
||||
themeName === 'auto' ? (resolved ?? 'dark') : themeName,
|
||||
);
|
||||
const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName);
|
||||
currentTheme.setPalette(palette);
|
||||
this.setAppState({ theme: themeName });
|
||||
this.updateEditorBorderHighlight();
|
||||
|
|
@ -1689,7 +1720,9 @@ export class KimiTUI {
|
|||
);
|
||||
}
|
||||
|
||||
private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean {
|
||||
private shouldPlaceActivitySpinnerInAgentSwarm(
|
||||
effectiveMode: EffectiveActivityPaneMode,
|
||||
): boolean {
|
||||
return (
|
||||
this.sessionEventHandler.hasActiveAgentSwarmToolCall() &&
|
||||
(effectiveMode === 'waiting' || effectiveMode === 'tool')
|
||||
|
|
@ -1701,6 +1734,7 @@ export class KimiTUI {
|
|||
}
|
||||
|
||||
private syncTerminalProgress(active: boolean): void {
|
||||
if (!this.state.terminalState.supportsProgress) return;
|
||||
if (this.state.terminalState.progressActive === active) return;
|
||||
this.state.terminal.setProgress(active);
|
||||
this.state.terminalState.progressActive = active;
|
||||
|
|
@ -1781,11 +1815,7 @@ export class KimiTUI {
|
|||
// Persist the skip marker `detectPendingMigration` checks, so "Never ask
|
||||
// again" actually stops the prompt from reappearing every launch.
|
||||
try {
|
||||
writeFileSync(
|
||||
join(this.harness.homeDir, '.skip-migration-from-kimi-cli'),
|
||||
'',
|
||||
'utf-8',
|
||||
);
|
||||
writeFileSync(join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), '', 'utf-8');
|
||||
} catch {
|
||||
// Non-blocking: a failed marker write must never crash startup.
|
||||
}
|
||||
|
|
@ -1812,27 +1842,28 @@ export class KimiTUI {
|
|||
|
||||
async showSessionPicker(): Promise<void> {
|
||||
await this.fetchSessions();
|
||||
this.mountSessionPicker(() => {
|
||||
this.hideSessionPicker();
|
||||
this.mountSessionPicker({
|
||||
onCancel: () => {
|
||||
this.hideSessionPicker();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async bootstrapFromPicker(): Promise<void> {
|
||||
await this.fetchSessions();
|
||||
this.mountSessionPicker(
|
||||
() => {
|
||||
this.mountSessionPicker({
|
||||
applyStartupModes: true,
|
||||
onCancel: () => {
|
||||
this.hideSessionPicker();
|
||||
void this.stop();
|
||||
},
|
||||
{
|
||||
onCtrlC: () => {
|
||||
this.state.editor.onCtrlC?.();
|
||||
},
|
||||
onCtrlD: () => {
|
||||
this.state.editor.onCtrlD?.();
|
||||
},
|
||||
onCtrlC: () => {
|
||||
this.state.editor.onCtrlC?.();
|
||||
},
|
||||
);
|
||||
onCtrlD: () => {
|
||||
this.state.editor.onCtrlD?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
hideSessionPicker(): void {
|
||||
|
|
@ -1841,10 +1872,15 @@ export class KimiTUI {
|
|||
this.restoreEditor();
|
||||
}
|
||||
|
||||
private mountSessionPicker(
|
||||
onCancel: () => void,
|
||||
shortcuts: { readonly onCtrlC?: () => void; readonly onCtrlD?: () => void } = {},
|
||||
): void {
|
||||
private mountSessionPicker(options: {
|
||||
readonly onCancel: () => void;
|
||||
readonly onCtrlC?: () => void;
|
||||
readonly onCtrlD?: () => void;
|
||||
// CLI mode flags (--auto/--yolo/--plan) target the session picked at
|
||||
// startup (bare --session); later /sessions switches keep the picked
|
||||
// session's own persisted modes.
|
||||
readonly applyStartupModes?: boolean;
|
||||
}): void {
|
||||
this.state.activeDialog = 'session-picker';
|
||||
this.mountEditorReplacement(
|
||||
new SessionPickerComponent({
|
||||
|
|
@ -1852,15 +1888,24 @@ export class KimiTUI {
|
|||
loading: this.state.loadingSessions,
|
||||
currentSessionId: this.state.appState.sessionId,
|
||||
onSelect: (sessionId: string) => {
|
||||
void this.resumeSession(sessionId).then((switched) => {
|
||||
if (switched) {
|
||||
void this.resumeSession(sessionId)
|
||||
.then(async (switched) => {
|
||||
if (!switched) {
|
||||
return;
|
||||
}
|
||||
if (options.applyStartupModes === true) {
|
||||
await this.applyStartupModesToResumedSession(this.requireSession());
|
||||
this.applyStartupPermissionAndPlanToAppState();
|
||||
}
|
||||
this.hideSessionPicker();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onCancel,
|
||||
onCtrlC: shortcuts.onCtrlC,
|
||||
onCtrlD: shortcuts.onCtrlD,
|
||||
onCancel: options.onCancel,
|
||||
onCtrlC: options.onCtrlC,
|
||||
onCtrlD: options.onCtrlD,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -1955,5 +2000,4 @@ export class KimiTUI {
|
|||
this.patchLivePane({ pendingQuestion: null });
|
||||
this.restoreEditor();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
filterModelsByPrefix,
|
||||
getOpenPlatformById,
|
||||
isOpenPlatformId,
|
||||
removeCustomRegistryProvider,
|
||||
resolveKimiCodeRuntimeAuth,
|
||||
type CustomRegistrySource,
|
||||
type ManagedKimiConfigShape,
|
||||
|
|
@ -39,10 +40,16 @@ export interface RefreshResult {
|
|||
readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>;
|
||||
}
|
||||
|
||||
export type RefreshProviderScope = 'all' | 'oauth';
|
||||
|
||||
export interface RefreshProviderOptions {
|
||||
readonly scope?: RefreshProviderScope;
|
||||
}
|
||||
|
||||
function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined {
|
||||
const source = provider.source;
|
||||
if (typeof source !== 'object' || source === null) return undefined;
|
||||
const candidate = source as Record<string, unknown>;
|
||||
const candidate = source;
|
||||
if (candidate['kind'] !== 'apiJson') return undefined;
|
||||
const url = candidate['url'];
|
||||
const apiKey = candidate['apiKey'];
|
||||
|
|
@ -51,6 +58,36 @@ function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySourc
|
|||
return { kind: 'apiJson', url, apiKey };
|
||||
}
|
||||
|
||||
function customRegistrySourceKey(source: CustomRegistrySource): string {
|
||||
return JSON.stringify([source.url]);
|
||||
}
|
||||
|
||||
function customRegistrySourceCredentialKey(source: CustomRegistrySource): string {
|
||||
return JSON.stringify([source.url, source.apiKey]);
|
||||
}
|
||||
|
||||
async function fetchCustomRegistryFromSources(
|
||||
sources: readonly CustomRegistrySource[],
|
||||
): Promise<{
|
||||
readonly entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
|
||||
readonly source: CustomRegistrySource;
|
||||
}> {
|
||||
let lastError: unknown;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
return {
|
||||
entries: await fetchCustomRegistry(source),
|
||||
source,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
if (typeof lastError === 'string') throw new Error(lastError);
|
||||
throw new Error('No custom registry sources configured.');
|
||||
}
|
||||
|
||||
function asManaged(config: KimiConfig): ManagedKimiConfigShape {
|
||||
return config as unknown as ManagedKimiConfigShape;
|
||||
}
|
||||
|
|
@ -143,6 +180,14 @@ function providerModelsEqual(
|
|||
);
|
||||
}
|
||||
|
||||
function providerConfigSnapshot(config: KimiConfig, providerId: string): string {
|
||||
return JSON.stringify(config.providers[providerId] ?? null);
|
||||
}
|
||||
|
||||
function providerConfigEqual(config: KimiConfig, nextConfig: KimiConfig, providerId: string): boolean {
|
||||
return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId);
|
||||
}
|
||||
|
||||
function providerRefreshAliasKeys(
|
||||
config: KimiConfig,
|
||||
nextConfig: KimiConfig,
|
||||
|
|
@ -199,6 +244,15 @@ function clampDanglingDefault(config: KimiConfig): void {
|
|||
}
|
||||
}
|
||||
|
||||
function clearDefaultThinkingWhenDefaultRemoved(
|
||||
config: KimiConfig,
|
||||
previousDefaultModel: string | undefined,
|
||||
): void {
|
||||
if (previousDefaultModel !== undefined && config.defaultModel === undefined) {
|
||||
config.defaultThinking = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string {
|
||||
const firstModel = models[0];
|
||||
if (firstModel === undefined) return '';
|
||||
|
|
@ -216,10 +270,14 @@ function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<
|
|||
return firstModel.id;
|
||||
}
|
||||
|
||||
export async function refreshAllProviderModels(host: RefreshProviderHost): Promise<RefreshResult> {
|
||||
export async function refreshAllProviderModels(
|
||||
host: RefreshProviderHost,
|
||||
options: RefreshProviderOptions = {},
|
||||
): Promise<RefreshResult> {
|
||||
const changed: ProviderChange[] = [];
|
||||
const unchanged: string[] = [];
|
||||
const failed: Array<{ provider: string; reason: string }> = [];
|
||||
const scope = options.scope ?? 'all';
|
||||
|
||||
let config = await host.getConfig();
|
||||
|
||||
|
|
@ -263,6 +321,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
);
|
||||
restoreDefaultSelection(next, config.defaultModel, config.defaultThinking);
|
||||
clampDanglingDefault(next);
|
||||
clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel);
|
||||
|
||||
if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) {
|
||||
unchanged.push(KIMI_CODE_PROVIDER_NAME);
|
||||
|
|
@ -294,6 +353,10 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
}
|
||||
}
|
||||
|
||||
if (scope === 'oauth') {
|
||||
return { changed, unchanged, failed };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Open Platforms (moonshot-cn, moonshot-ai, …)
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -332,6 +395,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys));
|
||||
restoreDefaultSelection(next, config.defaultModel, config.defaultThinking);
|
||||
clampDanglingDefault(next);
|
||||
clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel);
|
||||
|
||||
if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) {
|
||||
unchanged.push(providerId);
|
||||
|
|
@ -363,26 +427,42 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Custom Registry providers (grouped by {url, apiKey})
|
||||
// 3. Custom Registry providers (grouped by URL, with API-key candidates)
|
||||
// -------------------------------------------------------------------------
|
||||
const customSources = new Map<string, { source: CustomRegistrySource; providerIds: string[] }>();
|
||||
const customSources = new Map<
|
||||
string,
|
||||
{
|
||||
readonly sources: CustomRegistrySource[];
|
||||
readonly sourceKeys: Set<string>;
|
||||
readonly providerIds: string[];
|
||||
}
|
||||
>();
|
||||
for (const [providerId, providerConfig] of Object.entries(config.providers)) {
|
||||
if (providerId === KIMI_CODE_PROVIDER_NAME) continue;
|
||||
if (isOpenPlatformId(providerId)) continue;
|
||||
const source = readCustomRegistrySource(providerConfig);
|
||||
if (source === undefined) continue;
|
||||
const key = `${source.url}${source.apiKey}`;
|
||||
const key = customRegistrySourceKey(source);
|
||||
const sourceKey = customRegistrySourceCredentialKey(source);
|
||||
const entry = customSources.get(key);
|
||||
if (entry !== undefined) {
|
||||
if (!entry.sourceKeys.has(sourceKey)) {
|
||||
entry.sources.push(source);
|
||||
entry.sourceKeys.add(sourceKey);
|
||||
}
|
||||
entry.providerIds.push(providerId);
|
||||
} else {
|
||||
customSources.set(key, { source, providerIds: [providerId] });
|
||||
customSources.set(key, {
|
||||
sources: [source],
|
||||
sourceKeys: new Set([sourceKey]),
|
||||
providerIds: [providerId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { source, providerIds } of customSources.values()) {
|
||||
for (const { sources, providerIds } of customSources.values()) {
|
||||
try {
|
||||
const entries = await fetchCustomRegistry(source);
|
||||
const { entries, source } = await fetchCustomRegistryFromSources(sources);
|
||||
// Build the whole batch on one clone so that several changed providers
|
||||
// from the same source do not overwrite each other's aliases, and so the
|
||||
// config we compare is exactly the config we persist.
|
||||
|
|
@ -393,17 +473,47 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
readonly added: number;
|
||||
readonly removed: number;
|
||||
}> = [];
|
||||
const providersToRemoveBeforeSet = new Set<string>();
|
||||
let hasUnreportedConfigChange = false;
|
||||
const remoteEntries = Object.values(entries);
|
||||
const remoteEntriesByProviderId = new Map(
|
||||
remoteEntries.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
const providerIdsToSync = new Set(providerIds);
|
||||
for (const entry of remoteEntries) providerIdsToSync.add(entry.id);
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const entry = entries[providerId];
|
||||
if (entry === undefined) continue;
|
||||
for (const providerId of providerIdsToSync) {
|
||||
const entry = remoteEntriesByProviderId.get(providerId);
|
||||
if (entry === undefined) {
|
||||
const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId));
|
||||
removeCustomRegistryProvider(asManaged(next), providerId);
|
||||
changedProviders.push({
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
added: 0,
|
||||
removed: oldIds.size,
|
||||
});
|
||||
providersToRemoveBeforeSet.add(providerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existed = config.providers[providerId] !== undefined;
|
||||
applyCustomRegistryProvider(asManaged(next), entry, source);
|
||||
const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`);
|
||||
restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys));
|
||||
if (existed) {
|
||||
restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys));
|
||||
}
|
||||
|
||||
if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) {
|
||||
if (
|
||||
existed &&
|
||||
providerModelsEqual(config, next, providerId, refreshedAliasKeys) &&
|
||||
providerConfigEqual(config, next, providerId)
|
||||
) {
|
||||
unchanged.push(providerId);
|
||||
} else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) {
|
||||
unchanged.push(providerId);
|
||||
providersToRemoveBeforeSet.add(providerId);
|
||||
hasUnreportedConfigChange = true;
|
||||
} else {
|
||||
const { added, removed } = computeChanges(
|
||||
collectModelIdsForAliases(config, refreshedAliasKeys),
|
||||
|
|
@ -415,13 +525,15 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
added,
|
||||
removed,
|
||||
});
|
||||
if (existed) providersToRemoveBeforeSet.add(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProviders.length > 0) {
|
||||
if (changedProviders.length > 0 || hasUnreportedConfigChange) {
|
||||
restoreDefaultSelection(next, config.defaultModel, config.defaultThinking);
|
||||
clampDanglingDefault(next);
|
||||
for (const { providerId } of changedProviders) {
|
||||
clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel);
|
||||
for (const providerId of providersToRemoveBeforeSet) {
|
||||
await host.removeProvider(providerId);
|
||||
}
|
||||
config = await host.setConfig({
|
||||
|
|
@ -431,7 +543,12 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
|
|||
defaultThinking: next.defaultThinking,
|
||||
});
|
||||
for (const change of changedProviders) {
|
||||
changed.push(change);
|
||||
changed.push({
|
||||
providerId: change.providerId,
|
||||
providerName: change.providerName,
|
||||
added: change.added,
|
||||
removed: change.removed,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,25 @@ export function supportsOsc9Notification(env: NodeJS.ProcessEnv = process.env):
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort detection of ConEmu-style OSC 9;4 progress support, driven
|
||||
* off well-known environment variables like `supportsOsc9Notification`.
|
||||
* The two allow-lists must stay separate: iTerm2 posts a desktop
|
||||
* notification for ANY `OSC 9;<payload>` it receives, so sending the 9;4
|
||||
* progress sequence there pops a "4;3" notification every keepalive tick.
|
||||
* Terminals outside this list simply get no progress reporting, which is
|
||||
* always safe.
|
||||
*/
|
||||
export function supportsTerminalProgress(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if ((env['WT_SESSION'] ?? '').length > 0) return true;
|
||||
if (env['ConEmuANSI'] === 'ON') return true;
|
||||
const termProgram = env['TERM_PROGRAM'] ?? '';
|
||||
if (termProgram === 'ghostty' || termProgram === 'WezTerm') return true;
|
||||
const term = env['TERM'] ?? '';
|
||||
if (term === 'xterm-ghostty') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const tmux = env['TMUX'] ?? '';
|
||||
return tmux.length > 0;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { isInsideTmux, supportsOsc9Notification } from './terminal-notification';
|
||||
import {
|
||||
isInsideTmux,
|
||||
supportsOsc9Notification,
|
||||
supportsTerminalProgress,
|
||||
} from './terminal-notification';
|
||||
|
||||
export interface TerminalState {
|
||||
notificationKeys: Set<string>;
|
||||
focused: boolean;
|
||||
supportsOsc9: boolean;
|
||||
supportsProgress: boolean;
|
||||
insideTmux: boolean;
|
||||
progressActive: boolean;
|
||||
}
|
||||
|
|
@ -13,6 +18,7 @@ export function createTerminalState(): TerminalState {
|
|||
notificationKeys: new Set<string>(),
|
||||
focused: true,
|
||||
supportsOsc9: supportsOsc9Notification(),
|
||||
supportsProgress: supportsTerminalProgress(),
|
||||
insideTmux: isInsideTmux(),
|
||||
progressActive: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
|
|||
auth: { getCachedAccessToken: vi.fn() },
|
||||
ensureConfigFile: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })),
|
||||
getConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })),
|
||||
getExperimentalFeatures: vi.fn(async () => mocks.experimentalFeatures),
|
||||
createSession: vi.fn(async () => mocks.session),
|
||||
resumeSession: vi.fn(async () => mocks.session),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ describe('CLI options parsing', () => {
|
|||
describe('--version', () => {
|
||||
it('prints the version string and exits', () => {
|
||||
let output = '';
|
||||
const program = createProgram('1.2.3', () => {}, () => {});
|
||||
const program = createProgram(
|
||||
'1.2.3',
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: (s) => {
|
||||
|
|
@ -61,7 +65,11 @@ describe('CLI options parsing', () => {
|
|||
|
||||
it('supports -V as a short alias', () => {
|
||||
let output = '';
|
||||
const program = createProgram('4.5.6', () => {}, () => {});
|
||||
const program = createProgram(
|
||||
'4.5.6',
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: (s) => {
|
||||
|
|
@ -103,9 +111,7 @@ describe('CLI options parsing', () => {
|
|||
'--flag',
|
||||
]);
|
||||
|
||||
expect(pluginRunnerCalls).toEqual([
|
||||
{ entry: '/plugin/tool.mjs', args: ['query', '--flag'] },
|
||||
]);
|
||||
expect(pluginRunnerCalls).toEqual([{ entry: '/plugin/tool.mjs', args: ['query', '--flag'] }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -161,6 +167,50 @@ describe('CLI options parsing', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('--auto / --yolo / --plan with --session / --continue', () => {
|
||||
it('allows --auto with --continue', () => {
|
||||
const opts = parse(['--auto', '--continue']);
|
||||
expect(opts.auto).toBe(true);
|
||||
expect(opts.continue).toBe(true);
|
||||
expect(validateOptions(opts).uiMode).toBe('shell');
|
||||
});
|
||||
|
||||
it('allows --auto with an explicit session id', () => {
|
||||
const opts = parse(['--auto', '--session', 'ses_123']);
|
||||
expect(opts.auto).toBe(true);
|
||||
expect(opts.session).toBe('ses_123');
|
||||
expect(validateOptions(opts).uiMode).toBe('shell');
|
||||
});
|
||||
|
||||
it('allows --yolo with --continue', () => {
|
||||
const opts = parse(['--yolo', '--continue']);
|
||||
expect(opts.yolo).toBe(true);
|
||||
expect(opts.continue).toBe(true);
|
||||
expect(validateOptions(opts).uiMode).toBe('shell');
|
||||
});
|
||||
|
||||
it('allows --yolo with an explicit session id', () => {
|
||||
const opts = parse(['--yolo', '--session', 'ses_123']);
|
||||
expect(opts.yolo).toBe(true);
|
||||
expect(opts.session).toBe('ses_123');
|
||||
expect(validateOptions(opts).uiMode).toBe('shell');
|
||||
});
|
||||
|
||||
it('allows --plan with --continue', () => {
|
||||
const opts = parse(['--plan', '--continue']);
|
||||
expect(opts.plan).toBe(true);
|
||||
expect(opts.continue).toBe(true);
|
||||
expect(validateOptions(opts).uiMode).toBe('shell');
|
||||
});
|
||||
|
||||
it('allows --plan with an explicit session id', () => {
|
||||
const opts = parse(['--plan', '--session', 'ses_123']);
|
||||
expect(opts.plan).toBe(true);
|
||||
expect(opts.session).toBe('ses_123');
|
||||
expect(validateOptions(opts).uiMode).toBe('shell');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--model / -m', () => {
|
||||
it('parses -m as a model override', () => {
|
||||
expect(parse(['-m', 'kimi-code/k2']).model).toBe('kimi-code/k2');
|
||||
|
|
@ -211,7 +261,9 @@ describe('CLI options parsing', () => {
|
|||
it('rejects prompt mode with bare --session picker', () => {
|
||||
const opts = parse(['-p', 'resume here', '--session']);
|
||||
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
|
||||
expect(() => validateOptions(opts)).toThrow('Cannot use --session without an id in prompt mode.');
|
||||
expect(() => validateOptions(opts)).toThrow(
|
||||
'Cannot use --session without an id in prompt mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects prompt mode with --yolo because prompt mode always uses auto permission', () => {
|
||||
|
|
@ -281,7 +333,11 @@ describe('CLI options parsing', () => {
|
|||
});
|
||||
|
||||
it('registers the visible sub-commands', () => {
|
||||
const program = createProgram('0.0.0', () => {}, () => {});
|
||||
const program = createProgram(
|
||||
'0.0.0',
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
const commandNames: string[] = program.commands
|
||||
.filter((command) => !command.name().startsWith('__'))
|
||||
.map((command) => command.name());
|
||||
|
|
|
|||
|
|
@ -546,6 +546,30 @@ describe('registerProviderCommand', () => {
|
|||
expect(Object.keys(current().providers).toSorted()).toEqual(['kohub', 'kohub-responses']);
|
||||
expect(stdout.join('')).toContain('Imported 2 providers');
|
||||
});
|
||||
|
||||
it('reports write failures on stderr and exits 1 instead of crashing', async () => {
|
||||
const { harness } = makeHarness({
|
||||
providers: { kimi: { type: 'kimi' } },
|
||||
} as unknown as KimiConfig);
|
||||
// Simulate the strict write path rejecting because config.toml is invalid.
|
||||
harness.removeProvider = async () => {
|
||||
throw new Error(
|
||||
'Cannot change settings while config.toml is invalid — fix it first (run `kimi doctor` for details).',
|
||||
);
|
||||
};
|
||||
const { deps, stderr, exitCodes } = makeDeps(harness);
|
||||
|
||||
const program = new Command('kimi');
|
||||
registerProviderCommand(program, deps);
|
||||
|
||||
await tryRun(() =>
|
||||
program.parseAsync(['node', 'kimi', 'provider', 'remove', 'kimi'], { from: 'node' }),
|
||||
);
|
||||
|
||||
expect(exitCodes).toEqual([1]);
|
||||
expect(stderr.join('')).toContain('Cannot change settings');
|
||||
expect(stderr.join('')).not.toContain(' at '); // no stack trace dump
|
||||
});
|
||||
});
|
||||
|
||||
describe('kimi provider catalog list', () => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const mocks = vi.hoisted(() => {
|
|||
telemetry: true,
|
||||
}),
|
||||
),
|
||||
harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })),
|
||||
harnessGetExperimentalFeatures: vi.fn(async () => []),
|
||||
harnessCreateSession: vi.fn(async () => session),
|
||||
harnessResumeSession: vi.fn(async () => session),
|
||||
|
|
@ -91,6 +92,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
|
|||
auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken },
|
||||
ensureConfigFile: mocks.harnessEnsureConfigFile,
|
||||
getConfig: mocks.harnessGetConfig,
|
||||
getConfigDiagnostics: mocks.harnessGetConfigDiagnostics,
|
||||
getExperimentalFeatures: mocks.harnessGetExperimentalFeatures,
|
||||
createSession: mocks.harnessCreateSession,
|
||||
resumeSession: mocks.harnessResumeSession,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => {
|
|||
defaultModel: 'k2',
|
||||
telemetry: true,
|
||||
})),
|
||||
harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })),
|
||||
harnessGetCachedAccessToken: vi.fn(),
|
||||
harnessClose: vi.fn(),
|
||||
detectPendingMigration: vi.fn<() => Promise<unknown>>(async () => null),
|
||||
|
|
@ -82,6 +83,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
|
|||
},
|
||||
ensureConfigFile: mocks.harnessEnsureConfigFile,
|
||||
getConfig: mocks.harnessGetConfig,
|
||||
getConfigDiagnostics: mocks.harnessGetConfigDiagnostics,
|
||||
close: mocks.harnessClose,
|
||||
track: mocks.harnessTrack,
|
||||
};
|
||||
|
|
@ -483,6 +485,38 @@ describe('runShell', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('forwards config.toml diagnostics as startup notices', async () => {
|
||||
mocks.loadTuiConfig.mockResolvedValue({
|
||||
theme: 'dark',
|
||||
editorCommand: null,
|
||||
notifications: { enabled: true, condition: 'unfocused' },
|
||||
});
|
||||
mocks.harnessGetConfigDiagnostics.mockResolvedValue({
|
||||
warnings: ['Ignored invalid config in config.toml: loop_control.'],
|
||||
});
|
||||
mocks.tuiStart.mockResolvedValue(undefined);
|
||||
|
||||
await runShell(
|
||||
{
|
||||
session: '',
|
||||
continue: false,
|
||||
yolo: false,
|
||||
auto: false,
|
||||
plan: false,
|
||||
model: undefined,
|
||||
outputFormat: undefined,
|
||||
prompt: undefined,
|
||||
skillsDirs: [],
|
||||
},
|
||||
'1.2.3-test',
|
||||
);
|
||||
|
||||
const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!;
|
||||
expect(startupInput).toMatchObject({
|
||||
startupNotice: 'Ignored invalid config in config.toml: loop_control.',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the harness when TUI startup fails', async () => {
|
||||
mocks.loadTuiConfig.mockResolvedValue({
|
||||
theme: 'dark',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ function makeDriverWithTerminalProgress(): {
|
|||
const driver = new KimiTUI({} as never, makeStartupInput()) as unknown as ActivityDriver;
|
||||
vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {});
|
||||
driver.state.terminal = { columns: 80, setProgress } as unknown as TUIState['terminal'];
|
||||
driver.state.terminalState.supportsProgress = true;
|
||||
return { driver, state: driver.state, setProgress };
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +101,24 @@ describe('updateActivityPane terminal progress', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('never emits terminal progress when the terminal does not support OSC 9;4', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { driver, state, setProgress } = makeDriverWithTerminalProgress();
|
||||
state.terminalState.supportsProgress = false;
|
||||
|
||||
state.livePane = { ...state.livePane, mode: 'waiting' };
|
||||
driver.updateActivityPane();
|
||||
state.livePane = { ...state.livePane, mode: 'idle' };
|
||||
driver.updateActivityPane();
|
||||
|
||||
expect(setProgress).not.toHaveBeenCalled();
|
||||
expect(state.terminalState.progressActive).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps compaction visible as terminal progress even though the pane is hidden', () => {
|
||||
const { driver, state, setProgress } = makeDriverWithTerminalProgress();
|
||||
state.appState.isCompacting = true;
|
||||
|
|
|
|||
|
|
@ -742,7 +742,7 @@ command = "vim"
|
|||
let resolveSnapshot: (
|
||||
servers: Array<{
|
||||
name: string;
|
||||
transport: 'stdio' | 'http';
|
||||
transport: 'stdio' | 'http' | 'sse';
|
||||
status: 'pending' | 'connected' | 'failed' | 'disabled';
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
|
|
@ -3362,8 +3362,10 @@ command = "vim"
|
|||
|
||||
driver.handleUserInput('/model turbo');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
});
|
||||
const picker = driver.state.editorContainer.children[0];
|
||||
expect(picker).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n'));
|
||||
expect(pickerOutput).toMatch(/Kimi K2\s+Kimi Code ← current/);
|
||||
expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi Code/);
|
||||
|
|
@ -3411,8 +3413,10 @@ command = "vim"
|
|||
|
||||
driver.handleUserInput('/model k2');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
});
|
||||
const picker = driver.state.editorContainer.children[0];
|
||||
expect(picker).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
(picker as TabbedModelSelectorComponent).handleInput('\r');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
|
|
@ -3425,6 +3429,101 @@ command = "vim"
|
|||
expect(session.setThinking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes only OAuth provider models before opening /model picker', async () => {
|
||||
const { driver } = await makeDriver(makeSession(), {
|
||||
getConfig: vi.fn(async () => ({
|
||||
models: {
|
||||
k2: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Old Kimi K2',
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
},
|
||||
})),
|
||||
});
|
||||
const tui = driver as unknown as KimiTUI;
|
||||
const refreshProviderModels = vi
|
||||
.spyOn(tui.authFlow, 'refreshProviderModels')
|
||||
.mockRejectedValue(new Error('full provider refresh should not run'));
|
||||
const refreshOAuthProviderModels = vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
tui.setAppState({
|
||||
availableModels: {
|
||||
k2: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Fresh Kimi K2',
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { changed: [], unchanged: ['managed:kimi-code'], failed: [] };
|
||||
});
|
||||
(
|
||||
tui.authFlow as unknown as {
|
||||
refreshOAuthProviderModels: typeof refreshOAuthProviderModels;
|
||||
}
|
||||
).refreshOAuthProviderModels = refreshOAuthProviderModels;
|
||||
|
||||
driver.handleUserInput('/model');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const picker = driver.state.editorContainer.children[0];
|
||||
expect(picker).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n'));
|
||||
expect(output).toContain('Fresh Kimi K2');
|
||||
expect(output).not.toContain('Old Kimi K2');
|
||||
});
|
||||
expect(refreshOAuthProviderModels).toHaveBeenCalledOnce();
|
||||
expect(refreshProviderModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens /model picker after 2s when OAuth refresh is still pending', async () => {
|
||||
const { driver } = await makeDriver(makeSession(), {
|
||||
getConfig: vi.fn(async () => ({
|
||||
models: {
|
||||
k2: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Kimi K2',
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
},
|
||||
})),
|
||||
});
|
||||
const tui = driver as unknown as KimiTUI;
|
||||
const refreshOAuthProviderModels = vi.fn(() => new Promise<never>(() => {}));
|
||||
(
|
||||
tui.authFlow as unknown as {
|
||||
refreshOAuthProviderModels: typeof refreshOAuthProviderModels;
|
||||
}
|
||||
).refreshOAuthProviderModels = refreshOAuthProviderModels;
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
driver.handleUserInput('/model');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshOAuthProviderModels).toHaveBeenCalledOnce();
|
||||
expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const picker = driver.state.editorContainer.children[0];
|
||||
expect(picker).toBeInstanceOf(TabbedModelSelectorComponent);
|
||||
const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n'));
|
||||
expect(output).toContain('Kimi K2');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('enables search in the shared model selector helper', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
const selection = runModelSelector(driver as any, {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ import {
|
|||
isInsideTmux,
|
||||
notifyTerminalOnce,
|
||||
supportsOsc9Notification,
|
||||
supportsTerminalProgress,
|
||||
} from '#/tui/utils/terminal-notification';
|
||||
|
||||
function makeNotificationState(args: {
|
||||
|
|
@ -215,6 +216,32 @@ describe('supportsOsc9Notification', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('supportsTerminalProgress', () => {
|
||||
it('detects Windows Terminal / ConEmu via env flags', () => {
|
||||
expect(supportsTerminalProgress({ WT_SESSION: 'abc-123' })).toBe(true);
|
||||
expect(supportsTerminalProgress({ ConEmuANSI: 'ON' })).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Ghostty / WezTerm via TERM_PROGRAM and TERM', () => {
|
||||
expect(supportsTerminalProgress({ TERM_PROGRAM: 'ghostty' })).toBe(true);
|
||||
expect(supportsTerminalProgress({ TERM: 'xterm-ghostty' })).toBe(true);
|
||||
expect(supportsTerminalProgress({ TERM_PROGRAM: 'WezTerm' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects terminals that show every OSC 9 payload as a notification', () => {
|
||||
// iTerm2 treats any OSC 9 payload as a desktop notification, so the
|
||||
// ConEmu-style 9;4 progress sequence must never be sent there.
|
||||
expect(supportsTerminalProgress({ TERM_PROGRAM: 'iTerm.app' })).toBe(false);
|
||||
expect(supportsTerminalProgress({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false);
|
||||
expect(supportsTerminalProgress({ TERM_PROGRAM: 'WarpTerminal' })).toBe(false);
|
||||
expect(supportsTerminalProgress({ TERM: 'xterm-kitty' })).toBe(false);
|
||||
expect(supportsTerminalProgress({ TERM: 'xterm-256color' })).toBe(false);
|
||||
expect(supportsTerminalProgress({ ConEmuANSI: 'OFF' })).toBe(false);
|
||||
expect(supportsTerminalProgress({ WT_SESSION: '' })).toBe(false);
|
||||
expect(supportsTerminalProgress({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideTmux', () => {
|
||||
it('detects tmux via the TMUX env var', () => {
|
||||
expect(isInsideTmux({ TMUX: '/private/tmp/tmux-501/default,1234,0' })).toBe(true);
|
||||
|
|
|
|||
|
|
@ -126,6 +126,92 @@ describe('refreshAllProviderModels', () => {
|
|||
expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef);
|
||||
});
|
||||
|
||||
it('can refresh only the managed OAuth provider without fetching third-party registries', async () => {
|
||||
const baseUrl = 'https://api.example.test/coding/v1';
|
||||
const registryUrl = 'https://registry.example.test/v1/models/api.json';
|
||||
const config: KimiConfig = {
|
||||
providers: {
|
||||
[KIMI_CODE_PROVIDER_NAME]: {
|
||||
type: 'kimi',
|
||||
baseUrl,
|
||||
apiKey: '',
|
||||
oauth: {
|
||||
storage: 'file',
|
||||
key: resolveKimiCodeOAuthKey({ baseUrl }),
|
||||
},
|
||||
},
|
||||
custom: {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://custom.example.test/v1',
|
||||
apiKey: 'sk-test-token',
|
||||
source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'kimi-code/kimi-for-coding': {
|
||||
provider: KIMI_CODE_PROVIDER_NAME,
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 262144,
|
||||
capabilities: ['thinking', 'tool_use'],
|
||||
displayName: 'Old Kimi',
|
||||
},
|
||||
'custom/m1': {
|
||||
provider: 'custom',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'Custom M1',
|
||||
},
|
||||
},
|
||||
defaultModel: 'kimi-code/kimi-for-coding',
|
||||
telemetry: true,
|
||||
};
|
||||
const host = makeRefreshHost(config);
|
||||
const resolveOAuthToken = vi.fn(async () => 'oauth-access-token');
|
||||
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
||||
expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`);
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'kimi-for-coding',
|
||||
context_length: 262144,
|
||||
supports_reasoning: true,
|
||||
display_name: 'Fresh Kimi',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await refreshAllProviderModels(
|
||||
{
|
||||
getConfig: async () => host.current(),
|
||||
removeProvider: host.removeProvider,
|
||||
setConfig: host.setConfig,
|
||||
resolveOAuthToken,
|
||||
},
|
||||
{ scope: 'oauth' },
|
||||
);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.changed).toEqual([
|
||||
{
|
||||
providerId: KIMI_CODE_PROVIDER_NAME,
|
||||
providerName: 'Kimi Code',
|
||||
added: 0,
|
||||
removed: 0,
|
||||
},
|
||||
]);
|
||||
expect(result.unchanged).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(host.current().models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Fresh Kimi');
|
||||
expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1');
|
||||
});
|
||||
|
||||
it('refreshes custom-registry model capabilities even when model ids are unchanged', async () => {
|
||||
const registryUrl = 'https://registry.example.test/v1/models/api.json';
|
||||
const providerId = 'example_chat-completions';
|
||||
|
|
@ -261,6 +347,289 @@ describe('refreshAllProviderModels', () => {
|
|||
expect(host.current().models?.[userAlias]).toEqual(userAliasModel);
|
||||
});
|
||||
|
||||
it('adds custom-registry providers that appear under an existing source URL', async () => {
|
||||
const registryUrl = 'https://registry.example.test/v1/models/api.json';
|
||||
const apiKey = 'sk-test-token';
|
||||
const source = { kind: 'apiJson', url: registryUrl, apiKey };
|
||||
const host = makeRefreshHost({
|
||||
providers: {
|
||||
a: {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://a.example.test/v1',
|
||||
apiKey,
|
||||
source,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'a/m1': {
|
||||
provider: 'a',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm1',
|
||||
},
|
||||
},
|
||||
telemetry: true,
|
||||
} as unknown as KimiConfig);
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
||||
expect(fetchInputUrl(input)).toBe(registryUrl);
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
a: {
|
||||
id: 'a',
|
||||
name: 'Provider A',
|
||||
api: 'https://a.example.test/v1',
|
||||
type: 'openai',
|
||||
models: { m1: { id: 'm1' } },
|
||||
},
|
||||
b: {
|
||||
id: 'b',
|
||||
name: 'Provider B',
|
||||
api: 'https://b.example.test/v1',
|
||||
type: 'openai',
|
||||
models: { m1: { id: 'm1' } },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await refreshAllProviderModels({
|
||||
getConfig: async () => host.current(),
|
||||
removeProvider: host.removeProvider,
|
||||
setConfig: host.setConfig,
|
||||
resolveOAuthToken: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.unchanged).toEqual(['a']);
|
||||
expect(result.changed).toEqual([
|
||||
{
|
||||
providerId: 'b',
|
||||
providerName: 'Provider B',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(host.removeProvider).not.toHaveBeenCalled();
|
||||
expect(host.setConfig).toHaveBeenCalledTimes(1);
|
||||
expect(Object.keys(host.current().providers).toSorted()).toEqual(['a', 'b']);
|
||||
expect(host.current().providers['b']).toMatchObject({
|
||||
type: 'openai',
|
||||
baseUrl: 'https://b.example.test/v1',
|
||||
apiKey,
|
||||
source,
|
||||
});
|
||||
expect(host.current().models?.['b/m1']).toEqual({
|
||||
provider: 'b',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm1',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes custom-registry providers that disappear from an existing source URL', async () => {
|
||||
const registryUrl = 'https://registry.example.test/v1/models/api.json';
|
||||
const apiKey = 'sk-test-token';
|
||||
const source = { kind: 'apiJson', url: registryUrl, apiKey };
|
||||
const host = makeRefreshHost({
|
||||
providers: {
|
||||
a: {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://a.example.test/v1',
|
||||
apiKey,
|
||||
source,
|
||||
},
|
||||
b: {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://b.example.test/v1',
|
||||
apiKey,
|
||||
source,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'a/m1': {
|
||||
provider: 'a',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm1',
|
||||
},
|
||||
'b/m1': {
|
||||
provider: 'b',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm1',
|
||||
},
|
||||
'my-b': {
|
||||
provider: 'b',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'My B',
|
||||
},
|
||||
},
|
||||
defaultModel: 'my-b',
|
||||
defaultThinking: true,
|
||||
telemetry: true,
|
||||
} as unknown as KimiConfig);
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
||||
expect(fetchInputUrl(input)).toBe(registryUrl);
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
a: {
|
||||
id: 'a',
|
||||
name: 'Provider A',
|
||||
api: 'https://a.example.test/v1',
|
||||
type: 'openai',
|
||||
models: { m1: { id: 'm1' } },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await refreshAllProviderModels({
|
||||
getConfig: async () => host.current(),
|
||||
removeProvider: host.removeProvider,
|
||||
setConfig: host.setConfig,
|
||||
resolveOAuthToken: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.unchanged).toEqual(['a']);
|
||||
expect(result.changed).toEqual([
|
||||
{
|
||||
providerId: 'b',
|
||||
providerName: 'b',
|
||||
added: 0,
|
||||
removed: 1,
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(host.removeProvider).toHaveBeenCalledWith('b');
|
||||
expect(host.setConfig).toHaveBeenCalledTimes(1);
|
||||
expect(Object.keys(host.current().providers)).toEqual(['a']);
|
||||
expect(host.current().models?.['a/m1']).toBeDefined();
|
||||
expect(host.current().models?.['b/m1']).toBeUndefined();
|
||||
expect(host.current().models?.['my-b']).toBeUndefined();
|
||||
expect(host.current().defaultModel).toBeUndefined();
|
||||
expect(host.current().defaultThinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => {
|
||||
const registryUrl = 'https://registry.example.test/v1/models/api.json';
|
||||
const oldSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-old-token' };
|
||||
const newSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-new-token' };
|
||||
const host = makeRefreshHost({
|
||||
providers: {
|
||||
a: {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://a.example.test/v1',
|
||||
apiKey: 'sk-old-token',
|
||||
source: oldSource,
|
||||
},
|
||||
b: {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://b.example.test/v1',
|
||||
apiKey: 'sk-new-token',
|
||||
source: newSource,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'a/m1': {
|
||||
provider: 'a',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm1',
|
||||
},
|
||||
'b/m1': {
|
||||
provider: 'b',
|
||||
model: 'm1',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm1',
|
||||
},
|
||||
},
|
||||
telemetry: true,
|
||||
} as unknown as KimiConfig);
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
||||
expect(fetchInputUrl(input)).toBe(registryUrl);
|
||||
const authorization = new Headers(init?.headers).get('authorization');
|
||||
if (authorization === 'Bearer sk-old-token') {
|
||||
return new Response(JSON.stringify({ message: 'expired token' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
expect(authorization).toBe('Bearer sk-new-token');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
a: {
|
||||
id: 'a',
|
||||
name: 'Provider A',
|
||||
api: 'https://a.example.test/v1',
|
||||
type: 'openai',
|
||||
models: { m1: { id: 'm1' } },
|
||||
},
|
||||
b: {
|
||||
id: 'b',
|
||||
name: 'Provider B',
|
||||
api: 'https://b.example.test/v1',
|
||||
type: 'openai',
|
||||
models: { m1: { id: 'm1' }, m2: { id: 'm2' } },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await refreshAllProviderModels({
|
||||
getConfig: async () => host.current(),
|
||||
removeProvider: host.removeProvider,
|
||||
setConfig: host.setConfig,
|
||||
resolveOAuthToken: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.unchanged).toEqual(['a']);
|
||||
expect(result.changed).toEqual([
|
||||
{
|
||||
providerId: 'b',
|
||||
providerName: 'Provider B',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(host.removeProvider).toHaveBeenCalledWith('a');
|
||||
expect(host.removeProvider).toHaveBeenCalledWith('b');
|
||||
expect(host.setConfig).toHaveBeenCalledTimes(1);
|
||||
expect(host.current().providers['a']?.source).toEqual(newSource);
|
||||
expect(host.current().providers['b']?.source).toEqual(newSource);
|
||||
expect(host.current().providers['a']?.apiKey).toBe('sk-new-token');
|
||||
expect(host.current().providers['b']?.apiKey).toBe('sk-new-token');
|
||||
expect(host.current().models?.['b/m2']).toEqual({
|
||||
provider: 'b',
|
||||
model: 'm2',
|
||||
maxContextSize: 131072,
|
||||
capabilities: ['tool_use'],
|
||||
displayName: 'm2',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores user-defined aliases when custom-registry metadata is unchanged', async () => {
|
||||
const registryUrl = 'https://registry.example.test/v1/models/api.json';
|
||||
const providerId = 'example_chat-completions';
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ describe('kimi-datasource MCP server', () => {
|
|||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${address.port}/coding/v1`;
|
||||
const oauthHost = 'https://auth.dev.kimi.team';
|
||||
const oauthHost = 'https://auth.dev.example.test';
|
||||
const scopedCredential = kimiCodeEnvCredentialName({ oauthHost, baseUrl });
|
||||
|
||||
await mkdir(join(kimiHome, 'credentials'), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -482,7 +482,7 @@ export function createAgentProjector(): AgentProjector {
|
|||
reset(sessionId);
|
||||
const s = getOrCreate(sessionId);
|
||||
|
||||
const promptId = ulid('pr_');
|
||||
const promptId = turn.promptId ?? ulid('pr_');
|
||||
s.currentPromptId = promptId;
|
||||
s.turnPromptId.set(turn.turnId, promptId);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import type { KimiApiConfig } from '../config';
|
||||
import { buildRestUrl, buildWsUrl } from '../config';
|
||||
import type {
|
||||
AppConfig,
|
||||
AppMessage,
|
||||
AppMessageRole,
|
||||
AppModel,
|
||||
|
|
@ -35,6 +36,7 @@ import { createAgentProjector } from './agentEventProjector';
|
|||
import { DaemonHttpClient } from './http';
|
||||
import {
|
||||
toAppApprovalRequest,
|
||||
toAppConfig,
|
||||
toAppEvent,
|
||||
toAppFsEntry,
|
||||
toAppMessage,
|
||||
|
|
@ -54,6 +56,7 @@ import {
|
|||
import type {
|
||||
WireAuthResult,
|
||||
WireBackgroundTask,
|
||||
WireConfig,
|
||||
WireEvent,
|
||||
WireFileMeta,
|
||||
WireFsBrowseResult,
|
||||
|
|
@ -70,6 +73,7 @@ import type {
|
|||
WireProvider,
|
||||
WireProviderRefreshResult,
|
||||
WireSession,
|
||||
WireSessionAbortResult,
|
||||
WireSessionRuntimeStatus,
|
||||
WireSessionSnapshot,
|
||||
WireWorkspace,
|
||||
|
|
@ -443,6 +447,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
description: t.description,
|
||||
lastProgress: t.last_progress,
|
||||
})),
|
||||
promptId: data.in_flight_turn.current_prompt_id,
|
||||
},
|
||||
pendingApprovals: data.pending_approvals.map(toAppApprovalRequest),
|
||||
pendingQuestions: data.pending_questions.map(toAppQuestionRequest),
|
||||
|
|
@ -496,6 +501,16 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
return { aborted: data.aborted, atSeq: data.at_seq };
|
||||
}
|
||||
|
||||
// POST /sessions/{id}:abort — cancel whatever is running in the session,
|
||||
// including skill activations that bypass IPromptService.
|
||||
async abortSession(sessionId: string): Promise<{ aborted: boolean }> {
|
||||
const data = await this.http.post<WireSessionAbortResult>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}:abort`,
|
||||
{},
|
||||
);
|
||||
return { aborted: data.aborted };
|
||||
}
|
||||
|
||||
// POST /sessions/{id}:compact — request history compaction. Returns {};
|
||||
// progress and completion arrive via the WS compaction.* events (the
|
||||
// transcript itself is not reloaded — a divider marker is appended).
|
||||
|
|
@ -1038,6 +1053,49 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Config — REAL endpoints
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getConfig(): Promise<AppConfig> {
|
||||
const data = await this.http.get<WireConfig>('/config');
|
||||
return toAppConfig(data);
|
||||
}
|
||||
|
||||
async setConfig(patch: Partial<AppConfig>): Promise<AppConfig> {
|
||||
const wirePatch: Record<string, unknown> = {};
|
||||
const keyMap: Record<keyof AppConfig, string> = {
|
||||
providers: 'providers',
|
||||
defaultProvider: 'default_provider',
|
||||
defaultModel: 'default_model',
|
||||
models: 'models',
|
||||
thinking: 'thinking',
|
||||
planMode: 'plan_mode',
|
||||
yolo: 'yolo',
|
||||
defaultThinking: 'default_thinking',
|
||||
defaultPermissionMode: 'default_permission_mode',
|
||||
defaultPlanMode: 'default_plan_mode',
|
||||
permission: 'permission',
|
||||
hooks: 'hooks',
|
||||
services: 'services',
|
||||
mergeAllAvailableSkills: 'merge_all_available_skills',
|
||||
extraSkillDirs: 'extra_skill_dirs',
|
||||
loopControl: 'loop_control',
|
||||
background: 'background',
|
||||
experimental: 'experimental',
|
||||
telemetry: 'telemetry',
|
||||
raw: 'raw',
|
||||
};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const wireKey = keyMap[key as keyof AppConfig];
|
||||
if (wireKey !== undefined) {
|
||||
wirePatch[wireKey] = value;
|
||||
}
|
||||
}
|
||||
const data = await this.http.post<WireConfig>('/config', wirePatch);
|
||||
return toAppConfig(data);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth — REAL endpoints
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import type {
|
||||
AppApprovalRequest,
|
||||
AppConfig,
|
||||
AppEvent,
|
||||
AppGoal,
|
||||
AppMessage,
|
||||
|
|
@ -47,6 +48,7 @@ export interface KimiClientState {
|
|||
goalBySession: Record<string, AppGoal>;
|
||||
lastSeqBySession: Record<string, number>;
|
||||
compactionBySession: Record<string, CompactionStatus>;
|
||||
config?: AppConfig | null;
|
||||
warnings: AppWarning[];
|
||||
}
|
||||
|
||||
|
|
@ -526,6 +528,12 @@ export function reduceAppEvent(
|
|||
break;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
case 'configChanged': {
|
||||
next.config = event.config;
|
||||
break;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
case 'unknown': {
|
||||
// Distinguish no-op known events (sentinel _noop) from agent errors/warnings
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import type {
|
||||
AppApprovalRequest,
|
||||
AppConfig,
|
||||
AppEvent,
|
||||
AppGoal,
|
||||
AppModel,
|
||||
|
|
@ -49,6 +50,7 @@ import type {
|
|||
WireSessionUsage,
|
||||
WireWorkspace,
|
||||
WireEvent,
|
||||
WireConfig,
|
||||
} from './wire';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -669,6 +671,13 @@ export function toAppEvent(wire: WireEvent): AppEvent {
|
|||
outputBytes: w.payload.output_bytes,
|
||||
};
|
||||
|
||||
case 'event.config.changed':
|
||||
return {
|
||||
type: 'configChanged',
|
||||
changedFields: w.payload.changed_fields,
|
||||
config: toAppConfig(w.payload.config),
|
||||
};
|
||||
|
||||
default: {
|
||||
// Truly unknown event — record warning
|
||||
return { type: 'unknown', raw: wire };
|
||||
|
|
@ -704,6 +713,40 @@ export function toAppProvider(wire: WireProvider): AppProvider {
|
|||
};
|
||||
}
|
||||
|
||||
export function toAppConfig(wire: WireConfig): AppConfig {
|
||||
const providers: Record<string, { type: string; baseUrl?: string; defaultModel?: string; hasApiKey: boolean }> = {};
|
||||
for (const [id, provider] of Object.entries(wire.providers)) {
|
||||
providers[id] = {
|
||||
type: provider.type,
|
||||
baseUrl: provider.base_url,
|
||||
defaultModel: provider.default_model,
|
||||
hasApiKey: provider.has_api_key,
|
||||
};
|
||||
}
|
||||
return {
|
||||
providers,
|
||||
defaultProvider: wire.default_provider,
|
||||
defaultModel: wire.default_model,
|
||||
models: wire.models,
|
||||
thinking: wire.thinking,
|
||||
planMode: wire.plan_mode,
|
||||
yolo: wire.yolo,
|
||||
defaultThinking: wire.default_thinking,
|
||||
defaultPermissionMode: wire.default_permission_mode,
|
||||
defaultPlanMode: wire.default_plan_mode,
|
||||
permission: wire.permission,
|
||||
hooks: wire.hooks,
|
||||
services: wire.services,
|
||||
mergeAllAvailableSkills: wire.merge_all_available_skills,
|
||||
extraSkillDirs: wire.extra_skill_dirs,
|
||||
loopControl: wire.loop_control,
|
||||
background: wire.background,
|
||||
experimental: wire.experimental,
|
||||
telemetry: wire.telemetry,
|
||||
raw: wire.raw,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to extract sessionId from a WireEvent (needed by reducer for lastSeq update)
|
||||
export function wireEventSessionId(wire: WireEvent): string {
|
||||
return wire.session_id;
|
||||
|
|
|
|||
|
|
@ -352,6 +352,36 @@ export interface WireProviderRefreshResult {
|
|||
failed: Array<{ provider: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface WireConfigProvider {
|
||||
type: string;
|
||||
base_url?: string;
|
||||
default_model?: string;
|
||||
has_api_key: boolean;
|
||||
}
|
||||
|
||||
export interface WireConfig {
|
||||
providers: Record<string, WireConfigProvider>;
|
||||
default_provider?: string;
|
||||
default_model?: string;
|
||||
models?: Record<string, unknown>;
|
||||
thinking?: unknown;
|
||||
plan_mode?: boolean;
|
||||
yolo?: boolean;
|
||||
default_thinking?: boolean;
|
||||
default_permission_mode?: string;
|
||||
default_plan_mode?: boolean;
|
||||
permission?: unknown;
|
||||
hooks?: unknown[];
|
||||
services?: unknown;
|
||||
merge_all_available_skills?: boolean;
|
||||
extra_skill_dirs?: string[];
|
||||
loop_control?: unknown;
|
||||
background?: unknown;
|
||||
experimental?: Record<string, boolean>;
|
||||
telemetry?: boolean;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -489,6 +519,7 @@ export interface WireInFlightTurn {
|
|||
assistant_text: string;
|
||||
thinking_text: string;
|
||||
running_tools: WireInFlightToolCall[];
|
||||
current_prompt_id?: string;
|
||||
}
|
||||
|
||||
/** `GET /sessions/{sid}/snapshot` — atomic rebuild state at a watermark. */
|
||||
|
|
@ -502,6 +533,10 @@ export interface WireSessionSnapshot {
|
|||
pending_questions: WireQuestionRequest[];
|
||||
}
|
||||
|
||||
export interface WireSessionAbortResult {
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
export interface WireErrorFrame {
|
||||
type: 'error';
|
||||
timestamp: string;
|
||||
|
|
@ -700,6 +735,11 @@ type WireEventTaskCompleted = WireEventBase<'event.task.completed', {
|
|||
output_bytes?: number;
|
||||
}>;
|
||||
|
||||
type WireEventConfigChanged = WireEventBase<'event.config.changed', {
|
||||
changed_fields: string[];
|
||||
config: WireConfig;
|
||||
}>;
|
||||
|
||||
/** Catch-all for unrecognised event frames — keeps lastSeq advancing without warnings */
|
||||
type WireEventUnknown = { type: string; seq: number; session_id: string; timestamp: string; payload: unknown };
|
||||
|
||||
|
|
@ -743,5 +783,7 @@ export type WireEvent =
|
|||
| WireEventTaskCreated
|
||||
| WireEventTaskProgress
|
||||
| WireEventTaskCompleted
|
||||
// Config
|
||||
| WireEventConfigChanged
|
||||
// Unknown / future events
|
||||
| WireEventUnknown;
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ export type AppEvent =
|
|||
| { type: 'taskProgress'; sessionId: string; taskId: string; outputChunk: string; stream: 'stdout' | 'stderr' }
|
||||
| { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number }
|
||||
| { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null }
|
||||
| { type: 'configChanged'; changedFields: string[]; config: AppConfig }
|
||||
| { type: 'unknown'; raw: unknown };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -431,6 +432,8 @@ export interface AppInFlightTurn {
|
|||
assistantText: string;
|
||||
thinkingText: string;
|
||||
runningTools: AppInFlightToolCall[];
|
||||
/** Authoritative daemon prompt_id for the active prompt, if known. */
|
||||
promptId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -532,6 +535,36 @@ export interface ProviderRefreshResult {
|
|||
failed: Array<{ provider: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface AppConfigProvider {
|
||||
type: string;
|
||||
baseUrl?: string;
|
||||
defaultModel?: string;
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
providers: Record<string, AppConfigProvider>;
|
||||
defaultProvider?: string;
|
||||
defaultModel?: string;
|
||||
models?: Record<string, unknown>;
|
||||
thinking?: unknown;
|
||||
planMode?: boolean;
|
||||
yolo?: boolean;
|
||||
defaultThinking?: boolean;
|
||||
defaultPermissionMode?: string;
|
||||
defaultPlanMode?: boolean;
|
||||
permission?: unknown;
|
||||
hooks?: unknown[];
|
||||
services?: unknown;
|
||||
mergeAllAvailableSkills?: boolean;
|
||||
extraSkillDirs?: string[];
|
||||
loopControl?: unknown;
|
||||
background?: unknown;
|
||||
experimental?: Record<string, boolean>;
|
||||
telemetry?: boolean;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A session-scoped skill the user can invoke from the slash menu. */
|
||||
export interface AppSkill {
|
||||
name: string;
|
||||
|
|
@ -561,6 +594,8 @@ export interface KimiWebApi {
|
|||
/** Steer daemon-queued prompts into the active turn (TUI ctrl+s). */
|
||||
steerPrompts(sessionId: string, promptIds: string[]): Promise<{ steered: boolean; promptIds: string[] }>;
|
||||
abortPrompt(sessionId: string, promptId: string): Promise<{ aborted: boolean; atSeq?: number }>;
|
||||
/** Cancel whatever is running in the session, including skill activations. */
|
||||
abortSession(sessionId: string): Promise<{ aborted: boolean }>;
|
||||
compactSession(sessionId: string, instruction?: string): Promise<void>;
|
||||
undoSession(sessionId: string, count?: number): Promise<void>;
|
||||
forkSession(sessionId: string, input?: { title?: string }): Promise<AppSession>;
|
||||
|
|
@ -613,6 +648,10 @@ export interface KimiWebApi {
|
|||
uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }>;
|
||||
getFileUrl(fileId: string): string;
|
||||
|
||||
// Config — REAL endpoints
|
||||
getConfig(): Promise<AppConfig>;
|
||||
setConfig(patch: Partial<AppConfig>): Promise<AppConfig>;
|
||||
|
||||
// Auth — REAL endpoints
|
||||
getAuth(): Promise<{
|
||||
ready: boolean;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { getKimiWebApi } from '../api';
|
|||
import { isDaemonApiError, isDaemonNetworkError } from '../api/errors';
|
||||
import type {
|
||||
AppApprovalRequest,
|
||||
AppConfig,
|
||||
AppGoal,
|
||||
AppNotice,
|
||||
AppNoticeDetail,
|
||||
|
|
@ -76,6 +77,7 @@ const UI_FONT_SIZE_DEFAULT = 15;
|
|||
const UI_FONT_SIZE_MIN = 12;
|
||||
const UI_FONT_SIZE_MAX = 20;
|
||||
const SESSION_NOT_FOUND_CODE = 40401;
|
||||
const PROMPT_NOT_FOUND_CODE = 40402;
|
||||
const ONBOARDED_STORAGE_KEY = 'kimi-web.onboarded';
|
||||
const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||
|
||||
|
|
@ -403,6 +405,8 @@ interface ExtendedState extends KimiClientState {
|
|||
hiddenWorkspaceRoots: string[];
|
||||
/** Installed external apps that can be used with "Open in app". */
|
||||
availableOpenInApps: string[];
|
||||
/** Global daemon configuration (secrets redacted). */
|
||||
config: AppConfig | null;
|
||||
}
|
||||
|
||||
const rawState: ExtendedState = reactive({
|
||||
|
|
@ -432,6 +436,7 @@ const rawState: ExtendedState = reactive({
|
|||
recentRoots: [],
|
||||
hiddenWorkspaceRoots: loadHiddenWorkspacesFromStorage(),
|
||||
availableOpenInApps: [],
|
||||
config: null,
|
||||
});
|
||||
|
||||
// Models + Providers reactive state (lazy-loaded, cached)
|
||||
|
|
@ -849,6 +854,23 @@ function connectEventsIfNeeded(): void {
|
|||
// is kept, only a marker line records the compaction).
|
||||
applyEvent(appEvent, meta.sessionId, meta.seq);
|
||||
|
||||
// The daemon's prompt.submitted event is projected as a user messageCreated
|
||||
// carrying the real prompt_id. When the HTTP submit response is lost
|
||||
// (timeout / network error) this is the fallback that lets Stop work.
|
||||
if (
|
||||
appEvent.type === 'messageCreated' &&
|
||||
appEvent.message.role === 'user' &&
|
||||
appEvent.message.promptId !== undefined
|
||||
) {
|
||||
const sid = appEvent.message.sessionId;
|
||||
if (rawState.promptIdBySession[sid] !== appEvent.message.promptId) {
|
||||
rawState.promptIdBySession = {
|
||||
...rawState.promptIdBySession,
|
||||
[sid]: appEvent.message.promptId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (appEvent.type === 'assistantDelta' && meta.sessionId === rawState.activeSessionId) {
|
||||
recordMoonDelta((appEvent.delta.text?.length ?? 0) + (appEvent.delta.thinking?.length ?? 0));
|
||||
}
|
||||
|
|
@ -2035,6 +2057,7 @@ const sessionCost = computed<number>(() => {
|
|||
const authReady = computed<boolean>(() => rawState.authReady);
|
||||
const defaultModel = computed<string | null>(() => rawState.defaultModel);
|
||||
const managedProviderStatus = computed<string | null>(() => rawState.managedProviderStatus);
|
||||
const config = computed<AppConfig | null>(() => rawState.config);
|
||||
|
||||
/** path → status map for quick badge lookup in the file tree */
|
||||
const changesByPath = computed<Record<string, string>>(() => {
|
||||
|
|
@ -2278,6 +2301,13 @@ function onSessionIdle(sid: string): void {
|
|||
// The turn finished — this session no longer has a prompt in flight.
|
||||
inFlightPromptSessions.delete(sid);
|
||||
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
|
||||
// Drop any cached prompt_id so a later skill activation (which has no
|
||||
// prompt_id) doesn't accidentally reuse this stale id for :abort.
|
||||
if (rawState.promptIdBySession[sid] !== undefined) {
|
||||
const next = { ...rawState.promptIdBySession };
|
||||
delete next[sid];
|
||||
rawState.promptIdBySession = next;
|
||||
}
|
||||
|
||||
// For the session on screen, refresh git status (edits the agent just made)
|
||||
// and runtime status (model/context usage may have changed this turn).
|
||||
|
|
@ -2383,6 +2413,22 @@ async function checkAuth(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Fetch global config from GET /api/v1/config. Defensive — never throws. */
|
||||
async function loadConfig(): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
rawState.config = await api.getConfig();
|
||||
} catch {
|
||||
// Daemon may not have this endpoint yet; leave null
|
||||
}
|
||||
}
|
||||
|
||||
/** Update global config via POST /api/v1/config. */
|
||||
async function updateConfig(patch: Partial<AppConfig>): Promise<void> {
|
||||
const api = getKimiWebApi();
|
||||
rawState.config = await api.setConfig(patch);
|
||||
}
|
||||
|
||||
// False until the very first load() settles (success OR failure). Gates the
|
||||
// global connecting-splash so a page refresh doesn't flash a half-empty app.
|
||||
const initialized = ref(false);
|
||||
|
|
@ -2402,8 +2448,9 @@ async function load(): Promise<void> {
|
|||
loadModels(),
|
||||
]);
|
||||
|
||||
// Check auth readiness (separate call — defensive)
|
||||
// Check auth readiness and global config (separate calls — defensive)
|
||||
await checkAuth();
|
||||
await loadConfig();
|
||||
|
||||
rawState.sessions = sessionsPage.items;
|
||||
|
||||
|
|
@ -3085,13 +3132,41 @@ async function abortCurrentPrompt(): Promise<void> {
|
|||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return;
|
||||
const session = rawState.sessions.find((s) => s.id === sid);
|
||||
// Prefer the authoritative prompt_id captured at submit time; fall back to the
|
||||
// projector-derived one only if we never recorded a submit (e.g. resumed turn).
|
||||
const promptId = rawState.promptIdBySession[sid] ?? session?.currentPromptId;
|
||||
if (!promptId) return;
|
||||
|
||||
// 1. Authoritative id captured at submit time.
|
||||
let promptId = rawState.promptIdBySession[sid];
|
||||
|
||||
// 2. Fallback to projector-derived id only when it is a real daemon prompt_id
|
||||
// (synthetic `pr_...` ids are rejected by the daemon).
|
||||
if (promptId === undefined) {
|
||||
const candidate = session?.currentPromptId;
|
||||
if (candidate?.startsWith('prompt_')) {
|
||||
promptId = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const api = getKimiWebApi();
|
||||
|
||||
// 3. If we have a real id, try the per-prompt abort first. On 40402 fall back
|
||||
// to session-level abort (the daemon may have restarted or the id is stale).
|
||||
if (promptId !== undefined) {
|
||||
try {
|
||||
await api.abortPrompt(sid, promptId);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (isDaemonApiError(err) && err.code === PROMPT_NOT_FOUND_CODE) {
|
||||
// Stale id — try the session-level fallback below.
|
||||
} else {
|
||||
pushOperationFailure('abortCurrentPrompt', err, { sessionId: sid });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. No real id, or the prompt id is no longer recognized: cancel whatever
|
||||
// is running in the session (including skill activations).
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
await api.abortPrompt(sid, promptId);
|
||||
await api.abortSession(sid);
|
||||
} catch (err) {
|
||||
pushOperationFailure('abortCurrentPrompt', err, { sessionId: sid });
|
||||
}
|
||||
|
|
@ -4013,6 +4088,10 @@ export function useKimiWebClient() {
|
|||
defaultModel,
|
||||
managedProviderStatus,
|
||||
|
||||
// Config state + actions
|
||||
config,
|
||||
updateConfig,
|
||||
|
||||
// Auth actions
|
||||
checkAuth,
|
||||
startOAuthLogin,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ The manager displays providers as a list of entries grouped by source. Navigatio
|
|||
Two paths when adding:
|
||||
|
||||
- **Known third-party provider**: fetches the model catalog from [models.dev](https://models.dev/), select a provider → enter an API key → select a default model
|
||||
- **Custom registry (api.json)**: paste a custom registry URL and Bearer token; the CLI automatically creates the `providers` / `models` entries
|
||||
- **Custom registry (api.json)**: paste a custom registry URL and Bearer token; the CLI automatically creates the `providers` / `models` entries. On later startup, providers from the same registry URL are refreshed together, so upstream provider additions, removals, and model metadata changes are synced.
|
||||
|
||||
::: warning
|
||||
Kimi Code OAuth managed accounts logged in via `/login` do not appear in `/provider`. Use `/login` and `/logout` to manage them.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
## Connection Methods
|
||||
|
||||
Kimi Code CLI supports two MCP server connection methods:
|
||||
Kimi Code CLI supports three MCP server connection methods:
|
||||
|
||||
- **stdio**: The CLI starts the local MCP server as a child process and communicates via standard input/output. Suitable for local command-line tools.
|
||||
- **HTTP**: The CLI connects to an already-running HTTP endpoint. Suitable for remote services or processes that need to run persistently.
|
||||
- **SSE**: The CLI connects to a legacy HTTP+SSE endpoint (Server-Sent Events, a streaming HTTP mechanism). Prefer HTTP for new MCP servers, but use `transport: "sse"` when a service still exposes only the older SSE transport.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -31,12 +32,16 @@ Structure of `mcp.json`:
|
|||
},
|
||||
"linear": {
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
},
|
||||
"legacy-events": {
|
||||
"transport": "sse",
|
||||
"url": "https://mcp.example.com/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Entries with a `command` field are stdio servers; entries with a `url` field are HTTP servers. The `transport` field generally does not need to be written manually.
|
||||
Entries with a `command` field are stdio servers; entries with a `url` field and no `transport` are HTTP servers. For legacy SSE servers, set `transport` to `"sse"` explicitly.
|
||||
|
||||
Optional fields:
|
||||
|
||||
|
|
@ -44,14 +49,15 @@ Optional fields:
|
|||
| --- | --- | --- | --- |
|
||||
| `env` | `Record<string, string>` | stdio | Environment variables injected into the child process |
|
||||
| `cwd` | `string` | stdio | Working directory for the child process |
|
||||
| `headers` | `Record<string, string>` | HTTP | Static request headers appended to every request |
|
||||
| `enabled` | `boolean` | Both | Set to `false` to disable this server |
|
||||
| `startupTimeoutMs` | `number` | Both | Connection timeout; default `30000` milliseconds |
|
||||
| `toolTimeoutMs` | `number` | Both | Timeout for a single tool call |
|
||||
| `enabledTools` | `string[]` | Both | Tool allowlist |
|
||||
| `disabledTools` | `string[]` | Both | Tool blocklist |
|
||||
| `headers` | `Record<string, string>` | HTTP, SSE | Static request headers appended to every request |
|
||||
| `bearerTokenEnvVar` | `string` | HTTP, SSE | Name of an environment variable that contains a bearer token |
|
||||
| `enabled` | `boolean` | All | Set to `false` to disable this server |
|
||||
| `startupTimeoutMs` | `number` | All | Connection timeout; default `30000` milliseconds |
|
||||
| `toolTimeoutMs` | `number` | All | Timeout for a single tool call |
|
||||
| `enabledTools` | `string[]` | All | Tool allowlist |
|
||||
| `disabledTools` | `string[]` | All | Tool blocklist |
|
||||
|
||||
HTTP servers support providing static credentials via `headers` or `bearerTokenEnvVar`. When OAuth is needed, run `/mcp-config login <server-name>` to complete browser-based authorization.
|
||||
HTTP and SSE servers support providing static credentials via `headers` or `bearerTokenEnvVar`. When OAuth is needed, run `/mcp-config login <server-name>` to complete browser-based authorization.
|
||||
|
||||
Plugins can also declare MCP servers in their manifest. Servers declared by a plugin are enabled by default and can be disabled or re-enabled in `/plugins`, then a new session must be started. See [Plugins](./plugins.md) for details.
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ Paseo's generic ACP adapter does not drive the login flow, so complete the termi
|
|||
|
||||
- **Session disconnects immediately / IDE shows "agent exited"**: usually a wrong `command` path or a missing login. Run `kimi acp` in a terminal first to verify — if it blocks waiting for stdin, the CLI itself is fine and the problem is in the IDE configuration; if it exits immediately with an error, follow the error message (most commonly you need to run `/login`).
|
||||
- **IDE shows "auth required"**: the CLI has no usable authentication token. Exit the IDE, run `kimi` in a terminal to complete login, then restart the IDE.
|
||||
- **MCP tools not visible**: check the [`kimi acp` reference](../reference/kimi-acp.md) capability table to confirm that the MCP transport type configured in your IDE is supported. The Kimi Code CLI ACP adapter currently supports `http` and `stdio` transports; `sse` and `acp` types are silently dropped and a warning is written to the log.
|
||||
- **MCP tools not visible**: check the [`kimi acp` reference](../reference/kimi-acp.md) capability table to confirm that the MCP transport type configured in your IDE is supported. The Kimi Code CLI ACP adapter currently supports `http`, `stdio`, and `sse` transports; `acp` transport MCP servers are silently dropped and a warning is written to the log.
|
||||
|
||||
## Next steps
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ kimi --session
|
|||
```
|
||||
|
||||
::: warning
|
||||
`--continue` and `--session` are mutually exclusive. `--yolo` and `--plan` cannot be combined with them either.
|
||||
`--continue` and `--session` are mutually exclusive.
|
||||
:::
|
||||
|
||||
## Switching sessions inside the TUI
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ The table below lists the capabilities declared by the current ACP adapter layer
|
|||
| `promptCapabilities.audio` | `false` | Audio prompts not yet supported |
|
||||
| `promptCapabilities.embeddedContext` | `true` | Client may send `resource`/`resource_link` embedded resource blocks; text content is injected into the prompt as `<resource uri="...">...</resource>`; blob resources are dropped with a warn |
|
||||
| `mcpCapabilities.http` | `true` | Forwards HTTP MCP services configured by the IDE |
|
||||
| `mcpCapabilities.sse` | `false` | SSE MCP services not supported; matching entries are discarded and a warn is logged |
|
||||
| `mcpCapabilities.sse` | `true` | Forwards legacy SSE MCP services configured by the IDE |
|
||||
| `loadSession` | `true` | Supports `session/load` to resume an existing session, replaying history on load |
|
||||
| `sessionCapabilities.list` | `{}` | Supports `session/list` to enumerate the current user's sessions |
|
||||
|
||||
|
|
@ -74,7 +74,8 @@ When an ACP client provides `mcpServers` in `session/new` or `session/load`, the
|
|||
|
||||
- `http` → kimi's `transport: 'http'` configuration
|
||||
- `stdio` → kimi's `transport: 'stdio'` configuration
|
||||
- `sse` / `acp` → discarded with a warn log entry
|
||||
- `sse` → kimi's `transport: 'sse'` configuration
|
||||
- `acp` → discarded with a warn log entry
|
||||
|
||||
## Next steps
|
||||
|
||||
|
|
|
|||
|
|
@ -37,12 +37,10 @@ The following combinations are rejected at startup:
|
|||
|
||||
- `--continue` and `--session` are mutually exclusive — both mean "resume a previous session"
|
||||
- `--yolo` and `--auto` are mutually exclusive — the two permission modes cannot be combined
|
||||
- `--yolo` and `--auto` cannot be used together with `--continue` or `--session` — resumed sessions inherit the approval settings of the original session
|
||||
- `--plan` cannot be used with `--continue` or `--session` — Plan mode only takes effect for new sessions
|
||||
- `--prompt` cannot be used with `--yolo`, `--auto`, or `--plan` — non-interactive mode uses `auto` permission by default
|
||||
- `--output-format` can only be used together with `--prompt`
|
||||
|
||||
To force YOLO or Plan mode when resuming a session, switch via slash commands inside the interactive session instead.
|
||||
When resuming a session, you can override its saved permission or plan mode by adding `--auto`, `--yolo`, or `--plan`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode.
|
||||
|
||||
## Common Usage
|
||||
|
||||
|
|
@ -291,7 +289,7 @@ Five actions are available:
|
|||
|
||||
#### `kimi provider add <url>`
|
||||
|
||||
Bulk-import all providers from a custom registry (`api.json`). The command fetches the registry, creates a `[providers.<id>]` and `[models.<alias>]` entry for each item, and writes `source` metadata so the TUI refreshes the model list automatically on next startup.
|
||||
Bulk-import all providers from a custom registry (`api.json`). The command fetches the registry, creates a `[providers.<id>]` and `[models.<alias>]` entry for each item, and writes `source` metadata so the TUI refreshes providers and models from the same registry URL automatically on next startup.
|
||||
|
||||
| Parameter / Option | Description |
|
||||
| --- | --- |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ outline: 2
|
|||
|
||||
This page documents the changes in each Kimi Code CLI release.
|
||||
|
||||
## 0.14.2 (2026-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them.
|
||||
- Show completed and cancelled compaction records correctly when resuming a session.
|
||||
- Drop invalid config.toml sections with a warning instead of failing to start.
|
||||
|
||||
### Polish
|
||||
|
||||
- Stream foreground Bash stdout and stderr while commands are still running.
|
||||
- Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session.
|
||||
- Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI.
|
||||
- Sync custom registry provider additions, removals, and rotated registry keys during startup refresh.
|
||||
|
||||
## 0.14.1 (2026-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服
|
|||
添加时有两条路径:
|
||||
|
||||
- **Known third-party provider**:从 [models.dev](https://models.dev/) 拉取模型目录,选供应商 → 输入 API 密钥 → 选默认模型
|
||||
- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目
|
||||
- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。
|
||||
|
||||
::: warning
|
||||
通过 `/login` 登录的 Kimi Code OAuth 托管账号不会在 `/provider` 里显示,请用 `/login` 和 `/logout` 管理。
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
## 接入方式
|
||||
|
||||
Kimi Code CLI 支持两种 MCP server 接入方式:
|
||||
Kimi Code CLI 支持三种 MCP server 接入方式:
|
||||
|
||||
- **stdio**:CLI 以子进程方式启动本地 MCP server,通过标准输入输出通信。适合本地命令行工具。
|
||||
- **HTTP**:CLI 连接一个已在运行的 HTTP 端点。适合远程服务或需要持久运行的进程。
|
||||
- **SSE**:CLI 连接旧式 HTTP+SSE 端点(Server-Sent Events,一种流式 HTTP 机制)。新 MCP server 优先使用 HTTP;只有服务仍仅暴露旧式 SSE 传输时,才设置 `transport: "sse"`。
|
||||
|
||||
## 配置
|
||||
|
||||
|
|
@ -31,12 +32,16 @@ MCP server 配置写在 `mcp.json` 中,分两层:
|
|||
},
|
||||
"linear": {
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
},
|
||||
"legacy-events": {
|
||||
"transport": "sse",
|
||||
"url": "https://mcp.example.com/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
含 `command` 字段的条目为 stdio server,含 `url` 字段的条目为 HTTP server,通常不需要手写 `transport` 字段。
|
||||
含 `command` 字段的条目为 stdio server;含 `url` 字段且未写 `transport` 的条目为 HTTP server。旧式 SSE server 需要显式把 `transport` 设为 `"sse"`。
|
||||
|
||||
可选字段:
|
||||
|
||||
|
|
@ -44,14 +49,15 @@ MCP server 配置写在 `mcp.json` 中,分两层:
|
|||
| --- | --- | --- | --- |
|
||||
| `env` | `Record<string, string>` | stdio | 注入子进程的环境变量 |
|
||||
| `cwd` | `string` | stdio | 子进程工作目录 |
|
||||
| `headers` | `Record<string, string>` | HTTP | 附加到每次请求的静态请求头 |
|
||||
| `enabled` | `boolean` | 两者 | 设为 `false` 可禁用该 server |
|
||||
| `startupTimeoutMs` | `number` | 两者 | 连接超时,默认 `30000` 毫秒 |
|
||||
| `toolTimeoutMs` | `number` | 两者 | 单次工具调用超时 |
|
||||
| `enabledTools` | `string[]` | 两者 | 工具白名单 |
|
||||
| `disabledTools` | `string[]` | 两者 | 工具黑名单 |
|
||||
| `headers` | `Record<string, string>` | HTTP、SSE | 附加到每次请求的静态请求头 |
|
||||
| `bearerTokenEnvVar` | `string` | HTTP、SSE | 存放 bearer token 的环境变量名 |
|
||||
| `enabled` | `boolean` | 全部 | 设为 `false` 可禁用该 server |
|
||||
| `startupTimeoutMs` | `number` | 全部 | 连接超时,默认 `30000` 毫秒 |
|
||||
| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时 |
|
||||
| `enabledTools` | `string[]` | 全部 | 工具白名单 |
|
||||
| `disabledTools` | `string[]` | 全部 | 工具黑名单 |
|
||||
|
||||
HTTP server 支持通过 `headers` 或 `bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login <server-name>` 完成浏览器授权。
|
||||
HTTP 与 SSE server 支持通过 `headers` 或 `bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login <server-name>` 完成浏览器授权。
|
||||
|
||||
Plugins 也可以在 manifest 中声明 MCP servers。Plugin 声明的 servers 默认启用,可以在 `/plugins` 中禁用或重新启用,然后开启新会话。详见 [Plugins](./plugins.md)。
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ Paseo 的通用 ACP 适配层不会帮你走登录流程,所以请先完成终
|
|||
|
||||
- **会话立刻被中断 / IDE 提示 "agent exited"**:通常是 `command` 路径不对或 kimi 没登录。先在终端跑一次 `kimi acp` 验证:如果阻塞等待标准输入则说明 CLI 本身没问题,问题在 IDE 配置;如果立刻报错则按报错提示处理(多数是没 `/login`)。
|
||||
- **IDE 显示 "auth required"**:表示 CLI 没有可用的鉴权令牌。退出 IDE,在终端执行 `kimi` 完成登录后再启动 IDE 即可。
|
||||
- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http`、`stdio` 两种传输方式,`sse` 与 `acp` 类型会被静默丢弃并在日志中给出 warn。
|
||||
- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http`、`stdio` 与 `sse` 三种传输方式;`acp` 传输的 MCP server 会被静默丢弃并在日志中给出 warn。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ kimi --session
|
|||
```
|
||||
|
||||
::: warning 注意
|
||||
`--continue` 与 `--session` 互斥;`--yolo` 和 `--plan` 也不能与它们同时使用。
|
||||
`--continue` 与 `--session` 互斥。
|
||||
:::
|
||||
|
||||
## 在 TUI 中切换会话
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ kimi acp
|
|||
| `promptCapabilities.audio` | `false` | 暂不支持音频 prompt |
|
||||
| `promptCapabilities.embeddedContext` | `true` | 客户端可发送 `resource`/`resource_link` 嵌入式资源块,文本内容会以 `<resource uri="...">...</resource>` 形式注入 prompt;blob 资源被丢弃并写 warn |
|
||||
| `mcpCapabilities.http` | `true` | 转发 IDE 配置的 HTTP MCP 服务 |
|
||||
| `mcpCapabilities.sse` | `false` | 不支持 SSE MCP 服务,相关条目会被丢弃并写 warn 日志 |
|
||||
| `mcpCapabilities.sse` | `true` | 转发 IDE 配置的旧式 SSE MCP 服务 |
|
||||
| `loadSession` | `true` | 支持 `session/load` 续接已有会话,加载时会同步回放历史 |
|
||||
| `sessionCapabilities.list` | `{}` | 支持 `session/list` 枚举当前用户的会话 |
|
||||
|
||||
|
|
@ -74,7 +74,8 @@ ACP 客户端在 `session/new` 或 `session/load` 中提供 `mcpServers` 时,
|
|||
|
||||
- `http` → kimi 的 `transport: 'http'` 配置
|
||||
- `stdio` → kimi 的 `transport: 'stdio'` 配置
|
||||
- `sse` / `acp` → 丢弃并写一条 warn 日志
|
||||
- `sse` → kimi 的 `transport: 'sse'` 配置
|
||||
- `acp` → 丢弃并写一条 warn 日志
|
||||
|
||||
## 下一步
|
||||
|
||||
|
|
|
|||
|
|
@ -37,12 +37,10 @@ kimi <subcommand> [options]
|
|||
|
||||
- `--continue` 与 `--session` 互斥——两者都表示"恢复历史会话"
|
||||
- `--yolo` 和 `--auto` 互斥——两种权限模式互斥
|
||||
- `--yolo` 与 `--auto` 不能与 `--continue` 或 `--session` 同时使用——恢复会话时沿用原会话的审批设置
|
||||
- `--plan` 不能与 `--continue` 或 `--session` 同时使用——Plan 模式只对新会话生效
|
||||
- `--prompt` 不能与 `--yolo`、`--auto` 或 `--plan` 同时使用——非交互模式固定使用 `auto` 权限
|
||||
- `--output-format` 只能与 `--prompt` 一起使用
|
||||
|
||||
如需在恢复会话时强制使用 YOLO 或 Plan 模式,请改在交互式会话内通过斜杠命令切换。
|
||||
恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。
|
||||
|
||||
## 典型用法
|
||||
|
||||
|
|
@ -291,7 +289,7 @@ kimi provider <action> [options]
|
|||
|
||||
#### `kimi provider add <url>`
|
||||
|
||||
从自定义 registry(`api.json`)批量导入所有供应商。命令会拉取 registry,为每个条目创建 `[providers.<id>]` 和 `[models.<alias>]`,并写入 `source` 元数据,使 TUI 下次启动时自动刷新模型列表。
|
||||
从自定义 registry(`api.json`)批量导入所有供应商。命令会拉取 registry,为每个条目创建 `[providers.<id>]` 和 `[models.<alias>]`,并写入 `source` 元数据,使 TUI 下次启动时自动刷新同一 registry 地址下的供应商和模型。
|
||||
|
||||
| 参数 / 选项 | 说明 |
|
||||
| --- | --- |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ outline: 2
|
|||
|
||||
本页记录 Kimi Code CLI 每个版本的变更内容。
|
||||
|
||||
## 0.14.2(2026-06-12)
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 iTerm2 中无休止的桌面通知问题,仅向支持进度序列的终端发送终端进度序列。
|
||||
- 在恢复会话时正确显示已完成和已取消的压缩记录。
|
||||
- 丢弃无效的 `config.toml` 配置节并发出警告,而不是启动失败。
|
||||
|
||||
### 优化
|
||||
|
||||
- 在命令仍在运行时流式输出前台 Bash 的 stdout 和 stderr。
|
||||
- 允许 `--auto`、`--yolo` 和 `--plan` 与 `--session` 或 `--continue` 组合使用,将请求的模式应用到恢复的会话。
|
||||
- 为子 Skill 名称添加父前缀,并在 TUI 中将子 Skill 暴露为点状斜杠命令。
|
||||
- 在启动刷新期间同步自定义 registry provider 的新增、移除和轮换的 registry key。
|
||||
|
||||
## 0.14.1(2026-06-12)
|
||||
|
||||
### 修复
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
*
|
||||
* - `http` → kimi `transport: 'http'` with headers projected from
|
||||
* `Array<{name, value}>` to `Record<string, string>`.
|
||||
* - `sse` → kimi `transport: 'sse'` with headers projected the same way.
|
||||
* - `stdio` → kimi `transport: 'stdio'` with env projected similarly.
|
||||
* - `sse` → dropped with a `log.warn` (PLAN D3 declares
|
||||
* `mcp_capabilities: sse=false`).
|
||||
* - `acp` → dropped with a `log.warn` (experimental ACP-transport MCP
|
||||
* is not yet supported).
|
||||
*
|
||||
|
|
@ -33,7 +32,7 @@ import { log } from '@moonshot-ai/kimi-code-sdk';
|
|||
/**
|
||||
* Convert an ACP `McpServer[]` into the kernel-native
|
||||
* `Record<string, McpServerConfig>` keyed by server name. Unsupported
|
||||
* transports (`sse`, `acp`) are warn-dropped — the caller never has to
|
||||
* transports (`acp`) are warn-dropped — the caller never has to
|
||||
* filter them out.
|
||||
*
|
||||
* Caveat (ACP schema 0.23): the `McpServer` union types stdio as a
|
||||
|
|
@ -79,7 +78,14 @@ function acpMcpServerToConfig(
|
|||
};
|
||||
return { name: server.name, config };
|
||||
}
|
||||
case 'sse':
|
||||
case 'sse': {
|
||||
const config: McpServerConfig = {
|
||||
transport: 'sse',
|
||||
url: server.url,
|
||||
headers: headersArrayToRecord(server.headers),
|
||||
};
|
||||
return { name: server.name, config };
|
||||
}
|
||||
case 'acp':
|
||||
default: {
|
||||
// Defensive: future ACP transports land here too. The cast is the
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export class AcpServer implements Agent {
|
|||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: false,
|
||||
sse: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
list: {},
|
||||
|
|
@ -255,7 +255,7 @@ export class AcpServer implements Agent {
|
|||
// similar fields are wired in Phase 8 (per PLAN D3) — Phase 3.2 keeps
|
||||
// the surface minimal. Phase 10.1 adds `mcpServers` forwarding so
|
||||
// ACP-supplied servers (Zed config, JetBrains config) are passed
|
||||
// alongside the on-disk config; unsupported transports (sse/acp)
|
||||
// alongside the on-disk config; unsupported ACP-transport servers
|
||||
// are warn-dropped inside the conversion. `mcpServers` is NOT a
|
||||
// declared field on `CreateSessionOptions` — the SDK is a
|
||||
// transparent passthrough for unknown fields (see
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*
|
||||
* 1. `initialize` returns the documented capability matrix
|
||||
* (PLAN D4: image=true, audio=false, embeddedContext=true,
|
||||
* mcp.http=true, mcp.sse=false, loadSession=true,
|
||||
* mcp.http=true, mcp.sse=true, loadSession=true,
|
||||
* sessionCapabilities.list={}).
|
||||
* 2. `session/new` returns a non-empty sessionId.
|
||||
* 3. `session/prompt` streams at least one `agent_message_chunk`
|
||||
|
|
@ -171,7 +171,7 @@ describe('AcpServer end-to-end happy path', () => {
|
|||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: false,
|
||||
sse: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
list: {},
|
||||
|
|
|
|||
|
|
@ -192,16 +192,18 @@ describe('acpMcpServersToConfigs', () => {
|
|||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warn-drops sse servers (PLAN D3 — sse capability is false)', () => {
|
||||
it('converts an SSE server with headers to a Record keyed by name', () => {
|
||||
const out = acpMcpServersToConfigs([
|
||||
sseServer('events', 'https://stream.example.com', [{ name: 'X-K', value: 'V' }]),
|
||||
]);
|
||||
expect(out).toEqual({});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'acp: dropping unsupported MCP server transport',
|
||||
expect.objectContaining({ name: 'events', type: 'sse' }),
|
||||
);
|
||||
expect(out).toEqual({
|
||||
events: {
|
||||
transport: 'sse',
|
||||
url: 'https://stream.example.com',
|
||||
headers: { 'X-K': 'V' },
|
||||
},
|
||||
});
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warn-drops acp servers (experimental, not supported)', () => {
|
||||
|
|
@ -218,10 +220,12 @@ describe('acpMcpServersToConfigs', () => {
|
|||
const out = acpMcpServersToConfigs([
|
||||
httpServer('docs', 'https://h', [{ name: 'X', value: 'v' }]),
|
||||
sseServer('events', 'https://s', [{ name: 'X', value: 'v' }]),
|
||||
acpServer('inner', 'opaque-id'),
|
||||
stdioServer('fs', '/bin/fs', [], []),
|
||||
]);
|
||||
expect(Object.keys(out)).toEqual(['docs', 'fs']);
|
||||
expect(Object.keys(out)).toEqual(['docs', 'events', 'fs']);
|
||||
expect(out['docs']).toMatchObject({ transport: 'http' });
|
||||
expect(out['events']).toMatchObject({ transport: 'sse' });
|
||||
expect(out['fs']).toMatchObject({ transport: 'stdio' });
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -261,6 +265,11 @@ describe('AcpServer session/new MCP forwarding', () => {
|
|||
url: 'https://mcp.example.com',
|
||||
headers: { Auth: 'tok' },
|
||||
},
|
||||
events: {
|
||||
transport: 'sse',
|
||||
url: 'https://s',
|
||||
headers: { X: 'v' },
|
||||
},
|
||||
});
|
||||
void _agentConn;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('AcpServer + AgentSideConnection', () => {
|
|||
expect(response.agentCapabilities?.promptCapabilities?.audio).toBe(false);
|
||||
expect(response.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true);
|
||||
expect(response.agentCapabilities?.mcpCapabilities?.http).toBe(true);
|
||||
expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(false);
|
||||
expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(true);
|
||||
expect(response.agentCapabilities?.sessionCapabilities?.list).toEqual({});
|
||||
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
# @moonshot-ai/agent-core
|
||||
|
||||
## 0.12.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#651](https://github.com/MoonshotAI/kimi-code/pull/651) [`c39c625`](https://github.com/MoonshotAI/kimi-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI.
|
||||
|
||||
- [#617](https://github.com/MoonshotAI/kimi-code/pull/617) [`911e7c3`](https://github.com/MoonshotAI/kimi-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session.
|
||||
|
||||
- [#676](https://github.com/MoonshotAI/kimi-code/pull/676) [`dcf3075`](https://github.com/MoonshotAI/kimi-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running.
|
||||
|
||||
- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start.
|
||||
|
||||
## 0.12.2
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/agent-core",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"private": true,
|
||||
"description": "The unified agent engine for Kimi",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -65,3 +65,5 @@ The goal of compaction is to keep essential code patterns, technical details, an
|
|||
|
||||
- [Detailed non tool use user message]
|
||||
- ...
|
||||
|
||||
<!-- Must output a summary matching the above template in the **final answer**, not in thinking. -->
|
||||
|
|
|
|||
|
|
@ -238,10 +238,10 @@ export class ToolManager {
|
|||
// server flipping to needs-auth means previous tokens were invalidated.
|
||||
this.unregisterMcpServer(entry.name);
|
||||
const oauthService = mcp.oauthService;
|
||||
const serverUrl = mcp.getHttpServerUrl(entry.name);
|
||||
const serverUrl = mcp.getRemoteServerUrl(entry.name);
|
||||
if (oauthService === undefined || serverUrl === undefined) {
|
||||
// Misconfiguration: a server reached needs-auth without the manager
|
||||
// owning an OAuth service or being HTTP. Treat it as a no-op so the
|
||||
// owning an OAuth service or being remote. Treat it as a no-op so the
|
||||
// existing failure error message keeps the user informed.
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,9 +169,24 @@ export const McpServerHttpConfigSchema = z.object({
|
|||
|
||||
export type McpServerHttpConfig = z.infer<typeof McpServerHttpConfigSchema>;
|
||||
|
||||
export const McpServerSseConfigSchema = z.object({
|
||||
transport: z.literal('sse'),
|
||||
url: z.string().url(),
|
||||
headers: StringRecordSchema.optional(),
|
||||
// Indirect secret reference: the bearer token is looked up from
|
||||
// `process.env[bearerTokenEnvVar]` at connection time, never committed.
|
||||
bearerTokenEnvVar: z.string().min(1).optional(),
|
||||
...McpServerCommonFields,
|
||||
});
|
||||
|
||||
export type McpServerSseConfig = z.infer<typeof McpServerSseConfigSchema>;
|
||||
|
||||
export type McpRemoteServerConfig = McpServerHttpConfig | McpServerSseConfig;
|
||||
|
||||
const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [
|
||||
McpServerStdioConfigSchema,
|
||||
McpServerHttpConfigSchema,
|
||||
McpServerSseConfigSchema,
|
||||
]);
|
||||
|
||||
export const McpServerConfigSchema = z.preprocess((raw) => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
validateConfig,
|
||||
} from '#/config/schema';
|
||||
import { atomicWrite } from '#/utils/fs';
|
||||
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
||||
import { parse as parseToml, stringify as stringifyToml, TomlError } from 'smol-toml';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Key helpers – reuse generic snake / camel conversion instead of */
|
||||
|
|
@ -70,6 +70,27 @@ export function readConfigFile(filePath: string): KimiConfig {
|
|||
return parseConfigString(text, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict read for write paths (read-merge-write must never use a salvaged
|
||||
* config as its base, or the rewrite would drop the user's broken-but-fixable
|
||||
* sections). Re-throws validation failures with a short actionable message —
|
||||
* UIs surface it directly — instead of the raw validation details.
|
||||
*/
|
||||
export function readConfigFileForUpdate(filePath: string): KimiConfig {
|
||||
try {
|
||||
return readConfigFile(filePath);
|
||||
} catch (error) {
|
||||
if (error instanceof KimiError && error.code === ErrorCodes.CONFIG_INVALID) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Cannot change settings while ${filePath} is invalid — fix it first (run \`kimi doctor\` for details).`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the config for runtime consumption: the on-disk config plus any model
|
||||
* synthesized from `KIMI_MODEL_*` environment variables. Use this everywhere a
|
||||
|
|
@ -83,6 +104,164 @@ export function loadRuntimeConfig(
|
|||
return applyEnvModelConfig(readConfigFile(filePath), env);
|
||||
}
|
||||
|
||||
export interface RuntimeConfigLoadResult {
|
||||
readonly config: KimiConfig;
|
||||
/** Problems in config.toml itself; non-empty means parts (or all) of the file were ignored. */
|
||||
readonly fileWarnings: readonly string[];
|
||||
/** Problems applying KIMI_MODEL_* env overrides; the overlay was skipped. */
|
||||
readonly envWarnings: readonly string[];
|
||||
/**
|
||||
* Set when the file is entirely unusable (unreadable, TOML syntax error, or
|
||||
* nothing salvageable) and `config` is pure defaults. Startup fails fast on
|
||||
* this — defaults-only means the user looks logged out, which is worse than
|
||||
* an actionable parse error. Mid-run reloads ignore it and keep the last
|
||||
* good config instead.
|
||||
*/
|
||||
readonly fileError?: KimiError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient variant of `loadRuntimeConfig` that never throws: schema errors
|
||||
* drop only the offending sections (whole entry for `providers`/`models`,
|
||||
* whole top-level section otherwise) and a bad KIMI_MODEL_* env overlay is
|
||||
* skipped, each reported as a warning. A file that cannot be used at all
|
||||
* additionally sets `fileError` so startup can fail fast while mid-run
|
||||
* reloads degrade. Runtime read paths use this; write paths must keep using
|
||||
* the strict readers so a broken file is never silently rewritten.
|
||||
*/
|
||||
export function loadRuntimeConfigSafe(
|
||||
filePath: string,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): RuntimeConfigLoadResult {
|
||||
const fileWarnings: string[] = [];
|
||||
let fileError: KimiError | undefined;
|
||||
let config = getDefaultConfig();
|
||||
|
||||
let text: string | undefined;
|
||||
try {
|
||||
text = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : undefined;
|
||||
} catch (error) {
|
||||
fileError = new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Failed to read ${filePath}: ${describeUnknownError(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
fileWarnings.push(`Failed to read ${filePath}: ${describeUnknownError(error)}.`);
|
||||
}
|
||||
|
||||
if (text !== undefined && text.trim().length > 0) {
|
||||
let data: Record<string, unknown> | undefined;
|
||||
try {
|
||||
data = parseToml(text) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
// Same message as the strict parser, code frame included, so failing
|
||||
// startup points straight at the offending line.
|
||||
fileError = new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Invalid TOML in ${filePath}: ${describeUnknownError(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
fileWarnings.push(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}.`);
|
||||
}
|
||||
if (data !== undefined) {
|
||||
const raw = cloneRecord(data);
|
||||
const transformed = transformTomlData(data);
|
||||
transformed['raw'] = raw;
|
||||
const salvaged = salvageConfigData(transformed);
|
||||
if (salvaged.config === undefined) {
|
||||
fileError = new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Invalid configuration in ${filePath}: ${formatConfigValidationError(salvaged.error)}`,
|
||||
{ cause: salvaged.error },
|
||||
);
|
||||
fileWarnings.push(
|
||||
`Invalid configuration in ${filePath}: ${formatConfigValidationError(salvaged.error)}.`,
|
||||
);
|
||||
} else {
|
||||
config = salvaged.config;
|
||||
if (salvaged.dropped.length > 0) {
|
||||
fileWarnings.push(
|
||||
`Ignored invalid config in ${filePath}: ${salvaged.dropped.join(', ')}. Run \`kimi doctor\` for details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const envWarnings: string[] = [];
|
||||
try {
|
||||
config = applyEnvModelConfig(config, env);
|
||||
} catch (error) {
|
||||
envWarnings.push(
|
||||
`Ignoring KIMI_MODEL_* environment overrides: ${describeUnknownError(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { config, fileWarnings, envWarnings, fileError };
|
||||
}
|
||||
|
||||
/** Sections keyed by user-chosen names where single entries can be dropped. */
|
||||
const ENTRY_KEYED_SECTIONS = new Set(['providers', 'models']);
|
||||
|
||||
interface SalvageResult {
|
||||
readonly config: KimiConfig | undefined;
|
||||
readonly dropped: readonly string[];
|
||||
readonly error?: unknown;
|
||||
}
|
||||
|
||||
function salvageConfigData(transformed: Record<string, unknown>): SalvageResult {
|
||||
const dropped: string[] = [];
|
||||
for (;;) {
|
||||
const result = KimiConfigSchema.safeParse(transformed);
|
||||
if (result.success) {
|
||||
return { config: result.data, dropped };
|
||||
}
|
||||
let deletedAny = false;
|
||||
for (const issue of result.error.issues) {
|
||||
const [section, entry] = issue.path;
|
||||
if (typeof section !== 'string' || !(section in transformed)) continue;
|
||||
const sectionValue = transformed[section];
|
||||
if (
|
||||
ENTRY_KEYED_SECTIONS.has(section) &&
|
||||
typeof entry === 'string' &&
|
||||
isPlainObject(sectionValue)
|
||||
) {
|
||||
// Issues on entry-keyed sections only ever drop that entry. An entry
|
||||
// with several issues is deleted by the first one; later issues are
|
||||
// no-ops and must not escalate to deleting the whole section.
|
||||
if (entry in sectionValue) {
|
||||
delete sectionValue[entry];
|
||||
dropped.push(`${camelToSnake(section)}.${entry}`);
|
||||
deletedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
delete transformed[section];
|
||||
dropped.push(camelToSnake(section));
|
||||
deletedAny = true;
|
||||
}
|
||||
if (!deletedAny) {
|
||||
return { config: undefined, dropped, error: result.error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function describeUnknownError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line summary of a smol-toml parse error: first message line plus the
|
||||
* line/column location, without the multi-line code-frame block.
|
||||
*/
|
||||
function describeTomlSyntaxError(error: unknown): string {
|
||||
const firstLine = describeUnknownError(error).split('\n', 1)[0] ?? '';
|
||||
if (error instanceof TomlError) {
|
||||
return `${firstLine} (line ${error.line}, column ${error.column})`;
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
export function parseConfigString(tomlText: string, filePath = 'config.toml'): KimiConfig {
|
||||
if (tomlText.trim().length === 0) {
|
||||
return getDefaultConfig();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Synthetic `mcp__<server>__authenticate` tool.
|
||||
*
|
||||
* When an MCP HTTP server lands in the `needs-auth` state — i.e. its
|
||||
* When a remote MCP server lands in the `needs-auth` state — i.e. its
|
||||
* initial connection failed with a 401 / `UnauthorizedError` and no static
|
||||
* bearer token is configured — the {@link ToolManager} swaps the real MCP
|
||||
* tool list for this single tool. Calling it:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import type { McpServerHttpConfig } from '#/config/schema';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
type UnexpectedCloseListener,
|
||||
type UnexpectedCloseReason,
|
||||
} from './client-shared';
|
||||
import { buildMcpRemoteHeaders } from './client-remote';
|
||||
import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';
|
||||
|
||||
export interface HttpMcpClientOptions {
|
||||
|
|
@ -211,21 +211,5 @@ export function buildMcpHttpHeaders(
|
|||
config: McpServerHttpConfig,
|
||||
envLookup: (name: string) => string | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
const headers: Record<string, string> = { ...config.headers };
|
||||
if (config.bearerTokenEnvVar !== undefined) {
|
||||
const token = envLookup(config.bearerTokenEnvVar);
|
||||
if (token === undefined || token.length === 0) {
|
||||
throw new KimiError(ErrorCodes.CONFIG_INVALID, `MCP HTTP bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`);
|
||||
}
|
||||
// Strip any case-variant 'authorization' static header before injecting the
|
||||
// bearer; Fetch Headers folds duplicate keys into a comma-joined value,
|
||||
// which produces an invalid auth header rather than letting the bearer win.
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'authorization') {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
return buildMcpRemoteHeaders(config, envLookup);
|
||||
}
|
||||
|
|
|
|||
32
packages/agent-core/src/mcp/client-remote.ts
Normal file
32
packages/agent-core/src/mcp/client-remote.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { McpRemoteServerConfig, McpServerConfig } from '#/config/schema';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
|
||||
export function buildMcpRemoteHeaders(
|
||||
config: McpRemoteServerConfig,
|
||||
envLookup: (name: string) => string | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
const headers: Record<string, string> = { ...config.headers };
|
||||
if (config.bearerTokenEnvVar !== undefined) {
|
||||
const token = envLookup(config.bearerTokenEnvVar);
|
||||
if (token === undefined || token.length === 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`MCP ${config.transport.toUpperCase()} bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`,
|
||||
);
|
||||
}
|
||||
// Strip any case-variant 'authorization' static header before injecting the
|
||||
// bearer; Fetch Headers folds duplicate keys into a comma-joined value,
|
||||
// which produces an invalid auth header rather than letting the bearer win.
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'authorization') {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
}
|
||||
|
||||
export function isRemoteMcpConfig(config: McpServerConfig): config is McpRemoteServerConfig {
|
||||
return config.transport === 'http' || config.transport === 'sse';
|
||||
}
|
||||
169
packages/agent-core/src/mcp/client-sse.ts
Normal file
169
packages/agent-core/src/mcp/client-sse.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { McpServerSseConfig } from '#/config/schema';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
|
||||
import {
|
||||
buildRequestOptions,
|
||||
KIMI_MCP_CLIENT_NAME,
|
||||
KIMI_MCP_CLIENT_VERSION,
|
||||
toMcpToolDefinition,
|
||||
toMcpToolResult,
|
||||
type UnexpectedCloseListener,
|
||||
type UnexpectedCloseReason,
|
||||
} from './client-shared';
|
||||
import { buildMcpRemoteHeaders } from './client-remote';
|
||||
import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';
|
||||
|
||||
export interface SseMcpClientOptions {
|
||||
readonly clientName?: string;
|
||||
readonly clientVersion?: string;
|
||||
readonly toolCallTimeoutMs?: number;
|
||||
/**
|
||||
* Reads `process.env[name]` by default. Tests can inject a deterministic
|
||||
* lookup function so they do not have to mutate global env.
|
||||
*/
|
||||
readonly envLookup?: (name: string) => string | undefined;
|
||||
/**
|
||||
* Lets tests inject a fake `fetch` for the underlying transport.
|
||||
*/
|
||||
readonly fetch?: typeof fetch;
|
||||
/**
|
||||
* OAuth client provider attached to the transport. Set only when the server
|
||||
* has no static token configuration; the connection manager wires this in
|
||||
* and surfaces `UnauthorizedError` as a `needs-auth` status.
|
||||
*/
|
||||
readonly oauthProvider?: OAuthClientProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the SDK's deprecated HTTP+SSE transport as a kosong
|
||||
* {@link MCPClient}. This exists for compatibility with older MCP servers;
|
||||
* new remote servers should prefer streamable HTTP.
|
||||
*/
|
||||
export class SseMcpClient implements MCPClient {
|
||||
private readonly client: Client;
|
||||
private readonly transport: SSEClientTransport;
|
||||
private readonly toolCallTimeoutMs?: number;
|
||||
private started = false;
|
||||
private closed = false;
|
||||
// Mirrors HttpMcpClient: handshake failures surface through connect(), while
|
||||
// post-ready terminal transport errors become unexpected closes.
|
||||
private ready = false;
|
||||
private hooksInstalled = false;
|
||||
private unexpectedCloseListener: UnexpectedCloseListener | undefined;
|
||||
private lastTransportError: Error | undefined;
|
||||
private pendingUnexpectedClose: UnexpectedCloseReason | undefined;
|
||||
private unexpectedCloseFired = false;
|
||||
|
||||
constructor(config: McpServerSseConfig, options: SseMcpClientOptions = {}) {
|
||||
const envLookup = options.envLookup ?? ((name) => process.env[name]);
|
||||
const headers = buildMcpRemoteHeaders(config, envLookup);
|
||||
|
||||
this.transport = new SSEClientTransport(new URL(config.url), {
|
||||
requestInit: headers !== undefined ? { headers } : undefined,
|
||||
fetch: options.fetch,
|
||||
authProvider: options.oauthProvider,
|
||||
});
|
||||
this.client = new Client({
|
||||
name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
|
||||
version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
|
||||
});
|
||||
this.toolCallTimeoutMs = options.toolCallTimeoutMs;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('MCP SSE client is closed');
|
||||
}
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.installTransportHooks();
|
||||
try {
|
||||
await this.client.connect(this.transport);
|
||||
} catch (error) {
|
||||
await this.closeStartedClient();
|
||||
throw error;
|
||||
}
|
||||
if (this.closed) {
|
||||
await this.closeStartedClient();
|
||||
throw new Error('MCP SSE client was closed during startup');
|
||||
}
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
await this.closeStartedClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for unsolicited terminal transport drops. Brief SSE
|
||||
* stream flaps are left to EventSource's retry loop; terminal HTTP status
|
||||
* errors after startup remove the tools from the agent.
|
||||
*/
|
||||
onUnexpectedClose(listener: UnexpectedCloseListener): void {
|
||||
this.unexpectedCloseListener = listener;
|
||||
const pending = this.pendingUnexpectedClose;
|
||||
if (pending !== undefined) {
|
||||
this.pendingUnexpectedClose = undefined;
|
||||
listener(pending);
|
||||
}
|
||||
}
|
||||
|
||||
async listTools(): Promise<MCPToolDefinition[]> {
|
||||
const result = await this.client.listTools();
|
||||
return result.tools.map(toMcpToolDefinition);
|
||||
}
|
||||
|
||||
async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MCPToolResult> {
|
||||
const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal);
|
||||
const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions);
|
||||
return toMcpToolResult(result);
|
||||
}
|
||||
|
||||
private async closeStartedClient(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
await this.client.close();
|
||||
}
|
||||
|
||||
private installTransportHooks(): void {
|
||||
if (this.hooksInstalled) return;
|
||||
this.hooksInstalled = true;
|
||||
this.client.onclose = () => {
|
||||
if (this.closed) return;
|
||||
if (!this.ready) return;
|
||||
this.fireUnexpectedClose({ error: this.lastTransportError });
|
||||
};
|
||||
this.client.onerror = (error) => {
|
||||
this.lastTransportError = error;
|
||||
if (this.closed) return;
|
||||
if (!this.ready) return;
|
||||
if (isTerminalSseTransportError(error)) {
|
||||
this.fireUnexpectedClose({ error });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private fireUnexpectedClose(reason: UnexpectedCloseReason): void {
|
||||
if (this.unexpectedCloseFired) return;
|
||||
this.unexpectedCloseFired = true;
|
||||
const listener = this.unexpectedCloseListener;
|
||||
if (listener !== undefined) {
|
||||
listener(reason);
|
||||
} else {
|
||||
this.pendingUnexpectedClose = reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isTerminalSseTransportError(error: Error): boolean {
|
||||
if (error.name === 'UnauthorizedError') return true;
|
||||
return error instanceof SseError && error.code !== undefined;
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import type { Tool } from '@moonshot-ai/kosong';
|
|||
|
||||
import { abortable } from '../utils/abort';
|
||||
import { HttpMcpClient } from './client-http';
|
||||
import { isRemoteMcpConfig } from './client-remote';
|
||||
import { SseMcpClient } from './client-sse';
|
||||
import type { UnexpectedCloseReason } from './client-shared';
|
||||
import { StdioMcpClient } from './client-stdio';
|
||||
import type { McpOAuthService } from './oauth';
|
||||
|
|
@ -15,7 +17,7 @@ export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' |
|
|||
|
||||
export interface McpServerEntry {
|
||||
readonly name: string;
|
||||
readonly transport: 'stdio' | 'http';
|
||||
readonly transport: McpServerConfig['transport'];
|
||||
readonly status: McpServerStatus;
|
||||
readonly toolCount: number;
|
||||
readonly error?: string;
|
||||
|
|
@ -36,12 +38,12 @@ export type McpStatusListener = (entry: McpServerEntry) => void;
|
|||
|
||||
const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
type RuntimeMcpClient = StdioMcpClient | HttpMcpClient;
|
||||
type RuntimeMcpClient = StdioMcpClient | HttpMcpClient | SseMcpClient;
|
||||
|
||||
export interface McpConnectionManagerOptions {
|
||||
readonly envLookup?: (name: string) => string | undefined;
|
||||
/**
|
||||
* Optional OAuth orchestrator. When provided, HTTP servers without a
|
||||
* Optional OAuth orchestrator. When provided, remote servers without a
|
||||
* static bearer token participate in the OAuth-via-synthetic-tool flow:
|
||||
* - If `oauthService.hasTokens(name, url)` is true, the provider is
|
||||
* attached to the transport so the SDK can refresh tokens on 401.
|
||||
|
|
@ -88,17 +90,25 @@ export class McpConnectionManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of an HTTP MCP server by name, or `undefined` for
|
||||
* unknown / non-HTTP / disabled entries. Used by the synthetic auth tool
|
||||
* Returns the URL of a remote MCP server by name, or `undefined` for
|
||||
* unknown / non-remote / disabled entries. Used by the synthetic auth tool
|
||||
* to drive OAuth discovery against the right base URL.
|
||||
*/
|
||||
getHttpServerUrl(name: string): string | undefined {
|
||||
getRemoteServerUrl(name: string): string | undefined {
|
||||
const entry = this.entries.get(name);
|
||||
if (entry === undefined) return undefined;
|
||||
if (entry.config.transport !== 'http') return undefined;
|
||||
if (!isRemoteMcpConfig(entry.config)) return undefined;
|
||||
return entry.config.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link getRemoteServerUrl}. Kept for in-repo callers that
|
||||
* were written before legacy SSE support shared the same OAuth path.
|
||||
*/
|
||||
getHttpServerUrl(name: string): string | undefined {
|
||||
return this.getRemoteServerUrl(name);
|
||||
}
|
||||
|
||||
onStatusChange(listener: McpStatusListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
|
|
@ -323,6 +333,13 @@ export class McpConnectionManager {
|
|||
if (config.transport === 'stdio') {
|
||||
return new StdioMcpClient(config, { toolCallTimeoutMs });
|
||||
}
|
||||
if (config.transport === 'sse') {
|
||||
return new SseMcpClient(config, {
|
||||
toolCallTimeoutMs,
|
||||
envLookup: this.options.envLookup,
|
||||
oauthProvider: this.resolveOAuthProvider(config, name),
|
||||
});
|
||||
}
|
||||
return new HttpMcpClient(config, {
|
||||
toolCallTimeoutMs,
|
||||
envLookup: this.options.envLookup,
|
||||
|
|
@ -336,7 +353,7 @@ export class McpConnectionManager {
|
|||
): ReturnType<McpOAuthService['getProvider']> | undefined {
|
||||
const oauthService = this.oauthService;
|
||||
if (oauthService === undefined) return undefined;
|
||||
if (config.transport !== 'http') return undefined;
|
||||
if (!isRemoteMcpConfig(config)) return undefined;
|
||||
if (config.bearerTokenEnvVar !== undefined) return undefined;
|
||||
// Only attach the provider once tokens have been minted; before that,
|
||||
// the transport should propagate a clean 401 so we can flip the entry
|
||||
|
|
@ -348,7 +365,7 @@ export class McpConnectionManager {
|
|||
|
||||
private shouldMarkNeedsAuth(entry: InternalEntry, error: unknown): boolean {
|
||||
if (this.oauthService === undefined) return false;
|
||||
if (entry.config.transport !== 'http') return false;
|
||||
if (!isRemoteMcpConfig(entry.config)) return false;
|
||||
if (entry.config.bearerTokenEnvVar !== undefined) return false;
|
||||
// If the user pinned a static `headers` block, treat 401s as a bad header
|
||||
// rather than hijacking them into the OAuth flow — the real error is more
|
||||
|
|
|
|||
|
|
@ -417,12 +417,12 @@ function pluginMcpServerInfo(
|
|||
name: string,
|
||||
config: McpServerConfig,
|
||||
): PluginMcpServerInfo {
|
||||
if (config.transport === 'http') {
|
||||
if (config.transport === 'http' || config.transport === 'sse') {
|
||||
return {
|
||||
name,
|
||||
runtimeName: pluginMcpRuntimeName(record.id, name),
|
||||
enabled: isMcpServerEnabled(record, name, config),
|
||||
transport: 'http',
|
||||
transport: config.transport,
|
||||
url: config.url,
|
||||
headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(),
|
||||
};
|
||||
|
|
@ -454,7 +454,7 @@ function withPluginMcpRuntime(
|
|||
pluginRoot: string,
|
||||
kimiHomeDir: string,
|
||||
): McpServerConfig {
|
||||
if (config.transport === 'http') return config;
|
||||
if (config.transport === 'http' || config.transport === 'sse') return config;
|
||||
|
||||
const env = {
|
||||
...config.env,
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ async function normalizePluginMcpServer(input: {
|
|||
readonly diagnostics: PluginDiagnostic[];
|
||||
}): Promise<McpServerConfig | undefined> {
|
||||
const { config } = input;
|
||||
if (config.transport === 'http') return config;
|
||||
if (config.transport === 'http' || config.transport === 'sse') return config;
|
||||
|
||||
let command = config.command;
|
||||
if (command.startsWith('./')) {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export interface PluginMcpServerInfo {
|
|||
readonly name: string;
|
||||
readonly runtimeName: string;
|
||||
readonly enabled: boolean;
|
||||
readonly transport: 'stdio' | 'http';
|
||||
readonly transport: 'stdio' | 'http' | 'sse';
|
||||
readonly command?: string;
|
||||
readonly args?: readonly string[];
|
||||
readonly cwd?: string;
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export interface ActivateSkillPayload {
|
|||
|
||||
export interface McpServerInfo {
|
||||
readonly name: string;
|
||||
readonly transport: 'stdio' | 'http';
|
||||
readonly transport: 'stdio' | 'http' | 'sse';
|
||||
readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
|
||||
readonly toolCount: number;
|
||||
readonly error?: string;
|
||||
|
|
@ -294,6 +294,11 @@ export interface GetKimiConfigPayload {
|
|||
readonly reload?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigDiagnostics {
|
||||
/** Warnings from the most recent config.toml load attempt; empty when the config is fully valid. */
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export type SetKimiConfigPayload = KimiConfigPatch;
|
||||
|
||||
export interface RemoveKimiProviderPayload {
|
||||
|
|
@ -358,6 +363,7 @@ export interface CoreAPI extends SessionAPIWithId {
|
|||
getCoreInfo: (payload: EmptyPayload) => CoreInfo;
|
||||
getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[];
|
||||
getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig;
|
||||
getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics;
|
||||
setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig;
|
||||
removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig;
|
||||
createSession: (payload: CreateSessionPayload) => SessionSummary;
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import { resolveThinkingLevel } from '../agent/config/thinking';
|
|||
import { Agent } from '../agent';
|
||||
import {
|
||||
ensureKimiHome,
|
||||
loadRuntimeConfig,
|
||||
loadRuntimeConfigSafe,
|
||||
mergeConfigPatch,
|
||||
readConfigFile,
|
||||
readConfigFileForUpdate,
|
||||
resolveConfigPath,
|
||||
resolveKimiHome,
|
||||
writeConfigFile,
|
||||
|
|
@ -46,6 +46,7 @@ import type {
|
|||
CancelPayload,
|
||||
CancelPlanPayload,
|
||||
CloseSessionPayload,
|
||||
ConfigDiagnostics,
|
||||
CoreAPI,
|
||||
CoreInfo,
|
||||
CreateGoalPayload,
|
||||
|
|
@ -129,6 +130,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
private kaos: Promise<Kaos> | undefined;
|
||||
private runtime: ToolServices | undefined;
|
||||
private config: KimiConfig;
|
||||
private configWarnings: readonly string[] = [];
|
||||
private readonly runtimeOverride: ToolServices | undefined;
|
||||
private readonly userHomeDir: string;
|
||||
private readonly kimiRequestHeaders: Record<string, string> | undefined;
|
||||
|
|
@ -159,7 +161,19 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
this.telemetry = options.telemetry ?? noopTelemetryClient;
|
||||
this.appVersion = options.appVersion;
|
||||
ensureKimiHome(this.homeDir);
|
||||
this.config = loadRuntimeConfig(this.configPath);
|
||||
// Schema errors degrade (invalid sections are dropped with warnings) so a
|
||||
// typo cannot prevent startup, but a file that cannot be used at all —
|
||||
// TOML syntax error, unreadable — fails fast: defaults-only would start
|
||||
// the app looking logged out, which is worse than the parse error.
|
||||
const loaded = loadRuntimeConfigSafe(this.configPath);
|
||||
if (loaded.fileError !== undefined) {
|
||||
throw loaded.fileError;
|
||||
}
|
||||
this.config = loaded.config;
|
||||
this.configWarnings = [...loaded.fileWarnings, ...loaded.envWarnings];
|
||||
if (this.configWarnings.length > 0) {
|
||||
log.warn('config load degraded', { warnings: this.configWarnings });
|
||||
}
|
||||
this.experimentalFlags = new FlagResolver(
|
||||
process.env,
|
||||
FLAG_DEFINITIONS,
|
||||
|
|
@ -447,19 +461,23 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
|
||||
async getKimiConfig(input?: GetKimiConfigPayload): Promise<KimiConfig> {
|
||||
if (input?.reload) {
|
||||
this.setRuntimeConfig(loadRuntimeConfig(this.configPath));
|
||||
this.reloadRuntimeConfig();
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async getConfigDiagnostics(_input?: EmptyPayload): Promise<ConfigDiagnostics> {
|
||||
return { warnings: this.configWarnings };
|
||||
}
|
||||
|
||||
async setKimiConfig(input: SetKimiConfigPayload): Promise<KimiConfig> {
|
||||
const config = mergeConfigPatch(readConfigFile(this.configPath), input);
|
||||
const config = mergeConfigPatch(this.readConfigForWrite(), input);
|
||||
await writeConfigFile(this.configPath, config);
|
||||
return this.setRuntimeConfig(loadRuntimeConfig(this.configPath));
|
||||
return this.reloadRuntimeConfig();
|
||||
}
|
||||
|
||||
async removeKimiProvider(input: RemoveKimiProviderPayload): Promise<KimiConfig> {
|
||||
const config = readConfigFile(this.configPath);
|
||||
const config = this.readConfigForWrite();
|
||||
delete config.providers[input.providerId];
|
||||
|
||||
let removedDefault = false;
|
||||
|
|
@ -486,7 +504,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
}
|
||||
|
||||
await writeConfigFile(this.configPath, config);
|
||||
return this.setRuntimeConfig(loadRuntimeConfig(this.configPath));
|
||||
return this.reloadRuntimeConfig();
|
||||
}
|
||||
|
||||
prompt({ sessionId, ...payload }: SessionAgentPayload<PromptPayload>) {
|
||||
|
|
@ -860,7 +878,30 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
}
|
||||
|
||||
private reloadProviderManager(): KimiConfig {
|
||||
return this.setRuntimeConfig(loadRuntimeConfig(this.configPath));
|
||||
return this.reloadRuntimeConfig();
|
||||
}
|
||||
|
||||
private readConfigForWrite(): KimiConfig {
|
||||
return readConfigFileForUpdate(this.configPath);
|
||||
}
|
||||
|
||||
private reloadRuntimeConfig(): KimiConfig {
|
||||
const loaded = loadRuntimeConfigSafe(this.configPath);
|
||||
if (loaded.fileWarnings.length > 0) {
|
||||
// Keep the last good config: adopting a salvaged config mid-run could
|
||||
// silently drop providers or models a live session depends on.
|
||||
this.configWarnings = [
|
||||
...loaded.fileWarnings,
|
||||
...loaded.envWarnings,
|
||||
'config.toml has errors; keeping the previously loaded configuration.',
|
||||
];
|
||||
log.warn('config reload degraded; keeping previous config', {
|
||||
warnings: loaded.fileWarnings,
|
||||
});
|
||||
return this.config;
|
||||
}
|
||||
this.configWarnings = loaded.envWarnings;
|
||||
return this.setRuntimeConfig(loaded.config);
|
||||
}
|
||||
|
||||
private setRuntimeConfig(config: KimiConfig): KimiConfig {
|
||||
|
|
|
|||
|
|
@ -171,10 +171,10 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
|
|||
}
|
||||
|
||||
try {
|
||||
// Sniff header first — read the first 512 bytes before deciding
|
||||
// anything about MIME.
|
||||
// For media input, the bytes are authoritative; the extension is only
|
||||
// a fallback for formats that cannot be sniffed from the header.
|
||||
const header = await this.kaos.readBytes(safePath, MEDIA_SNIFF_BYTES);
|
||||
const fileType = detectFileType(safePath, header);
|
||||
const fileType = detectFileType(safePath, header, 'media');
|
||||
|
||||
if (fileType.kind === 'text') {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface FileType {
|
|||
readonly mimeType: string;
|
||||
}
|
||||
|
||||
export type DetectFileTypeMode = 'text' | 'media';
|
||||
|
||||
export const IMAGE_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
|
|
@ -340,7 +342,11 @@ function getSuffix(path: string): string {
|
|||
return path.slice(idx).toLowerCase();
|
||||
}
|
||||
|
||||
export function detectFileType(path: string, header?: Buffer | Uint8Array): FileType {
|
||||
export function detectFileType(
|
||||
path: string,
|
||||
header?: Buffer | Uint8Array,
|
||||
type: DetectFileTypeMode = 'text',
|
||||
): FileType {
|
||||
const suffix = getSuffix(path);
|
||||
let mediaHint: FileType | null = null;
|
||||
if (suffix in TEXT_MIME_BY_SUFFIX) {
|
||||
|
|
@ -351,16 +357,15 @@ export function detectFileType(path: string, header?: Buffer | Uint8Array): File
|
|||
mediaHint = { kind: 'video', mimeType: VIDEO_MIME_BY_SUFFIX[suffix]! };
|
||||
}
|
||||
|
||||
// When a header is supplied, cross-validate against the ext hint —
|
||||
// a mismatch reports `unknown` rather than blindly trusting the
|
||||
// extension. When ext hint + sniff agree on kind, prefer the ext's
|
||||
// mimeType so the reported MIME matches what the filename advertised.
|
||||
// A disagreement on `kind` (e.g. `.mp4` with JPEG magic) still
|
||||
// collapses to `unknown`.
|
||||
// When a header is supplied, cross-validate against the ext hint by
|
||||
// default: a kind mismatch reports `unknown` rather than blindly trusting
|
||||
// either signal. Media readers treat bytes as authoritative and only fall
|
||||
// back to media suffixes when the header cannot be sniffed.
|
||||
if (header !== undefined) {
|
||||
const buf = toBuffer(header);
|
||||
const sniffed = sniffMediaFromMagic(buf);
|
||||
if (sniffed) {
|
||||
if (type === 'media') return sniffed;
|
||||
if (mediaHint) {
|
||||
if (sniffed.kind !== mediaHint.kind) {
|
||||
return { kind: 'unknown', mimeType: '' };
|
||||
|
|
@ -369,6 +374,13 @@ export function detectFileType(path: string, header?: Buffer | Uint8Array): File
|
|||
}
|
||||
return sniffed;
|
||||
}
|
||||
if (
|
||||
type === 'media' &&
|
||||
mediaHint !== null &&
|
||||
mediaHint.kind !== 'text'
|
||||
) {
|
||||
return mediaHint;
|
||||
}
|
||||
if (buf.includes(0x00)) {
|
||||
return { kind: 'unknown', mimeType: '' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ import { ErrorCodes, KimiError } from '../../src/errors';
|
|||
import {
|
||||
KimiConfigSchema,
|
||||
ensureConfigFile,
|
||||
loadRuntimeConfig,
|
||||
loadRuntimeConfigSafe,
|
||||
mergeConfigPatch,
|
||||
parseConfigString,
|
||||
parseBooleanEnv,
|
||||
readConfigFile,
|
||||
readConfigFileForUpdate,
|
||||
resolveConfigPath,
|
||||
resolveConfigValue,
|
||||
resolveKimiHome,
|
||||
|
|
@ -232,29 +235,29 @@ source = { kind = "apiJson", url = "https://registry.example/api.json", apiKey =
|
|||
const toml = `
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "https://coding.deva.msh.team/coding/v1"
|
||||
base_url = "https://api.dev.example.test/coding/v1"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" }
|
||||
|
||||
[services.moonshot_search]
|
||||
base_url = "https://coding.deva.msh.team/coding/v1/search"
|
||||
base_url = "https://api.dev.example.test/coding/v1/search"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" }
|
||||
`;
|
||||
const config = parseConfigString(toml, configPath);
|
||||
expect(config.providers['managed:kimi-code']?.oauth).toEqual({
|
||||
storage: 'file',
|
||||
key: 'oauth/kimi-code-env-1234',
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
});
|
||||
expect(config.services?.moonshotSearch?.oauth?.oauthHost).toBe('https://auth.dev.kimi.team');
|
||||
expect(config.services?.moonshotSearch?.oauth?.oauthHost).toBe('https://auth.dev.example.test');
|
||||
|
||||
await writeConfigFile(configPath, config);
|
||||
const text = await readFile(configPath, 'utf-8');
|
||||
expect(text).toContain('oauth_host = "https://auth.dev.kimi.team"');
|
||||
expect(text).toContain('oauth_host = "https://auth.dev.example.test"');
|
||||
const roundTripped = parseConfigString(text, configPath);
|
||||
expect(roundTripped.providers['managed:kimi-code']?.oauth?.oauthHost).toBe(
|
||||
'https://auth.dev.kimi.team',
|
||||
'https://auth.dev.example.test',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -661,3 +664,209 @@ describe('config value env override helpers', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadRuntimeConfigSafe', () => {
|
||||
const VALID_TOML = `
|
||||
default_model = "k2"
|
||||
|
||||
[providers.kimi]
|
||||
type = "kimi"
|
||||
api_key = "sk-good"
|
||||
|
||||
[models.k2]
|
||||
provider = "kimi"
|
||||
model = "kimi-for-coding"
|
||||
max_context_size = 128000
|
||||
`;
|
||||
|
||||
async function writeTempConfig(text: string): Promise<string> {
|
||||
const configPath = join(makeTempDir(), 'config.toml');
|
||||
await writeFile(configPath, text, 'utf-8');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
it('loads a valid file with no warnings, matching the strict loader', async () => {
|
||||
const configPath = await writeTempConfig(VALID_TOML);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.fileWarnings).toEqual([]);
|
||||
expect(result.envWarnings).toEqual([]);
|
||||
expect(result.config).toEqual(loadRuntimeConfig(configPath, {}));
|
||||
});
|
||||
|
||||
it('returns defaults with no warnings when the file is missing', () => {
|
||||
const configPath = join(makeTempDir(), 'config.toml');
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.fileWarnings).toEqual([]);
|
||||
expect(result.envWarnings).toEqual([]);
|
||||
expect(result.config.providers).toEqual({});
|
||||
});
|
||||
|
||||
it('reports a fileError and defaults on invalid TOML syntax', async () => {
|
||||
const configPath = await writeTempConfig('[[[');
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.providers).toEqual({});
|
||||
// The whole file is unusable: callers decide to fail startup (fileError)
|
||||
// or keep the last good config mid-run (fileWarnings).
|
||||
expect(result.fileError).toBeInstanceOf(KimiError);
|
||||
expect(result.fileError?.code).toBe(ErrorCodes.CONFIG_INVALID);
|
||||
expect(result.fileError?.message).toContain('Invalid TOML');
|
||||
expect(result.fileError?.message).toContain(configPath);
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
const warning = result.fileWarnings[0]!;
|
||||
expect(warning).toContain('Invalid TOML');
|
||||
// Single-line summary with the error location, not the multi-line code frame.
|
||||
expect(warning).not.toContain('\n');
|
||||
expect(warning).toContain('line 1');
|
||||
});
|
||||
|
||||
it('does not set fileError when only sections are dropped', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "nope"
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.fileError).toBeUndefined();
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops only an invalid section on schema errors and keeps the rest', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "not-a-number"
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.loopControl).toBeUndefined();
|
||||
expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi', apiKey: 'sk-good' });
|
||||
expect(result.config.models?.['k2']).toMatchObject({ maxContextSize: 128000 });
|
||||
expect(result.config.defaultModel).toBe('k2');
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
expect(result.fileWarnings[0]).toContain('loop_control');
|
||||
// The original file content stays visible in raw so nothing is lost.
|
||||
expect(result.config.raw?.['loop_control']).toEqual({ max_steps_per_turn: 'not-a-number' });
|
||||
});
|
||||
|
||||
it('drops only the broken provider entry, keeping other providers', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[providers.bad]
|
||||
type = "not-a-provider"
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.providers['bad']).toBeUndefined();
|
||||
expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi' });
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
expect(result.fileWarnings[0]).toContain('providers.bad');
|
||||
});
|
||||
|
||||
it('keeps other providers when one entry has multiple validation issues', async () => {
|
||||
// Two issues on the same entry: the second must not escalate to
|
||||
// deleting the whole providers section after the first dropped the entry.
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[providers.bad]
|
||||
type = "not-a-provider"
|
||||
api_key = 123
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.providers['bad']).toBeUndefined();
|
||||
expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi' });
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
expect(result.fileWarnings[0]).toContain('providers.bad');
|
||||
expect(result.fileWarnings[0]).not.toMatch(/providers[,.]? /);
|
||||
});
|
||||
|
||||
it('drops only the broken model entry', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[models.broken]
|
||||
provider = "kimi"
|
||||
model = "x"
|
||||
max_context_size = -5
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.models?.['broken']).toBeUndefined();
|
||||
expect(result.config.models?.['k2']).toBeDefined();
|
||||
expect(result.fileWarnings[0]).toContain('models.broken');
|
||||
});
|
||||
|
||||
it('drops the whole hooks list when one hook is invalid', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[[hooks]]
|
||||
event = "NotARealEvent"
|
||||
command = "echo hi"
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.hooks).toBeUndefined();
|
||||
expect(result.config.providers['kimi']).toBeDefined();
|
||||
expect(result.fileWarnings[0]).toContain('hooks');
|
||||
});
|
||||
|
||||
it('reports every dropped section in the warning', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "nope"
|
||||
|
||||
[background]
|
||||
max_running_tasks = 0
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.loopControl).toBeUndefined();
|
||||
expect(result.config.background).toBeUndefined();
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
expect(result.fileWarnings[0]).toContain('loop_control');
|
||||
expect(result.fileWarnings[0]).toContain('background');
|
||||
});
|
||||
|
||||
it('applies KIMI_MODEL_* env overrides on top of a salvaged config', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "nope"
|
||||
`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {
|
||||
KIMI_MODEL_NAME: 'env-model',
|
||||
KIMI_MODEL_API_KEY: 'sk-env',
|
||||
KIMI_MODEL_MAX_CONTEXT_SIZE: '262144',
|
||||
});
|
||||
expect(result.envWarnings).toEqual([]);
|
||||
expect(result.config.models?.['__kimi_env_model__']).toBeDefined();
|
||||
expect(result.config.providers['kimi']).toBeDefined();
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips KIMI_MODEL_* overrides with an env warning instead of throwing', async () => {
|
||||
const configPath = await writeTempConfig(VALID_TOML);
|
||||
const result = loadRuntimeConfigSafe(configPath, {
|
||||
KIMI_MODEL_NAME: 'env-model',
|
||||
});
|
||||
expect(result.fileWarnings).toEqual([]);
|
||||
expect(result.envWarnings).toHaveLength(1);
|
||||
expect(result.envWarnings[0]).toContain('KIMI_MODEL');
|
||||
expect(result.config).toEqual(readConfigFile(configPath));
|
||||
});
|
||||
|
||||
it('readConfigFileForUpdate rewraps validation errors with an actionable message', async () => {
|
||||
const configPath = await writeTempConfig(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "nope"
|
||||
`);
|
||||
try {
|
||||
readConfigFileForUpdate(configPath);
|
||||
throw new Error('expected readConfigFileForUpdate to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(KimiError);
|
||||
expect((error as KimiError).message).toContain('fix it first');
|
||||
expect((error as KimiError).message).toContain('kimi doctor');
|
||||
expect((error as KimiError).message).not.toContain('invalid_type');
|
||||
}
|
||||
|
||||
const goodPath = await writeTempConfig(VALID_TOML);
|
||||
expect(readConfigFileForUpdate(goodPath)).toEqual(readConfigFile(goodPath));
|
||||
});
|
||||
|
||||
it('drops invalid top-level scalars and keeps the rest', async () => {
|
||||
const configPath = await writeTempConfig(`default_thinking = "not-a-boolean"
|
||||
${VALID_TOML}`);
|
||||
const result = loadRuntimeConfigSafe(configPath, {});
|
||||
expect(result.config.defaultThinking).toBeUndefined();
|
||||
expect(result.config.providers['kimi']).toBeDefined();
|
||||
expect(result.fileWarnings).toHaveLength(1);
|
||||
expect(result.fileWarnings[0]).toContain('default_thinking');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
142
packages/agent-core/test/mcp/client-sse.test.ts
Normal file
142
packages/agent-core/test/mcp/client-sse.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { SseError } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SseMcpClient, isTerminalSseTransportError } from '../../src/mcp/client-sse';
|
||||
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function startInProcessSseMcpServer(opts?: {
|
||||
authToken?: string;
|
||||
}): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
const httpServer: Server = createServer((req, res) => {
|
||||
if (opts?.authToken !== undefined) {
|
||||
const auth = req.headers['authorization'];
|
||||
if (auth !== `Bearer ${opts.authToken}`) {
|
||||
res.writeHead(401, { 'content-type': 'text/plain' });
|
||||
res.end('unauthorized');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
if (req.method === 'GET' && url.pathname === '/mcp') {
|
||||
const mcpServer = new McpServer({ name: 'mock-sse', version: '0.0.1' });
|
||||
mcpServer.registerTool(
|
||||
'echo',
|
||||
{ description: 'Echoes text', inputSchema: { text: z.string() } },
|
||||
({ text }) => ({ content: [{ type: 'text', text }] }),
|
||||
);
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
transports.set(transport.sessionId, transport);
|
||||
transport.onclose = () => {
|
||||
transports.delete(transport.sessionId);
|
||||
};
|
||||
void mcpServer.connect(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/messages') {
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
const transport = sessionId === null ? undefined : transports.get(sessionId);
|
||||
if (transport === undefined) {
|
||||
res.writeHead(404).end('Session not found');
|
||||
return;
|
||||
}
|
||||
void transport.handlePostMessage(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end('not found');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const port = (httpServer.address() as AddressInfo).port;
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
async close() {
|
||||
await Promise.all([...transports.values()].map((transport) => transport.close()));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SseMcpClient', () => {
|
||||
it('connects, lists tools, and round-trips a call over real SSE', async () => {
|
||||
const server = await startInProcessSseMcpServer();
|
||||
cleanups.push(server.close);
|
||||
|
||||
const client = new SseMcpClient({ transport: 'sse', url: server.url });
|
||||
try {
|
||||
await client.connect();
|
||||
const tools = await client.listTools();
|
||||
expect(tools.map((t) => t.name)).toEqual(['echo']);
|
||||
|
||||
const result = await client.callTool('echo', { text: 'hello sse' });
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'hello sse' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it('forwards bearer token from envLookup on the SSE and POST requests', async () => {
|
||||
const server = await startInProcessSseMcpServer({ authToken: 'good-token' });
|
||||
cleanups.push(server.close);
|
||||
|
||||
const client = new SseMcpClient(
|
||||
{
|
||||
transport: 'sse',
|
||||
url: server.url,
|
||||
bearerTokenEnvVar: 'EXAMPLE_TOKEN',
|
||||
},
|
||||
{ envLookup: (name) => (name === 'EXAMPLE_TOKEN' ? 'good-token' : undefined) },
|
||||
);
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.callTool('echo', { text: 'with auth' });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'with auth' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it('classifies terminal SSE transport errors without treating reconnect flaps as terminal', () => {
|
||||
const unauthorized = new Error('Unauthorized');
|
||||
unauthorized.name = 'UnauthorizedError';
|
||||
expect(isTerminalSseTransportError(unauthorized)).toBe(true);
|
||||
expect(
|
||||
isTerminalSseTransportError(
|
||||
new SseError(
|
||||
204,
|
||||
'Server sent HTTP 204',
|
||||
{} as ConstructorParameters<typeof SseError>[2],
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -194,7 +194,7 @@ describe('loadMcpServers', () => {
|
|||
const home = makeTempDir();
|
||||
const cwd = makeTempDir();
|
||||
await writeJson(join(home, 'mcp.json'), {
|
||||
mcpServers: { bad: { transport: 'sse', url: 'https://x' } },
|
||||
mcpServers: { bad: { transport: 'websocket', url: 'https://x' } },
|
||||
});
|
||||
await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({
|
||||
code: ErrorCodes.CONFIG_INVALID,
|
||||
|
|
@ -243,6 +243,28 @@ describe('loadMcpServers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('loads explicit SSE server config', async () => {
|
||||
const home = makeTempDir();
|
||||
const cwd = makeTempDir();
|
||||
await writeJson(join(home, 'mcp.json'), {
|
||||
mcpServers: {
|
||||
legacy: {
|
||||
transport: 'sse',
|
||||
url: 'https://mcp.example.com/sse',
|
||||
headers: { 'X-Tenant': 'kimi' },
|
||||
bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
|
||||
},
|
||||
},
|
||||
});
|
||||
const servers = await loadMcpServers({ cwd, homeDir: home });
|
||||
expect(servers['legacy']).toEqual({
|
||||
transport: 'sse',
|
||||
url: 'https://mcp.example.com/sse',
|
||||
headers: { 'X-Tenant': 'kimi' },
|
||||
bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('honors KIMI_CODE_HOME env var when homeDir is not supplied', async () => {
|
||||
const home = makeTempDir();
|
||||
const cwd = makeTempDir();
|
||||
|
|
|
|||
|
|
@ -117,6 +117,25 @@ describe('McpConnectionManager', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('marks SSE servers failed when configured bearer token env var is missing', async () => {
|
||||
const cm = new McpConnectionManager({ envLookup: () => undefined });
|
||||
try {
|
||||
await cm.connectAll({
|
||||
legacy: {
|
||||
transport: 'sse',
|
||||
url: 'https://example.invalid/sse',
|
||||
bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
|
||||
},
|
||||
});
|
||||
const entry = cm.get('legacy');
|
||||
expect(entry?.transport).toBe('sse');
|
||||
expect(entry?.status).toBe('failed');
|
||||
expect(entry?.error).toContain('"LEGACY_MCP_TOKEN" is not set or is empty');
|
||||
} finally {
|
||||
await cm.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks disabled servers without attempting a connection', async () => {
|
||||
const cm = new McpConnectionManager();
|
||||
try {
|
||||
|
|
@ -377,6 +396,47 @@ describe('McpConnectionManager', () => {
|
|||
}
|
||||
}, 15000);
|
||||
|
||||
it('flips SSE servers into needs-auth when the server returns 401 and no static token is set', async () => {
|
||||
const server: HttpServer = createHttpServer((_req, res) => {
|
||||
res.writeHead(401, {
|
||||
'content-type': 'text/plain',
|
||||
'www-authenticate': 'Bearer realm="mcp", resource_metadata="http://x/.well-known/oauth-protected-resource"',
|
||||
});
|
||||
res.end('unauthorized');
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = (server.address() as HttpAddress).port;
|
||||
const storeDir = await mkdtemp(join(tmpdir(), 'kimi-mcp-oauth-sse-cm-'));
|
||||
const oauthService = new McpOAuthService({ store: new JsonFileStore(storeDir) });
|
||||
const cm = new McpConnectionManager({ oauthService });
|
||||
try {
|
||||
await cm.connectAll({
|
||||
legacy: {
|
||||
transport: 'sse',
|
||||
url: `http://127.0.0.1:${port}/sse`,
|
||||
startupTimeoutMs: 5_000,
|
||||
},
|
||||
});
|
||||
const entry = cm.get('legacy');
|
||||
expect(entry?.transport).toBe('sse');
|
||||
expect(entry?.status).toBe('needs-auth');
|
||||
expect(entry?.error).toContain('run /mcp-config login legacy');
|
||||
expect(entry?.toolCount).toBe(0);
|
||||
} finally {
|
||||
await cm.shutdown();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await rm(storeDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it('flips cached OAuth credentials that require reauth into needs-auth', async () => {
|
||||
const server: HttpServer = createHttpServer((req, res) => {
|
||||
if (req.url === '/token') {
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ describe('PluginManager', () => {
|
|||
mcpServers: {
|
||||
finance: { command: 'finance-mcp' },
|
||||
docs: { url: 'https://example.com/mcp' },
|
||||
events: { transport: 'sse', url: 'https://example.com/sse' },
|
||||
},
|
||||
});
|
||||
const manager = new PluginManager({ kimiHomeDir: home });
|
||||
|
|
@ -356,10 +357,18 @@ describe('PluginManager', () => {
|
|||
command: 'finance-mcp',
|
||||
}),
|
||||
);
|
||||
expect(manager.info('demo')?.mcpServers).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'events',
|
||||
runtimeName: 'plugin-demo:events',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
}),
|
||||
);
|
||||
expect(manager.summaries()[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
mcpServerCount: 2,
|
||||
enabledMcpServerCount: 2,
|
||||
mcpServerCount: 3,
|
||||
enabledMcpServerCount: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -373,6 +382,10 @@ describe('PluginManager', () => {
|
|||
'plugin-demo:docs': expect.objectContaining({
|
||||
url: 'https://example.com/mcp',
|
||||
}),
|
||||
'plugin-demo:events': expect.objectContaining({
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -381,8 +394,8 @@ describe('PluginManager', () => {
|
|||
expect(manager.enabledMcpServers()).not.toHaveProperty('plugin-demo:finance');
|
||||
expect(manager.summaries()[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
mcpServerCount: 2,
|
||||
enabledMcpServerCount: 1,
|
||||
mcpServerCount: 3,
|
||||
enabledMcpServerCount: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,11 @@ describe('parseManifest', () => {
|
|||
url: 'https://example.com/mcp',
|
||||
headers: { 'X-Test': '1' },
|
||||
},
|
||||
events: {
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
headers: { 'X-Events': '1' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
|
@ -324,6 +329,11 @@ describe('parseManifest', () => {
|
|||
url: 'https://example.com/mcp',
|
||||
headers: { 'X-Test': '1' },
|
||||
});
|
||||
expect(result.manifest?.mcpServers?.['events']).toEqual({
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
headers: { 'X-Events': '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and skips invalid plugin mcpServers entries', async () => {
|
||||
|
|
|
|||
110
packages/agent-core/test/rpc/config-rpc.test.ts
Normal file
110
packages/agent-core/test/rpc/config-rpc.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { KimiCore } from '../../src/rpc/core-impl';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function makeHome(configToml?: string): Promise<string> {
|
||||
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
||||
tempDirs.push(home);
|
||||
if (configToml !== undefined) {
|
||||
await writeFile(path.join(home, 'config.toml'), configToml, 'utf-8');
|
||||
}
|
||||
return home;
|
||||
}
|
||||
|
||||
function makeCore(home: string): KimiCore {
|
||||
return new KimiCore(async () => ({}) as never, { homeDir: home });
|
||||
}
|
||||
|
||||
const VALID_TOML = `
|
||||
default_model = "k2"
|
||||
|
||||
[providers.kimi]
|
||||
type = "kimi"
|
||||
api_key = "sk-good"
|
||||
|
||||
[models.k2]
|
||||
provider = "kimi"
|
||||
model = "kimi-for-coding"
|
||||
max_context_size = 128000
|
||||
`;
|
||||
|
||||
describe('KimiCore degraded config loading', () => {
|
||||
it('reports no diagnostics for a valid config', async () => {
|
||||
const core = makeCore(await makeHome(VALID_TOML));
|
||||
const config = await core.getKimiConfig({});
|
||||
expect(config.providers['kimi']).toBeDefined();
|
||||
await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] });
|
||||
});
|
||||
|
||||
it('refuses to start when the TOML cannot be parsed at all', async () => {
|
||||
const home = await makeHome('[[[');
|
||||
// A fully unusable file means defaults-only (looks logged out), which is
|
||||
// worse than failing fast with the parse location.
|
||||
expect(() => makeCore(home)).toThrow(/Invalid TOML/);
|
||||
});
|
||||
|
||||
it('starts with a partially invalid config, keeping the valid sections', async () => {
|
||||
const core = makeCore(
|
||||
await makeHome(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "nope"
|
||||
`),
|
||||
);
|
||||
const config = await core.getKimiConfig({});
|
||||
expect(config.providers['kimi']).toBeDefined();
|
||||
expect(config.loopControl).toBeUndefined();
|
||||
const diagnostics = await core.getConfigDiagnostics({});
|
||||
expect(diagnostics.warnings).toHaveLength(1);
|
||||
expect(diagnostics.warnings[0]).toContain('loop_control');
|
||||
});
|
||||
|
||||
it('rejects config writes with an actionable error while the file is invalid', async () => {
|
||||
const home = await makeHome(`${VALID_TOML}
|
||||
[loop_control]
|
||||
max_steps_per_turn = "nope"
|
||||
`);
|
||||
const core = makeCore(home);
|
||||
const before = await readFile(path.join(home, 'config.toml'), 'utf-8');
|
||||
|
||||
// Write paths stay strict: changing settings on top of a broken file
|
||||
// must fail with a short, actionable message — not raw validation JSON —
|
||||
// and must leave the file untouched.
|
||||
const write = core.setKimiConfig({ defaultThinking: true });
|
||||
await expect(write).rejects.toThrow(/fix it first/i);
|
||||
await expect(write).rejects.toThrow(/kimi doctor/);
|
||||
await expect(write).rejects.not.toThrow(/invalid_type/);
|
||||
|
||||
const after = await readFile(path.join(home, 'config.toml'), 'utf-8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('keeps the last good config when the file breaks mid-run', async () => {
|
||||
const home = await makeHome(VALID_TOML);
|
||||
const core = makeCore(home);
|
||||
const configPath = path.join(home, 'config.toml');
|
||||
|
||||
await writeFile(configPath, '[[[', 'utf-8');
|
||||
const kept = await core.getKimiConfig({ reload: true });
|
||||
expect(kept.providers['kimi']).toBeDefined();
|
||||
const degraded = await core.getConfigDiagnostics({});
|
||||
expect(degraded.warnings.some((w) => w.includes('Invalid TOML'))).toBe(true);
|
||||
expect(degraded.warnings.some((w) => w.includes('previous'))).toBe(true);
|
||||
|
||||
await writeFile(configPath, `default_thinking = true\n${VALID_TOML}`, 'utf-8');
|
||||
const adopted = await core.getKimiConfig({ reload: true });
|
||||
expect(adopted.defaultThinking).toBe(true);
|
||||
await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -99,9 +99,9 @@ describe('KimiCore plugin RPCs', () => {
|
|||
`
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "https://coding.deva.msh.team/coding/v1"
|
||||
base_url = "https://api.dev.example.test/coding/v1"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" }
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
|
|
@ -130,8 +130,8 @@ oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "http
|
|||
|
||||
expect(mcpConfig.servers['plugin-kimi-datasource:data']?.env).toEqual(
|
||||
expect.objectContaining({
|
||||
KIMI_CODE_BASE_URL: 'https://coding.deva.msh.team/coding/v1',
|
||||
KIMI_CODE_OAUTH_HOST: 'https://auth.dev.kimi.team',
|
||||
KIMI_CODE_BASE_URL: 'https://api.dev.example.test/coding/v1',
|
||||
KIMI_CODE_OAUTH_HOST: 'https://auth.dev.example.test',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -193,6 +193,25 @@ describe('detectFileType', () => {
|
|||
expect(result.kind).toBe('unknown');
|
||||
});
|
||||
|
||||
it('can prefer the sniffed media header over the extension in media mode', () => {
|
||||
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
expect(detectFileType('mismatch.mp4', pngHeader, 'media')).toEqual<FileType>({
|
||||
kind: 'image',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a media extension in media mode when sniffing is inconclusive', () => {
|
||||
const mpegProgramStreamHeader = Buffer.from([0x00, 0x00, 0x01, 0xba, 0x21, 0x00]);
|
||||
expect(detectFileType('clip.mpg', mpegProgramStreamHeader, 'media')).toEqual<
|
||||
FileType
|
||||
>({
|
||||
kind: 'video',
|
||||
mimeType: 'video/mpeg',
|
||||
});
|
||||
expect(detectFileType('clip.mpg', mpegProgramStreamHeader).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
it('extension in NON_TEXT_SUFFIXES → unknown', () => {
|
||||
// A `.zip` file with no header and no image/video hint must not
|
||||
// be treated as text.
|
||||
|
|
|
|||
|
|
@ -318,6 +318,27 @@ describe('ReadMediaFileTool', () => {
|
|||
expect(parts[3]).toEqual({ type: 'text', text: '</video>' });
|
||||
});
|
||||
|
||||
it('falls back to a media extension when the header cannot be sniffed', async () => {
|
||||
const data = Buffer.from([0x00, 0x00, 0x01, 0xba, 0x21, 0x00, 0x01, 0x00]);
|
||||
const tool = makeReadMediaTool({
|
||||
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
|
||||
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
|
||||
});
|
||||
|
||||
const result = await executeTool(tool, {
|
||||
turnId: 't1',
|
||||
toolCallId: 'c_mpg',
|
||||
args: { path: '/workspace/sample.mpg' },
|
||||
signal,
|
||||
});
|
||||
|
||||
const parts = outputParts(result);
|
||||
expect(parts[1]).toEqual({ type: 'text', text: '<video path="/workspace/sample.mpg">' });
|
||||
expect((parts[2] as { videoUrl: { url: string } }).videoUrl.url).toBe(
|
||||
`data:video/mpeg;base64,${data.toString('base64')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses injected videoUploader for video files when available', async () => {
|
||||
const videoUploader = vi.fn().mockResolvedValue({
|
||||
type: 'video_url',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# @moonshot-ai/kimi-code-sdk
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start.
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-code-sdk",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"private": true,
|
||||
"description": "TypeScript SDK for the Kimi Code Agent",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { readConfigFile, writeConfigFile, type KimiConfig, type OAuthRef } from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
loadRuntimeConfigSafe,
|
||||
readConfigFile,
|
||||
readConfigFileForUpdate,
|
||||
writeConfigFile,
|
||||
type KimiConfig,
|
||||
type OAuthRef,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
applyManagedKimiCodeConfig,
|
||||
applyManagedKimiCodeLogoutConfig,
|
||||
|
|
@ -59,7 +66,9 @@ export class KimiAuthFacade {
|
|||
onRefresh: options.onRefresh,
|
||||
configAdapter: {
|
||||
configPath: options.configPath,
|
||||
read: () => readConfigFile(options.configPath) as SDKManagedConfig,
|
||||
// Write-path base read: strict (a salvaged base would drop the user's
|
||||
// broken-but-fixable sections on rewrite) with an actionable message.
|
||||
read: () => readConfigFileForUpdate(options.configPath) as SDKManagedConfig,
|
||||
write: async (config) => {
|
||||
await writeConfigFile(options.configPath, config);
|
||||
},
|
||||
|
|
@ -169,7 +178,10 @@ export class KimiAuthFacade {
|
|||
readonly baseUrl?: string | undefined;
|
||||
} {
|
||||
const name = providerName ?? KIMI_CODE_PROVIDER_NAME;
|
||||
const config = readConfigFile(this.options.configPath);
|
||||
// Read path: token/status resolution must work off a degraded config
|
||||
// instead of failing the session when an unrelated section is broken.
|
||||
// Write paths (the toolkit's configAdapter.read) stay strict.
|
||||
const config = loadRuntimeConfigSafe(this.options.configPath).config;
|
||||
const provider = config.providers[name];
|
||||
return {
|
||||
oauthRef: provider?.oauth,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Session } from '#/session';
|
|||
import type { KimiAuthFacade } from '#/auth';
|
||||
import type { SDKRpcClientBase } from '#/rpc';
|
||||
import type {
|
||||
ConfigDiagnostics,
|
||||
CreateSessionOptions,
|
||||
ExportSessionInput,
|
||||
ExportSessionResult,
|
||||
|
|
@ -215,6 +216,11 @@ export class KimiHarness {
|
|||
return this.rpc.getConfig(options);
|
||||
}
|
||||
|
||||
/** Warnings from the most recent config.toml load; empty when the config is fully valid. */
|
||||
async getConfigDiagnostics(): Promise<ConfigDiagnostics> {
|
||||
return this.rpc.getConfigDiagnostics();
|
||||
}
|
||||
|
||||
async getExperimentalFeatures(): Promise<readonly ExperimentalFeatureState[]> {
|
||||
return this.rpc.getExperimentalFeatures();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type { Kaos } from '@moonshot-ai/kaos';
|
|||
import type { ApprovalHandler, QuestionHandler } from '#/events';
|
||||
import type {
|
||||
BackgroundTaskInfo,
|
||||
ConfigDiagnostics,
|
||||
CreateSessionOptions,
|
||||
ExportSessionInput,
|
||||
ExportSessionResult,
|
||||
|
|
@ -197,6 +198,11 @@ export abstract class SDKRpcClientBase {
|
|||
return rpc.getKimiConfig(input ?? {});
|
||||
}
|
||||
|
||||
async getConfigDiagnostics(): Promise<ConfigDiagnostics> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.getConfigDiagnostics({});
|
||||
}
|
||||
|
||||
async getExperimentalFeatures(): Promise<readonly ExperimentalFeatureState[]> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.getExperimentalFeatures({});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type {
|
|||
BackgroundConfig,
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskStatus,
|
||||
ConfigDiagnostics,
|
||||
ContextMessage,
|
||||
ExperimentalFeatureState,
|
||||
ExperimentalFlagMap,
|
||||
|
|
|
|||
|
|
@ -62,10 +62,33 @@ describe('KimiHarness.auth', () => {
|
|||
await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token');
|
||||
});
|
||||
|
||||
it('resolves managed auth from a partially invalid config without throwing', async () => {
|
||||
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
||||
await writeFile(
|
||||
join(homeDir, 'config.toml'),
|
||||
`
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
api_key = ""
|
||||
|
||||
[loop_control]
|
||||
max_steps_per_turn = "abc"
|
||||
`,
|
||||
);
|
||||
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
||||
|
||||
// Token resolution is a read path: a broken section elsewhere in
|
||||
// config.toml must degrade, not break OAuth-backed sessions.
|
||||
await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token');
|
||||
await expect(harness.auth.status()).resolves.toMatchObject({
|
||||
providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves cached access tokens from the configured scoped OAuth ref', async () => {
|
||||
const oauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
||||
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
||||
|
|
@ -76,9 +99,9 @@ describe('KimiHarness.auth', () => {
|
|||
`
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "https://coding.deva.msh.team/coding/v1"
|
||||
base_url = "https://api.dev.example.test/coding/v1"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
||||
`,
|
||||
);
|
||||
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
||||
|
|
@ -88,8 +111,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.
|
|||
|
||||
it('reports auth status from the configured scoped OAuth ref', async () => {
|
||||
const oauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
await new FileTokenStorage(join(homeDir, 'credentials')).save(
|
||||
resolveKimiTokenStorageName({ oauthKey }),
|
||||
|
|
@ -100,9 +123,9 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.
|
|||
`
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "https://coding.deva.msh.team/coding/v1"
|
||||
base_url = "https://api.dev.example.test/coding/v1"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
||||
`,
|
||||
);
|
||||
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
||||
|
|
@ -174,8 +197,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.
|
|||
});
|
||||
|
||||
it('logs in against the configured scoped OAuth host and base URL when env is absent', async () => {
|
||||
const baseUrl = 'https://coding.deva.msh.team/coding/v1';
|
||||
const oauthHost = 'https://auth.dev.kimi.team';
|
||||
const baseUrl = 'https://api.dev.example.test/coding/v1';
|
||||
const oauthHost = 'https://auth.dev.example.test';
|
||||
const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl });
|
||||
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
||||
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
||||
|
|
@ -367,7 +390,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/
|
|||
});
|
||||
});
|
||||
|
||||
it('fails clearly when a configured model alias does not have max_context_size', async () => {
|
||||
it('starts degraded when a configured model alias does not have max_context_size', async () => {
|
||||
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
||||
await writeFile(
|
||||
join(homeDir, 'config.toml'),
|
||||
|
|
@ -404,9 +427,14 @@ model = "kimi-for-coding"
|
|||
),
|
||||
);
|
||||
|
||||
expect(() => createKimiHarness({ homeDir, identity: TEST_IDENTITY })).toThrow(
|
||||
/Model "kimi-code\/kimi-for-coding" must define a positive max_context_size/,
|
||||
);
|
||||
// A broken config must not prevent startup: the invalid model alias is
|
||||
// dropped, the rest of the config survives, and a warning is reported.
|
||||
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
||||
const config = await harness.getConfig();
|
||||
expect(config.models?.['kimi-code/kimi-for-coding']).toBeUndefined();
|
||||
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined();
|
||||
const { warnings } = await harness.getConfigDiagnostics();
|
||||
expect(warnings.some((w) => w.includes('models.kimi-code/kimi-for-coding'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes managed Kimi config on logout', async () => {
|
||||
|
|
@ -474,8 +502,8 @@ oauth = { storage = "file", key = "oauth/kimi-code" }
|
|||
|
||||
it('removes the configured scoped OAuth token on logout without touching the production token', async () => {
|
||||
const oauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
||||
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
||||
|
|
@ -488,9 +516,9 @@ default_model = "kimi-code/kimi-for-coding"
|
|||
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "https://coding.deva.msh.team/coding/v1"
|
||||
base_url = "https://api.dev.example.test/coding/v1"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
||||
|
||||
[models."kimi-code/kimi-for-coding"]
|
||||
provider = "managed:kimi-code"
|
||||
|
|
@ -580,9 +608,9 @@ max_context_size = 262144
|
|||
});
|
||||
|
||||
it('uses configured scoped OAuth refs and base URLs for managed usage and feedback', async () => {
|
||||
const baseUrl = 'https://coding.deva.msh.team/coding/v1';
|
||||
const baseUrl = 'https://api.dev.example.test/coding/v1';
|
||||
const oauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl,
|
||||
});
|
||||
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
||||
|
|
@ -597,7 +625,7 @@ max_context_size = 262144
|
|||
type = "kimi"
|
||||
base_url = "${baseUrl}"
|
||||
api_key = ""
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" }
|
||||
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
||||
`,
|
||||
);
|
||||
const fetchMock = vi.fn<FetchMock>(async (input) => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ export type { ManagedKimiConfigShape };
|
|||
|
||||
/**
|
||||
* Identifies where a custom-registry-managed provider came from. The same
|
||||
* `{url, apiKey}` pair may produce multiple providers (one per top-level entry
|
||||
* in the api.json document) — the refresh dispatcher groups by these fields to
|
||||
* issue a single HTTP GET per source.
|
||||
* URL may produce multiple providers (one per top-level entry in the api.json
|
||||
* document). Refresh treats the URL as the stable registry identity and may try
|
||||
* more than one API key when existing provider records drift during key
|
||||
* rotation.
|
||||
*/
|
||||
export interface CustomRegistrySource {
|
||||
readonly kind: 'apiJson';
|
||||
|
|
|
|||
|
|
@ -55,16 +55,16 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
|
||||
it('scopes credential keys for non-default OAuth hosts and API base URLs', () => {
|
||||
const devKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
|
||||
expect(devKey).not.toBe(KIMI_CODE_OAUTH_KEY);
|
||||
expect(devKey).toMatch(/^oauth\/kimi-code-env-[a-f0-9]{16}$/);
|
||||
expect(
|
||||
resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team/',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1/',
|
||||
oauthHost: 'https://auth.dev.example.test/',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1/',
|
||||
}),
|
||||
).toBe(devKey);
|
||||
});
|
||||
|
|
@ -94,16 +94,16 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
// A non-default environment yields a scoped key AND the normalized host,
|
||||
// both derived from the same input — login and runtime cannot drift apart.
|
||||
const devRef = resolveKimiCodeOAuthRef({
|
||||
oauthHost: 'https://auth.dev.kimi.team/',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test/',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
expect(devRef).toEqual({
|
||||
storage: 'file',
|
||||
key: resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
}),
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -136,14 +136,14 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
});
|
||||
|
||||
it('preserves a matching configured runtime OAuth ref when env is not overridden', () => {
|
||||
const baseUrl = 'https://coding.deva.msh.team/coding/v1';
|
||||
const baseUrl = 'https://api.dev.example.test/coding/v1';
|
||||
const configuredOAuthRef = {
|
||||
storage: 'keyring' as const,
|
||||
key: resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl,
|
||||
}),
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
};
|
||||
|
||||
expect(
|
||||
|
|
@ -277,15 +277,15 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
providers: {},
|
||||
};
|
||||
const oauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
|
||||
await provisionManagedKimiCodeConfig({
|
||||
accessToken: 'oauth-access-token',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
oauthKey,
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
||||
adapter: {
|
||||
read: () => config,
|
||||
|
|
@ -295,22 +295,22 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
});
|
||||
|
||||
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
oauth: {
|
||||
storage: 'file',
|
||||
key: oauthKey,
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
},
|
||||
});
|
||||
expect(config.services?.moonshotSearch?.oauth).toEqual({
|
||||
storage: 'file',
|
||||
key: oauthKey,
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
});
|
||||
expect(config.services?.moonshotFetch?.oauth).toEqual({
|
||||
storage: 'file',
|
||||
key: oauthKey,
|
||||
oauthHost: 'https://auth.dev.kimi.team',
|
||||
oauthHost: 'https://auth.dev.example.test',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -745,22 +745,22 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
|
||||
const promise = fetchManagedKimiCodeModels({
|
||||
accessToken: 'oauth-access-token',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Kimi Code models endpoint https://coding.deva.msh.team/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.",
|
||||
"Kimi Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.",
|
||||
);
|
||||
await expect(
|
||||
fetchManagedKimiCodeModels({
|
||||
accessToken: 'oauth-access-token',
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 402,
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
await expect(
|
||||
fetchManagedKimiCodeModels({
|
||||
|
|
|
|||
|
|
@ -146,10 +146,10 @@ describe('KimiOAuthToolkit', () => {
|
|||
|
||||
it('refreshes configured bearer token refs against their OAuth host', async () => {
|
||||
const storage = new MemoryTokenStorage();
|
||||
const oauthHost = 'https://auth.dev.kimi.team';
|
||||
const oauthHost = 'https://auth.dev.example.test';
|
||||
const oauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost,
|
||||
baseUrl: 'https://coding.deva.msh.team/coding/v1',
|
||||
baseUrl: 'https://api.dev.example.test/coding/v1',
|
||||
});
|
||||
storage.tokens.set(resolveKimiTokenStorageName({ oauthKey }), {
|
||||
...token('expired-dev-access'),
|
||||
|
|
@ -419,8 +419,8 @@ describe('KimiOAuthToolkit', () => {
|
|||
const storage = new MemoryTokenStorage();
|
||||
storage.tokens.set('kimi-code', token('prod-access'));
|
||||
const config: ManagedKimiConfigShape = { providers: {} };
|
||||
const devBaseUrl = 'https://coding.deva.msh.team/coding/v1';
|
||||
const devOauthHost = 'https://auth.dev.kimi.team';
|
||||
const devBaseUrl = 'https://api.dev.example.test/coding/v1';
|
||||
const devOauthHost = 'https://auth.dev.example.test';
|
||||
const devOauthKey = resolveKimiCodeOAuthKey({
|
||||
oauthHost: devOauthHost,
|
||||
baseUrl: devBaseUrl,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,30 @@ describe('rest/snapshot — session snapshot', () => {
|
|||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('parses an in-flight turn with current_prompt_id', () => {
|
||||
const result = sessionSnapshotResponseSchema.safeParse({
|
||||
as_of_seq: 12,
|
||||
epoch: 'ep_01ABC',
|
||||
session: SESSION,
|
||||
messages: { items: [], has_more: false },
|
||||
in_flight_turn: {
|
||||
turn_id: 3,
|
||||
assistant_text: 'partial answer…',
|
||||
thinking_text: '',
|
||||
running_tools: [],
|
||||
current_prompt_id: 'prompt_01KV589KCS5PG9ZYDNP8KFDQHZ',
|
||||
},
|
||||
pending_approvals: [],
|
||||
pending_questions: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.in_flight_turn?.current_prompt_id).toBe(
|
||||
'prompt_01KV589KCS5PG9ZYDNP8KFDQHZ',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a snapshot missing the watermark', () => {
|
||||
const result = sessionSnapshotResponseSchema.safeParse({
|
||||
epoch: 'ep_01ABC',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ToolInputDisplaySchema, type ToolInputDisplay } from './display';
|
|||
import { messageContentSchema, type MessageContent } from './message';
|
||||
import { sessionSchema, type Session } from './session';
|
||||
import { isoDateTimeSchema } from './time';
|
||||
import { configResponseSchema, type ConfigResponse } from './rest/config';
|
||||
|
||||
export interface TokenUsage {
|
||||
readonly inputOther: number;
|
||||
|
|
@ -314,6 +315,12 @@ export interface SessionCreatedEvent {
|
|||
readonly session: Session;
|
||||
}
|
||||
|
||||
export interface ConfigChangedEvent {
|
||||
readonly type: 'event.config.changed';
|
||||
readonly changedFields: string[];
|
||||
readonly config: ConfigResponse;
|
||||
}
|
||||
|
||||
export interface GoalUpdatedEvent {
|
||||
readonly type: 'goal.updated';
|
||||
readonly snapshot: GoalSnapshot | null;
|
||||
|
|
@ -547,7 +554,7 @@ export interface McpServerStatusEvent {
|
|||
|
||||
export interface McpServerStatusPayload {
|
||||
readonly name: string;
|
||||
readonly transport: 'stdio' | 'http';
|
||||
readonly transport: 'stdio' | 'http' | 'sse';
|
||||
readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
|
||||
readonly toolCount: number;
|
||||
readonly error?: string;
|
||||
|
|
@ -559,6 +566,7 @@ export type AgentEvent =
|
|||
| AgentStatusUpdatedEvent
|
||||
| SessionMetaUpdatedEvent
|
||||
| SessionCreatedEvent
|
||||
| ConfigChangedEvent
|
||||
| GoalUpdatedEvent
|
||||
| SkillActivatedEvent
|
||||
| TurnStartedEvent
|
||||
|
|
@ -905,6 +913,12 @@ export const sessionCreatedEventSchema = z.object({
|
|||
session: sessionSchema,
|
||||
}) satisfies z.ZodType<SessionCreatedEvent>;
|
||||
|
||||
export const configChangedEventSchema = z.object({
|
||||
type: z.literal('event.config.changed'),
|
||||
changedFields: z.array(z.string()),
|
||||
config: configResponseSchema,
|
||||
}) satisfies z.ZodType<ConfigChangedEvent>;
|
||||
|
||||
export const goalUpdatedEventSchema = z.object({
|
||||
type: z.literal('goal.updated'),
|
||||
snapshot: goalSnapshotSchema.nullable(),
|
||||
|
|
|
|||
|
|
@ -37,4 +37,5 @@ export * from './rest/task';
|
|||
export * from './rest/fs';
|
||||
export * from './rest/file';
|
||||
export * from './rest/modelCatalog';
|
||||
export * from './rest/config';
|
||||
export * from './rest/terminal';
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue