diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts index 90636cea55..e1930f12ad 100644 --- a/apps/kimi-code/src/tui/commands/add-dir.ts +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -6,10 +6,13 @@ type AddDirChoice = 'session' | 'remember' | 'cancel'; export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise { const input = args.trim(); - const session = host.session; + let session = host.session; if (input.length === 0 || input.toLowerCase() === 'list') { - const additionalDirs = session?.summary?.additionalDirs ?? []; + // With no session yet (v2 session-less startup) the pending startup + // directories live in appState and will be passed to the lazy-created + // session; reflect them instead of reporting an empty list. + const additionalDirs = session?.summary?.additionalDirs ?? host.state.appState.additionalDirs; if (additionalDirs.length === 0) { host.showStatus('No additional directories configured.'); return; @@ -19,8 +22,14 @@ export async function handleAddDirCommand(host: SlashCommandHost, args: string): } if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // The path-adding form needs a live session; lazy-create it on first use + // (the read-only `list`/bare forms above tolerate a missing session). + session = await host.ensureSession(); + if (session === undefined) return; } host.mountEditorReplacement( diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 2422dc5648..4b0dc70aa4 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -93,6 +93,13 @@ export async function handlePlanCommand(host: SlashCommandHost, args: string): P return; } + // The session may already be in the requested mode (e.g. it was created + // with config.defaultPlanMode applied), and re-entering plan mode throws. + if (host.state.appState.planMode === enabled) { + host.showNotice(`Plan mode is already ${enabled ? 'on' : 'off'}`); + return; + } + await applyPlanMode(host, session, enabled); } @@ -117,10 +124,12 @@ async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: export async function handleYoloCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; - if (session === undefined) { + if (session === undefined && !host.engineV2) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } + // v2 session-less: the chosen mode is recorded in appState and passed to the + // lazy-created session; apply the runtime permission only when one exists. const subcmd = args.trim().toLowerCase(); const currentMode = host.state.appState.permissionMode; @@ -130,7 +139,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P host.showNotice('YOLO mode is already on'); return; } - await session.setPermission('yolo'); + await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); return; @@ -141,7 +150,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P host.showNotice('YOLO mode is already off'); return; } - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('YOLO mode: OFF'); return; @@ -149,11 +158,11 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P // toggle if (currentMode === 'yolo') { - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('YOLO mode: OFF'); } else { - await session.setPermission('yolo'); + await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); } @@ -161,10 +170,12 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P export async function handleAutoCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; - if (session === undefined) { + if (session === undefined && !host.engineV2) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } + // v2 session-less: the chosen mode is recorded in appState and passed to the + // lazy-created session; apply the runtime permission only when one exists. const subcmd = args.trim().toLowerCase(); const currentMode = host.state.appState.permissionMode; @@ -174,7 +185,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P host.showNotice('Auto mode is already on'); return; } - await session.setPermission('auto'); + await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); return; @@ -185,7 +196,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P host.showNotice('Auto mode is already off'); return; } - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('Auto mode: OFF'); return; @@ -193,11 +204,11 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P // toggle if (currentMode === 'auto') { - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('Auto mode: OFF'); } else { - await session.setPermission('auto'); + await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); } @@ -875,7 +886,14 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod } try { - await host.requireSession().setPermission(mode); + if (host.session !== undefined) { + await host.session.setPermission(mode); + } else if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: the chosen mode is recorded in appState and passed to + // the lazy-created session. } catch (error) { const msg = formatErrorMessage(error); host.showError(`Failed to set permission mode: ${msg}`); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b2feffaebe..f467b0c345 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -43,9 +43,17 @@ import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; -import type { BuiltinSlashCommandName } from './registry'; +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommandName, +} from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; +import { + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -103,6 +111,8 @@ export interface SlashCommandHost { state: TUIState; session: Session | undefined; readonly harness: KimiHarness; + /** agent-core-v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG); enables lazy session creation. */ + readonly engineV2: boolean; cancelInFlight: (() => void) | undefined; deferUserMessages: boolean; @@ -117,9 +127,28 @@ export interface SlashCommandHost { restoreEditor(): void; restoreInputText(text: string): void; refreshSlashCommandAutocomplete(): void; + /** + * Rebuild the plugin slash-command list. With no session (v2 session-less + * startup) this reads the app-global plugin commands instead, so `/plugins` + * mutations apply before the first session exists. + */ + refreshPluginCommands(session?: Session): Promise; + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap). No-op semantics on a live session path: only /reload calls + * it while still session-less. + */ + hydrateLazyConfigDefaults(): Promise; // Session requireSession(): Session; + /** + * Lazy-create the session on first use (v2 engine). Returns the existing + * session, or undefined (with the error already surfaced) when creation + * fails. + */ + ensureSession(): Promise; switchToSession(session: Session, message: string): Promise; reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; @@ -204,11 +233,26 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(`Invalid slash command: /${intent.commandName}`); return; case 'skill': { - const session = host.session; - if (host.state.appState.model.trim().length === 0 || session === undefined) { + if (host.state.appState.model.trim().length === 0) { host.showError(LLM_NOT_SET_MESSAGE); return; } + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // A first prompt may have started a turn while the session was being + // created; skill commands are always busy-gated, so re-check the gate + // resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } + } host.track('input_command', { command: intent.commandName, skill_name: intent.skillName, @@ -221,10 +265,20 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(LLM_NOT_SET_MESSAGE); return; } - const session = host.session; + let session = host.session; if (session === undefined) { - host.showError(LLM_NOT_SET_MESSAGE); - return; + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // Same busy re-check as the skill path: plugin commands are always + // busy-gated too. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } } host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); @@ -253,11 +307,60 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi } } +/** + * Lazy-create the session for a slash command that needs one (v2 engine). + * v1 keeps the historical "no active session" error; on v2 a missing session + * means the TUI started session-less, so commands create it on first use. + * Returns undefined (error already shown) when creation fails. + */ +async function ensureSessionForCommand(host: SlashCommandHost): Promise { + if (!host.engineV2) { + host.showError(LLM_NOT_SET_MESSAGE); + return undefined; + } + return host.ensureSession(); +} + +/** Builtin commands that need an active session; lazy-created on the v2 engine. */ +const SESSION_REQUIRING_COMMANDS: ReadonlySet = new Set([ + 'btw', + 'compact', + 'export-debug-zip', + 'export-md', + 'fork', + 'goal', + 'init', + 'plan', + 'swarm', + 'undo', + 'web', +]); + async function handleBuiltInSlashCommand( host: SlashCommandHost, name: BuiltinSlashCommandName, args: string, ): Promise { + if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) { + const session = await ensureSessionForCommand(host); + if (session === undefined) return; + // A first prompt may have started a turn while the session was being + // created; re-check the availability gate that was resolved before the + // await (idle-only commands are blocked while a turn is active). + const command = findBuiltInSlashCommand(name); + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if ( + busyReason !== undefined && + command !== undefined && + resolveSlashCommandAvailability(command, args) === 'idle-only' + ) { + host.showError(slashBusyMessage(name, busyReason)); + return; + } + } switch (name) { case 'exit': void host.stop(); @@ -282,7 +385,14 @@ async function handleBuiltInSlashCommand( void showMcpServers(host); return; case 'plugins': - void handlePluginsCommand(host, args); + // `handlePluginsCommand` throws when no session is active (its own + // requireSession), so catch here instead of letting the `void` call + // reject unhandled. + try { + await handlePluginsCommand(host, args); + } catch (error) { + host.showError(formatErrorMessage(error)); + } return; case 'add-dir': await handleAddDirCommand(host, args); diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..baebf3f57c 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -167,7 +167,15 @@ export async function showStatusReport(host: SlashCommandHost): Promise { export async function showMcpServers(host: SlashCommandHost): Promise { let servers: readonly McpServerInfo[]; try { - servers = await host.requireSession().listMcpServers(); + if (host.session !== undefined) { + servers = await host.session.listMcpServers(); + } else if (host.engineV2) { + // v2 session-less: the MCP connection set is workspace-scoped, so it is + // inspectable before the first session exists. + servers = await host.harness.listWorkspaceMcpServers(host.state.appState.workDir); + } else { + servers = await host.requireSession().listMcpServers(); + } } catch (error) { host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); return; diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 51bd3515a0..d47e421948 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -1,8 +1,9 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, @@ -50,11 +51,46 @@ interface ShowPluginMcpPickerOptions { readonly serverHint?: PluginMcpServerHint; } +/** The plugin-management surface `/plugins` operates on. */ +type PluginApi = Pick< + Session, + | 'listPlugins' + | 'installPlugin' + | 'setPluginEnabled' + | 'setPluginMcpServerEnabled' + | 'removePlugin' + | 'reloadPlugins' + | 'getPluginInfo' +>; + +/** + * Resolve the plugin-management API. On the v2 engine plugin state is + * app-global, so a session-less startup still gets a working `/plugins` + * through the harness's global facade; on v1 (and once a session exists) the + * session's own API is used. + */ +async function resolvePluginApi(host: SlashCommandHost): Promise { + if (host.session !== undefined) return host.session; + if (!host.engineV2) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return { + listPlugins: () => host.harness.listPlugins(), + installPlugin: (source) => host.harness.installPlugin(source), + setPluginEnabled: (id, enabled) => host.harness.setPluginEnabled(id, enabled), + setPluginMcpServerEnabled: (id, server, enabled) => + host.harness.setPluginMcpServerEnabled(id, server, enabled), + removePlugin: (id) => host.harness.removePlugin(id), + reloadPlugins: () => host.harness.reloadPlugins(), + getPluginInfo: (id) => host.harness.getPluginInfo(id), + }; +} + export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise { const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); const sub = args[0]; const rest = args.slice(1); - const session = host.requireSession(); + const session = await resolvePluginApi(host); try { if (sub === undefined) { @@ -163,7 +199,7 @@ async function showPluginsPicker( ): Promise { let plugins: readonly PluginSummary[]; try { - plugins = await host.requireSession().listPlugins(); + plugins = await (await resolvePluginApi(host)).listPlugins(); } catch (error) { host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); return; @@ -230,7 +266,7 @@ async function showPluginMcpPicker( ): Promise { let info: PluginInfo; try { - info = await host.requireSession().getPluginInfo(id); + info = await (await resolvePluginApi(host)).getPluginInfo(id); } catch (error) { host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); return; @@ -259,7 +295,7 @@ async function showPluginMcpPicker( async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise { let displayName = id; try { - displayName = (await host.requireSession().getPluginInfo(id)).displayName; + displayName = (await (await resolvePluginApi(host)).getPluginInfo(id)).displayName; } catch { // Keep the confirmation available even when plugin details cannot be loaded. } @@ -345,7 +381,7 @@ async function applyPluginEnabled( enabled: boolean, showStatus = true, ): Promise { - const session = host.requireSession(); + const session = await resolvePluginApi(host); await session.setPluginEnabled(id, enabled); let info: PluginInfo | undefined; try { @@ -432,11 +468,9 @@ async function handlePluginMcpSelection( ): Promise { switch (selection.kind) { case 'toggle': - await host.requireSession().setPluginMcpServerEnabled( - selection.pluginId, - selection.server, - selection.enabled, - ); + await ( + await resolvePluginApi(host) + ).setPluginMcpServerEnabled(selection.pluginId, selection.server, selection.enabled); await showPluginMcpPicker(host, selection.pluginId, { selectedServer: selection.server, serverHint: { @@ -452,7 +486,7 @@ async function handlePluginMcpSelection( } async function removePlugin(host: SlashCommandHost, id: string): Promise { - await host.requireSession().removePlugin(id); + await (await resolvePluginApi(host)).removePlugin(id); host.showStatus(`Removed ${id}.`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } @@ -461,7 +495,7 @@ async function renderPluginsList( host: SlashCommandHost, plugins?: readonly PluginSummary[], ): Promise { - const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); + const currentPlugins = plugins ?? (await (await resolvePluginApi(host)).listPlugins()); const title = ` Plugins (${currentPlugins.length}) `; const panel = new UsagePanelComponent( () => buildPluginsListLines({ plugins: currentPlugins }), @@ -473,7 +507,7 @@ async function renderPluginsList( } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { - const info = await host.requireSession().getPluginInfo(id); + const info = await (await resolvePluginApi(host)).getPluginInfo(id); const panel = new UsagePanelComponent( () => buildPluginsInfoLines({ info }), 'primary', @@ -487,7 +521,7 @@ async function installPluginFromSource( host: SlashCommandHost, source: string, ): Promise { - const session = host.requireSession(); + const session = await resolvePluginApi(host); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), @@ -558,10 +592,13 @@ function truncateForStatus(input: string): string { } async function reloadPlugins(host: SlashCommandHost): Promise { - const summary = await host.requireSession().reloadPlugins(); + const summary = await (await resolvePluginApi(host)).reloadPlugins(); const line = `Reload: +${summary.added.length} -${summary.removed.length}` + (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); host.showStatus(line); + // Rebuild the TUI's plugin slash-command list from the reloaded service so + // newly added/enabled commands resolve in this session-less UI right away. + await host.refreshPluginCommands(host.session); } function resolvePluginInstallSource(source: string, workDir: string): string { diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 2c0010334b..1345604be4 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -31,6 +31,12 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise await applyReloadedTuiConfig(host, tuiConfig); if (session === undefined) { + // Still session-less on the v2 engine: refresh the lazy defaults too, so + // defaults edited externally (config.toml, a newly added default model) + // reach the first lazy-created session instead of staying stale. + if (host.engineV2) { + await host.hydrateLazyConfigDefaults(); + } host.showStatus( 'Runtime and TUI config reloaded; no active session.', 'success', diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0d..cac6babfc4 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -29,10 +29,16 @@ export async function handleTitleCommand(host: SlashCommandHost, args: string): return; } - const session = host.session; + let session = host.session; if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // Setting a title needs a live session; lazy-create it on first use (the + // bare read-only form above works session-less). + session = await host.ensureSession(); + if (session === undefined) return; } const newTitle = title.slice(0, 200); diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 06c5f46d83..09fc0c8c44 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -5,6 +5,7 @@ import { type KimiHarness, type OAuthRef, type Session, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; @@ -32,6 +33,7 @@ export interface AuthFlowHost { session: Session | undefined; readonly harness: KimiHarness; readonly options: KimiTUIOptions; + readonly engineV2: boolean; setAppState(patch: Partial): void; setStartupReady(): void; @@ -83,6 +85,20 @@ export class AuthFlowController { return; } + if (host.engineV2) { + // Lazy session creation (v2 engine): configure the model only; the + // session is created on the first message. The effort is carried as the + // first session's thinking override so a session-only choice (Alt+S) + // made before any session exists is applied on creation. + const patch: Partial = { model }; + if (effort !== undefined) { + patch.thinkingEffort = effort as ThinkingEffort; + patch.lazySessionThinking = effort as ThinkingEffort; + } + host.setAppState(patch); + return; + } + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd77771..829d8733ba 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -10,8 +10,10 @@ import type { CreateSessionOptions, KimiHarness, PermissionMode, + PluginCommandDef, PromptPart, Session, + SkillSummary, WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; @@ -144,6 +146,7 @@ import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; +import { thinkingEffortFromConfig } from './utils/thinking-config'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; @@ -305,6 +308,8 @@ export class KimiTUI { readonly options: KimiTUIOptions; session: Session | undefined; state: TUIState; + /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ + private ensureSessionPromise: Promise | null = null; private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; @@ -328,7 +333,8 @@ export class KimiTUI { private backgroundRefreshPromise: Promise | undefined; private readonly migrationPlan: MigrationPlan | null; private readonly migrateOnly: boolean; - private readonly engineV2: boolean; + /** Whether the harness runs on the agent-core-v2 engine (lazy session creation). */ + readonly engineV2: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = @@ -486,6 +492,19 @@ export class KimiTUI { async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { + // v2 engine: skills live on the workspace handler, not the session, so + // they are available before the first (lazy) session is created. The + // workspace-level list lacks plugin skills; the session list fills that + // in once a session exists. + if (this.engineV2) { + try { + const skills = await this.harness.listWorkspaceSkills(this.state.appState.workDir); + this.applySkillCommands(skills); + return; + } catch { + return; + } + } this.skillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); @@ -498,6 +517,10 @@ export class KimiTUI { } catch { return; } + this.applySkillCommands(skills); + } + + private applySkillCommands(skills: readonly SkillSummary[]): void { const skillCommands = buildSkillSlashCommands(skills); this.skillCommands = skillCommands.commands; this.skillCommandMap.clear(); @@ -509,6 +532,17 @@ export class KimiTUI { async refreshPluginCommands(session?: Session): Promise { if (session === undefined) { + // v2 engine: the enabled plugin commands are an app-global live view, + // available before the first (lazy) session is created. + if (this.engineV2) { + try { + const defs = await this.harness.listPluginCommands(); + this.applyPluginCommands(defs); + return; + } catch { + return; + } + } this.pluginCommands = []; this.pluginCommandMap.clear(); this.setupAutocomplete(); @@ -521,6 +555,10 @@ export class KimiTUI { } catch { return; } + this.applyPluginCommands(defs); + } + + private applyPluginCommands(defs: readonly PluginCommandDef[]): void { const pluginSlashCommands = buildPluginSlashCommands(defs); this.pluginCommands = pluginSlashCommands.commands; this.pluginCommandMap.clear(); @@ -823,6 +861,13 @@ export class KimiTUI { ); } } + } else if (this.engineV2) { + // Lazy session creation (v2 engine): start session-less and create the + // session on the first message. Startup flags are carried in appState + // and applied when that session is created; until then the footer + // shows the config defaults the engine would apply at createSession + // time (model, permission, plan mode, thinking effort, context cap). + await this.hydrateLazyConfigDefaults(); } else { session = await this.harness.createSession(createSessionOptions); } @@ -838,11 +883,13 @@ export class KimiTUI { return false; } - if (session === undefined) { + if (!this.engineV2 && session === undefined) { throw new Error('Startup session was not initialized.'); } - await this.setSession(session); - await this.syncRuntimeState(session); + if (session !== undefined) { + await this.setSession(session); + await this.syncRuntimeState(session); + } this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; @@ -1040,17 +1087,31 @@ export class KimiTUI { this.state.ui.requestRender(); return; } - this.runShellCommandFromInput(text); + void this.runShellCommandFromInput(text); return; } slashCommands.dispatchInput(this, text); } - private runShellCommandFromInput(command: string): void { - const session = this.session; + private async runShellCommandFromInput(command: string): Promise { + let session = this.session; if (session === undefined) { - this.showError('No active session for shell command.'); - return; + if (!this.engineV2) { + this.showError('No active session for shell command.'); + return; + } + session = await this.ensureSession(); + if (session === undefined) return; + // A concurrent first message may have started a prompt while this lazy + // creation was in flight (both inputs share the same creation promise); + // honor the busy gate here, like handleUserInput does before the await, + // instead of running the shell command concurrently with an agent turn. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(command, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } } // Echo the command locally (bash-input) with a `$` prompt. The agent also // records it for resume; this is the live view. @@ -1154,14 +1215,14 @@ export class KimiTUI { const session = this.session; if (session === undefined) return; if (item.mode === 'bash') { - this.runShellCommandFromInput(item.text); + void this.runShellCommandFromInput(item.text); } else { this.sendQueuedMessage(session, item); } this.updateQueueDisplay(); } - sendNormalUserInput(text: string): void { + async sendNormalUserInput(text: string): Promise { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); @@ -1180,10 +1241,14 @@ export class KimiTUI { return; } if (!this.validateMediaCapabilities(extraction)) return; - const session = this.session; + let session = this.session; if (session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); - return; + if (!this.engineV2) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + session = await this.ensureSession(); + if (session === undefined) return; } if (extraction.hasMedia) { this.sendMessage(session, text, { @@ -1309,7 +1374,7 @@ export class KimiTUI { sendQueuedMessage(session: Session, item: QueuedMessage): void { if (item.mode === 'bash') { - this.runShellCommandFromInput(item.text); + void this.runShellCommandFromInput(item.text); return; } this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { @@ -1571,24 +1636,166 @@ export class KimiTUI { return this.session; } - private async createSessionFromCurrentState(): Promise { + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap), so the footer and the lazy create path reflect them while + * no session exists. Runs at session-less startup and again on /reload + * while still session-less, so externally edited defaults take effect + * before the first lazy-created session. + */ + async hydrateLazyConfigDefaults(): Promise { + const { startup } = this.options; + const config = await this.harness.getConfig({ reload: true }); + const patch: Partial = {}; + const startupModel = startup.model ?? config.defaultModel; + if (startupModel !== undefined) { + patch.model = startupModel; + const selected = config.models?.[startupModel]; + if (selected?.maxContextSize !== undefined) { + patch.maxContextTokens = selected.maxContextSize; + } + } else { + // The default disappeared from config (edited externally): clear the + // previously hydrated value instead of passing a stale explicit model + // to the first lazy-created session. + patch.model = ''; + patch.maxContextTokens = 0; + } + // CLI --auto/--yolo/--plan win over config defaults; the flags are + // re-applied by applyStartupPermissionAndPlanToAppState at startup. + if (!startup.auto && !startup.yolo) { + // Reset to manual when the default was removed from config — a stale + // elevated mode must not be passed to the first lazy-created session. + patch.permissionMode = config.defaultPermissionMode ?? 'manual'; + } + // Track the config default itself (vs an explicit CLI --plan) so the lazy + // create path can tell which one would activate plan mode; a removed + // default also clears the hydrated footer value. + patch.configDefaultPlanMode = config.defaultPlanMode === true; + if (!startup.plan) { + patch.planMode = config.defaultPlanMode === true; + } + const effort = thinkingEffortFromConfig(config.thinking); + if (effort !== undefined) { + patch.thinkingEffort = effort; + } + if (startup.agentProfile !== undefined || startup.agentFiles !== undefined) { + patch.agentProfile = startup.agentProfile; + patch.agentFiles = startup.agentFiles?.length ? [...startup.agentFiles] : undefined; + } + this.setAppState(patch); + } + + private async createSessionFromCurrentState(bindStartupAgent = false): Promise { const model = this.state.appState.model.trim(); if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } + // With an active session, carry the live plan state. Session-less (lazy + // creation / `/new` before the first session) on v2, pass only the + // explicit CLI --plan intent — and only when the engine is not already + // applying `defaultPlanMode` at create time (sessionLifecycleService), + // since re-entering an active plan mode throws. On v1 (which never + // pre-fills plan mode from config), keep the historical appState value. + const explicitPlanMode = + this.session !== undefined || !this.engineV2 + ? this.state.appState.planMode + : this.options.startup.plan && this.state.appState.configDefaultPlanMode !== true; const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, - thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, + // With an active session, carry the live effort. Session-less (lazy + // creation / `/new` before the first session), carry the session-only + // thinking override chosen via Alt+S if any — never the initial 'off' + // default, which would force thinking off where the engine's config or + // model default would apply. + thinking: + this.session === undefined + ? this.state.appState.lazySessionThinking + : this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, - planMode: this.state.appState.planMode ? true : undefined, + planMode: explicitPlanMode ? true : undefined, }; if (this.state.appState.additionalDirs.length > 0) { options.additionalDirs = [...this.state.appState.additionalDirs]; } + if (bindStartupAgent) { + // The --agent/--agent-file startup binding is consumed by the first + // lazy-created session; `/new` sessions fall back to the default profile. + if (this.state.appState.agentProfile !== undefined) { + options.agentProfile = this.state.appState.agentProfile; + } + if (this.state.appState.agentFiles !== undefined) { + options.agentFiles = [...this.state.appState.agentFiles]; + } + } return this.harness.createSession(options); } + /** + * Lazy-create the session on first use (v2 engine, session-less startup). + * Returns the existing session, or creates one from the current state and + * runs the same assembly `createNewSession` performs. Returns undefined and + * shows the error when creation fails; callers must still guard on + * `appState.model`. + * + * Concurrent first-use triggers (a double Enter, or a slash command right + * after a prompt) both observe `session === undefined`, so the first caller + * owns the creation and the rest share the in-flight promise — otherwise + * two sessions would be created and the later `setSession` would close the + * first one mid-dispatch. + */ + async ensureSession(): Promise { + // Even when a session is already assigned, a previous lazy creation may + // still be finishing its assembly (runtime sync, command refresh, + // subscription). Wait for it so callers never dispatch against a + // partially initialized session. + if (this.ensureSessionPromise !== null) return this.ensureSessionPromise; + if (this.session !== undefined) return this.session; + this.ensureSessionPromise = this.lazyCreateSession().finally(() => { + this.ensureSessionPromise = null; + }); + return this.ensureSessionPromise; + } + + private async lazyCreateSession(): Promise { + let session: Session; + try { + session = await this.createSessionFromCurrentState(true); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to start a session: ${msg}`); + return undefined; + } + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + this.sessionEventHandler.startSubscription(); + const msg = formatErrorMessage(error); + this.showError(`Post-create setup failed: ${msg}`); + return undefined; + } + try { + await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(session); + // The session-only thinking override was consumed by this session; the + // runtime status now owns the displayed effort. + if (this.state.appState.lazySessionThinking !== undefined) { + this.setAppState({ lazySessionThinking: undefined }); + } + return session; + } + async setSession(session: Session): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 29cc57bff5..8ff0041a04 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -31,6 +31,11 @@ export interface AppState { sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** Resolved profile name from --agent/--agent-file, carried to the + * lazy-created first session when the TUI starts session-less. */ + agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + agentFiles?: readonly string[]; /** 'bash' when the editor is in `!` shell-command mode. */ inputMode: 'prompt' | 'bash'; swarmMode: boolean; @@ -38,6 +43,20 @@ export interface AppState { * mirrors the runtime. The single source of truth for the thinking state in * the TUI. */ thinkingEffort: ThinkingEffort; + /** + * The current `defaultPlanMode` value from config (false when absent), + * refreshed by `hydrateLazyConfigDefaults`. Used to tell a config-driven + * plan-mode entry apart from an explicit CLI `--plan` when lazy-creating + * the first session (the engine applies the config default itself). + */ + configDefaultPlanMode?: boolean; + /** + * Session-only thinking effort chosen (e.g. via the model picker's Alt+S) + * while no session exists yet on the v2 engine. Applied to the first + * lazy-created session and cleared once it exists; the engine's config + * default is used instead when unset. + */ + lazySessionThinking?: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; 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 b46eab17bf..ec128f0c4d 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 @@ -97,6 +97,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; streamingUI: StreamingUIController; + pluginCommandMap: Map; sessionEventHandler: { startSubscription(): void; handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void; @@ -303,13 +304,14 @@ function makeHarness(session = makeSession(), overrides: Record async function makeDriver( session = makeSession(), harnessOverrides: Record = {}, + startupInput: KimiTUIStartupInput = makeStartupInput(), ): Promise<{ driver: MessageDriver; session: ReturnType; harness: ReturnType; }> { const harness = makeHarness(session, harnessOverrides); - const driver = new KimiTUI(harness as never, makeStartupInput()) as unknown as MessageDriver; + const driver = new KimiTUI(harness as never, startupInput) as unknown as MessageDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); @@ -462,6 +464,685 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('shortcut_paste', { kind: 'text' }); }); + it('lazily creates the session on the first message (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Startup stays session-less on the v2 engine. + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + expect(driver.state.appState.model).toBe('k2'); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + model: 'k2', + thinking: undefined, + permission: 'manual', + planMode: undefined, + }); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + + it('lazily creates the session for session-requiring slash commands (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + expect(harness.createSession).not.toHaveBeenCalled(); + + driver.handleUserInput('/compact'); + + await vi.waitFor(() => { + expect(session.compact).toHaveBeenCalledWith({ instruction: undefined }); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + + it('lazily creates the session for skill commands (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy', activateSkill: vi.fn(async () => {}) }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { + name: 'my-skill', + description: 'A test skill', + path: '/tmp/my-skill', + source: 'user', + }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + // `makeDriver` stops after init(); the skill command list is refreshed in + // finishStartup, so resolve it here to exercise the workspace-level path. + await ( + driver as unknown as { refreshSkillCommands(): Promise } + ).refreshSkillCommands(); + + // Startup resolves skill commands from the workspace, no session needed. + expect(harness.createSession).not.toHaveBeenCalled(); + + driver.handleUserInput('/skill:my-skill'); + + await vi.waitFor(() => { + expect(session.activateSkill).toHaveBeenCalledWith('my-skill', ''); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + + it('serializes concurrent lazy session creation (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Hold the first createSession open so both triggers land inside the + // in-flight window. + let resolveCreate!: (s: ReturnType) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + + const ensure = (driver as unknown as { ensureSession(): Promise }).ensureSession; + const first = ensure.call(driver); + const second = ensure.call(driver); + resolveCreate(session); + await Promise.all([first, second]); + + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + + it('carries a session-only thinking choice into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Alt+S session-only thinking before any session exists. + await ( + driver as unknown as { + authFlow: { activateModelAfterLogin(model: string, effort?: string): Promise }; + } + ).authFlow.activateModelAfterLogin('k2', 'high'); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2', thinking: 'high' }), + ); + expect(driver.state.appState.lazySessionThinking).toBeUndefined(); + }); + + it('does not pass the config default plan mode into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPlanMode: true, + })), + }, + startupInput, + ); + + // The footer shows the config default… + expect(driver.state.appState.planMode).toBe(true); + + // …but the create call must not repeat it: the v2 engine applies + // defaultPlanMode at create time, and re-entering plan mode throws. + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ planMode: undefined }), + ); + }); + + it('passes the explicit --plan flag into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2', plan: true }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ planMode: true }), + ); + }); + + it('queues a bash command submitted while the lazy session is being created (v2 engine)', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ id: 'ses-lazy', runShellCommand }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + + // A prompt and a bash command both trigger the same in-flight creation. + driver.handleUserInput('hello'); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + driver.handleUserInput('ls'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + // The shell command must be queued, not run concurrently with the prompt. + expect(runShellCommand).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('opens /settings without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: /settings must still open so the user can fix + // local editor/theme/update settings before picking a model. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/settings'); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('blocks a skill command submitted while the lazy session is being created (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy', activateSkill: vi.fn(async () => {}) }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { + name: 'my-skill', + description: 'A test skill', + path: '/tmp/my-skill', + source: 'user', + }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise } + ).refreshSkillCommands(); + + // A prompt and a skill command both trigger the same in-flight creation. + driver.handleUserInput('hello'); + driver.handleUserInput('/skill:my-skill'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + // The skill activation must be blocked, not run concurrently with the + // prompt's turn. + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('manages plugins without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const listPlugins = vi.fn(async () => []); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: /plugins must still work via the app-global API. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, { listPlugins }, startupInput); + + driver.handleUserInput('/plugins list'); + + await vi.waitFor(() => { + expect(listPlugins).toHaveBeenCalled(); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('lists additional directories without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: the read-only form must still work. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/add-dir list'); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('lazily creates the session when adding a directory (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/add-dir /tmp/extra'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('shows pending startup directories in /add-dir list before the lazy session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + additionalDirs: ['/tmp/extra'], + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + const showStatus = vi.spyOn( + driver as unknown as { showStatus: (msg: string) => void }, + 'showStatus', + ); + + driver.handleUserInput('/add-dir list'); + + await vi.waitFor(() => { + expect(showStatus).toHaveBeenCalledWith(expect.stringContaining('/tmp/extra')); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + }); + + it('refreshes plugin slash commands after a sessionless /plugins reload (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const listPluginCommands = vi.fn(async () => [ + { + pluginId: 'my-plugin', + name: 'my-command', + body: 'do things', + description: 'A plugin command', + }, + ]); + const reloadPlugins = vi.fn(async () => ({ added: [], removed: [], errors: [] })); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver( + session, + { listPluginCommands, reloadPlugins }, + startupInput, + ); + + driver.handleUserInput('/plugins reload'); + + await vi.waitFor(() => { + expect(reloadPlugins).toHaveBeenCalled(); + expect(listPluginCommands).toHaveBeenCalled(); + expect(driver.pluginCommandMap.get('my-plugin:my-command')).toBe('do things'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + }); + + it('hydrates lazy config defaults on a sessionless /reload (v2 engine)', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + const session = makeSession({ id: 'ses-lazy' }); + const getConfig = vi.fn( + async (): Promise<{ models: Record; defaultModel?: string }> => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + // Initially no default model configured. + }), + ); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, { getConfig }, startupInput); + expect(driver.state.appState.model).toBe(''); + + // A default model is added externally, then /reload runs before the first + // prompt — the lazy defaults must be refreshed, not left stale. + getConfig.mockResolvedValue({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + }); + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(driver.state.appState.model).toBe('k2'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + }); + + it('clears stale lazy defaults when the default model is removed (v2 engine)', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + const session = makeSession({ id: 'ses-lazy' }); + const getConfig = vi.fn( + async (): Promise<{ models: Record; defaultModel?: string }> => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + }), + ); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver } = await makeDriver(session, { getConfig }, startupInput); + expect(driver.state.appState.model).toBe('k2'); + expect(driver.state.appState.maxContextTokens).toBe(100); + + // The default model is removed externally, then /reload runs — the + // hydrated value must not survive as a stale explicit model. + getConfig.mockResolvedValue({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + }); + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(driver.state.appState.model).toBe(''); + }); + expect(driver.state.appState.maxContextTokens).toBe(0); + }); + + it('does not re-enter plan mode on /plan on when config already applied it (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPlanMode: true, + })), + }, + startupInput, + ); + + driver.handleUserInput('/plan on'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + // The engine already applied defaultPlanMode at create; the command must + // notice the active plan mode instead of re-entering (which would throw). + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('clears the stale permission default when it is removed from config (v2 engine)', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + const session = makeSession({ id: 'ses-lazy' }); + const getConfig = vi.fn( + async (): Promise<{ + models: Record; + defaultModel?: string; + defaultPermissionMode?: string; + }> => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', + }), + ); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver } = await makeDriver(session, { getConfig }, startupInput); + expect(driver.state.appState.permissionMode).toBe('auto'); + + // The elevated default is removed externally, then /reload runs — a stale + // elevated mode must not reach the first lazy-created session. + getConfig.mockResolvedValue({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + }); + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(driver.state.appState.permissionMode).toBe('manual'); + }); + }); + + it('does not pass --plan when config already applies default plan mode (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + // The engine applied the config default at create. + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2', plan: true }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPlanMode: true, + })), + }, + startupInput, + ); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + // The engine applies the config default at create; repeating --plan would + // re-enter plan mode and throw, so it must not be passed again. + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ planMode: undefined }), + ); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('opens read-only status commands without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: read-only views must still open. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/status'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Status'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('applies /yolo on session-less and passes the mode to the lazy session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/yolo on'); + + await vi.waitFor(() => { + expect(driver.state.appState.permissionMode).toBe('yolo'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(session.setPermission).not.toHaveBeenCalled(); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ permission: 'yolo' }), + ); + }); + + it('waits for lazy session assembly before dispatching further input (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Hold the post-create assembly open inside setPermission: the session is + // assigned but setup is not finished yet. + let resolvePermission!: () => void; + session.setPermission.mockImplementationOnce( + () => new Promise((resolve) => { resolvePermission = resolve; }), + ); + + const ensure = (driver as unknown as { ensureSession(): Promise }).ensureSession; + const first = ensure.call(driver); + await vi.waitFor(() => { + expect(session.setPermission).toHaveBeenCalled(); + }); + + // A second trigger must wait for the assembly instead of dispatching + // against the half-initialized session. + const second = ensure.call(driver); + let secondResolved = false; + void second.then(() => { + secondResolved = true; + }); + await Promise.resolve(); + expect(secondResolved).toBe(false); + + resolvePermission(); + await Promise.all([first, second]); + expect(secondResolved).toBe(true); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('lists MCP servers before the lazy session via the workspace view (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const listWorkspaceMcpServers = vi.fn(async () => [ + { name: 'my-mcp', status: 'connected', transport: 'stdio', tools: [] }, + ]); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver( + session, + { listWorkspaceMcpServers }, + startupInput, + ); + + driver.handleUserInput('/mcp'); + + await vi.waitFor(() => { + expect(listWorkspaceMcpServers).toHaveBeenCalledWith('/tmp/proj-a'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(session.listMcpServers).not.toHaveBeenCalled(); + }); + it('tracks /clear as the clear alias for /new', async () => { const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); const nextSession = makeSession({ id: 'ses-2' }); 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 daaaa79e12..751efae488 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -282,6 +282,80 @@ describe('KimiTUI startup', () => { }); }); + it('starts session-less on the v2 engine and carries startup flags to appState', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 200 }, + }, + defaultModel: 'k2', + // CLI --yolo must win over the config default. + defaultPermissionMode: 'auto', + })), + }); + const driver = makeDriver( + harness, + { ...makeStartupInput({ model: 'k2', yolo: true }), engineV2: true }, + ); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'yolo', + }); + }); + + it('shows config defaults in appState before the lazy session exists (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 200 }, + }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', + defaultPlanMode: true, + thinking: { enabled: true, effort: 'high' }, + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + maxContextTokens: 200, + permissionMode: 'auto', + planMode: true, + thinkingEffort: 'high', + }); + }); + + it('carries the --agent/--agent-file binding for the lazy-created first session (v2)', async () => { + const harness = makeHarness(makeSession()); + const driver = makeDriver( + harness, + { + ...makeStartupInput({ model: 'k2', agentFiles: ['agent.md'] }), + engineV2: true, + agentProfile: 'reviewer', + }, + ); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + agentProfile: 'reviewer', + agentFiles: ['agent.md'], + }); + }); + it('binds the resolved agent profile and agent files to the startup session', async () => { const session = makeSession(); const harness = makeHarness(session); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index a57a8d81e5..bc203f13b1 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -23,7 +23,12 @@ import type { KimiHostIdentity, ListSessionsOptions, McpServerConfig, + McpServerInfo, McpTestResult, + PluginCommandDef, + PluginInfo, + PluginSummary, + ReloadSummary, RenameSessionInput, ResumeSessionInput, ReloadSessionInput, @@ -256,6 +261,57 @@ export class KimiHarness { return this.rpc.listWorkspaceSkills(workDir); } + /** + * App-global plugin command list, no session required. Empty on the v1 + * engine, which only exposes plugin commands through a live session. + */ + async listPluginCommands(): Promise { + return this.rpc.listPluginCommandsGlobal(); + } + + /** + * App-global plugin management, no session required. The v2 engine keeps + * plugin state app-global (these calls are routed through the klient + * `global.plugins` facade), so `/plugins` works before the first session + * exists; the v1 engine only exposes plugins through a live session. + */ + async listPlugins(): Promise { + return this.rpc.listPlugins(); + } + + /** + * Workspace-level MCP server list, no session required. The v2 engine owns + * one shared connection set per workspace handler, so `/mcp` is inspectable + * before the first session exists; empty on the v1 engine. + */ + async listWorkspaceMcpServers(workDir: string): Promise { + return this.rpc.listWorkspaceMcpServers(workDir); + } + + async installPlugin(source: string): Promise { + return this.rpc.installPlugin(source); + } + + async setPluginEnabled(id: string, enabled: boolean): Promise { + return this.rpc.setPluginEnabled(id, enabled); + } + + async setPluginMcpServerEnabled(id: string, server: string, enabled: boolean): Promise { + return this.rpc.setPluginMcpServerEnabled(id, server, enabled); + } + + async removePlugin(id: string): Promise { + return this.rpc.removePlugin(id); + } + + async reloadPlugins(): Promise { + return this.rpc.reloadPlugins(); + } + + async getPluginInfo(id: string): Promise { + return this.rpc.getPluginInfo(id); + } + /** * Trust state of `workDir` (agent-core-v2 only; the v1 engine reports an * always-trusted workspace). Querying may register the workDir as a diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index fbcc4618cb..fc631b8c0a 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -654,6 +654,15 @@ export abstract class SDKRpcClientBase { return rpc.listPluginCommands({ sessionId: input.sessionId }); } + /** + * App-global plugin command list, no session required. The v1 engine only + * exposes plugin commands through a live session, so the base returns an + * empty list; the v2 client overrides with the app-global live view. + */ + async listPluginCommandsGlobal(): Promise { + return []; + } + async listBackgroundTasks( input: SessionIdRpcInput & { activeOnly?: boolean; limit?: number }, ): Promise { @@ -760,6 +769,17 @@ export abstract class SDKRpcClientBase { return rpc.listMcpServers({ sessionId: input.sessionId }); } + /** + * Workspace-level MCP server list, no session required. The v2 engine owns + * one shared connection set per workspace handler, so `/mcp` is inspectable + * before the first session exists; the v1 engine only exposes MCP through + * a live session and the base returns an empty list. + */ + async listWorkspaceMcpServers(workDir: string): Promise { + void workDir; + return []; + } + async getMcpStartupMetrics(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.getMcpStartupMetrics({ sessionId: input.sessionId }); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 2190f85257..db1f4c3930 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -196,6 +196,7 @@ import { ITelemetryService, IWorkspaceAliases, IWorkspaceDirs, + IWorkspaceMcpService, ISessionLifecycleService, IWorkspaceLifecycleService, IWorkspaceTrust, @@ -741,6 +742,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { input: SessionIdRpcInput, ): Promise { void input; + return this.listPluginCommandsGlobal(); + } + + /** App-global live view of the enabled plugin commands, no session required. */ + override async listPluginCommandsGlobal(): Promise { return this.klient.global.plugins.listCommands(); } @@ -2145,6 +2151,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return mcp.connectionManager.list() as readonly McpServerInfo[]; } + /** + * Workspace-level MCP view (the handler's one shared connection set), so + * `/mcp` is inspectable on a v2 session-less startup before any session + * exists. Same `McpServerEntry`-as-`McpServerInfo` cast as listMcpServers. + */ + override async listWorkspaceMcpServers(workDir: string): Promise { + const handler = await this.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: normalizeRequiredWorkDir('listWorkspaceMcpServers', workDir) }); + return handler.accessor.get(IWorkspaceMcpService).connectionManager().list() as readonly McpServerInfo[]; + } + override async getMcpStartupMetrics(input: SessionIdRpcInput): Promise { const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); await mcp.connectionManager.waitForInitialLoad();