From ad239cb1c08266a442c9ca0382fefed87bcb1fd4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 14:17:12 +0800 Subject: [PATCH 01/17] fix(cli,tui): allow --auto, --yolo, and --plan with resumed sessions (#683) * fix(cli,tui): allow --auto, --yolo, and --plan with resumed sessions * docs(cli): update flag conflict docs for resumed sessions * fix(tui): apply startup permission/plan overrides after picker selection --- ...llow-auto-yolo-plan-with-session-resume.md | 5 + apps/kimi-code/src/cli/options.ts | 9 - apps/kimi-code/src/tui/kimi-tui.ts | 144 ++-- apps/kimi-code/test/cli/options.test.ts | 70 +- .../test/tui/kimi-tui-startup.test.ts | 751 ++++++++++++------ docs/en/guides/sessions.md | 2 +- docs/en/reference/kimi-command.md | 4 +- docs/zh/guides/sessions.md | 2 +- docs/zh/reference/kimi-command.md | 4 +- 9 files changed, 652 insertions(+), 339 deletions(-) create mode 100644 .changeset/allow-auto-yolo-plan-with-session-resume.md diff --git a/.changeset/allow-auto-yolo-plan-with-session-resume.md b/.changeset/allow-auto-yolo-plan-with-session-resume.md new file mode 100644 index 000000000..17ef5eeb0 --- /dev/null +++ b/.changeset/allow-auto-yolo-plan-with-session-resume.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. 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/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 7a43affc5..759a92667 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 { this.showStatus(parts.join(' · ') + '.'); } 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,18 @@ 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) { + if (startup.auto) { + await session.setPermission('auto'); + } else if (startup.yolo) { + await session.setPermission('yolo'); + } + if (startup.plan) { + await session.setPlanMode(true); + } + if (startup.model !== undefined) { + await session.setModel(startup.model); + } } } catch (error) { if (!isOAuthLoginRequiredError(error)) throw error; @@ -610,6 +611,7 @@ export class KimiTUI { } await this.setSession(session); await this.syncRuntimeState(session); + this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; } @@ -1079,10 +1081,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 +1097,21 @@ export class KimiTUI { }); } + // 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(); @@ -1336,10 +1350,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 +1407,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 +1439,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 +1469,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 +1649,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 +1695,9 @@ export class KimiTUI { ); } - private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean { + private shouldPlaceActivitySpinnerInAgentSwarm( + effectiveMode: EffectiveActivityPaneMode, + ): boolean { return ( this.sessionEventHandler.hasActiveAgentSwarmToolCall() && (effectiveMode === 'waiting' || effectiveMode === 'tool') @@ -1781,11 +1789,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. } @@ -1852,10 +1856,21 @@ export class KimiTUI { loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, onSelect: (sessionId: string) => { - void this.resumeSession(sessionId).then((switched) => { - if (switched) { - this.hideSessionPicker(); + void this.resumeSession(sessionId).then(async (switched) => { + if (!switched) { + return; } + const session = this.requireSession(); + if (this.options.startup.auto) { + await session.setPermission('auto'); + } else if (this.options.startup.yolo) { + await session.setPermission('yolo'); + } + if (this.options.startup.plan) { + await session.setPlanMode(true); + } + this.applyStartupPermissionAndPlanToAppState(); + this.hideSessionPicker(); }); }, onCancel, @@ -1955,5 +1970,4 @@ export class KimiTUI { this.patchLivePane({ pendingQuestion: null }); this.restoreEditor(); } - } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 90fb53ecf..e14629e01 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/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index f207d3914..a46d67466 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,253 @@ 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('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 +499,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 +513,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 +562,92 @@ 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('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 +655,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 +674,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,23 +697,23 @@ 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(); expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); - it("starts TUI without a session when fresh startup needs OAuth login", async () => { + it('starts TUI without a session when fresh startup needs OAuth login', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { throw loginRequiredError(); @@ -457,11 +723,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, @@ -470,12 +736,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, @@ -488,10 +754,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, @@ -501,9 +767,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, }); @@ -511,31 +777,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, @@ -548,10 +814,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, @@ -563,25 +829,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 }, }, })), }); @@ -593,25 +859,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(), @@ -627,22 +893,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: [] })), @@ -662,20 +928,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).', }), }), ); @@ -684,18 +950,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(), @@ -707,38 +973,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(), @@ -750,33 +1014,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(), @@ -787,17 +1051,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(); }), @@ -806,29 +1068,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(), @@ -836,11 +1098,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; @@ -853,40 +1115,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 @@ -896,23 +1158,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. @@ -923,31 +1183,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); }); @@ -965,29 +1221,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/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-command.md b/docs/en/reference/kimi-command.md index b8be01096..5b96a9496 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 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-command.md b/docs/zh/reference/kimi-command.md index 36fb20d4c..67d259f30 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 权限模式。 ## 典型用法 From 1b55185f841b4d32883e0a188c5168fce6078346 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 12 Jun 2026 15:02:49 +0800 Subject: [PATCH 02/17] fix(changeset): downgrade agent-core and kimi-code bumps from minor to patch (#687) Update .changeset/qualify-sub-skill-names.md to use patch instead of minor for both packages. --- .changeset/qualify-sub-skill-names.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/qualify-sub-skill-names.md b/.changeset/qualify-sub-skill-names.md index f6f5823aa..a7c4d4700 100644 --- a/.changeset/qualify-sub-skill-names.md +++ b/.changeset/qualify-sub-skill-names.md @@ -1,6 +1,6 @@ --- -"@moonshot-ai/agent-core": minor -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch --- Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI. From c1191f5794f298d42cc256d066d653913ed4665e Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Fri, 12 Jun 2026 15:05:26 +0800 Subject: [PATCH 03/17] test: redact internal endpoint fixtures (#688) --- .../test/utils/kimi-datasource-plugin.test.ts | 2 +- .../agent-core/test/config/configs.test.ts | 16 +++---- .../agent-core/test/rpc/plugins-rpc.test.ts | 8 ++-- packages/node-sdk/test/auth-facade.test.ts | 34 ++++++------- packages/oauth/test/managed-kimi-code.test.ts | 48 +++++++++---------- packages/oauth/test/toolkit.test.ts | 8 ++-- 6 files changed, 58 insertions(+), 58 deletions(-) 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/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index dd3bb2eb8..3eb85fe98 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -232,29 +232,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', ); }); 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/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 09e8b71fb..0c6209090 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -64,8 +64,8 @@ describe('KimiHarness.auth', () => { it('resolves cached access tokens from the configured scoped OAuth ref', async () => { const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test', + baseUrl: 'https://api.dev.example.test/coding/v1', }); const storageName = resolveKimiTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); @@ -76,9 +76,9 @@ describe('KimiHarness.auth', () => { ` [providers."managed:kimi-code"] type = "kimi" -base_url = "https://coding.deva.msh.team/coding/v1" +base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" } +oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } `, ); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); @@ -88,8 +88,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. it('reports auth status from the configured scoped OAuth ref', async () => { const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test', + baseUrl: 'https://api.dev.example.test/coding/v1', }); await new FileTokenStorage(join(homeDir, 'credentials')).save( resolveKimiTokenStorageName({ oauthKey }), @@ -100,9 +100,9 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. ` [providers."managed:kimi-code"] type = "kimi" -base_url = "https://coding.deva.msh.team/coding/v1" +base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" } +oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } `, ); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); @@ -174,8 +174,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. }); it('logs in against the configured scoped OAuth host and base URL when env is absent', async () => { - const baseUrl = 'https://coding.deva.msh.team/coding/v1'; - const oauthHost = 'https://auth.dev.kimi.team'; + const baseUrl = 'https://api.dev.example.test/coding/v1'; + const oauthHost = 'https://auth.dev.example.test'; const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl }); const storageName = resolveKimiTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); @@ -474,8 +474,8 @@ oauth = { storage = "file", key = "oauth/kimi-code" } it('removes the configured scoped OAuth token on logout without touching the production token', async () => { const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test', + baseUrl: 'https://api.dev.example.test/coding/v1', }); const storageName = resolveKimiTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); @@ -488,9 +488,9 @@ default_model = "kimi-code/kimi-for-coding" [providers."managed:kimi-code"] type = "kimi" -base_url = "https://coding.deva.msh.team/coding/v1" +base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" } +oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } [models."kimi-code/kimi-for-coding"] provider = "managed:kimi-code" @@ -580,9 +580,9 @@ max_context_size = 262144 }); it('uses configured scoped OAuth refs and base URLs for managed usage and feedback', async () => { - const baseUrl = 'https://coding.deva.msh.team/coding/v1'; + const baseUrl = 'https://api.dev.example.test/coding/v1'; const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', baseUrl, }); const storageName = resolveKimiTokenStorageName({ oauthKey }); @@ -597,7 +597,7 @@ max_context_size = 262144 type = "kimi" base_url = "${baseUrl}" api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.kimi.team" } +oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } `, ); const fetchMock = vi.fn(async (input) => { diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index 6857925c6..472c6c458 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -55,16 +55,16 @@ describe('provisionManagedKimiCodeConfig', () => { it('scopes credential keys for non-default OAuth hosts and API base URLs', () => { const devKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test', + baseUrl: 'https://api.dev.example.test/coding/v1', }); expect(devKey).not.toBe(KIMI_CODE_OAUTH_KEY); expect(devKey).toMatch(/^oauth\/kimi-code-env-[a-f0-9]{16}$/); expect( resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team/', - baseUrl: 'https://coding.deva.msh.team/coding/v1/', + oauthHost: 'https://auth.dev.example.test/', + baseUrl: 'https://api.dev.example.test/coding/v1/', }), ).toBe(devKey); }); @@ -94,16 +94,16 @@ describe('provisionManagedKimiCodeConfig', () => { // A non-default environment yields a scoped key AND the normalized host, // both derived from the same input — login and runtime cannot drift apart. const devRef = resolveKimiCodeOAuthRef({ - oauthHost: 'https://auth.dev.kimi.team/', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test/', + baseUrl: 'https://api.dev.example.test/coding/v1', }); expect(devRef).toEqual({ storage: 'file', key: resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test', + baseUrl: 'https://api.dev.example.test/coding/v1', }), - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', }); }); @@ -136,14 +136,14 @@ describe('provisionManagedKimiCodeConfig', () => { }); it('preserves a matching configured runtime OAuth ref when env is not overridden', () => { - const baseUrl = 'https://coding.deva.msh.team/coding/v1'; + const baseUrl = 'https://api.dev.example.test/coding/v1'; const configuredOAuthRef = { storage: 'keyring' as const, key: resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', baseUrl, }), - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', }; expect( @@ -277,15 +277,15 @@ describe('provisionManagedKimiCodeConfig', () => { providers: {}, }; const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.kimi.team', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + oauthHost: 'https://auth.dev.example.test', + baseUrl: 'https://api.dev.example.test/coding/v1', }); await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + baseUrl: 'https://api.dev.example.test/coding/v1', oauthKey, - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, @@ -295,22 +295,22 @@ describe('provisionManagedKimiCodeConfig', () => { }); expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - baseUrl: 'https://coding.deva.msh.team/coding/v1', + baseUrl: 'https://api.dev.example.test/coding/v1', oauth: { storage: 'file', key: oauthKey, - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', }, }); expect(config.services?.moonshotSearch?.oauth).toEqual({ storage: 'file', key: oauthKey, - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', }); expect(config.services?.moonshotFetch?.oauth).toEqual({ storage: 'file', key: oauthKey, - oauthHost: 'https://auth.dev.kimi.team', + oauthHost: 'https://auth.dev.example.test', }); }); @@ -745,22 +745,22 @@ describe('provisionManagedKimiCodeConfig', () => { const promise = fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + baseUrl: 'https://api.dev.example.test/coding/v1', fetchImpl, }); await expect(promise).rejects.toThrow( - "Kimi Code models endpoint https://coding.deva.msh.team/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", + "Kimi Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", ); await expect( fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', - baseUrl: 'https://coding.deva.msh.team/coding/v1', + baseUrl: 'https://api.dev.example.test/coding/v1', fetchImpl, }), ).rejects.toMatchObject({ status: 402, - baseUrl: 'https://coding.deva.msh.team/coding/v1', + baseUrl: 'https://api.dev.example.test/coding/v1', }); await expect( fetchManagedKimiCodeModels({ diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index a50a07e0c..04abc3456 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -146,10 +146,10 @@ describe('KimiOAuthToolkit', () => { it('refreshes configured bearer token refs against their OAuth host', async () => { const storage = new MemoryTokenStorage(); - const oauthHost = 'https://auth.dev.kimi.team'; + const oauthHost = 'https://auth.dev.example.test'; const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, - baseUrl: 'https://coding.deva.msh.team/coding/v1', + baseUrl: 'https://api.dev.example.test/coding/v1', }); storage.tokens.set(resolveKimiTokenStorageName({ oauthKey }), { ...token('expired-dev-access'), @@ -419,8 +419,8 @@ describe('KimiOAuthToolkit', () => { const storage = new MemoryTokenStorage(); storage.tokens.set('kimi-code', token('prod-access')); const config: ManagedKimiConfigShape = { providers: {} }; - const devBaseUrl = 'https://coding.deva.msh.team/coding/v1'; - const devOauthHost = 'https://auth.dev.kimi.team'; + const devBaseUrl = 'https://api.dev.example.test/coding/v1'; + const devOauthHost = 'https://auth.dev.example.test'; const devOauthKey = resolveKimiCodeOAuthKey({ oauthHost: devOauthHost, baseUrl: devBaseUrl, From 8d251f8ab44ead65f6c1bb264980ee7d075142ad Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 15:56:13 +0800 Subject: [PATCH 04/17] feat(config): tolerate invalid config.toml sections instead of failing startup (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): tolerate invalid config.toml sections instead of failing startup Schema errors now drop only the offending sections (single entries for providers/models) with a warning, so a typo no longer prevents startup or drops the login state. TOML syntax errors still fail fast with the parse location. Mid-run reloads keep the last good config when the file breaks. Warnings surface via the new getConfigDiagnostics API: as a startup notice in the TUI, on stderr in print mode, and in the status bar after /new. Write paths stay strict so a broken file is never silently rewritten, and now fail with a short actionable message instead of raw validation JSON; the /provider TUI flow and the kimi provider CLI report these errors instead of crashing on an unhandled rejection. * fix(config): keep entry-keyed sections when one entry has multiple issues A providers/models entry with several validation issues was deleted by the first issue, and the remaining issues from the same safeParse pass then escalated to deleting the entire section — one badly-typed custom provider could drop every provider, including the managed OAuth login. Issues on entry-keyed sections now only ever target the entry itself; once it is gone, later issues are no-ops. --- .changeset/tolerate-invalid-config.md | 7 + apps/kimi-code/src/cli/run-prompt.ts | 3 + apps/kimi-code/src/cli/run-shell.ts | 4 + apps/kimi-code/src/cli/sub/provider.ts | 43 ++-- apps/kimi-code/src/tui/commands/provider.ts | 24 +- apps/kimi-code/src/tui/kimi-tui.ts | 13 ++ apps/kimi-code/test/cli/goal-prompt.test.ts | 1 + apps/kimi-code/test/cli/provider.test.ts | 24 ++ apps/kimi-code/test/cli/run-prompt.test.ts | 2 + apps/kimi-code/test/cli/run-shell.test.ts | 34 +++ packages/agent-core/src/config/toml.ts | 181 ++++++++++++++- packages/agent-core/src/rpc/core-api.ts | 6 + packages/agent-core/src/rpc/core-impl.ts | 59 ++++- .../agent-core/test/config/configs.test.ts | 209 ++++++++++++++++++ .../agent-core/test/rpc/config-rpc.test.ts | 110 +++++++++ packages/node-sdk/src/auth.ts | 18 +- packages/node-sdk/src/kimi-harness.ts | 6 + packages/node-sdk/src/rpc.ts | 6 + packages/node-sdk/src/types.ts | 1 + packages/node-sdk/test/auth-facade.test.ts | 36 ++- 20 files changed, 749 insertions(+), 38 deletions(-) create mode 100644 .changeset/tolerate-invalid-config.md create mode 100644 packages/agent-core/test/rpc/config-rpc.test.ts diff --git a/.changeset/tolerate-invalid-config.md b/.changeset/tolerate-invalid-config.md new file mode 100644 index 000000000..0cae217e9 --- /dev/null +++ b/.changeset/tolerate-invalid-config.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Drop invalid config.toml sections with a warning instead of failing to start. 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..b712891b2 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -410,13 +410,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 +437,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 +446,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 +465,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 +487,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/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/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 759a92667..569e68070 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1312,6 +1312,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 */ + } } // ========================================================================= 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/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/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/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index af2e00a4d..b080802ee 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -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/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 3eb85fe98..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, @@ -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/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/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 5443464a2..28d13ed3c 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -1,4 +1,11 @@ -import { readConfigFile, writeConfigFile, type KimiConfig, type OAuthRef } from '@moonshot-ai/agent-core'; +import { + loadRuntimeConfigSafe, + readConfigFile, + readConfigFileForUpdate, + writeConfigFile, + type KimiConfig, + type OAuthRef, +} from '@moonshot-ai/agent-core'; import { applyManagedKimiCodeConfig, applyManagedKimiCodeLogoutConfig, @@ -59,7 +66,9 @@ export class KimiAuthFacade { onRefresh: options.onRefresh, configAdapter: { configPath: options.configPath, - read: () => readConfigFile(options.configPath) as SDKManagedConfig, + // Write-path base read: strict (a salvaged base would drop the user's + // broken-but-fixable sections on rewrite) with an actionable message. + read: () => readConfigFileForUpdate(options.configPath) as SDKManagedConfig, write: async (config) => { await writeConfigFile(options.configPath, config); }, @@ -169,7 +178,10 @@ export class KimiAuthFacade { readonly baseUrl?: string | undefined; } { const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const config = readConfigFile(this.options.configPath); + // Read path: token/status resolution must work off a degraded config + // instead of failing the session when an unrelated section is broken. + // Write paths (the toolkit's configAdapter.read) stay strict. + const config = loadRuntimeConfigSafe(this.options.configPath).config; const provider = config.providers[name]; return { oauthRef: provider?.oauth, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index d54d989d0..1dbd1bdf8 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -10,6 +10,7 @@ import { Session } from '#/session'; import type { KimiAuthFacade } from '#/auth'; import type { SDKRpcClientBase } from '#/rpc'; import type { + ConfigDiagnostics, CreateSessionOptions, ExportSessionInput, ExportSessionResult, @@ -215,6 +216,11 @@ export class KimiHarness { return this.rpc.getConfig(options); } + /** Warnings from the most recent config.toml load; empty when the config is fully valid. */ + async getConfigDiagnostics(): Promise { + return this.rpc.getConfigDiagnostics(); + } + async getExperimentalFeatures(): Promise { return this.rpc.getExperimentalFeatures(); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index ebebf9d90..10ab9cec5 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -22,6 +22,7 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ApprovalHandler, QuestionHandler } from '#/events'; import type { BackgroundTaskInfo, + ConfigDiagnostics, CreateSessionOptions, ExportSessionInput, ExportSessionResult, @@ -197,6 +198,11 @@ export abstract class SDKRpcClientBase { return rpc.getKimiConfig(input ?? {}); } + async getConfigDiagnostics(): Promise { + const rpc = await this.getRpc(); + return rpc.getConfigDiagnostics({}); + } + async getExperimentalFeatures(): Promise { const rpc = await this.getRpc(); return rpc.getExperimentalFeatures({}); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 3d4896257..041d78495 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -22,6 +22,7 @@ export type { BackgroundConfig, BackgroundTaskInfo, BackgroundTaskStatus, + ConfigDiagnostics, ContextMessage, ExperimentalFeatureState, ExperimentalFlagMap, diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 0c6209090..59d267b0b 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -62,6 +62,29 @@ describe('KimiHarness.auth', () => { await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); }); + it('resolves managed auth from a partially invalid config without throwing', async () => { + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers."managed:kimi-code"] +type = "kimi" +api_key = "" + +[loop_control] +max_steps_per_turn = "abc" +`, + ); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + // Token resolution is a read path: a broken section elsewhere in + // config.toml must degrade, not break OAuth-backed sessions. + await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); + await expect(harness.auth.status()).resolves.toMatchObject({ + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], + }); + }); + it('resolves cached access tokens from the configured scoped OAuth ref', async () => { const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', @@ -367,7 +390,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ }); }); - it('fails clearly when a configured model alias does not have max_context_size', async () => { + it('starts degraded when a configured model alias does not have max_context_size', async () => { await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), @@ -404,9 +427,14 @@ model = "kimi-for-coding" ), ); - expect(() => createKimiHarness({ homeDir, identity: TEST_IDENTITY })).toThrow( - /Model "kimi-code\/kimi-for-coding" must define a positive max_context_size/, - ); + // A broken config must not prevent startup: the invalid model alias is + // dropped, the rest of the config survives, and a warning is reported. + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + const config = await harness.getConfig(); + expect(config.models?.['kimi-code/kimi-for-coding']).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined(); + const { warnings } = await harness.getConfigDiagnostics(); + expect(warnings.some((w) => w.includes('models.kimi-code/kimi-for-coding'))).toBe(true); }); it('removes managed Kimi config on logout', async () => { From 7f0dde2ece3f9a004e934d69258dfd47c954043c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 16:49:00 +0800 Subject: [PATCH 05/17] fix(tui): gate terminal progress sequences behind OSC 9;4 support (#690) iTerm2 interprets any OSC 9 payload as a desktop notification, so the ConEmu-style 9;4 progress sequence (re-sent every second by the progress keepalive) flooded users with notifications. Only emit progress on terminals known to implement OSC 9;4: Windows Terminal, ConEmu, Ghostty, and WezTerm. --- .changeset/iterm2-progress-notifications.md | 5 ++++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + .../src/tui/utils/terminal-notification.ts | 19 +++++++++++++ .../kimi-code/src/tui/utils/terminal-state.ts | 8 +++++- apps/kimi-code/test/tui/activity-pane.test.ts | 19 +++++++++++++ .../test/tui/terminal-notification.test.ts | 27 +++++++++++++++++++ 6 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .changeset/iterm2-progress-notifications.md diff --git a/.changeset/iterm2-progress-notifications.md b/.changeset/iterm2-progress-notifications.md new file mode 100644 index 000000000..473a415db --- /dev/null +++ b/.changeset/iterm2-progress-notifications.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 569e68070..0551b2a64 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1722,6 +1722,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; 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/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/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); From d1ba14562bafdb6b93c3eec1b5c453186507ed56 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 12 Jun 2026 17:08:40 +0800 Subject: [PATCH 06/17] feat(providers): sync custom registry providers on startup refresh (#675) * feat(providers): sync custom registry providers on startup refresh - group registry providers by URL and retry available API keys\n- automatically add new providers and remove disappeared ones\n- coalesce duplicate source URLs to avoid false config-change reports\n- clear defaultThinking when default model is removed\n- update docs and add tests for registry sync scenarios * fix(tui): only show provider refresh status for added models Skip removed / metadata-only provider updates when reporting model list changes.\n\n add: test to enforce the behavior. --- .changeset/sync-registry-providers.md | 5 + apps/kimi-code/src/cli/sub/provider.ts | 5 +- apps/kimi-code/src/tui/kimi-tui.ts | 8 +- .../src/tui/utils/refresh-providers.ts | 133 +++++++- .../test/tui/utils/refresh-providers.test.ts | 283 ++++++++++++++++++ docs/en/configuration/providers.md | 2 +- docs/en/reference/kimi-command.md | 2 +- docs/zh/configuration/providers.md | 2 +- docs/zh/reference/kimi-command.md | 2 +- packages/oauth/src/custom-registry.ts | 7 +- 10 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 .changeset/sync-registry-providers.md diff --git a/.changeset/sync-registry-providers.md b/.changeset/sync-registry-providers.md new file mode 100644 index 000000000..b0908a7ca --- /dev/null +++ b/.changeset/sync-registry-providers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index b712891b2..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 { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0551b2a64..87c0b4cb2 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -475,10 +475,10 @@ export class KimiTUI { try { const result = await this.authFlow.refreshProviderModels(); for (const c of result.changed) { - const parts: string[] = [c.providerName]; - if (c.added > 0) parts.push(`+${String(c.added)} model${c.added > 1 ? 's' : ''}`); - if (c.removed > 0) parts.push(`-${String(c.removed)} model${c.removed > 1 ? 's' : ''}`); - this.showStatus(parts.join(' · ') + '.'); + if (c.added <= 0) continue; + this.showStatus( + `${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`, + ); } for (const f of result.failed) { this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index aa8cd7577..bdda7aa40 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, @@ -42,7 +43,7 @@ export interface RefreshResult { 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 +52,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 +174,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 +238,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 ''; @@ -263,6 +311,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); @@ -332,6 +381,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 +413,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 +459,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 +511,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 +529,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/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index d2b0d778b..17749a61a 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -261,6 +261,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/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/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 5b96a9496..a0623445b 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -226,7 +226,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/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/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 67d259f30..9e8c9180b 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -226,7 +226,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/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index cad5fd9f2..0c5d720f7 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -6,9 +6,10 @@ export type { ManagedKimiConfigShape }; /** * Identifies where a custom-registry-managed provider came from. The same - * `{url, apiKey}` pair may produce multiple providers (one per top-level entry - * in the api.json document) — the refresh dispatcher groups by these fields to - * issue a single HTTP GET per source. + * URL may produce multiple providers (one per top-level entry in the api.json + * document). Refresh treats the URL as the stable registry identity and may try + * more than one API key when existing provider records drift during key + * rotation. */ export interface CustomRegistrySource { readonly kind: 'apiJson'; From 7ca9bdfed516d148b063229a9686a28f9e29aaef Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 17:41:25 +0800 Subject: [PATCH 07/17] fix(tui): skip re-entering plan mode on resume and scope startup flags to startup (#692) Resuming a session that was already in plan mode with --plan crashed with "Already in plan mode": the resume path called setPlanMode(true) unconditionally while session replay had already restored the active plan state. Check the session status first and only enable plan mode when it is not active yet, in both the resume startup path and the startup session picker. The /sessions picker shared the same onSelect callback, so startup flags were also re-applied on every mid-session switch, overriding the picked session's own persisted modes. Gate the flag application behind an applyStartupModes option that only the startup picker enables, and surface post-switch setup errors instead of leaving them as unhandled rejections. --- ...eplan-on-resume-and-scope-startup-flags.md | 5 + apps/kimi-code/src/tui/kimi-tui.ts | 102 ++++++++++-------- .../test/tui/kimi-tui-startup.test.ts | 102 ++++++++++++++++++ 3 files changed, 166 insertions(+), 43 deletions(-) create mode 100644 .changeset/skip-replan-on-resume-and-scope-startup-flags.md diff --git a/.changeset/skip-replan-on-resume-and-scope-startup-flags.md b/.changeset/skip-replan-on-resume-and-scope-startup-flags.md new file mode 100644 index 000000000..ec7e549e7 --- /dev/null +++ b/.changeset/skip-replan-on-resume-and-scope-startup-flags.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +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. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 87c0b4cb2..0337785f0 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -588,14 +588,7 @@ export class KimiTUI { session = await this.harness.createSession(createSessionOptions); } if (session !== undefined && shouldReplayHistory) { - if (startup.auto) { - await session.setPermission('auto'); - } else if (startup.yolo) { - await session.setPermission('yolo'); - } - if (startup.plan) { - await session.setPlanMode(true); - } + await this.applyStartupModesToResumedSession(session); if (startup.model !== undefined) { await session.setModel(startup.model); } @@ -1097,6 +1090,25 @@ 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. @@ -1830,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 { @@ -1859,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({ @@ -1870,26 +1888,24 @@ export class KimiTUI { loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, onSelect: (sessionId: string) => { - void this.resumeSession(sessionId).then(async (switched) => { - if (!switched) { - return; - } - const session = this.requireSession(); - if (this.options.startup.auto) { - await session.setPermission('auto'); - } else if (this.options.startup.yolo) { - await session.setPermission('yolo'); - } - if (this.options.startup.plan) { - await session.setPlanMode(true); - } - this.applyStartupPermissionAndPlanToAppState(); - this.hideSessionPicker(); - }); + 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, }), ); } 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 a46d67466..bed844af8 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -363,6 +363,33 @@ describe('KimiTUI startup', () => { 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', @@ -627,6 +654,81 @@ describe('KimiTUI startup', () => { 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, { From 1cb49dba5bbc7d015a791ec9699d45df931ead92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:43:06 +0800 Subject: [PATCH 08/17] ci: release packages (#678) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- ...llow-auto-yolo-plan-with-session-resume.md | 5 ----- .changeset/iterm2-progress-notifications.md | 5 ----- .changeset/qualify-sub-skill-names.md | 6 ------ .changeset/replay-compaction-records.md | 6 ------ .changeset/shell-streaming-output.md | 6 ------ ...eplan-on-resume-and-scope-startup-flags.md | 5 ----- .changeset/sync-registry-providers.md | 5 ----- .changeset/tolerate-invalid-config.md | 7 ------- apps/kimi-code/CHANGELOG.md | 20 +++++++++++++++++++ apps/kimi-code/package.json | 2 +- packages/agent-core/CHANGELOG.md | 12 +++++++++++ packages/agent-core/package.json | 2 +- packages/node-sdk/CHANGELOG.md | 6 ++++++ packages/node-sdk/package.json | 2 +- 14 files changed, 41 insertions(+), 48 deletions(-) delete mode 100644 .changeset/allow-auto-yolo-plan-with-session-resume.md delete mode 100644 .changeset/iterm2-progress-notifications.md delete mode 100644 .changeset/qualify-sub-skill-names.md delete mode 100644 .changeset/replay-compaction-records.md delete mode 100644 .changeset/shell-streaming-output.md delete mode 100644 .changeset/skip-replan-on-resume-and-scope-startup-flags.md delete mode 100644 .changeset/sync-registry-providers.md delete mode 100644 .changeset/tolerate-invalid-config.md diff --git a/.changeset/allow-auto-yolo-plan-with-session-resume.md b/.changeset/allow-auto-yolo-plan-with-session-resume.md deleted file mode 100644 index 17ef5eeb0..000000000 --- a/.changeset/allow-auto-yolo-plan-with-session-resume.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. diff --git a/.changeset/iterm2-progress-notifications.md b/.changeset/iterm2-progress-notifications.md deleted file mode 100644 index 473a415db..000000000 --- a/.changeset/iterm2-progress-notifications.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them. diff --git a/.changeset/qualify-sub-skill-names.md b/.changeset/qualify-sub-skill-names.md deleted file mode 100644 index a7c4d4700..000000000 --- a/.changeset/qualify-sub-skill-names.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI. diff --git a/.changeset/replay-compaction-records.md b/.changeset/replay-compaction-records.md deleted file mode 100644 index 3bc6c9de3..000000000 --- a/.changeset/replay-compaction-records.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Show completed and cancelled compaction records correctly when resuming a session. diff --git a/.changeset/shell-streaming-output.md b/.changeset/shell-streaming-output.md deleted file mode 100644 index 5d4fb7ada..000000000 --- a/.changeset/shell-streaming-output.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Stream foreground Bash stdout and stderr while commands are still running. diff --git a/.changeset/skip-replan-on-resume-and-scope-startup-flags.md b/.changeset/skip-replan-on-resume-and-scope-startup-flags.md deleted file mode 100644 index ec7e549e7..000000000 --- a/.changeset/skip-replan-on-resume-and-scope-startup-flags.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -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. diff --git a/.changeset/sync-registry-providers.md b/.changeset/sync-registry-providers.md deleted file mode 100644 index b0908a7ca..000000000 --- a/.changeset/sync-registry-providers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. diff --git a/.changeset/tolerate-invalid-config.md b/.changeset/tolerate-invalid-config.md deleted file mode 100644 index 0cae217e9..000000000 --- a/.changeset/tolerate-invalid-config.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/kimi-code": patch ---- - -Drop invalid config.toml sections with a warning instead of failing to start. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 29e6a229d..b38fac3a3 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/kimi-code +## 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 ff223543a..2c7289645 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.2", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", 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 c88a79bc4..54e0b12f3 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/node-sdk/CHANGELOG.md b/packages/node-sdk/CHANGELOG.md index 3bee845bb..32a65d0f3 100644 --- a/packages/node-sdk/CHANGELOG.md +++ b/packages/node-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code-sdk +## 0.9.3 + +### Patch Changes + +- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start. + ## 0.9.2 ### Patch Changes diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 81ff4ab87..1d83cf528 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code-sdk", - "version": "0.9.2", + "version": "0.9.3", "private": true, "description": "TypeScript SDK for the Kimi Code Agent", "license": "MIT", From 1c65cbf6c3589bac07c12da546cae3669a28bfb0 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 21:42:51 +0800 Subject: [PATCH 09/17] docs(changelog): sync 0.14.2 from apps/kimi-code/CHANGELOG.md (#698) --- docs/en/release-notes/changelog.md | 15 +++++++++++++++ docs/zh/release-notes/changelog.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) 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/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) ### 修复 From f874251288927243a9b9d4bfd546e8c17754d566 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Sat, 13 Jun 2026 20:55:35 +0800 Subject: [PATCH 10/17] feat(kimi-code): refresh OAuth provider models before opening model picker (#713) - call refreshOAuthProviderModels before /model picker opens - add scoped refreshAllProviderModels with 'oauth' and 'all' scopes - update tests for async picker rendering and OAuth-only refresh --- .changeset/refresh-model-picker-oauth.md | 5 + apps/kimi-code/src/tui/commands/config.ts | 36 +++++- apps/kimi-code/src/tui/commands/dispatch.ts | 2 +- .../src/tui/controllers/auth-flow.ts | 44 ++++---- .../src/tui/utils/refresh-providers.ts | 16 ++- .../test/tui/kimi-tui-message-flow.test.ts | 103 +++++++++++++++++- .../test/tui/utils/refresh-providers.test.ts | 86 +++++++++++++++ 7 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 .changeset/refresh-model-picker-oauth.md diff --git a/.changeset/refresh-model-picker-oauth.md b/.changeset/refresh-model-picker-oauth.md new file mode 100644 index 000000000..fd8034a3d --- /dev/null +++ b/.changeset/refresh-model-picker-oauth.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refresh provider model metadata before opening the model picker. 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/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/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index bdda7aa40..a25c4b7cf 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -40,6 +40,12 @@ 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; @@ -264,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(); @@ -343,6 +353,10 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi } } + if (scope === 'oauth') { + return { changed, unchanged, failed }; + } + // ------------------------------------------------------------------------- // 2. Open Platforms (moonshot-cn, moonshot-ai, …) // ------------------------------------------------------------------------- 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..3868f8fd2 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 @@ -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/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index 17749a61a..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'; From 93928066dc308052de8c4a48e9c10b2f3dba361b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:12:54 +0800 Subject: [PATCH 11/17] ci: release packages (#717) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/refresh-model-picker-oauth.md | 5 ----- apps/kimi-code/CHANGELOG.md | 6 ++++++ apps/kimi-code/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/refresh-model-picker-oauth.md diff --git a/.changeset/refresh-model-picker-oauth.md b/.changeset/refresh-model-picker-oauth.md deleted file mode 100644 index fd8034a3d..000000000 --- a/.changeset/refresh-model-picker-oauth.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Refresh provider model metadata before opening the model picker. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index b38fac3a3..b292b7096 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,11 @@ # @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 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 2c7289645..4941f1c31 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.2", + "version": "0.14.3", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", From 18f299fd0b266545a1f7cebae9f58b83b9d9776e Mon Sep 17 00:00:00 2001 From: oocz <37055139+oocz@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:04:26 +0800 Subject: [PATCH 12/17] mcp suport sse (#744) Co-authored-by: yuchengzhen --- .changeset/sse-mcp-servers.md | 8 + .../components/dialogs/plugins-selector.ts | 4 +- .../test/tui/kimi-tui-message-flow.test.ts | 2 +- docs/en/customization/mcp.md | 24 ++- docs/en/guides/ides.md | 2 +- docs/en/reference/kimi-acp.md | 5 +- docs/zh/customization/mcp.md | 24 ++- docs/zh/guides/ides.md | 2 +- docs/zh/reference/kimi-acp.md | 5 +- packages/acp-adapter/src/mcp.ts | 14 +- packages/acp-adapter/src/server.ts | 4 +- .../acp-adapter/test/e2e-happy-path.test.ts | 4 +- packages/acp-adapter/test/mcp-forward.test.ts | 25 ++- packages/acp-adapter/test/server.test.ts | 2 +- packages/agent-core/src/agent/tool/index.ts | 4 +- packages/agent-core/src/config/schema.ts | 15 ++ packages/agent-core/src/mcp/auth-tool.ts | 2 +- packages/agent-core/src/mcp/client-http.ts | 20 +-- packages/agent-core/src/mcp/client-remote.ts | 32 ++++ packages/agent-core/src/mcp/client-sse.ts | 169 ++++++++++++++++++ .../agent-core/src/mcp/connection-manager.ts | 35 +++- packages/agent-core/src/plugin/manager.ts | 6 +- packages/agent-core/src/plugin/manifest.ts | 2 +- packages/agent-core/src/plugin/types.ts | 2 +- packages/agent-core/src/rpc/core-api.ts | 2 +- .../agent-core/test/mcp/client-sse.test.ts | 142 +++++++++++++++ .../agent-core/test/mcp/config-loader.test.ts | 24 ++- .../test/mcp/connection-manager.test.ts | 60 +++++++ .../agent-core/test/plugin/manager.test.ts | 21 ++- .../agent-core/test/plugin/manifest.test.ts | 10 ++ packages/protocol/src/events.ts | 2 +- 31 files changed, 587 insertions(+), 86 deletions(-) create mode 100644 .changeset/sse-mcp-servers.md create mode 100644 packages/agent-core/src/mcp/client-remote.ts create mode 100644 packages/agent-core/src/mcp/client-sse.ts create mode 100644 packages/agent-core/test/mcp/client-sse.test.ts 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/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/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 3868f8fd2..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; 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/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/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/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/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/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/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 b080802ee..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; 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/protocol/src/events.ts b/packages/protocol/src/events.ts index c2d703e36..763052d78 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -528,7 +528,7 @@ export interface McpServerStatusEvent { export interface McpServerStatusPayload { readonly name: string; - readonly transport: 'stdio' | 'http'; + readonly transport: 'stdio' | 'http' | 'sse'; readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; readonly toolCount: number; readonly error?: string; From 9cef89656311974a57e6675f474ea6c2adb1d8e9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 15 Jun 2026 12:06:56 +0800 Subject: [PATCH 13/17] fix: clarify compaction summary output target (#766) --- .changeset/quiet-compaction-final-answer.md | 6 ++++++ .../src/agent/compaction/compaction-instruction.md | 2 ++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/quiet-compaction-final-answer.md 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/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] - ... + + From 046856b740afb604132e914f1fc489de72394036 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 15 Jun 2026 12:09:05 +0800 Subject: [PATCH 14/17] fix: prefer media headers when reading media files (#765) --- .changeset/read-media-header-types.md | 6 +++++ .../src/tools/builtin/file/read-media.ts | 6 ++--- .../agent-core/src/tools/support/file-type.ts | 26 ++++++++++++++----- .../agent-core/test/tools/file-type.test.ts | 19 ++++++++++++++ .../agent-core/test/tools/read-media.test.ts | 21 +++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 .changeset/read-media-header-types.md 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/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/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: '