diff --git a/.changeset/qualify-sub-skill-names.md b/.changeset/qualify-sub-skill-names.md deleted file mode 100644 index 6a5f04ae2..000000000 --- a/.changeset/qualify-sub-skill-names.md +++ /dev/null @@ -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. diff --git a/.changeset/quiet-compaction-final-answer.md b/.changeset/quiet-compaction-final-answer.md new file mode 100644 index 000000000..7fd86399b --- /dev/null +++ b/.changeset/quiet-compaction-final-answer.md @@ -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. diff --git a/.changeset/read-media-header-types.md b/.changeset/read-media-header-types.md new file mode 100644 index 000000000..fc6711415 --- /dev/null +++ b/.changeset/read-media-header-types.md @@ -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. diff --git a/.changeset/replay-compaction-records.md b/.changeset/replay-compaction-records.md deleted file mode 100644 index 3d6de014a..000000000 --- a/.changeset/replay-compaction-records.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Show completed and cancelled compaction records correctly when resuming a session. diff --git a/.changeset/server-send-cancel-e2e.md b/.changeset/server-send-cancel-e2e.md new file mode 100644 index 000000000..7a936174f --- /dev/null +++ b/.changeset/server-send-cancel-e2e.md @@ -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. diff --git a/.changeset/shell-streaming-output.md b/.changeset/shell-streaming-output.md deleted file mode 100644 index 2c7fdf3bb..000000000 --- a/.changeset/shell-streaming-output.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Stream foreground Bash stdout and stderr while commands are still running. diff --git a/.changeset/sse-mcp-servers.md b/.changeset/sse-mcp-servers.md new file mode 100644 index 000000000..b5dce2f66 --- /dev/null +++ b/.changeset/sse-mcp-servers.md @@ -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. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 29e6a229d..b292b7096 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -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 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index d0edd7ed1..133b1942d 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -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", diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 5b1c50699..98f4cb196 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -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' }; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index d5253531e..f7cef067d 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index b3443ee99..e5bdfef24 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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, diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 38cb75521..cd30c1957 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -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 Promise): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } + }; + provider .command('add ') .description('Import every provider listed in a custom registry (api.json).') .option('--api-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 { 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 { 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 { 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 { 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 }), + }), + ); }, ); } diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index d95c02e35..9b91d4ba0 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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 { 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 { 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 { + 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(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(undefined); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + async function applyEditorChoice(host: SlashCommandHost, value: string): Promise { const previous = host.state.appState.editorCommand ?? ''; if (value === previous && value.length > 0) { diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 397404e0f..ed67da39c 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -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); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 55f9817fa..242252bfb 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -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 { 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>; 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(); diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 3a4cce8d1..d2bcc8620 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -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(); diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 39af925fb..b0d1cc22d 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -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 { + return this.refreshProviderModelsWithScope('all'); + } + + async refreshOAuthProviderModels(): Promise { + return this.refreshProviderModelsWithScope('oauth'); + } + + private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise { 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(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b275024ee..0337785f0 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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; - track( - event: string, - properties?: Parameters[1], - ): void { + track(event: string, properties?: Parameters[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 { - 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 { + 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 { 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 { + 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 { - 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 { await this.fetchSessions(); - this.mountSessionPicker(() => { - this.hideSessionPicker(); + this.mountSessionPicker({ + onCancel: () => { + this.hideSessionPicker(); + }, }); } private async bootstrapFromPicker(): Promise { 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(); } - } diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index aa8cd7577..a25c4b7cf 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -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; + 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>; + 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 { +export async function refreshAllProviderModels( + host: RefreshProviderHost, + options: RefreshProviderOptions = {}, +): Promise { 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(); + const customSources = new Map< + string, + { + readonly sources: CustomRegistrySource[]; + readonly sourceKeys: Set; + 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(); + 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) { diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index 2a0247cc7..ab6f1bff7 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -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;` 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; diff --git a/apps/kimi-code/src/tui/utils/terminal-state.ts b/apps/kimi-code/src/tui/utils/terminal-state.ts index 86d2bab9f..29127ea02 100644 --- a/apps/kimi-code/src/tui/utils/terminal-state.ts +++ b/apps/kimi-code/src/tui/utils/terminal-state.ts @@ -1,9 +1,14 @@ -import { isInsideTmux, supportsOsc9Notification } from './terminal-notification'; +import { + isInsideTmux, + supportsOsc9Notification, + supportsTerminalProgress, +} from './terminal-notification'; export interface TerminalState { notificationKeys: Set; focused: boolean; supportsOsc9: boolean; + supportsProgress: boolean; insideTmux: boolean; progressActive: boolean; } @@ -13,6 +18,7 @@ export function createTerminalState(): TerminalState { notificationKeys: new Set(), focused: true, supportsOsc9: supportsOsc9Notification(), + supportsProgress: supportsTerminalProgress(), insideTmux: isInsideTmux(), progressActive: false, }; diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index e8a9955d7..04780bd26 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -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), diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 5ff2dd9a2..65481ff05 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -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()); diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index d1f6a05e6..56768a78c 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -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', () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 9913dd926..a3620aa35 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -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, diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index b61641dd0..bab4fb152 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -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>(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', diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 2b12a76ee..b719da163 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -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; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index d069dd112..2906e2f2f 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -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(() => {})); + ( + 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, { diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 1c86f5aa7..e014fa83e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,30 +1,23 @@ -import { describe, expect, it, vi } from "vitest"; +import { log, type GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { describe, expect, it, vi } from 'vitest'; -import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; -import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; - -import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; -import { BannerProvider } from "#/tui/banner/banner-provider"; -import { BannerComponent } from "#/tui/components/chrome/banner"; -import { WelcomeComponent } from "#/tui/components/chrome/welcome"; -import { - handleLoginCommand, - handleLogoutCommand, -} from "#/tui/commands/auth"; -import { - promptPlatformSelection, - promptLogoutProviderSelection, -} from "#/tui/commands/prompts"; +import { BannerProvider } from '#/tui/banner/banner-provider'; +import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; +import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, OSC11_QUERY, QUERY_TERMINAL_THEME, TERMINAL_THEME_LIGHT, -} from "#/tui/utils/terminal-theme"; +} from '#/tui/utils/terminal-theme'; -vi.mock("#/tui/commands/prompts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('#/tui/commands/prompts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; }); @@ -53,7 +46,7 @@ interface MigrateExitDriver extends StartupDriver { } const MIGRATION_PLAN: MigrationPlan = { - sourceHome: "/x/.kimi", + sourceHome: '/x/.kimi', hasConfig: false, hasMcp: false, hasUserHistory: false, @@ -65,8 +58,8 @@ const MIGRATION_PLAN: MigrationPlan = { }; function makeStartupInput( - cliOptions: Partial = {}, - tuiConfig: Partial = {}, + cliOptions: Partial = {}, + tuiConfig: Partial = {}, ): KimiTUIStartupInput { return { cliOptions: { @@ -82,26 +75,26 @@ function makeStartupInput( ...cliOptions, }, tuiConfig: { - theme: "dark", + theme: 'dark', editorCommand: null, - notifications: { enabled: true, condition: "unfocused" }, + notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, ...tuiConfig, }, - version: "0.0.0-test", - workDir: "/tmp/proj-a", + version: '0.0.0-test', + workDir: '/tmp/proj-a', }; } function makeSession(overrides: Record = {}) { return { - id: "ses-1", - model: "k2", - summary: { title: "Session title" }, + id: 'ses-1', + model: 'k2', + summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "manual", + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -124,9 +117,9 @@ function makeSession(overrides: Record = {}) { function goalSnapshot(overrides: Partial = {}): GoalSnapshot { return { - goalId: "goal-1", - objective: "Ship feature X", - status: "paused", + goalId: 'goal-1', + objective: 'Ship feature X', + status: 'paused', turnsUsed: 2, tokensUsed: 100, wallClockMs: 1000, @@ -146,9 +139,39 @@ function goalSnapshot(overrides: Partial = {}): GoalSnapshot { }; } +function createResumeState(overrides: { permissionMode?: string; planMode?: boolean } = {}) { + return { + id: 'ses-latest', + workDir: '/tmp/proj-a', + sessionDir: '/tmp/proj-a/.kimi/sessions/ses-latest', + createdAt: Date.now(), + updatedAt: Date.now(), + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + cwd: '/tmp/proj-a', + modelCapabilities: { max_context_tokens: 100 }, + thinkingLevel: 'off', + systemPrompt: '', + }, + context: { history: [], tokenCount: 10 }, + replay: [], + permission: { mode: overrides.permissionMode ?? 'manual', rules: [] }, + plan: overrides.planMode ? { id: 'plan-1', content: '', path: '/tmp/plan.md' } : null, + swarmMode: false, + usage: {}, + tools: [], + background: [], + }, + }, + } as never; +} + function loginRequiredError(): Error & { readonly code: string } { return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { - code: "auth.login_required", + code: 'auth.login_required', }); } @@ -156,7 +179,7 @@ function makeHarness(session = makeSession(), overrides: Record return { getConfig: vi.fn(async () => ({ models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession: vi.fn(async () => session), @@ -178,21 +201,21 @@ function makeHarness(session = makeSession(), overrides: Record function makeDriver(harness: ReturnType, input: KimiTUIStartupInput) { const driver = new KimiTUI(harness as never, input) as unknown as StartupDriver; - vi.spyOn(driver.state.ui, "requestRender").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "setProgress").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); return driver; } -type InputListener = Parameters[0]; -const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; -const LIGHT_OSC11_REPORT = "\u001B]11;rgb:fafa/fbfb/fcfc\u0007"; +type InputListener = Parameters[0]; +const DARK_OSC11_REPORT = '\u001B]11;rgb:2828/2c2c/3434\u0007'; +const LIGHT_OSC11_REPORT = '\u001B]11;rgb:fafa/fbfb/fcfc\u0007'; function captureInputListeners(driver: StartupDriver) { const listeners: InputListener[] = []; const removeInputListener = vi.fn<() => void>(); - const write = vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + const write = vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); const addInputListener = vi - .spyOn(driver.state.ui, "addInputListener") + .spyOn(driver.state.ui, 'addInputListener') .mockImplementation((listener: InputListener) => { listeners.push(listener); return removeInputListener; @@ -201,13 +224,13 @@ function captureInputListeners(driver: StartupDriver) { return { listeners, removeInputListener, write, addInputListener }; } -describe("KimiTUI startup", () => { - it("creates a fresh session from startup flags and syncs runtime state", async () => { +describe('KimiTUI startup', () => { + it('creates a fresh session from startup flags and syncs runtime state', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingLevel: 'off', + permission: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, @@ -220,51 +243,280 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(session.setApprovalHandler).toHaveBeenCalledOnce(); expect(session.setQuestionHandler).toHaveBeenCalledOnce(); expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); - expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: "ses-1" }); - expect(driver.state.startupState).toBe("ready"); + expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); + expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, contextUsage: 0.125, - sessionTitle: "Session title", + sessionTitle: 'Session title', }); }); - it("resumes the latest session for --continue and marks history for replay", async () => { - const session = makeSession({ id: "ses-latest" }); + it('resumes the latest session for --continue and marks history for replay', async () => { + const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }, { id: "ses-old" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }, { id: 'ses-old' }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); await expect(driver.init()).resolves.toBe(true); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe("ses-latest"); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe('ses-latest'); }); - it("syncs a persisted goal when resuming a session", async () => { - const goal = goalSnapshot({ status: "blocked", terminalReason: "needs input" }); + it('applies --auto permission when resuming a session via --continue', async () => { + let permission = 'manual'; const session = makeSession({ - id: "ses-latest", + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('applies --yolo permission when resuming a session via --continue', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, yolo: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(driver.state.appState.permissionMode).toBe('yolo'); + }); + + it('applies --plan mode when resuming a session via --continue', async () => { + let planMode = false; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async (enabled: boolean) => { + planMode = enabled; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('skips setPlanMode when the resumed session is already in plan mode', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('forces footer state to reflect --auto even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('forces footer state to reflect --plan even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('keeps --auto in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise; + } + ).finishStartup(true); + + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('keeps --plan in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise; + } + ).finishStartup(true); + + expect(driver.state.appState.planMode).toBe(true); + }); + + it('applies --auto permission when resuming an explicit session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-target', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target', auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('syncs a persisted goal when resuming a session', async () => { + const goal = goalSnapshot({ status: 'blocked', terminalReason: 'needs input' }); + const session = makeSession({ + id: 'ses-latest', getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), - getExperimentalFeatures: vi.fn(async () => [{ id: "micro_compaction", enabled: true }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); @@ -274,7 +526,7 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.goal).toEqual(goal); }); - it("syncs goal state regardless of the goal flag", async () => { + it('syncs goal state regardless of the goal flag', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), @@ -288,48 +540,48 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.goal).toEqual(goal); }); - it("clears goal state when closing the current session", async () => { + it('clears goal state when closing the current session', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - getExperimentalFeatures: vi.fn(async () => [{ id: "micro_compaction", enabled: true }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState.goal).toEqual(goal); - await driver.closeSession("test close"); + await driver.closeSession('test close'); expect(driver.state.appState.goal).toBeNull(); }); - it("passes the CLI model override when creating a fresh startup session", async () => { + it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: "kimi-code/k2.5" })); + const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - model: "kimi-code/k2.5", + workDir: '/tmp/proj-a', + model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, }); }); - it("applies the CLI model override when resuming a startup session", async () => { - let model = "k2"; + it('applies the CLI model override when resuming a startup session', async () => { + let model = 'k2'; const session = makeSession({ setModel: vi.fn(async (nextModel: string) => { model = nextModel; }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: "off", - permission: "manual", + thinkingLevel: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -337,51 +589,167 @@ describe("KimiTUI startup", () => { })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: "kimi-code/k2.5" }), + makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith("kimi-code/k2.5"); - expect(driver.state.appState.model).toBe("kimi-code/k2.5"); + expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); }); - it("enters picker startup for bare --session without creating a session", async () => { + it('enters picker startup for bare --session without creating a session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).not.toHaveBeenCalled(); expect(harness.resumeSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("picker"); + expect(driver.state.startupState).toBe('picker'); }); - it("clears startup picker exit confirmation before resuming a selected session", async () => { - const session = makeSession({ id: "ses-picked" }); + it('applies --auto after picking a session from bare --session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); const harness = makeHarness(session, { listSessions: vi.fn(async () => [ { - id: "ses-picked", - title: "Picked session", - workDir: "/tmp/proj-a", + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', updatedAt: Date.now(), }, ]), }); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); - const stop = vi.spyOn(driver, "stop").mockResolvedValue(undefined); + const driver = makeDriver(harness, makeStartupInput({ session: '', auto: true })); + + await (driver as unknown as { initMainTui(): Promise }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('skips setPlanMode after picking a session already in plan mode', async () => { + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '', plan: true })); + + await (driver as unknown as { initMainTui(): Promise }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('does not apply startup flags when switching sessions via the /sessions picker', async () => { + const initial = makeSession({ id: 'ses-1' }); + const picked = makeSession({ + id: 'ses-2', + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(initial, { + resumeSession: vi.fn(async () => picked), + listSessions: vi.fn(async () => [ + { + id: 'ses-2', + title: 'Other session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ auto: true, plan: true })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.state.appState.sessionId).toBe('ses-2'); + expect(picked.setPermission).not.toHaveBeenCalled(); + expect(picked.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.permissionMode).toBe('manual'); + expect(driver.state.appState.planMode).toBe(false); + }); + + it('clears startup picker exit confirmation before resuming a selected session', async () => { + const session = makeSession({ id: 'ses-picked' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; - picker.handleInput("\u0003"); - picker.handleInput("\r"); + picker.handleInput('\u0003'); + picker.handleInput('\r'); await new Promise((resolve) => setImmediate(resolve)); driver.state.editor.onCtrlC?.(); @@ -389,11 +757,11 @@ describe("KimiTUI startup", () => { expect(stop).not.toHaveBeenCalled(); }); - it("tracks terminal theme reports while auto theme is active", () => { + it('tracks terminal theme reports while auto theme is active', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { listeners, write, addInputListener } = captureInputListeners(driver); @@ -408,19 +776,19 @@ describe("KimiTUI startup", () => { write.mockClear(); expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); expect(write).toHaveBeenCalledWith(OSC11_QUERY); - expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).toHaveBeenCalled(); }); - it("does not track terminal theme reports for explicit themes", () => { + it('does not track terminal theme reports for explicit themes', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput()) as unknown as ThemeTrackingDriver; const { write, addInputListener } = captureInputListeners(driver); @@ -431,16 +799,16 @@ describe("KimiTUI startup", () => { expect(write).not.toHaveBeenCalled(); }); - it("disables terminal theme reports after leaving auto theme", () => { + it('disables terminal theme reports after leaving auto theme', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { write, removeInputListener } = captureInputListeners(driver); driver.refreshTerminalThemeTracking(); - driver.state.appState.theme = "dark"; + driver.state.appState.theme = 'dark'; driver.refreshTerminalThemeTracking(); expect(removeInputListener).toHaveBeenCalledOnce(); @@ -477,11 +845,11 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(driver.state.startupState).toBe("ready"); - expect((driver as any).startupNotice).toContain("OAuth login expired"); + expect(driver.state.startupState).toBe('ready'); + expect((driver as any).startupNotice).toContain('OAuth login expired'); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", + sessionId: '', + model: '', thinking: false, contextTokens: 0, maxContextTokens: 0, @@ -490,12 +858,12 @@ describe("KimiTUI startup", () => { }); }); - it("preserves fresh startup yolo and plan intent after OAuth login", async () => { + it('preserves fresh startup yolo and plan intent after OAuth login', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingLevel: 'off', + permission: 'yolo', planMode: true, contextTokens: 10, maxContextTokens: 100, @@ -508,10 +876,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", + defaultModel: 'k2', defaultThinking: false, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -521,9 +889,9 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", - permissionMode: "yolo", + sessionId: '', + model: '', + permissionMode: 'yolo', planMode: true, }); @@ -531,31 +899,31 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(1, { - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", - permission: "yolo", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: 'yolo', planMode: true, }); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, }); }); - it("does not force manual permission after OAuth login without --yolo", async () => { + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "auto", + model: 'k2', + thinkingLevel: 'off', + permission: 'auto', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -568,10 +936,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", + defaultModel: 'k2', defaultThinking: false, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -583,25 +951,25 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', permission: undefined, planMode: undefined, }); expect(driver.state.appState).toMatchObject({ - permissionMode: "auto", + permissionMode: 'auto', }); }); - it("syncs configured thinking after OAuth login refreshes an active session", async () => { + it('syncs configured thinking after OAuth login refreshes an active session', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", + defaultModel: 'k2', defaultThinking: true, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), }); @@ -613,25 +981,25 @@ describe("KimiTUI startup", () => { vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(session.setModel).toHaveBeenCalledWith("k2"); - expect(session.setThinking).toHaveBeenCalledWith("on"); + expect(session.setModel).toHaveBeenCalledWith('k2'); + expect(session.setThinking).toHaveBeenCalledWith('on'); expect(driver.state.appState).toMatchObject({ - model: "k2", + model: 'k2', thinking: true, maxContextTokens: 100, }); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', already_logged_in: false, }); }); - it("tracks login with already_logged_in when a token already exists", async () => { + it('tracks login with already_logged_in when a token already exists', async () => { const session = makeSession(); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -647,22 +1015,22 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', already_logged_in: true, }); }); - it("logs login failures with session context", async () => { - const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + it('logs login failures with session context', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); const session = makeSession(); - const loginError = new Error("Failed to list Kimi Code models (HTTP 402)."); + const loginError = new Error('Failed to list Kimi Code models (HTTP 402).'); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ providers: [] })), @@ -682,20 +1050,20 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); expect(warn).toHaveBeenCalledWith( - "login failed", + 'login failed', expect.objectContaining({ - providerName: "managed:kimi-code", + providerName: 'managed:kimi-code', alreadyLoggedIn: false, - sessionId: "ses-1", + sessionId: 'ses-1', error: expect.objectContaining({ - message: "Failed to list Kimi Code models (HTTP 402).", + message: 'Failed to list Kimi Code models (HTTP 402).', }), }), ); @@ -704,18 +1072,18 @@ describe("KimiTUI startup", () => { } }); - it("tracks logout after managed credentials and session state are cleared", async () => { + it('tracks logout after managed credentials and session state are cleared', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -727,38 +1095,36 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); expect(session.close).toHaveBeenCalledOnce(); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", + sessionId: '', + model: '', sessionTitle: null, }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "managed:kimi-code" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); }); - it("keeps the active session when logging out a different provider", async () => { + it('keeps the active session when logging out a different provider', async () => { const session = makeSession(); const removeProvider = vi.fn(async () => {}); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, providers: { - "managed:kimi-code": { type: "kimi" }, - openai: { type: "openai", baseUrl: "https://api.openai.com/v1" }, + 'managed:kimi-code': { type: 'kimi' }, + openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -770,33 +1136,33 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('openai'); await handleLogoutCommand(driver as any); - expect(removeProvider).toHaveBeenCalledWith("openai"); + expect(removeProvider).toHaveBeenCalledWith('openai'); expect(harness.auth.logout).not.toHaveBeenCalled(); expect(session.close).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", + sessionId: 'ses-1', + model: 'k2', }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "openai" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' }); }); - it("can log out a stale managed entry even after the OAuth token is gone", async () => { + it('can log out a stale managed entry even after the OAuth token is gone', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { // Token gone (e.g. credentials file deleted) but the managed entry // is still sitting in config.providers. status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: false }], + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -807,17 +1173,15 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); }); - it("starts TUI without replaying when --continue needs OAuth login", async () => { + it('starts TUI without replaying when --continue needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), @@ -826,29 +1190,29 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("starts TUI without replaying when an explicit resume needs OAuth login", async () => { + it('starts TUI without replaying when an explicit resume needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), }); - const driver = makeDriver(harness, makeStartupInput({ session: "ses-target" })); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target' })); await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("disposes terminal focus/theme tracking on the kimi migrate exit", async () => { + it('disposes terminal focus/theme tracking on the kimi migrate exit', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), @@ -856,11 +1220,11 @@ describe("KimiTUI startup", () => { migrateOnly: true, }) as unknown as MigrateExitDriver; // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen would await user input; resolve it immediately. - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); const onExit = vi.fn(async () => {}); driver.onExit = onExit; @@ -873,40 +1237,40 @@ describe("KimiTUI startup", () => { expect(onExit).toHaveBeenCalledWith(0); }); - it("disposes terminal tracking when post-migration startup fails", async () => { + it('disposes terminal tracking when post-migration startup fails', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), migrationPlan: MIGRATION_PLAN, migrateOnly: false, }) as unknown as MigrateExitDriver; - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen resolves "later"; startup then continues into // initMainTui(), which fails (e.g. a session-resume error). - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); - vi.spyOn(driver, "initMainTui").mockRejectedValue(new Error("resume boom")); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); - await expect(driver.start()).rejects.toThrow("resume boom"); + await expect(driver.start()).rejects.toThrow('resume boom'); // The focus tracking installed by startEventLoop() must be torn down // before the error propagates — not left active after the process exits. expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); - it("keeps non-login startup session errors fatal", async () => { + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { - throw new Error("provider config is invalid"); + throw new Error('provider config is invalid'); }), }); const driver = makeDriver(harness, makeStartupInput()); - await expect(driver.init()).rejects.toThrow("provider config is invalid"); + await expect(driver.init()).rejects.toThrow('provider config is invalid'); }); - it("does not mount the footer when resuming a missing session fails", async () => { + it('does not mount the footer when resuming a missing session fails', async () => { // Regression: a stray pre-startEventLoop render used to paint the footer // (cwd/git + "context:" statusline) to the terminal before the fatal // error, leaving it stranded above the error message. The footer must not @@ -916,23 +1280,21 @@ describe("KimiTUI startup", () => { }); const driver = makeDriver( harness, - makeStartupInput({ session: "missing-session" }), + makeStartupInput({ session: 'missing-session' }), ) as unknown as MigrateExitDriver; - await expect(driver.initMainTui()).rejects.toThrow( - 'Session "missing-session" not found.', - ); + await expect(driver.initMainTui()).rejects.toThrow('Session "missing-session" not found.'); expect(uiContainsFooter(driver)).toBe(false); }); - it("mounts the footer once startup reaches the main TUI", async () => { - const session = makeSession({ id: "ses-target" }); + it('mounts the footer once startup reaches the main TUI', async () => { + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - makeStartupInput({ session: "ses-target" }), + makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; // Not mounted until init() succeeds. @@ -943,31 +1305,27 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); - it("renders the banner below the welcome message after it loads", async () => { + it('renders the banner below the welcome message after it loads', async () => { const banner = { - tag: "New", - mainText: "Banner main", + tag: 'New', + mainText: 'Banner main', subText: null, }; - const loadSpy = vi - .spyOn(BannerProvider.prototype, "load") - .mockResolvedValue(banner); - const session = makeSession({ id: "ses-target" }); + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - makeStartupInput({ session: "ses-target" }), + makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; await driver.initMainTui(); await vi.waitFor(() => { expect( - driver.state.transcriptContainer.children.some( - (child) => child instanceof BannerComponent, - ), + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), ).toBe(true); }); @@ -985,29 +1343,24 @@ describe("KimiTUI startup", () => { loadSpy.mockRestore(); }); - it("resumes a startup session when Windows workdir uses backslashes", async () => { - const session = makeSession({ id: "ses-target" }); + it('resumes a startup session when Windows workdir uses backslashes', async () => { + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [ - { id: "ses-target", workDir: "C:/Users/kimi/project" }, - ]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: 'C:/Users/kimi/project' }]), + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ session: 'ses-target' }), + workDir: String.raw`C:\Users\kimi\project`, }); - const driver = makeDriver( - harness, - { - ...makeStartupInput({ session: "ses-target" }), - workDir: String.raw`C:\Users\kimi\project`, - }, - ); await expect(driver.init()).resolves.toBe(true); expect(harness.listSessions).toHaveBeenCalledWith({ - sessionId: "ses-target", + sessionId: 'ses-target', workDir: String.raw`C:\Users\kimi\project`, }); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.appState.sessionId).toBe("ses-target"); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.appState.sessionId).toBe('ses-target'); }); }); diff --git a/apps/kimi-code/test/tui/terminal-notification.test.ts b/apps/kimi-code/test/tui/terminal-notification.test.ts index 2d5d904ee..cef7b8086 100644 --- a/apps/kimi-code/test/tui/terminal-notification.test.ts +++ b/apps/kimi-code/test/tui/terminal-notification.test.ts @@ -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); diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index d2b0d778b..aadb8e764 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -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(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(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(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(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'; diff --git a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts index 51b6d04a2..456ee3007 100644 --- a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts +++ b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts @@ -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 }); diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 13de5b2bf..5c29b1231 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -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); diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 95df3e1ae..699490da3 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -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( + `/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 { + const data = await this.http.get('/config'); + return toAppConfig(data); + } + + async setConfig(patch: Partial): Promise { + const wirePatch: Record = {}; + const keyMap: Record = { + 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('/config', wirePatch); + return toAppConfig(data); + } + // ------------------------------------------------------------------------- // Auth — REAL endpoints // ------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 49fa12ff8..0622cede5 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -10,6 +10,7 @@ import type { AppApprovalRequest, + AppConfig, AppEvent, AppGoal, AppMessage, @@ -47,6 +48,7 @@ export interface KimiClientState { goalBySession: Record; lastSeqBySession: Record; compactionBySession: Record; + 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 diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index cb0b528f9..497bd55f5 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -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 = {}; + 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; diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 5ed76279a..b9d42c699 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -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; + default_provider?: string; + default_model?: string; + models?: Record; + 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; + telemetry?: boolean; + raw?: Record; +} + // --------------------------------------------------------------------------- // 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; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 981d75528..e4ebc4543 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -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; + defaultProvider?: string; + defaultModel?: string; + models?: Record; + 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; + telemetry?: boolean; + raw?: Record; +} + /** 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; undoSession(sessionId: string, count?: number): Promise; forkSession(sessionId: string, input?: { title?: string }): Promise; @@ -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; + setConfig(patch: Partial): Promise; + // Auth — REAL endpoints getAuth(): Promise<{ ready: boolean; diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 82c8cf72f..93066ef78 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -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(() => { const authReady = computed(() => rawState.authReady); const defaultModel = computed(() => rawState.defaultModel); const managedProviderStatus = computed(() => rawState.managedProviderStatus); +const config = computed(() => rawState.config); /** path → status map for quick badge lookup in the file tree */ const changesByPath = computed>(() => { @@ -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 { } } +/** Fetch global config from GET /api/v1/config. Defensive — never throws. */ +async function loadConfig(): Promise { + 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): Promise { + 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 { 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 { 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, diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index f57c7730e..8fed5c4e1 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -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. diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 9df67d746..dfad6acd6 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -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` | stdio | Environment variables injected into the child process | | `cwd` | `string` | stdio | Working directory for the child process | -| `headers` | `Record` | 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` | 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 ` 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 ` 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. diff --git a/docs/en/guides/ides.md b/docs/en/guides/ides.md index 9899745c1..f275af8ac 100644 --- a/docs/en/guides/ides.md +++ b/docs/en/guides/ides.md @@ -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 diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index 6f8a05913..15c56fcca 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -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 diff --git a/docs/en/reference/kimi-acp.md b/docs/en/reference/kimi-acp.md index 950ce39b2..30f078765 100644 --- a/docs/en/reference/kimi-acp.md +++ b/docs/en/reference/kimi-acp.md @@ -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 `...`; 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 diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 4d57d46d9..e4f3840ee 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -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 ` -Bulk-import all providers from a custom registry (`api.json`). The command fetches the registry, creates a `[providers.]` and `[models.]` 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.]` and `[models.]` 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 | | --- | --- | diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 30c358284..e18a2ee08 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -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 diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 939b56181..41aae2736 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -32,7 +32,7 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服 添加时有两条路径: - **Known third-party provider**:从 [models.dev](https://models.dev/) 拉取模型目录,选供应商 → 输入 API 密钥 → 选默认模型 -- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目 +- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。 ::: warning 通过 `/login` 登录的 Kimi Code OAuth 托管账号不会在 `/provider` 里显示,请用 `/login` 和 `/logout` 管理。 diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index a51f16cc3..96a4e66dd 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -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` | stdio | 注入子进程的环境变量 | | `cwd` | `string` | stdio | 子进程工作目录 | -| `headers` | `Record` | HTTP | 附加到每次请求的静态请求头 | -| `enabled` | `boolean` | 两者 | 设为 `false` 可禁用该 server | -| `startupTimeoutMs` | `number` | 两者 | 连接超时,默认 `30000` 毫秒 | -| `toolTimeoutMs` | `number` | 两者 | 单次工具调用超时 | -| `enabledTools` | `string[]` | 两者 | 工具白名单 | -| `disabledTools` | `string[]` | 两者 | 工具黑名单 | +| `headers` | `Record` | 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 ` 完成浏览器授权。 +HTTP 与 SSE server 支持通过 `headers` 或 `bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login ` 完成浏览器授权。 Plugins 也可以在 manifest 中声明 MCP servers。Plugin 声明的 servers 默认启用,可以在 `/plugins` 中禁用或重新启用,然后开启新会话。详见 [Plugins](./plugins.md)。 diff --git a/docs/zh/guides/ides.md b/docs/zh/guides/ides.md index 713a5233b..a8193ed4e 100644 --- a/docs/zh/guides/ides.md +++ b/docs/zh/guides/ides.md @@ -88,7 +88,7 @@ Paseo 的通用 ACP 适配层不会帮你走登录流程,所以请先完成终 - **会话立刻被中断 / IDE 提示 "agent exited"**:通常是 `command` 路径不对或 kimi 没登录。先在终端跑一次 `kimi acp` 验证:如果阻塞等待标准输入则说明 CLI 本身没问题,问题在 IDE 配置;如果立刻报错则按报错提示处理(多数是没 `/login`)。 - **IDE 显示 "auth required"**:表示 CLI 没有可用的鉴权令牌。退出 IDE,在终端执行 `kimi` 完成登录后再启动 IDE 即可。 -- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http`、`stdio` 两种传输方式,`sse` 与 `acp` 类型会被静默丢弃并在日志中给出 warn。 +- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http`、`stdio` 与 `sse` 三种传输方式;`acp` 传输的 MCP server 会被静默丢弃并在日志中给出 warn。 ## 下一步 diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index ad042f610..444fb4489 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -51,7 +51,7 @@ kimi --session ``` ::: warning 注意 -`--continue` 与 `--session` 互斥;`--yolo` 和 `--plan` 也不能与它们同时使用。 +`--continue` 与 `--session` 互斥。 ::: ## 在 TUI 中切换会话 diff --git a/docs/zh/reference/kimi-acp.md b/docs/zh/reference/kimi-acp.md index d114a2724..d58c4460a 100644 --- a/docs/zh/reference/kimi-acp.md +++ b/docs/zh/reference/kimi-acp.md @@ -22,7 +22,7 @@ kimi acp | `promptCapabilities.audio` | `false` | 暂不支持音频 prompt | | `promptCapabilities.embeddedContext` | `true` | 客户端可发送 `resource`/`resource_link` 嵌入式资源块,文本内容会以 `...` 形式注入 prompt;blob 资源被丢弃并写 warn | | `mcpCapabilities.http` | `true` | 转发 IDE 配置的 HTTP MCP 服务 | -| `mcpCapabilities.sse` | `false` | 不支持 SSE MCP 服务,相关条目会被丢弃并写 warn 日志 | +| `mcpCapabilities.sse` | `true` | 转发 IDE 配置的旧式 SSE MCP 服务 | | `loadSession` | `true` | 支持 `session/load` 续接已有会话,加载时会同步回放历史 | | `sessionCapabilities.list` | `{}` | 支持 `session/list` 枚举当前用户的会话 | @@ -74,7 +74,8 @@ ACP 客户端在 `session/new` 或 `session/load` 中提供 `mcpServers` 时, - `http` → kimi 的 `transport: 'http'` 配置 - `stdio` → kimi 的 `transport: 'stdio'` 配置 -- `sse` / `acp` → 丢弃并写一条 warn 日志 +- `sse` → kimi 的 `transport: 'sse'` 配置 +- `acp` → 丢弃并写一条 warn 日志 ## 下一步 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index cc596e00b..cb04d4945 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -37,12 +37,10 @@ kimi [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 [options] #### `kimi provider add ` -从自定义 registry(`api.json`)批量导入所有供应商。命令会拉取 registry,为每个条目创建 `[providers.]` 和 `[models.]`,并写入 `source` 元数据,使 TUI 下次启动时自动刷新模型列表。 +从自定义 registry(`api.json`)批量导入所有供应商。命令会拉取 registry,为每个条目创建 `[providers.]` 和 `[models.]`,并写入 `source` 元数据,使 TUI 下次启动时自动刷新同一 registry 地址下的供应商和模型。 | 参数 / 选项 | 说明 | | --- | --- | diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 01b4826ce..a1e8029b7 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,21 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.14.2(2026-06-12) + +### 修复 + +- 修复 iTerm2 中无休止的桌面通知问题,仅向支持进度序列的终端发送终端进度序列。 +- 在恢复会话时正确显示已完成和已取消的压缩记录。 +- 丢弃无效的 `config.toml` 配置节并发出警告,而不是启动失败。 + +### 优化 + +- 在命令仍在运行时流式输出前台 Bash 的 stdout 和 stderr。 +- 允许 `--auto`、`--yolo` 和 `--plan` 与 `--session` 或 `--continue` 组合使用,将请求的模式应用到恢复的会话。 +- 为子 Skill 名称添加父前缀,并在 TUI 中将子 Skill 暴露为点状斜杠命令。 +- 在启动刷新期间同步自定义 registry provider 的新增、移除和轮换的 registry key。 + ## 0.14.1(2026-06-12) ### 修复 diff --git a/packages/acp-adapter/src/mcp.ts b/packages/acp-adapter/src/mcp.ts index f2e8f7b99..44d419fa0 100644 --- a/packages/acp-adapter/src/mcp.ts +++ b/packages/acp-adapter/src/mcp.ts @@ -10,9 +10,8 @@ * * - `http` → kimi `transport: 'http'` with headers projected from * `Array<{name, value}>` to `Record`. + * - `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` 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 diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index e1f114ee4..f4d343d29 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -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 diff --git a/packages/acp-adapter/test/e2e-happy-path.test.ts b/packages/acp-adapter/test/e2e-happy-path.test.ts index 60ff443c8..8ee7c56da 100644 --- a/packages/acp-adapter/test/e2e-happy-path.test.ts +++ b/packages/acp-adapter/test/e2e-happy-path.test.ts @@ -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: {}, diff --git a/packages/acp-adapter/test/mcp-forward.test.ts b/packages/acp-adapter/test/mcp-forward.test.ts index 89ab039d8..862301b0e 100644 --- a/packages/acp-adapter/test/mcp-forward.test.ts +++ b/packages/acp-adapter/test/mcp-forward.test.ts @@ -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; }); diff --git a/packages/acp-adapter/test/server.test.ts b/packages/acp-adapter/test/server.test.ts index 0f9a967f4..883fae3d3 100644 --- a/packages/acp-adapter/test/server.test.ts +++ b/packages/acp-adapter/test/server.test.ts @@ -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({}); }); diff --git a/packages/agent-core/CHANGELOG.md b/packages/agent-core/CHANGELOG.md index 062eee985..94b25f66a 100644 --- a/packages/agent-core/CHANGELOG.md +++ b/packages/agent-core/CHANGELOG.md @@ -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 diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 2f11186a8..78125c5df 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -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", diff --git a/packages/agent-core/src/agent/compaction/compaction-instruction.md b/packages/agent-core/src/agent/compaction/compaction-instruction.md index 68a3f3b12..49b0d80b4 100644 --- a/packages/agent-core/src/agent/compaction/compaction-instruction.md +++ b/packages/agent-core/src/agent/compaction/compaction-instruction.md @@ -65,3 +65,5 @@ The goal of compaction is to keep essential code patterns, technical details, an - [Detailed non tool use user message] - ... + + diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 33679f88c..76a49f2ac 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -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; } diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 094239b73..9b3d11cf0 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -169,9 +169,24 @@ export const McpServerHttpConfigSchema = z.object({ export type McpServerHttpConfig = z.infer; +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; + +export type McpRemoteServerConfig = McpServerHttpConfig | McpServerSseConfig; + const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [ McpServerStdioConfigSchema, McpServerHttpConfigSchema, + McpServerSseConfigSchema, ]); export const McpServerConfigSchema = z.preprocess((raw) => { diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 56452e41a..172e97cfc 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -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> = 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 | undefined; + try { + data = parseToml(text) as Record; + } 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): 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(); diff --git a/packages/agent-core/src/mcp/auth-tool.ts b/packages/agent-core/src/mcp/auth-tool.ts index 3dd64c10f..414bd0b40 100644 --- a/packages/agent-core/src/mcp/auth-tool.ts +++ b/packages/agent-core/src/mcp/auth-tool.ts @@ -1,7 +1,7 @@ /** * Synthetic `mcp____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: diff --git a/packages/agent-core/src/mcp/client-http.ts b/packages/agent-core/src/mcp/client-http.ts index 626c5d2bd..214217e26 100644 --- a/packages/agent-core/src/mcp/client-http.ts +++ b/packages/agent-core/src/mcp/client-http.ts @@ -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 | undefined { - const headers: Record = { ...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); } diff --git a/packages/agent-core/src/mcp/client-remote.ts b/packages/agent-core/src/mcp/client-remote.ts new file mode 100644 index 000000000..20572e0e5 --- /dev/null +++ b/packages/agent-core/src/mcp/client-remote.ts @@ -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 | undefined { + const headers: Record = { ...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'; +} diff --git a/packages/agent-core/src/mcp/client-sse.ts b/packages/agent-core/src/mcp/client-sse.ts new file mode 100644 index 000000000..4ac2e3dc0 --- /dev/null +++ b/packages/agent-core/src/mcp/client-sse.ts @@ -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 { + 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 { + 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 { + const result = await this.client.listTools(); + return result.tools.map(toMcpToolDefinition); + } + + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal); + const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions); + return toMcpToolResult(result); + } + + private async closeStartedClient(): Promise { + 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; +} diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 30f269e1c..7d3c9c1f3 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -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 | 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 diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index a1badcc31..2d3c1a700 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -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, diff --git a/packages/agent-core/src/plugin/manifest.ts b/packages/agent-core/src/plugin/manifest.ts index 529f440c7..93355f5ea 100644 --- a/packages/agent-core/src/plugin/manifest.ts +++ b/packages/agent-core/src/plugin/manifest.ts @@ -291,7 +291,7 @@ async function normalizePluginMcpServer(input: { readonly diagnostics: PluginDiagnostic[]; }): Promise { 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('./')) { diff --git a/packages/agent-core/src/plugin/types.ts b/packages/agent-core/src/plugin/types.ts index ad4fc3086..82ae27bb2 100644 --- a/packages/agent-core/src/plugin/types.ts +++ b/packages/agent-core/src/plugin/types.ts @@ -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; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index af2e00a4d..c3ba9f6a5 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -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; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index c8edc631d..204715da6 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -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 { private kaos: Promise | 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 | undefined; @@ -159,7 +161,19 @@ export class KimiCore implements PromisableMethods { 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 { async getKimiConfig(input?: GetKimiConfigPayload): Promise { if (input?.reload) { - this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + this.reloadRuntimeConfig(); } return this.config; } + async getConfigDiagnostics(_input?: EmptyPayload): Promise { + return { warnings: this.configWarnings }; + } + async setKimiConfig(input: SetKimiConfigPayload): Promise { - 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 { - 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 { } await writeConfigFile(this.configPath, config); - return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + return this.reloadRuntimeConfig(); } prompt({ sessionId, ...payload }: SessionAgentPayload) { @@ -860,7 +878,30 @@ export class KimiCore implements PromisableMethods { } 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 { diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts index fa1ab671a..f21886974 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.ts +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -171,10 +171,10 @@ export class ReadMediaFileTool implements BuiltinTool { } 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 { diff --git a/packages/agent-core/src/tools/support/file-type.ts b/packages/agent-core/src/tools/support/file-type.ts index 56e40529d..708cd9678 100644 --- a/packages/agent-core/src/tools/support/file-type.ts +++ b/packages/agent-core/src/tools/support/file-type.ts @@ -9,6 +9,8 @@ export interface FileType { readonly mimeType: string; } +export type DetectFileTypeMode = 'text' | 'media'; + export const IMAGE_MIME_BY_SUFFIX: Readonly> = 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: '' }; } diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index dd3bb2eb8..091eee384 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -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 { + 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'); + }); +}); diff --git a/packages/agent-core/test/mcp/client-sse.test.ts b/packages/agent-core/test/mcp/client-sse.test.ts new file mode 100644 index 000000000..8a3c900f1 --- /dev/null +++ b/packages/agent-core/test/mcp/client-sse.test.ts @@ -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> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup(); + } +}); + +async function startInProcessSseMcpServer(opts?: { + authToken?: string; +}): Promise<{ url: string; close: () => Promise }> { + const transports = new Map(); + 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((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((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[2], + ), + ), + ).toBe(true); + expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false); + }); +}); diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts index 120ffb2d6..f281c0551 100644 --- a/packages/agent-core/test/mcp/config-loader.test.ts +++ b/packages/agent-core/test/mcp/config-loader.test.ts @@ -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(); diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 21c967390..a6930b948 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -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((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((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') { diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index 65644ff18..49dd383ce 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -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, }), ); diff --git a/packages/agent-core/test/plugin/manifest.test.ts b/packages/agent-core/test/plugin/manifest.test.ts index dff208f62..4a436c4be 100644 --- a/packages/agent-core/test/plugin/manifest.test.ts +++ b/packages/agent-core/test/plugin/manifest.test.ts @@ -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 () => { diff --git a/packages/agent-core/test/rpc/config-rpc.test.ts b/packages/agent-core/test/rpc/config-rpc.test.ts new file mode 100644 index 000000000..06ceb42a6 --- /dev/null +++ b/packages/agent-core/test/rpc/config-rpc.test.ts @@ -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 { + 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: [] }); + }); +}); diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index 5fa2c8538..fa7f9fd84 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -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 { diff --git a/packages/agent-core/test/tools/file-type.test.ts b/packages/agent-core/test/tools/file-type.test.ts index 43e92e947..3bdedb180 100644 --- a/packages/agent-core/test/tools/file-type.test.ts +++ b/packages/agent-core/test/tools/file-type.test.ts @@ -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({ + 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. diff --git a/packages/agent-core/test/tools/read-media.test.ts b/packages/agent-core/test/tools/read-media.test.ts index 59913a1e4..8e0e9a6f0 100644 --- a/packages/agent-core/test/tools/read-media.test.ts +++ b/packages/agent-core/test/tools/read-media.test.ts @@ -318,6 +318,27 @@ describe('ReadMediaFileTool', () => { expect(parts[3]).toEqual({ type: 'text', text: '' }); }); + 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().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }), + readBytes: vi.fn().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: '