From d3755653bdd62932b97c9e553dfe4202e9726f2a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 15:59:18 +0800 Subject: [PATCH 1/6] feat(tui): lazy-create the session on first use with the v2 engine Start the interactive TUI session-less under the v2 engine and create the session on first use (message, bash input, or a session-requiring slash command) instead of at startup. Skills and plugin commands are resolved from the workspace/app-global catalogs without a session, and the footer shows config defaults (model, permission, plan mode, thinking effort, context cap) until the session exists. --- apps/kimi-code/src/tui/commands/dispatch.ts | 74 +++++++- .../src/tui/controllers/auth-flow.ts | 19 ++- apps/kimi-code/src/tui/kimi-tui.ts | 161 ++++++++++++++++-- apps/kimi-code/src/tui/types.ts | 5 + .../test/tui/kimi-tui-message-flow.test.ts | 94 +++++++++- .../test/tui/kimi-tui-startup.test.ts | 74 ++++++++ packages/node-sdk/src/kimi-harness.ts | 9 + packages/node-sdk/src/rpc.ts | 9 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 5 + 9 files changed, 426 insertions(+), 24 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b2feffaebe..810d97e4a5 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -103,6 +103,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; @@ -120,6 +122,12 @@ export interface SlashCommandHost { // Session requireSession(): Session; + /** + * Lazy-create the session on first use (v2 engine). Returns the existing + * session, or undefined (with the error already surfaced) when creation + * fails. + */ + ensureSession(): Promise; switchToSession(session: Session, message: string): Promise; reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; @@ -204,11 +212,15 @@ 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; + } host.track('input_command', { command: intent.commandName, skill_name: intent.skillName, @@ -221,10 +233,10 @@ 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; } host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); @@ -253,11 +265,54 @@ 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([ + 'add-dir', + 'auto', + 'btw', + 'compact', + 'export-debug-zip', + 'export-md', + 'fork', + 'goal', + 'init', + 'mcp', + 'permission', + 'plan', + 'plugins', + 'settings', + 'status', + 'swarm', + 'title', + 'undo', + 'usage', + 'web', + 'yolo', +]); + 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; + } switch (name) { case 'exit': void host.stop(); @@ -282,7 +337,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/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index bf8699ff2c..3c3ae9bb9e 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,4 +1,9 @@ -import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { + CreateSessionOptions, + KimiHarness, + Session, + ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; @@ -24,6 +29,7 @@ export interface AuthFlowHost { session: Session | undefined; readonly harness: KimiHarness; readonly options: KimiTUIOptions; + readonly engineV2: boolean; setAppState(patch: Partial): void; setStartupReady(): void; @@ -75,6 +81,17 @@ export class AuthFlowController { return; } + if (host.engineV2) { + // Lazy session creation (v2 engine): configure the model only; the + // session is created on the first message. + const patch: Partial = { model }; + if (effort !== undefined) { + patch.thinkingEffort = effort as ThinkingEffort; + } + host.setAppState(patch); + return; + } + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2061725f48..122ecde9ba 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -10,8 +10,10 @@ import type { CreateSessionOptions, KimiHarness, PermissionMode, + PluginCommandDef, PromptPart, Session, + SkillSummary, WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; @@ -144,6 +146,7 @@ import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; +import { thinkingEffortFromConfig } from './utils/thinking-config'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; @@ -327,7 +330,8 @@ export class KimiTUI { private isShuttingDown = false; 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 = @@ -485,6 +489,19 @@ export class KimiTUI { async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { + // v2 engine: skills live on the workspace handler, not the session, so + // they are available before the first (lazy) session is created. The + // workspace-level list lacks plugin skills; the session list fills that + // in once a session exists. + if (this.engineV2) { + try { + const skills = await this.harness.listWorkspaceSkills(this.state.appState.workDir); + this.applySkillCommands(skills); + return; + } catch { + return; + } + } this.skillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); @@ -497,6 +514,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(); @@ -508,6 +529,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(); @@ -520,6 +552,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(); @@ -822,6 +858,39 @@ 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). + 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; + } + } + // CLI --auto/--yolo/--plan win over config defaults; the flags are + // re-applied by applyStartupPermissionAndPlanToAppState below. + if (!startup.auto && !startup.yolo && config.defaultPermissionMode !== undefined) { + patch.permissionMode = config.defaultPermissionMode; + } + if (!startup.plan && config.defaultPlanMode !== undefined) { + patch.planMode = config.defaultPlanMode; + } + const effort = thinkingEffortFromConfig(config.thinking); + if (effort !== undefined) { + patch.thinkingEffort = effort; + } + if (startup.agentProfile !== undefined || startup.agentFiles !== undefined) { + patch.agentProfile = startup.agentProfile; + patch.agentFiles = startup.agentFiles?.length ? [...startup.agentFiles] : undefined; + } + this.setAppState(patch); } else { session = await this.harness.createSession(createSessionOptions); } @@ -837,11 +906,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; @@ -1029,17 +1100,21 @@ 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; } // Echo the command locally (bash-input) with a `$` prompt. The agent also // records it for resume; this is the live view. @@ -1143,14 +1218,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); @@ -1169,10 +1244,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, { @@ -1298,7 +1377,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, () => { @@ -1560,7 +1639,7 @@ export class KimiTUI { return this.session; } - private async createSessionFromCurrentState(): Promise { + private async createSessionFromCurrentState(bindStartupAgent = false): Promise { const model = this.state.appState.model.trim(); if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); @@ -1575,9 +1654,59 @@ export class KimiTUI { 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`. + */ + async ensureSession(): Promise { + if (this.session !== undefined) return this.session; + 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); + return session; + } + async setSession(session: Session): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 29cc57bff5..29ff804ba9 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; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index b46eab17bf..120da2274b 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 @@ -303,13 +303,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 +463,97 @@ 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('/auto on'); + + await vi.waitFor(() => { + expect(session.setPermission).toHaveBeenCalledWith('auto'); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.state.appState.permissionMode).toBe('auto'); + 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('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 b0689431ef..c1881d5f7c 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -281,6 +281,80 @@ describe('KimiTUI startup', () => { }); }); + it('starts session-less on the v2 engine and carries startup flags to appState', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 200 }, + }, + defaultModel: 'k2', + // CLI --yolo must win over the config default. + defaultPermissionMode: 'auto', + })), + }); + const driver = makeDriver( + harness, + { ...makeStartupInput({ model: 'k2', yolo: true }), engineV2: true }, + ); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'yolo', + }); + }); + + it('shows config defaults in appState before the lazy session exists (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 200 }, + }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', + defaultPlanMode: true, + thinking: { enabled: true, effort: 'high' }, + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + maxContextTokens: 200, + permissionMode: 'auto', + planMode: true, + thinkingEffort: 'high', + }); + }); + + it('carries the --agent/--agent-file binding for the lazy-created first session (v2)', async () => { + const harness = makeHarness(makeSession()); + const driver = makeDriver( + harness, + { + ...makeStartupInput({ model: 'k2', agentFiles: ['agent.md'] }), + engineV2: true, + agentProfile: 'reviewer', + }, + ); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + agentProfile: 'reviewer', + agentFiles: ['agent.md'], + }); + }); + it('binds the resolved agent profile and agent files to the startup session', async () => { const session = makeSession(); const harness = makeHarness(session); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index f8b71fb271..0f33e37301 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -24,6 +24,7 @@ import type { ListSessionsOptions, McpServerConfig, McpTestResult, + PluginCommandDef, RenameSessionInput, ResumeSessionInput, ReloadSessionInput, @@ -256,6 +257,14 @@ 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(); + } + /** * 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 86624d3ae7..7dd4f89949 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -630,6 +630,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 { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 009a59d075..477040709e 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -733,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(); } From 79fe2a18cbc8df26589067690b4edfc2ea102a76 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 16:23:38 +0800 Subject: [PATCH 2/6] fix(tui): serialize lazy session creation and carry session-only thinking Concurrent first-use triggers (double Enter, a slash command right after a prompt) both observed `session === undefined` and created their own session, letting the later setSession close the first one mid-dispatch. Share an in-flight creation promise instead. A session-only thinking choice (model picker Alt+S) made before the first session exists only updated appState, so the lazy-created session fell back to the engine default while the footer showed the chosen effort. Carry it as a first-session thinking override and clear it on creation. --- .../src/tui/controllers/auth-flow.ts | 5 +- apps/kimi-code/src/tui/kimi-tui.ts | 30 ++++++++++- apps/kimi-code/src/tui/types.ts | 7 +++ .../test/tui/kimi-tui-message-flow.test.ts | 53 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 3c3ae9bb9e..47f9bab326 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -83,10 +83,13 @@ export class AuthFlowController { if (host.engineV2) { // Lazy session creation (v2 engine): configure the model only; the - // session is created on the first message. + // 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; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 122ecde9ba..f23ec9d53d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -308,6 +308,8 @@ export class KimiTUI { readonly options: KimiTUIOptions; session: Session | undefined; state: TUIState; + /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ + private ensureSessionPromise: Promise | null = null; private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; @@ -1647,7 +1649,15 @@ export class KimiTUI { 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, }; @@ -1673,9 +1683,22 @@ export class KimiTUI { * 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 { if (this.session !== undefined) return this.session; + this.ensureSessionPromise ??= this.lazyCreateSession().finally(() => { + this.ensureSessionPromise = null; + }); + return this.ensureSessionPromise; + } + + private async lazyCreateSession(): Promise { let session: Session; try { session = await this.createSessionFromCurrentState(true); @@ -1704,6 +1727,11 @@ export class KimiTUI { } 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; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 29ff804ba9..df3d2080ab 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -43,6 +43,13 @@ export interface AppState { * mirrors the runtime. The single source of truth for the thinking state in * the TUI. */ thinkingEffort: ThinkingEffort; + /** + * Session-only thinking effort chosen (e.g. via the model picker's Alt+S) + * while no session exists yet on the v2 engine. Applied to the first + * lazy-created session and cleared once it exists; the engine's config + * default is used instead when unset. + */ + lazySessionThinking?: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 120da2274b..594559ec81 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 @@ -554,6 +554,59 @@ describe('KimiTUI message flow', () => { expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); + it('serializes concurrent lazy session creation (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Hold the first createSession open so both triggers land inside the + // in-flight window. + let resolveCreate!: (s: ReturnType) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + + const ensure = (driver as unknown as { ensureSession(): Promise }).ensureSession; + const first = ensure.call(driver); + const second = ensure.call(driver); + resolveCreate(session); + await Promise.all([first, second]); + + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + + it('carries a session-only thinking choice into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Alt+S session-only thinking before any session exists. + await ( + driver as unknown as { + authFlow: { activateModelAfterLogin(model: string, effort?: string): Promise }; + } + ).authFlow.activateModelAfterLogin('k2', 'high'); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello'); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2', thinking: 'high' }), + ); + expect(driver.state.appState.lazySessionThinking).toBeUndefined(); + }); + it('tracks /clear as the clear alias for /new', async () => { const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); const nextSession = makeSession({ id: 'ses-2' }); From cd52d72a04de4dd9b65dcc6cdcb2ae14d73d3815 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 16:47:37 +0800 Subject: [PATCH 3/6] fix(tui): don't re-enter plan mode when creating the lazy session The v2 engine applies config.defaultPlanMode at session create time (sessionLifecycleService), so passing the pre-filled appState.planMode as the create override entered plan mode twice and threw 'Already in plan mode' on the first message. Pass only the explicit CLI --plan intent for session-less v2 creation; the config default stays footer-only. --- apps/kimi-code/src/tui/kimi-tui.ts | 12 ++++- .../test/tui/kimi-tui-message-flow.test.ts | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f23ec9d53d..87e4542e75 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1646,6 +1646,16 @@ export class KimiTUI { 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: the engine applies `defaultPlanMode` itself + // at create time (sessionLifecycleService), so passing the config default + // here too would enter plan mode twice and throw. 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; const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, @@ -1659,7 +1669,7 @@ export class KimiTUI { ? 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]; 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 594559ec81..99c137dc35 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 @@ -607,6 +607,59 @@ describe('KimiTUI message flow', () => { 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('tracks /clear as the clear alias for /new', async () => { const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); const nextSession = makeSession({ id: 'ses-2' }); From 2e21d4cfa3347c87ae606bd421410d8f1cadc769 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 17:02:55 +0800 Subject: [PATCH 4/6] fix(tui): re-check the busy gate after lazy shell startup, keep /settings session-less A bash command submitted while the first prompt is still being lazy-created shared the in-flight creation promise but resumed past handleUserInput's busy check, running runShellCommand concurrently with the prompt. Re-check streamingPhase after the await and queue instead. /settings is a local settings entry point: opening it must not require or create a session, so it no longer triggers lazy creation; its session-requiring sub-items keep their own errors. --- apps/kimi-code/src/tui/commands/dispatch.ts | 1 - apps/kimi-code/src/tui/kimi-tui.ts | 10 +++++ .../test/tui/kimi-tui-message-flow.test.ts | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 810d97e4a5..b372f6e372 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -294,7 +294,6 @@ const SESSION_REQUIRING_COMMANDS: ReadonlySet = new Set 'permission', 'plan', 'plugins', - 'settings', 'status', 'swarm', 'title', diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 90f5b70724..5a5e3f3a9e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1128,6 +1128,16 @@ export class KimiTUI { } 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. 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 99c137dc35..ac6cb64646 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 @@ -660,6 +660,49 @@ describe('KimiTUI message flow', () => { ); }); + 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('tracks /clear as the clear alias for /new', async () => { const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); const nextSession = makeSession({ id: 'ses-2' }); From c5e87a1ccd2e5c6f4b0d74cfb5238bd00723d2b0 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 17:25:54 +0800 Subject: [PATCH 5/6] fix(tui): re-check busy state after lazy command creation, make /plugins session-free A skill/plugin or idle-only slash command submitted while the first prompt was still being lazy-created shared the in-flight creation promise but resumed past the availability check resolved before the await, running concurrently with the prompt's turn. Re-check the busy gate after the shared await in the skill, plugin-command and builtin dispatch paths. /plugins is app-global on the v2 engine, so a session-less startup no longer creates a session (or fails with LLM-not-set) just to manage plugins: the harness now exposes the global plugin API and the command routes through it until a session exists. --- apps/kimi-code/src/tui/commands/dispatch.ts | 50 +++++++++++++- apps/kimi-code/src/tui/commands/plugins.ts | 66 ++++++++++++++----- .../test/tui/kimi-tui-message-flow.test.ts | 59 +++++++++++++++++ packages/node-sdk/src/kimi-harness.ts | 37 +++++++++++ 4 files changed, 193 insertions(+), 19 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b372f6e372..49b04f11cc 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -43,9 +43,17 @@ import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; -import type { BuiltinSlashCommandName } from './registry'; +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommandName, +} from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; +import { + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -220,6 +228,17 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi 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, @@ -237,6 +256,16 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi if (session === undefined) { 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); @@ -293,7 +322,6 @@ const SESSION_REQUIRING_COMMANDS: ReadonlySet = new Set 'mcp', 'permission', 'plan', - 'plugins', 'status', 'swarm', 'title', @@ -311,6 +339,22 @@ async function handleBuiltInSlashCommand( 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': diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 51bd3515a0..27df8a2be7 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,7 +592,7 @@ 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); 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 ac6cb64646..9a5d31ee29 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 @@ -703,6 +703,65 @@ describe('KimiTUI message flow', () => { 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('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/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 8e0451fc4d..0962fc4574 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -25,6 +25,9 @@ import type { McpServerConfig, McpTestResult, PluginCommandDef, + PluginInfo, + PluginSummary, + ReloadSummary, RenameSessionInput, ResumeSessionInput, ReloadSessionInput, @@ -265,6 +268,40 @@ export class KimiHarness { 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(); + } + + 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 From c7392b65bfafabdbcb8e733be4e39050c550a513 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 17:42:52 +0800 Subject: [PATCH 6/6] fix(tui): keep the read-only /add-dir forms session-less The bare and `list` forms already tolerate a missing session, but the blanket lazy-create gate forced a session (or failed with LLM-not-set) before they could run. Only the path-adding form needs a live session, so it now lazy-creates inside the handler instead of in the dispatch preflight. --- apps/kimi-code/src/tui/commands/add-dir.ts | 12 +++++-- apps/kimi-code/src/tui/commands/dispatch.ts | 1 - .../test/tui/kimi-tui-message-flow.test.ts | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts index 90636cea55..ec128aaf8d 100644 --- a/apps/kimi-code/src/tui/commands/add-dir.ts +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -6,7 +6,7 @@ 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 ?? []; @@ -19,8 +19,14 @@ export async function handleAddDirCommand(host: SlashCommandHost, args: string): } if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // The path-adding form needs a live session; lazy-create it on first use + // (the read-only `list`/bare forms above tolerate a missing session). + session = await host.ensureSession(); + if (session === undefined) return; } host.mountEditorReplacement( diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 49b04f11cc..5e0290f858 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -310,7 +310,6 @@ async function ensureSessionForCommand(host: SlashCommandHost): Promise = new Set([ - 'add-dir', 'auto', 'btw', 'compact', 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 9a5d31ee29..56d3a4b710 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 @@ -762,6 +762,39 @@ describe('KimiTUI message flow', () => { 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('tracks /clear as the clear alias for /new', async () => { const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); const nextSession = makeSession({ id: 'ses-2' });