diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts index 90636cea55..228b71d14c 100644 --- a/apps/kimi-code/src/tui/commands/add-dir.ts +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -1,15 +1,19 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import type { SlashCommandHost } from './dispatch'; +import { slashBusyMessage, slashCommandBusyReason } from './resolve'; 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 +23,24 @@ 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; + // A first prompt may have started a turn during the await; /add-dir is + // idle-only, so re-check the busy gate resolved before it. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage('add-dir', busyReason)); + 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..221b39ecfb 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.'); } @@ -463,6 +474,14 @@ async function performModelSwitch( effort: ThinkingEffort, persist: boolean, ): Promise { + let session = host.session; + if (session === undefined && host.engineV2) { + // A first prompt may still be inside lazy creation: wait it out so the + // switch lands on the new session instead of being overwritten by its + // assembly. + await host.waitForLazyCreation(); + session = host.session; + } if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; @@ -476,7 +495,6 @@ async function performModelSwitch( let effectiveAlias = alias; let effectiveEffort = effort; - const session = host.session; try { if (session === undefined && runtimeChanged) { await host.authFlow.activateModelAfterLogin(alias, effort); @@ -875,7 +893,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..e5aef1639c 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -43,9 +43,18 @@ 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 type { SkillListSession } from './skills'; +import { + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -103,6 +112,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 +128,35 @@ 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; + /** + * Rebuild the skill slash-command list. With no session (v2 session-less + * startup) this reads the workspace skills instead. + */ + refreshSkillCommands(session?: SkillListSession): 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; + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + waitForLazyCreation(): Promise; switchToSession(session: Session, message: string): Promise; reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; @@ -204,11 +241,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 +273,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 +315,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(); @@ -268,10 +379,24 @@ async function handleBuiltInSlashCommand( case 'version': host.showStatus(`Kimi Code v${host.state.appState.version}`); return; - case 'new': + case 'new': { + // A first-use lazy creation may still be in flight: wait it out so /new + // never races a second createSession against the pending prompt. + await host.waitForLazyCreation(); + // The waited-out prompt may have started a turn meanwhile; /new is + // idle-only, so re-run the busy 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(name, busyReason)); + return; + } await host.createNewSession(); host.state.ui.requestRender(); return; + } case 'sessions': void host.showSessionPicker(); return; @@ -282,7 +407,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..15dc411651 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -26,11 +26,24 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); + const sessionlessV2 = session === undefined && host.engineV2; + if (sessionlessV2) { + // Session-less v2: rebuild the workspace-level dynamic commands too, so + // skill/plugin changes apply before the first session exists. + await host.refreshSkillCommands(); + await host.refreshPluginCommands(); + } host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); 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 (sessionlessV2) { + 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 6817561dff..1a80c1947f 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/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 64537df22e..0299c6fde0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -134,7 +134,7 @@ export function effortLabel(effort: string): string { * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when * thinking is unsupported. */ -function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { +export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { if (thinkingAvailability(model) === 'unsupported') return 'off'; const efforts = effortsOf(model); if (efforts.length > 0) { diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index 8c8f9807b4..4539d1b9fe 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -8,6 +8,8 @@ export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; +export const SESSIONLESS_STARTUP_NOTICE = + 'No session yet — one will be created on your first message.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; // Time window for treating two consecutive Esc presses as a double-Esc, which // opens the undo selector. Kept short (double-click feel) so two deliberate diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 06c5f46d83..67fac913c2 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; @@ -40,6 +42,7 @@ export interface AuthFlowHost { syncRuntimeState(session?: Session): Promise; closeSession(reason: string): Promise; appendStartupNotice(extra: string): void; + hydrateLazyConfigDefaults(): Promise; readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise; updateTerminalTitle(): void; @@ -83,6 +86,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, @@ -138,11 +155,23 @@ export class AuthFlowController { const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; if (defaultModel === undefined || selected === undefined) { + if (host.session === undefined && host.engineV2) { + // Session-less v2: hydrate permission/plan defaults even without a + // default model. + await host.hydrateLazyConfigDefaults(); + } host.setAppState({ availableModels, availableProviders }); return; } await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); + if (host.session === undefined && host.engineV2) { + // Session-less v2: also hydrate permission/plan defaults from the + // refreshed config, same as startup. + await host.hydrateLazyConfigDefaults(); + host.setAppState({ availableModels, availableProviders }); + return; + } const appStatePatch: Partial = { availableModels, availableProviders, diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 76d0363f80..55df80609b 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -23,6 +23,7 @@ import type { BtwPanelController } from './btw-panel'; export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; + readonly engineV2: boolean; cancelInFlight: (() => void) | undefined; /** * The host's harness (KimiTUI always has one). Its `imageLimits` drives @@ -51,6 +52,7 @@ export interface EditorKeyboardHost { hideSessionPicker(): void; openUndoSelector(): void; stop(exitCode?: number): Promise; + ensureSession(): Promise; handlePlanToggle(next: boolean): void; handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; @@ -212,14 +214,25 @@ export class EditorKeyboardController { }; editor.onShiftTab = () => { + const togglePlan = (): void => { + const next = !host.state.appState.planMode; + host.track('shortcut_plan_toggle', { enabled: next }); + host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); + host.handlePlanToggle(next); + }; if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: lazy-create the session, then toggle — the same + // path /plan takes. + void host.ensureSession().then((session) => { + if (session !== undefined) togglePlan(); + }); return; } - const next = !host.state.appState.planMode; - host.track('shortcut_plan_toggle', { enabled: next }); - host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); - host.handlePlanToggle(next); + togglePlan(); }; editor.onInputModeChange = (mode) => { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c64c65dff4..714dd7e4b7 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; -import { log } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias, log } from '@moonshot-ai/kimi-code-sdk'; import type { ApprovalRequest, ApprovalResponse, @@ -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'; @@ -63,6 +65,7 @@ import { } from './components/dialogs/approval-preview'; import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; +import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; @@ -100,6 +103,7 @@ import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, + SESSIONLESS_STARTUP_NOTICE, } from './constant/kimi-tui'; import { CHROME_GUTTER } from './constant/rendering'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; @@ -144,6 +148,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 +310,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 +335,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 +494,18 @@ 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 catalog is the same merged view a session would serve. + 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 +518,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 +533,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 +556,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(); @@ -827,6 +866,14 @@ 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(); + this.appendStartupNotice(SESSIONLESS_STARTUP_NOTICE); } else { session = await this.harness.createSession(createSessionOptions); } @@ -842,11 +889,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; @@ -1044,17 +1093,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. @@ -1158,14 +1221,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); @@ -1184,10 +1247,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, { @@ -1313,7 +1380,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, () => { @@ -1575,24 +1642,181 @@ 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; + } else if (startupModel !== undefined) { + // No concrete effort configured: mirror the engine, which resolves the + // model's default effort at createSession time. + const raw = config.models?.[startupModel]; + if (raw !== undefined) { + const providerType = config.providers?.[raw.provider]?.type; + patch.thinkingEffort = defaultThinkingEffortFor( + effectiveModelAlias(raw, providerType ?? raw.protocol), + ); + } + } + 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; + } + + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + async waitForLazyCreation(): Promise { + await 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(); @@ -1759,6 +1983,10 @@ export class KimiTUI { } private async resumeSession(targetSessionId: string): Promise { + // A first-use lazy creation may still be in flight: wait it out so the + // checks below see settled state — the pending prompt would otherwise + // replace the resumed session when creation completes. + await this.waitForLazyCreation(); if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); return true; 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/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts index 0c3381a870..60b96a66ca 100644 --- a/apps/kimi-code/test/tui/commands/add-dir.test.ts +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -187,4 +187,26 @@ describe('handleAddDirCommand', () => { expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); + + it('re-checks the busy gate after lazy session creation (v2)', async () => { + const { host, session, getMountedPanel } = makeHost(); + // Session-less v2: the path-adding form lazy-creates via ensureSession. + Object.assign(host, { + session: undefined, + engineV2: true, + ensureSession: vi.fn(async () => { + // A first prompt starts a turn while the session is being created. + host.state.appState.streamingPhase = 'waiting'; + return session; + }), + }); + + await handleAddDirCommand(host, '../shared'); + + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /add-dir while streaming — press Esc or Ctrl-C first.', + ); + expect(getMountedPanel()).toBeNull(); + expect(session.addAdditionalDir).not.toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 77f582a1b9..c07a312751 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -113,6 +113,34 @@ auto_install = false expect(themeWhenTracked).toBe('auto'); }); + + it('refreshes workspace commands and lazy defaults on a session-less v2 reload', async () => { + await writeTuiConfig('theme = "dark"\n'); + const host = makeHost(); + const refreshSkillCommands = vi.fn(async () => {}); + const refreshPluginCommands = vi.fn(async () => {}); + const hydrateLazyConfigDefaults = vi.fn(async () => {}); + Object.assign(host, { + engineV2: true, + refreshSkillCommands, + refreshPluginCommands, + hydrateLazyConfigDefaults, + }); + + await handleReloadCommand(host); + + expect(refreshSkillCommands).toHaveBeenCalledOnce(); + expect(refreshPluginCommands).toHaveBeenCalledOnce(); + expect(hydrateLazyConfigDefaults).toHaveBeenCalledOnce(); + // Autocomplete must rebuild after the command maps are refreshed. + expect(refreshSkillCommands.mock.invocationCallOrder[0]).toBeLessThan( + host.refreshSlashCommandAutocomplete.mock.invocationCallOrder[0]!, + ); + expect(host.showStatus).toHaveBeenCalledWith( + 'Runtime and TUI config reloaded; no active session.', + 'success', + ); + }); }); async function writeTuiConfig(text: string): Promise { diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 8f15e68426..049d2e4801 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/kimi-tui'; +import { DOUBLE_ESC_WINDOW_MS, NO_ACTIVE_SESSION_MESSAGE } from '#/tui/constant/kimi-tui'; import { EditorKeyboardController, type EditorKeyboardHost, @@ -285,3 +285,84 @@ describe('EditorKeyboardController shell history recall', () => { expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); }); }); + +describe('EditorKeyboardController Shift-Tab plan toggle', () => { + function createShiftTabHarness(options: { sessionless?: boolean; engineV2?: boolean } = {}) { + const editor: Record unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const handlePlanToggle = vi.fn(); + const track = vi.fn(); + const showError = vi.fn(); + const ensureSession = vi.fn(async (): Promise<{ id: string } | undefined> => ({ id: 'ses-lazy' })); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false, planMode: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: options.sessionless ? undefined : { cancel: vi.fn(async () => {}) }, + engineV2: options.engineV2 ?? false, + ensureSession, + handlePlanToggle, + track, + showError, + btwPanelController: { cancelRunning: vi.fn(), closeOrCancel: vi.fn() }, + } as unknown as EditorKeyboardHost; + + new EditorKeyboardController(host, undefined as unknown as ImageAttachmentStore).install(); + const onShiftTab = editor['onShiftTab'] as unknown as () => void; + return { onShiftTab, handlePlanToggle, track, showError, ensureSession }; + } + + it('toggles plan mode directly with an active session', () => { + const { onShiftTab, handlePlanToggle, ensureSession } = createShiftTabHarness(); + + onShiftTab(); + + expect(ensureSession).not.toHaveBeenCalled(); + expect(handlePlanToggle).toHaveBeenCalledWith(true); + }); + + it('reports no active session on v1 when session-less', () => { + const { onShiftTab, showError, handlePlanToggle } = createShiftTabHarness({ + sessionless: true, + }); + + onShiftTab(); + + expect(showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE); + expect(handlePlanToggle).not.toHaveBeenCalled(); + }); + + it('lazy-creates the session before toggling on v2 when session-less', async () => { + const { onShiftTab, ensureSession, handlePlanToggle, track } = createShiftTabHarness({ + sessionless: true, + engineV2: true, + }); + + onShiftTab(); + expect(handlePlanToggle).not.toHaveBeenCalled(); + + await vi.waitFor(() => { + expect(handlePlanToggle).toHaveBeenCalledWith(true); + }); + expect(ensureSession).toHaveBeenCalledOnce(); + expect(track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); + }); + + it('does not toggle when the lazy creation fails on v2', async () => { + const { onShiftTab, ensureSession, handlePlanToggle } = createShiftTabHarness({ + sessionless: true, + engineV2: true, + }); + ensureSession.mockResolvedValue(undefined); + + onShiftTab(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(handlePlanToggle).not.toHaveBeenCalled(); + }); +}); 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 1889310829..25ba20a06f 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,891 @@ 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('waits out the in-flight lazy creation before /new (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const newSession = makeSession({ id: 'ses-new' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(lazySession, {}, startupInput); + + // Hold the lazy createSession open so it is still in flight when /new + // arrives (triggered directly, without a prompt starting a turn). + let resolveCreate!: (s: ReturnType) => void; + harness.createSession + .mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ) + .mockResolvedValueOnce(newSession); + const ensure = (driver as unknown as { ensureSession(): Promise }).ensureSession; + const pending = ensure.call(driver); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + driver.handleUserInput('/new'); + // /new must not race a second createSession while the lazy one is held. + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.createSession).toHaveBeenCalledTimes(1); + + resolveCreate(lazySession); + await pending; + // No turn started, so /new proceeds after the wait. + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(2); + expect(driver.getCurrentSessionId()).toBe('ses-new'); + }); + }); + + it('blocks /new while the waited-out first prompt starts a turn (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(lazySession, {}, startupInput); + + // Hold the lazy createSession open so the first prompt is still pending + // when /new arrives. + let resolveCreate!: (s: ReturnType) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + + driver.handleUserInput('hello'); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + driver.handleUserInput('/new'); + + resolveCreate(lazySession); + // The prompt continuation starts its turn first; /new (idle-only) must + // then be blocked instead of switching away from the active session. + await vi.waitFor(() => { + expect(lazySession.prompt).toHaveBeenCalledWith('hello'); + expect(stripSgr(renderTranscript(driver))).toContain('Cannot /new while streaming'); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + + const thinkingModelsConfig = () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + }); + + it('blocks an effort switch once the waited-out first prompt starts a turn (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + lazySession, + { getConfig: vi.fn(async () => thinkingModelsConfig()) }, + startupInput, + ); + + // Hold the lazy createSession open so the first prompt is still pending + // when the effort switch arrives. + let resolveCreate!: (s: ReturnType) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + + driver.handleUserInput('hello'); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + driver.handleUserInput('/effort low'); + + resolveCreate(lazySession); + // The prompt starts its turn first; the switch must then be rejected + // instead of being silently overwritten by the session assembly. + await vi.waitFor(() => { + expect(lazySession.prompt).toHaveBeenCalledWith('hello'); + expect(stripSgr(renderTranscript(driver))).toContain('Cannot switch models while streaming'); + }); + expect(lazySession.setThinking).not.toHaveBeenCalled(); + }); + + it('applies an effort switch after waiting out an in-flight lazy creation (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + lazySession, + { + getConfig: vi.fn(async () => thinkingModelsConfig()), + setConfig: vi.fn(async () => ({ providers: {} })), + }, + startupInput, + ); + + // Trigger the lazy creation directly, without a prompt starting a turn. + let resolveCreate!: (s: ReturnType) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + const ensure = (driver as unknown as { ensureSession(): Promise }).ensureSession; + const pending = ensure.call(driver); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + driver.handleUserInput('/effort low'); + // While the creation is held the switch must wait, not write pending + // state that the assembly would overwrite. + await new Promise((resolve) => setImmediate(resolve)); + expect(driver.state.appState.thinkingEffort).toBe('high'); + + resolveCreate(lazySession); + await pending; + await vi.waitFor(() => { + expect(lazySession.setThinking).toHaveBeenCalledWith('low'); + }); + }); + + it('blocks a session-picker switch once the waited-out first prompt starts a turn (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + lazySession, + { + listSessions: vi.fn(async () => [ + { id: 'ses-old', title: 'Old session', workDir: '/tmp/proj-a', updatedAt: Date.now() }, + ]), + }, + startupInput, + ); + + // Hold the lazy createSession open so the first prompt is still pending + // when the picker selection arrives. + let resolveCreate!: (s: ReturnType) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + + driver.handleUserInput('hello'); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + + resolveCreate(lazySession); + // The prompt starts its turn first; the switch must then be rejected + // instead of being overwritten when the lazy creation completes. + await vi.waitFor(() => { + expect(lazySession.prompt).toHaveBeenCalledWith('hello'); + expect(stripSgr(renderTranscript(driver))).toContain('Cannot switch sessions while streaming'); + }); + expect(harness.resumeSession).not.toHaveBeenCalled(); + 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..fe816442b6 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,220 @@ 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 a session-less notice on v2 startup', async () => { + const harness = makeHarness(makeSession()); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + await ( + driver as unknown as { finishStartup(shouldReplayHistory: boolean): Promise } + ).finishStartup(false); + + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('No session yet — one will be created on your first message.'); + }); + + 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('hydrates the model default effort when thinking is enabled without an effort (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 200, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.thinkingEffort).toBe('high'); + }); + + it('hydrates the model default effort when no [thinking] section exists (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 200, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + }, + }, + defaultModel: 'k2', + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + + expect(driver.state.appState.thinkingEffort).toBe('medium'); + }); + + it('hydrates permission/plan defaults after a session-less v2 login', async () => { + let loggedIn = false; + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => + loggedIn + ? { + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', + defaultPlanMode: true, + } + : { models: {} }, + ), + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => { + loggedIn = true; + }), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: '', + permissionMode: 'manual', + planMode: false, + }); + + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); + + // Login must not create a session on v2, but the refreshed config + // defaults must reach the first lazy-created session. + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'auto', + planMode: true, + configDefaultPlanMode: true, + }); + }); + + it('hydrates permission defaults after a session-less v2 login without a default model', async () => { + let loggedIn = false; + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => + loggedIn + ? { + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultPermissionMode: 'auto', + } + : { models: {} }, + ), + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => { + loggedIn = true; + }), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: '', + permissionMode: 'auto', + }); + }); + + 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 3e3dfda2b3..974fe38749 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -14,8 +14,8 @@ * Migrated so far: * - `getExperimentalFeatures` → `klient.global.flags.list()` * - `listWorkspaceSkills` → not covered by the klient facade, so it goes - * through the `engineAccessor` escape hatch (`ISkillDiscovery` + the v2 - * skill-root helpers) instead. + * through the `engineAccessor` escape hatch (the workspace handler's + * `IWorkspaceSkillCatalog`) instead. * - `getConfig` / `setConfig` / `removeProvider` / `getConfigDiagnostics` → * `klient.global.config.*`, with the v1 `KimiConfig` shape restored by the * pure mapping layer in `src/v2/config-mapper.ts`. @@ -155,7 +155,6 @@ import { loadMcpServers } from '@moonshot-ai/agent-core-v2/workspace/workspaceMc import { applyPromptMetadataUpdate, bootstrap, - BUILTIN_SKILLS, DEFAULT_AGENT_PROFILE_NAME, ensureKimiHome, ensureMainAgent, @@ -168,7 +167,6 @@ import { IAgentPermissionModeService, IAgentPermissionRulesService, IAgentProfileService, - configuredRoots, IAgentRPCService, IAgentSkillService, IAgentSwarmService, @@ -193,12 +191,13 @@ import { ISessionSecondaryModelWarningService, ISessionSkillCatalog, ISessionWorkspaceContext, - ISkillDiscovery, ITelemetryService, IWorkspaceAliases, IWorkspaceDirs, + IWorkspaceMcpService, ISessionLifecycleService, IWorkspaceLifecycleService, + IWorkspaceSkillCatalog, IWorkspaceTrust, closeSessionById, followWorkspaceHandlers, @@ -214,7 +213,6 @@ import { PRINT_WAIT_CEILING_S_DEFAULT, ProfileError, ProfileErrors, - projectRoots, promptMetadataTextFromSkill, resolveAgentTaskConfig, resolveConfigPath, @@ -222,7 +220,6 @@ import { resolveLoggingConfig, resolvePrintBackgroundMode, summarizeSkill, - userRoots, type IAgentScopeHandle, type IDisposable, type ISessionScopeHandle, @@ -535,40 +532,19 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * klient has no skills facade; composed directly from the engine's - * app-scope `ISkillDiscovery` plus the v2 root helpers (user + project - * roots) and the code-defined `BUILTIN_SKILLS` via {@link engineAccessor}. - * `skillDirs` (explicit dirs) replaces the default user / project roots, - * matching the engine's session skill catalog. Gap vs the v1 - * implementation: plugin skills are not included. + * Through the workspace handler's `IWorkspaceSkillCatalog` — the engine's + * own merged view (builtin / user / explicit / extra / workspace-root / + * plugin), so the session-less list matches what a session would serve. + * `handlerFor` is create-or-get: session creation materializes the handler + * anyway. */ override async listWorkspaceSkills(workDir: string): Promise { - const bootstrapService = this.engineAccessor.get(IBootstrapService); - const discovery = this.engineAccessor.get(ISkillDiscovery); - const explicitDirs = bootstrapService.args.skillDirs ?? []; - const roots = - explicitDirs.length > 0 - ? await configuredRoots(explicitDirs, workDir, bootstrapService.osHomeDir, 'user') - : [ - ...(await userRoots(bootstrapService.homeDir, bootstrapService.osHomeDir)), - ...(await projectRoots(workDir)), - ]; - const { skills } = await discovery.discover(roots); - // Builtins are the lowest-priority contribution: a discovered skill with - // the same name shadows the builtin (v1 registry semantics). - const byName = new Map(); - for (const skill of [...BUILTIN_SKILLS, ...skills]) { - byName.set(skill.name, { - name: skill.name, - description: skill.description, - path: skill.path, - source: skill.source, - type: skill.metadata.type, - disableModelInvocation: skill.metadata.disableModelInvocation, - isSubSkill: skill.metadata.isSubSkill, - }); - } - return [...byName.values()]; + const handler = await this.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: normalizeRequiredWorkDir('listWorkspaceSkills', workDir) }); + const catalog = handler.accessor.get(IWorkspaceSkillCatalog); + await catalog.ready; + return catalog.catalog.listSkills().map(summarizeSkill); } /** @@ -757,6 +733,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(); } @@ -2161,6 +2142,22 @@ 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. Awaits `ready` so a fresh handler's initial connect settles + * before the list is read. + * 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) }); + const mcp = handler.accessor.get(IWorkspaceMcpService); + await mcp.ready; + return mcp.connectionManager().list() as readonly McpServerInfo[]; + } + override async getMcpStartupMetrics(input: SessionIdRpcInput): Promise { const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); await mcp.connectionManager.waitForInitialLoad();