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:
qer 2026-06-16 00:38:50 +08:00
commit 7ac2dbeb14
126 changed files with 4826 additions and 661 deletions

View file

@ -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.

View 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.

View 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.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Show completed and cancelled compaction records correctly when resuming a session.

View 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.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Stream foreground Bash stdout and stderr while commands are still running.

View 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.

View file

@ -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

View file

@ -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",

View file

@ -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' };
}

View file

@ -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,

View file

@ -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,

View file

@ -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 }),
}),
);
},
);
}

View file

@ -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) {

View file

@ -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);

View file

@ -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();

View file

@ -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();

View file

@ -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();
}

View file

@ -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();
}
}

View file

@ -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) {

View file

@ -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;

View file

@ -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,
};

View file

@ -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),

View file

@ -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());

View file

@ -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', () => {

View file

@ -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,

View file

@ -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',

View file

@ -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;

View file

@ -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

View file

@ -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);

View file

@ -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';

View file

@ -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 });

View file

@ -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);

View file

@ -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
// -------------------------------------------------------------------------

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 |
| --- | --- |

View file

@ -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

View file

@ -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 tokenCLI 自动创建 `providers` / `models` 条目
- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer tokenCLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。
::: warning
通过 `/login` 登录的 Kimi Code OAuth 托管账号不会在 `/provider` 里显示,请用 `/login``/logout` 管理。

View file

@ -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)。

View file

@ -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。
## 下一步

View file

@ -51,7 +51,7 @@ kimi --session
```
::: warning 注意
`--continue``--session` 互斥`--yolo``--plan` 也不能与它们同时使用
`--continue``--session` 互斥。
:::
## 在 TUI 中切换会话

View file

@ -22,7 +22,7 @@ kimi acp
| `promptCapabilities.audio` | `false` | 暂不支持音频 prompt |
| `promptCapabilities.embeddedContext` | `true` | 客户端可发送 `resource`/`resource_link` 嵌入式资源块,文本内容会以 `<resource uri="...">...</resource>` 形式注入 promptblob 资源被丢弃并写 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 日志
## 下一步

View file

@ -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 地址下的供应商和模型。
| 参数 / 选项 | 说明 |
| --- | --- |

View file

@ -6,6 +6,21 @@ outline: 2
本页记录 Kimi Code CLI 每个版本的变更内容。
## 0.14.22026-06-12
### 修复
- 修复 iTerm2 中无休止的桌面通知问题,仅向支持进度序列的终端发送终端进度序列。
- 在恢复会话时正确显示已完成和已取消的压缩记录。
- 丢弃无效的 `config.toml` 配置节并发出警告,而不是启动失败。
### 优化
- 在命令仍在运行时流式输出前台 Bash 的 stdout 和 stderr。
- 允许 `--auto``--yolo``--plan``--session``--continue` 组合使用,将请求的模式应用到恢复的会话。
- 为子 Skill 名称添加父前缀,并在 TUI 中将子 Skill 暴露为点状斜杠命令。
- 在启动刷新期间同步自定义 registry provider 的新增、移除和轮换的 registry key。
## 0.14.12026-06-12
### 修复

View file

@ -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

View file

@ -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

View file

@ -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: {},

View file

@ -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;
});

View file

@ -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({});
});

View file

@ -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

View file

@ -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",

View file

@ -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. -->

View file

@ -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;
}

View file

@ -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) => {

View file

@ -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();

View file

@ -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:

View file

@ -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);
}

View 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';
}

View 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;
}

View file

@ -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

View file

@ -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,

View file

@ -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('./')) {

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -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 {

View file

@ -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: '' };
}

View file

@ -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');
});
});

View 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);
});
});

View file

@ -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();

View file

@ -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') {

View file

@ -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,
}),
);

View file

@ -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 () => {

View 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: [] });
});
});

View file

@ -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 {

View file

@ -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.

View file

@ -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',

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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();
}

View file

@ -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({});

View file

@ -22,6 +22,7 @@ export type {
BackgroundConfig,
BackgroundTaskInfo,
BackgroundTaskStatus,
ConfigDiagnostics,
ContextMessage,
ExperimentalFeatureState,
ExperimentalFlagMap,

View file

@ -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) => {

View file

@ -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';

View file

@ -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({

View file

@ -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,

View file

@ -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',

View file

@ -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(),

View file

@ -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