diff --git a/.changeset/reload-session-tui.md b/.changeset/reload-session-tui.md new file mode 100644 index 0000000000..364f384553 --- /dev/null +++ b/.changeset/reload-session-tui.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add /reload to reload the current session and apply updated config files, plus /reload-tui to reload only TUI preferences. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 6102cf51f8..e5dbd47f10 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -40,6 +40,7 @@ import { handleGoalCommand } from './goal'; import { handleProviderCommand } from './provider'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; import { handlePluginsCommand } from './plugins'; +import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -77,6 +78,7 @@ export { showUsage, } from './info'; export { handlePluginsCommand } from './plugins'; +export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand } from './goal'; export { handleExportDebugZipCommand, @@ -111,6 +113,7 @@ export interface SlashCommandHost { // Session requireSession(): Session; switchToSession(session: Session, message: string): Promise; + reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; failSessionRequest(message: string): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; @@ -235,6 +238,12 @@ async function handleBuiltInSlashCommand( case 'plugins': void handlePluginsCommand(host, args); return; + case 'reload': + await handleReloadCommand(host); + return; + case 'reload-tui': + await handleReloadTuiCommand(host); + return; case 'editor': await handleEditorCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index ba639312f3..45cf1c220f 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -29,6 +29,7 @@ export { showUsage, } from './info'; export { handlePluginsCommand } from './plugins'; +export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand, parseGoalCommand } from './goal'; export { goalArgumentCompletions } from './registry'; export { diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 9abe88e558..80ebb73798 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -114,6 +114,20 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'reload', + aliases: [], + description: 'Reload session and apply config.toml settings plus tui.toml UI preferences', + priority: 60, + availability: 'idle-only', + }, + { + name: 'reload-tui', + aliases: [], + description: 'Reload only tui.toml UI preferences', + priority: 60, + availability: 'always', + }, { name: 'compact', aliases: [], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts new file mode 100644 index 0000000000..402c07765e --- /dev/null +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -0,0 +1,52 @@ +import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; + +import { loadTuiConfig, type TuiConfig } from '../config'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { + const tuiConfig = await loadTuiConfig(); + applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', host.state.theme.colors.success); +} + +export async function handleReloadCommand(host: SlashCommandHost): Promise { + const tuiConfig = await loadTuiConfig(); + const session = host.session; + + if (session !== undefined) { + await session.reloadSession(); + await host.reloadCurrentSessionView(session, 'Session reloaded.'); + } + + const config = await host.harness.getConfig({ reload: true }); + applyRuntimeConfig(host, config); + applyReloadedTuiConfig(host, tuiConfig); + + if (session === undefined) { + host.showStatus( + 'Runtime and TUI config reloaded; no active session.', + host.state.theme.colors.success, + ); + } +} + +export function applyReloadedTuiConfig( + host: SlashCommandHost, + config: TuiConfig, +): void { + const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme; + host.applyTheme(config.theme, resolved); + host.refreshTerminalThemeTracking(); + host.setAppState({ + editorCommand: config.editorCommand, + notifications: config.notifications, + upgrade: config.upgrade, + }); +} + +function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { + host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + }); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index db152fe271..df7b5db073 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1169,6 +1169,34 @@ export class KimiTUI { this.showStatus(statusMessage); } + async reloadCurrentSessionView(session: Session, statusMessage: string): Promise { + this.sessionEventUnsubscribe?.(); + this.sessionEventUnsubscribe = undefined; + this.clearReverseRpcPanels(); + session.setApprovalHandler(undefined); + session.setQuestionHandler(undefined); + this.approvalController.cancelAll('reloading session'); + this.questionController.cancelAll('reloading session'); + + this.resetSessionRuntime(); + this.session = session; + this.harness.setTelemetryContext({ sessionId: session.id }); + this.registerSessionHandlers(session); + await this.syncRuntimeState(session); + this.updateTerminalTitle(); + try { + await this.refreshSkillCommands(session); + } catch { + /* keep the reloaded session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + const resumeState = session.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + } + this.showStatus(statusMessage); + } + async createNewSession(): Promise { if (this.state.appState.isReplaying) { this.showError('Cannot start a new session while history is replaying.'); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index b67aaff6e3..ae9eeb8f62 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -112,6 +112,8 @@ describe('built-in slash command registry', () => { 'new', 'permission', 'plan', + 'reload', + 'reload-tui', 'sessions', 'settings', 'status', @@ -124,4 +126,14 @@ describe('built-in slash command registry', () => { ]), ); }); + + it('keeps TUI reload always available and full reload idle-only', () => { + const reload = findBuiltInSlashCommand('reload'); + const reloadTui = findBuiltInSlashCommand('reload-tui'); + + expect(reload).toBeDefined(); + expect(reloadTui).toBeDefined(); + expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts new file mode 100644 index 0000000000..717297df10 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -0,0 +1,139 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handleReloadCommand, + handleReloadTuiCommand, +} from '#/tui/commands/reload'; +import type { SlashCommandHost } from '#/tui/commands'; + +const tempDirs: string[] = []; +const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (originalKimiCodeHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalKimiCodeHome; + } +}); + +describe('reload slash commands', () => { + it('reloads tui.toml without touching Core session state', async () => { + await writeTuiConfig(` +theme = "light" + +[editor] +command = "vim" + +[notifications] +enabled = false +notification_condition = "always" + +[upgrade] +auto_install = false +`); + const session = { reloadSession: vi.fn() }; + const host = makeHost({ session }); + + await handleReloadTuiCommand(host); + + expect(host.harness.getConfig).not.toHaveBeenCalled(); + expect(session.reloadSession).not.toHaveBeenCalled(); + expect(host.state.appState).toMatchObject({ + theme: 'light', + editorCommand: 'vim', + notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, + }); + expect(host.showStatus).toHaveBeenCalledWith( + 'TUI config reloaded.', + host.state.theme.colors.success, + ); + }); + + it('reloads the active session, refreshes runtime config, and applies tui.toml', async () => { + await writeTuiConfig('theme = "light"\n'); + const session = { id: 'ses-1', reloadSession: vi.fn(async () => ({})) }; + const host = makeHost({ session }); + + await handleReloadCommand(host); + + expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( + session, + 'Session reloaded.', + ); + expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); + expect(host.state.appState.theme).toBe('light'); + expect(host.state.appState.availableModels).toEqual({ + fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, + }); + }); +}); + +async function writeTuiConfig(text: string): Promise { + const dir = join(tmpdir(), `kimi-tui-reload-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDirs.push(dir); + await mkdir(dir, { recursive: true }); + process.env['KIMI_CODE_HOME'] = dir; + await writeFile(join(dir, 'tui.toml'), text, 'utf-8'); +} + +function makeHost({ + session, +}: { + readonly session?: Record; +} = {}) { + const state = { + appState: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + }, + theme: { + resolvedTheme: 'dark', + colors: { + success: '#00ff00', + }, + }, + }; + return { + state, + session, + harness: { + getConfig: vi.fn(async () => ({ + models: { + fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, + }, + providers: { + test: { type: 'kimi', apiKey: 'test-key' }, + }, + })), + }, + setAppState: vi.fn((patch: Record) => { + Object.assign(state.appState, patch); + }), + applyTheme: vi.fn((theme: string) => { + state.appState.theme = theme; + }), + refreshTerminalThemeTracking: vi.fn(), + reloadCurrentSessionView: vi.fn(async () => {}), + showStatus: vi.fn(), + } as unknown as SlashCommandHost & { + readonly harness: { + readonly getConfig: ReturnType; + }; + readonly reloadCurrentSessionView: ReturnType; + readonly showStatus: ReturnType; + }; +} diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index e0538ad45b..15bdbbeab7 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -74,6 +74,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'undo', reason: 'streaming', }); + expect(resolve('/reload', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'reload', + reason: 'streaming', + }); }); it('blocks model and session pickers while compacting', () => { @@ -87,6 +92,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'resume', reason: 'compacting', }); + expect(resolve('/reload', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'reload', + reason: 'compacting', + }); }); it('allows always-available built-ins while streaming', () => { @@ -105,6 +115,16 @@ describe('resolveSlashCommandInput', () => { name: 'mcp', args: '', }); + expect(resolve('/reload-tui', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'reload-tui', + args: '', + }); + expect(resolve('/reload-tui', { isCompacting: true })).toMatchObject({ + kind: 'builtin', + name: 'reload-tui', + args: '', + }); expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({ kind: 'builtin', name: 'btw', 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 644e0ebf39..71d7a9a9ba 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 @@ -172,6 +172,7 @@ function makeSession(overrides: Record = {}) { setPluginMcpServerEnabled: vi.fn(async () => {}), removePlugin: vi.fn(async () => {}), reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), + reloadSession: vi.fn(async () => ({})), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -368,6 +369,57 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('theme_switch', { theme: 'light' }); }); + it('dispatches /reload-tui without reloading the active session', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + await writeFile( + join(homeDir, 'tui.toml'), + ` +theme = "light" + +[editor] +command = "vim" +`, + 'utf-8', + ); + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + session.reloadSession.mockClear(); + + driver.handleUserInput('/reload-tui'); + + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(driver.state.appState.editorCommand).toBe('vim'); + expect(session.reloadSession).not.toHaveBeenCalled(); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload-tui' }); + }); + + it('dispatches /reload through session reload and applies tui.toml', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + await writeFile(join(homeDir, 'tui.toml'), 'theme = "light"\n', 'utf-8'); + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + session.reloadSession.mockClear(); + driver.handleUserInput('hello before reload'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(session.reloadSession).toHaveBeenCalledOnce(); + }); + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload' }); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('hello before reload'); + expect(transcript).toContain('Session reloaded.'); + }); + it('tracks successful feedback submissions only after the request succeeds', async () => { const { driver, harness } = await makeDriver( makeSession(), diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 922d1fa5fc..76c4bbb89a 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -59,6 +59,10 @@ export interface ResumeSessionPayload { readonly mcpServers?: Readonly>; } +export interface ReloadSessionPayload { + readonly sessionId: string; +} + export interface ForkSessionPayload { readonly sessionId: string; readonly id?: string; @@ -360,6 +364,7 @@ export interface CoreAPI extends SessionAPIWithId { createSession: (payload: CreateSessionPayload) => SessionSummary; closeSession: (payload: CloseSessionPayload) => void; resumeSession: (payload: ResumeSessionPayload) => ResumeSessionResult; + reloadSession: (payload: ReloadSessionPayload) => ResumeSessionResult; forkSession: (payload: ForkSessionPayload) => ResumeSessionResult; listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[]; exportSession: (payload: ExportSessionPayload) => ExportSessionResult; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 764724866c..1ffd92b1d8 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -68,6 +68,7 @@ import type { PromptPayload, ReconnectMcpServerPayload, RegisterToolPayload, + ReloadSessionPayload, ReloadPluginsResult, RemoveKimiProviderPayload, RemovePluginPayload, @@ -124,6 +125,7 @@ export class KimiCore implements PromisableMethods { private kaos: Promise; private runtime: ToolServices | undefined; private config: KimiConfig; + private readonly runtimeOverride: ToolServices | undefined; private readonly userHomeDir: string; private readonly kimiRequestHeaders: Record | undefined; private readonly resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined; @@ -150,6 +152,7 @@ export class KimiCore implements PromisableMethods { } throw error; }); + this.runtimeOverride = options.runtime; this.runtime = options.runtime; this.kimiRequestHeaders = options.kimiRequestHeaders; this.resolveOAuthTokenProvider = options.resolveOAuthTokenProvider; @@ -322,6 +325,28 @@ export class KimiCore implements PromisableMethods { return resumeSessionResult(summary, session, warning); } + async reloadSession(input: ReloadSessionPayload): Promise { + const summary = await this.sessionStore.get(input.sessionId); + const active = this.sessions.get(summary.id); + if (active?.hasActiveTurn === true) { + throw new KimiError( + ErrorCodes.TURN_AGENT_BUSY, + `Session "${summary.id}" cannot be reloaded while a turn is running`, + { details: { sessionId: summary.id } }, + ); + } + + this.reloadProviderManager(); + this.clearRuntimeCache(); + await this.reloadPlugins({}); + + if (active !== undefined) { + await active.closeForReload(); + this.sessions.delete(summary.id); + } + return this.resumeSession({ sessionId: summary.id }); + } + async forkSession(input: ForkSessionPayload): Promise { const source = await this.sessionStore.get(input.sessionId); const active = this.sessions.get(source.id); @@ -746,6 +771,11 @@ export class KimiCore implements PromisableMethods { return this.config = loadRuntimeConfig(this.configPath); } + private clearRuntimeCache(): void { + if (this.runtimeOverride !== undefined) return; + this.runtime = undefined; + } + private async refreshSessionRuntimeConfig( session: Session, config: KimiConfig, diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 274b9d574a..ca0913be5a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -237,6 +237,21 @@ export class Session { } } + async closeForReload(): Promise { + try { + await Promise.allSettled( + Array.from(this.readyAgents(), async (agent) => agent.cron?.stop()), + ); + await this.flushMetadata(); + } finally { + try { + await this.mcp.shutdown(); + } finally { + await this.logHandle?.close(); + } + } + } + private async stopBackgroundTasksOnExit(): Promise { const keepAliveOnExit = resolveConfigValue({ env: process.env, diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index be3766dae8..eb64073285 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -8,6 +8,7 @@ import { FLAG_DEFINITIONS, MASTER_ENV, createRPC, + ErrorCodes, KimiCore, type ApprovalResponse, type CoreAPI, @@ -162,4 +163,93 @@ max_context_size = 100000 expect(mainAgent?.config.modelAlias).toBe('default-mock'); }); + + it('reloads an active session with fresh runtime services from config.toml', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const configPath = join(homeDir, 'config.toml'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(configPath, baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_reload', + workDir, + model: 'default-mock', + }); + const before = core.sessions.get(created.id); + expect(before?.options.toolServices?.webSearcher).toBeUndefined(); + + await writeFile( + configPath, + `${baseModelConfig()} +[services.moonshot_search] +base_url = "https://search.example.test/v1" +`, + ); + + const reloaded = await rpc.reloadSession({ sessionId: created.id }); + const after = core.sessions.get(created.id); + + expect(after).toBeDefined(); + expect(after).not.toBe(before); + expect(after?.options.toolServices?.webSearcher).toBeDefined(); + expect(reloaded.agents['main']).toBeDefined(); + }); + + it('rejects reloadSession while the active session has a running turn', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_reload_busy', + workDir, + model: 'default-mock', + }); + const active = core.sessions.get(created.id); + const main = active?.getReadyAgent('main'); + vi.spyOn(main!.turn, 'hasActiveTurn', 'get').mockReturnValue(true); + + await expect(rpc.reloadSession({ sessionId: created.id })).rejects.toMatchObject({ + code: ErrorCodes.TURN_AGENT_BUSY, + }); + expect(core.sessions.get(created.id)).toBe(active); + }); }); + +function baseModelConfig(): string { + return `default_model = "default-mock" + +[providers.test] +type = "kimi" +api_key = "test-key" + +[models."default-mock"] +provider = "test" +model = "default-mock" +max_context_size = 100000 +`; +} diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index a83e2e4ddb..7422272e7f 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -154,6 +154,42 @@ describe('Session lifecycle hooks', () => { expect(killSpy).toHaveBeenCalledWith('SIGTERM'); expect(agent.background.getTask(taskId)?.status).toBe('killed'); }); + + it('keeps background tasks alive and skips SessionEnd hooks when closing for reload', async () => { + const { command, logPath, sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-reload-close', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { keepAliveOnExit: false }, + hooks: [ + { event: 'SessionStart', matcher: 'startup', command, timeout: 5 }, + { event: 'SessionEnd', matcher: 'exit', command, timeout: 5 }, + ], + }); + const agent = await session.createMain(); + const stopSpy = vi.spyOn(agent.cron!, 'stop'); + const { proc, killSpy } = pendingProcess(); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'reload keeps alive'), + ); + + await session.closeForReload(); + + expect(stopSpy).toHaveBeenCalledOnce(); + expect(killSpy).not.toHaveBeenCalled(); + expect(agent.background.getTask(taskId)?.status).toBe('running'); + expect(await readHookPayloads(logPath)).toMatchObject([ + { + hook_event_name: 'SessionStart', + session_id: 'session-reload-close', + cwd: workDir, + source: 'startup', + }, + ]); + }); }); async function hookFixture(): Promise<{ diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 12bdf298ab..183de20295 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -125,6 +125,31 @@ export class KimiHarness { return session; } + async reloadSession(input: ResumeSessionInput): Promise { + const id = normalizeSessionId(input.id); + const active = this.activeSessions.get(id); + if (active !== undefined) { + await active.reloadSession(); + this.trackSessionEvent(active.id, 'session_reload'); + return active; + } + + const summary = await this.rpc.reloadSession({ sessionId: id }); + const session = new Session({ + id: summary.id, + workDir: summary.workDir, + summary, + rpc: this.rpc, + onClose: () => { + this.activeSessions.delete(summary.id); + }, + }); + this.activeSessions.set(session.id, session); + this.trackSessionStarted(summary.id, true); + this.trackSessionEvent(session.id, 'session_reload'); + return session; + } + async forkSession(input: ForkSessionInput): Promise { const summary = await this.rpc.forkSession({ id: normalizeSessionId(input.id), diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 570b13dcd5..86cc6dd1e9 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -111,6 +111,11 @@ export abstract class SDKRpcClientBase { return rpc.resumeSession({ ...input, sessionId: input.id }); } + async reloadSession(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.reloadSession({ sessionId: input.sessionId }); + } + async forkSession(input: ForkSessionInput): Promise { const rpc = await this.getRpc(); return rpc.forkSession({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index ea3d63dbff..f84a7006bd 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -21,6 +21,7 @@ import type { PromptInput, ReloadSummary, ResumedSessionState, + ResumedSessionSummary, SessionPlan, SessionStatus, SessionSummary, @@ -43,8 +44,8 @@ export interface SessionOptions { export class Session { readonly id: string; readonly workDir: string; - readonly summary?: SessionSummary | undefined; - private readonly resumeState: ResumedSessionState | undefined; + summary?: SessionSummary | undefined; + private resumeState: ResumedSessionState | undefined; private readonly rpc: SDKRpcClientBase; private readonly onClose?: (() => void | Promise) | undefined; @@ -64,6 +65,14 @@ export class Session { return this.resumeState; } + async reloadSession(): Promise { + this.ensureOpen(); + const summary = await this.rpc.reloadSession({ sessionId: this.id }); + this.summary = summary; + this.resumeState = resumeStateFromSummary(summary); + return summary; + } + onEvent(listener: (event: Event) => void): Unsubscribe { this.ensureOpen(); return this.rpc.onEvent((event) => { diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 3ec7986592..4d551e9b73 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -302,4 +302,26 @@ describe('KimiHarness config API', () => { expect(config.defaultModel).toBeUndefined(); expect(config.defaultThinking).toBeUndefined(); }); + + it('reloads an active session without closing the SDK session wrapper', async () => { + const homeDir = await makeTempDir(); + const workDir = join(homeDir, 'work'); + const configPath = join(homeDir, 'config.toml'); + await writeFile(configPath, COMPLETE_TOML, 'utf-8'); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + const session = await harness.createSession({ + id: 'session-sdk-reload', + workDir, + model: 'kimi-for-coding', + }); + + expect(session.getResumeState()).toBeUndefined(); + + const reloaded = await harness.reloadSession({ id: session.id }); + + expect(reloaded).toBe(session); + expect(harness.getSession(session.id)).toBe(session); + expect(session.getResumeState()?.agents['main']).toBeDefined(); + await expect(session.getStatus()).resolves.toMatchObject({ model: 'kimi-for-coding' }); + }); }); diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..2b678039da --- /dev/null +++ b/plan.md @@ -0,0 +1,678 @@ +# /reload 与 /reload-tui 实施计划 + +> **给执行该计划的 agent:** 必须按任务逐项执行。推荐使用 `superpowers:subagent-driven-development` 或 `superpowers:executing-plans`。所有步骤用 checkbox(`- [ ]`)跟踪完成状态。 + +**目标:** 新增两个 reload 命令:`/reload-tui` 只重载并应用 `tui.toml`;`/reload` 重载完整 runtime,内部通过 Core 的 `reloadSession({ sessionId })` 重建当前 session runtime 并 resume 同一个 session,同时应用 `tui.toml`。 + +**架构:** TUI-only reload 留在 TUI 层,不触碰 Core session。完整 runtime reload 由 Core 原子地关闭旧 session runtime、重新读取配置、重建同一个 session 并返回 `ResumeSessionResult`;SDK 保持同一个 `Session` wrapper,并更新 wrapper 内的 resume snapshot;TUI 则重置 UI runtime、刷新 skill commands、重新 hydrate transcript,并应用最新 `tui.toml`。 + +**技术栈:** TypeScript monorepo、`@moonshot-ai/agent-core`、`@moonshot-ai/kimi-code-sdk`、`apps/kimi-code` TUI、Vitest、oxlint。 + +--- + +## 已确定产品语义 + +### `/reload-tui` + +- 只读取并应用 `tui.toml`。 +- 不碰 Core。 +- 不重建 session。 +- 不清 transcript。 +- 不影响 MCP / skills / plugins / cron / background。 +- 建议 `availability: 'always'`,因为它只改 TUI 本地状态。 +- 应用范围: + - `theme` + - `editor.command` + - `notifications.enabled` + - `notifications.notification_condition` + - `upgrade.auto_install` + +状态文案: + +```text +TUI config reloaded: theme, editor, notifications +``` + +无变化: + +```text +TUI config reloaded: no changes +``` + +### `/reload` + +- 重载完整 runtime,并同时应用 `tui.toml`。 +- 必须是 `idle-only`。 +- 有 active session 时: + - 先读取 `tui.toml`,失败则不动 runtime; + - Core 执行 `reloadSession({ sessionId })`; + - TUI 应用 `tui.toml`; + - TUI 重置 runtime UI 状态; + - TUI 刷新 skill slash commands; + - TUI 重新 hydrate transcript; + - TUI 重新订阅 session events。 +- 没有 active session 时: + - 重读 Core config; + - 应用 `tui.toml`; + - 刷新 TUI available models/providers; + - 不执行 `reloadSession()`。 +- 允许破坏 prompt cache。 +- 不需要 skills 后续 reminder / injector。session 被重新 resume 后,新的 system prompt / skills / tools 列表通过正常构造路径进入上下文。 +- 当前 model / permission / plan / thinking 不走手写 deferred 提示。它们由 resume replay 和 `refreshSessionRuntimeConfig()` 的现有行为决定。 +- 如果当前 model alias 被删,允许现有 fallback 逻辑切到新的 `default_model`,并通过已有 resume warning / TUI 状态体现。 +- reload 期间必须停止旧 cron scheduler,但 cron 任务不能丢。新 Agent 构造后会新建 CronManager,resume 时从磁盘重新加载 cron tasks。 +- reload 不能按普通 exit 语义杀 background tasks。 +- reload 不触发普通 `SessionEnd(exit)` hook。 + +状态文案: + +```text +Runtime reloaded: session resumed with latest config. +``` + +无 active session: + +```text +Runtime config reloaded; no active session. +``` + +--- + +## 配置生效模型 + +| 来源 | `/reload-tui` | `/reload` | +|---|---|---| +| `tui.toml` | 读取并应用 | 读取并应用 | +| `config.toml` providers/models | 不处理 | Core reload 后 resume 新 session runtime | +| `config.toml` default model / thinking / permission / plan | 不处理 | 由 resume replay + existing refresh fallback 决定 | +| `config.toml` skills 配置 | 不处理 | 新 Session 构造时重新 resolve skill roots | +| `mcp.json` | 不处理 | 新 Session 构造时重新 connect MCP | +| plugin state | 不处理 | Core reload 前 `plugins.reload()`,新 Session 构造时读取最新 plugin runtime | +| hooks / permission rules / loopControl / background | 不处理 | 新 Session 构造时用最新 config 注入 | +| services | 不处理 | Core 清空 `runtime` cache 后按最新 config 重建 | + +--- + +## 文件结构 + +- 修改:`packages/agent-core/src/rpc/core-api.ts` + - 新增 `ReloadSessionPayload` / `ReloadSessionResult`。 + - `CoreAPI` 新增 `reloadSession(payload)`。 +- 修改:`packages/agent-core/src/rpc/core-impl.ts` + - 实现 `KimiCore.reloadSession()`。 + - reload 前清空按 config 创建的 `this.runtime` cache。 + - reload 前刷新 plugin manager。 +- 修改:`packages/agent-core/src/session/index.ts` + - 新增 reload 专用关闭生命周期 `closeForReload()`。 + - 普通 `close()` 仍保留 exit 语义。 +- 修改:`packages/node-sdk/src/types.ts` + - 导出 `ReloadSessionPayload` / `ReloadSessionResult`。 +- 修改:`packages/node-sdk/src/rpc.ts` + - 新增 `reloadSession(input)`。 +- 修改:`packages/node-sdk/src/kimi-harness.ts` + - 新增 `reloadSession(input)`。 + - 无 active session 的 Core config 重读继续使用 `getConfig({ reload: true })`。 +- 修改:`packages/node-sdk/src/session.ts` + - 新增 `reloadSession()`。 + - reload 后更新当前 wrapper 的 summary / resume state。 + - 不关闭当前 wrapper。 +- 新建:`apps/kimi-code/src/tui/commands/reload.ts` + - `handleReloadCommand()`:实现 `/reload`。 + - `handleReloadTuiCommand()`:实现 `/reload-tui`。 + - `applyReloadedTuiConfig()`:可测试的 TUI config 应用 helper。 +- 修改:`apps/kimi-code/src/tui/commands/dispatch.ts` + - 接入 `/reload` 和 `/reload-tui`。 + - 不补 `invalid` 分支。 +- 修改:`apps/kimi-code/src/tui/commands/registry.ts` + - 注册 `/reload` 为 `idle-only`。 + - 注册 `/reload-tui` 为 `always`。 +- 修改:`apps/kimi-code/src/tui/commands/index.ts` + - 导出新的 reload handlers/helpers。 +- 测试: + - `packages/node-sdk/test/config.test.ts` + - `packages/agent-core/test/session/cron-stop-on-close.test.ts` + - `apps/kimi-code/test/tui/config.test.ts` + - `apps/kimi-code/test/tui/commands/registry.test.ts` + - `apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts` + - 必要时新增 `packages/agent-core/test/harness/reload-session.test.ts` + +--- + +## Task 1:Core API 增加 `reloadSession` + +- [ ] **Step 1:写失败测试,证明 SDK 期望存在 `session.reloadSession()`** + +在 `packages/node-sdk/test/config.test.ts` 增加测试:创建 session,修改 `config.toml`,调用 `session.reloadSession()`,断言同一个 session id 仍可用且 `maxContextTokens` 来自新配置。 + +核心断言: + +```ts +const summary = await session.reloadSession(); + +expect(summary.id).toBe(session.id); +await expect(session.getStatus()).resolves.toMatchObject({ + model: 'old-model', + maxContextTokens: 8192, +}); +await expect(harness.getConfig()).resolves.toMatchObject({ + defaultModel: 'new-model', +}); +``` + +- [ ] **Step 2:运行测试,确认失败** + +```bash +pnpm exec vitest run packages/node-sdk/test/config.test.ts +``` + +预期:失败,错误类似 `session.reloadSession is not a function`。 + +- [ ] **Step 3:修改 `packages/agent-core/src/rpc/core-api.ts`** + +新增: + +```ts +export interface ReloadSessionPayload { + readonly sessionId: string; +} + +export type ReloadSessionResult = ResumeSessionResult; +``` + +在 `CoreAPI` 增加: + +```ts +reloadSession: (payload: ReloadSessionPayload) => ReloadSessionResult; +``` + +--- + +## Task 2:实现 `Session.closeForReload()` + +- [ ] **Step 1:写失败测试:reload close 会 stop cron,但不触发 exit hook** + +在 `packages/agent-core/test/session/cron-stop-on-close.test.ts` 增加: + +```ts +it('closeForReload stops cron without running SessionEnd exit hooks', async () => { + const { session, main } = await createCronSessionForTest('session-cron-reload'); + const stopSpy = vi.spyOn(main.cron!, 'stop'); + const hookSpy = vi.spyOn(session.hookEngine, 'trigger'); + + await session.closeForReload(); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(hookSpy).not.toHaveBeenCalledWith( + 'SessionEnd', + expect.objectContaining({ matcherValue: 'exit' }), + ); +}); +``` + +如果该测试文件没有 `createCronSessionForTest()`,按现有 `cron-stop-on-close.test.ts` 的 session 构造方式内联创建 session,不要新增多余 test helper 文件。 + +- [ ] **Step 2:写失败测试:reload close 不按 exit 策略杀 background** + +```ts +it('closeForReload does not stop background tasks through exit policy', async () => { + const { session, main } = await createCronSessionForTest('session-reload-background'); + const stopAllSpy = vi.spyOn(main.background, 'stopAll'); + + await session.closeForReload(); + + expect(stopAllSpy).not.toHaveBeenCalledWith('Session closed'); +}); +``` + +- [ ] **Step 3:实现 `closeForReload()`** + +在 `packages/agent-core/src/session/index.ts` 增加: + +```ts +async closeForReload(): Promise { + try { + await Promise.allSettled( + Array.from(this.agents.values(), async (agent) => agent.cron?.stop()), + ); + await this.flushMetadata(); + } finally { + try { + await this.mcp.shutdown(); + } finally { + await this.logHandle?.close(); + } + } +} +``` + +明确不要调用: + +```ts +await this.stopBackgroundTasksOnExit(); +await this.triggerSessionEnd('exit'); +``` + +--- + +## Task 3:实现 `KimiCore.reloadSession()` + +- [ ] **Step 1:写失败测试** + +新增 `packages/agent-core/test/harness/reload-session.test.ts`,覆盖: + +- `reloadSession` 返回同一个 session id; +- 新 session runtime 使用最新 `config.toml`; +- 当前 session wrapper 仍可通过 `getConfig({ agentId: 'main' })` 读取到新 model capabilities; +- plugin reload 失败时本次 reload 失败。 + +- [ ] **Step 2:实现 Core 方法** + +在 `packages/agent-core/src/rpc/core-impl.ts` 增加: + +```ts +async reloadSession({ sessionId }: ReloadSessionPayload): Promise { + const active = this.sessions.get(sessionId); + if (active?.hasActiveTurn) { + throw new KimiError( + ErrorCodes.SESSION_NOT_IDLE, + `Session "${sessionId}" cannot be reloaded while a turn is active.`, + { details: { sessionId } }, + ); + } + + this.reloadProviderManager(); + this.runtime = undefined; + + try { + await this.pluginsReady; + await this.plugins.reload(); + this.pluginsLoadError = undefined; + } catch (error) { + this.pluginsLoadError = error instanceof Error ? error : new Error(String(error)); + throw new KimiError( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to reload plugins: ${this.pluginsLoadError.message}`, + { cause: error, details: { kimiHomeDir: this.homeDir } }, + ); + } + + if (active !== undefined) { + await active.closeForReload(); + this.sessions.delete(sessionId); + } + + return this.resumeSession({ sessionId }); +} +``` + +执行前用 `rg "SESSION_NOT_IDLE|SESSION_BUSY|ACTIVE_TURN" packages/agent-core/src/errors packages/agent-core/src` 确认错误码;如果没有合适错误码,使用现有最接近错误码,不要随意新增兼容错误码。 + +--- + +## Task 4:SDK 暴露 `reloadSession()` + +- [ ] **Step 1:更新 `packages/node-sdk/src/types.ts`** + +导出: + +```ts +ReloadSessionPayload, +ReloadSessionResult, +``` + +- [ ] **Step 2:更新 `packages/node-sdk/src/rpc.ts`** + +新增: + +```ts +async reloadSession(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.reloadSession({ sessionId: input.sessionId }); +} +``` + +- [ ] **Step 3:更新 `packages/node-sdk/src/kimi-harness.ts`** + +新增: + +```ts +async reloadSession(input: ResumeSessionInput): Promise { + const id = normalizeSessionId(input.id); + const session = this.activeSessions.get(id); + if (session === undefined) { + return this.resumeSession({ id }); + } + await session.reloadSession(); + this.trackSessionEvent(session.id, 'session_reload'); + return session; +} +``` + +- [ ] **Step 4:更新 `packages/node-sdk/src/session.ts`** + +把 `summary` / `resumeState` 改成可更新字段: + +```ts +summary?: SessionSummary | undefined; +private resumeState: ResumedSessionState | undefined; +``` + +新增: + +```ts +async reloadSession(): Promise { + this.ensureOpen(); + const summary = await this.rpc.reloadSession({ sessionId: this.id }); + this.summary = summary; + this.resumeState = resumeStateFromSummary(summary); + return summary; +} +``` + +注意:不要 close 当前 SDK wrapper。 + +--- + +## Task 5:TUI 实现 `/reload-tui` 和 `/reload` + +- [ ] **Step 1:新建 `apps/kimi-code/src/tui/commands/reload.ts`** + +导出: + +```ts +export async function handleReloadTuiCommand(host: SlashCommandHost): Promise; +export async function handleReloadCommand(host: SlashCommandHost): Promise; +export function applyReloadedTuiConfig(...): TuiReloadResult; +``` + +- [ ] **Step 2:实现 `/reload-tui`** + +行为: + +```ts +export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { + try { + const tuiConfig = await loadTuiConfig(); + const result = applyReloadedTuiConfig(host, tuiConfig); + host.showStatus(formatTuiReloadStatus(result)); + } catch (error) { + host.showError(`Failed to reload TUI config: ${formatErrorMessage(error)}`); + } +} +``` + +`/reload-tui` 不创建 spinner也可以;如果保持一致,也可以使用短 spinner。关键是不要触碰 `host.session` 和 `host.harness.getConfig()`。 + +- [ ] **Step 3:新增 TUI host 方法** + +在 `SlashCommandHost` 中新增: + +```ts +reloadCurrentSessionView(session: Session): Promise; +``` + +在 `KimiTUI` 实现: + +```ts +async reloadCurrentSessionView(session: Session): Promise { + this.resetSessionRuntime(); + await this.syncRuntimeState(session); + this.refreshSessionTitle(); + try { + await this.refreshSkillCommands(session); + } catch { + /* keep the reloaded session usable even if dynamic skills fail */ + } + this.clearTranscriptAndRedraw(); + try { + await this.sessionReplay.hydrateFromReplay(session); + } finally { + this.sessionEventHandler.startSubscription(); + } +} +``` + +- [ ] **Step 4:实现 `/reload`** + +有 active session: + +```ts +export async function handleReloadCommand(host: SlashCommandHost): Promise { + const spinner = host.showProgressSpinner('Reloading runtime'); + try { + const tuiConfig = await loadTuiConfig(); + const session = host.session; + + if (session !== undefined) { + await session.reloadSession(); + await host.reloadCurrentSessionView(session); + const tui = applyReloadedTuiConfig(host, tuiConfig); + spinner.stop({ ok: true, label: 'Runtime reloaded' }); + host.showStatus(formatSessionReloadStatus(tui)); + return; + } + + const config = await host.harness.getConfig({ reload: true }); + host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers, + }); + const tui = applyReloadedTuiConfig(host, tuiConfig); + spinner.stop({ ok: true, label: 'Runtime config reloaded' }); + host.showStatus(formatNoSessionReloadStatus(config, tui)); + } catch (error) { + spinner.stop({ ok: false, label: 'Reload failed' }); + host.showError(`Failed to reload runtime: ${formatErrorMessage(error)}`); + } +} +``` + +文案: + +```text +Runtime reloaded: session resumed with latest config. +Runtime config reloaded; no active session. +``` + +- [ ] **Step 5:注册命令** + +`apps/kimi-code/src/tui/commands/registry.ts`: + +```ts +{ + name: 'reload', + aliases: [], + description: 'Reload runtime config and resume the current session', + priority: 60, + availability: 'idle-only', +}, +{ + name: 'reload-tui', + aliases: [], + description: 'Reload tui.toml without restarting the session', + priority: 60, + availability: 'always', +}, +``` + +不要新增旧的 config reload 命令。 + +- [ ] **Step 6:接入 dispatch** + +`apps/kimi-code/src/tui/commands/dispatch.ts`: + +```ts +case 'reload': + await handleReloadCommand(host); + return; +case 'reload-tui': + await handleReloadTuiCommand(host); + return; +``` + +不要补 `invalid` 分支。 + +--- + +## Task 6:测试 TUI 命令行为 + +- [ ] **Step 1:测试 `/reload-tui` 注册为 always** + +`apps/kimi-code/test/tui/commands/registry.test.ts`: + +```ts +it('registers reload-tui as always available and reload as idle-only', () => { + const reload = findBuiltInSlashCommand('reload'); + const reloadTui = findBuiltInSlashCommand('reload-tui'); + + expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); +}); +``` + +- [ ] **Step 2:测试 `/reload-tui` 只应用 TUI config** + +`apps/kimi-code/test/tui/config.test.ts` 保留 `applyReloadedTuiConfig()` 测试,并新增 command-level mock 测试: + +```ts +it('reload-tui applies tui.toml without reloading the session', async () => { + const host = makeReloadCommandHost(); + await handleReloadTuiCommand(host); + + expect(host.session?.reloadSession).not.toHaveBeenCalled(); + expect(host.harness.getConfig).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith(expect.stringContaining('TUI config reloaded')); +}); +``` + +- [ ] **Step 3:测试 `/reload` reload session 并 hydrate** + +`apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts`: + +```ts +it('runs /reload by reloading the active session and hydrating replay', async () => { + const session = makeSessionMock(); + session.reloadSession = vi.fn(async () => resumedSummaryFixture); + const host = makeTuiHost({ session }); + + dispatchInput(host, '/reload'); + await flushPromises(); + + expect(session.reloadSession).toHaveBeenCalledTimes(1); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith(session); + expect(host.showStatus).toHaveBeenCalledWith( + expect.stringContaining('Runtime reloaded'), + ); +}); +``` + +--- + +## Task 7:清理旧命名 + +- [ ] **Step 1:全仓搜索旧命令名** + +```bash +rg -n "config-reload|/config-reload|reload-session|/reload-session" packages apps docs +``` + +预期:没有旧命令名。 + +- [ ] **Step 2:全仓搜索旧自定义 reload API** + +```bash +rg -n "reloadKimiConfig|reloadConfig\\(|ReloadKimiConfig|ConfigReloadApplied|ConfigReloadDeferredItem" packages apps +``` + +预期:没有旧自定义 reload API;`getConfig({ reload: true })` 可以保留。 + +--- + +## Task 8:验证 + +- [ ] **Step 1:聚焦测试** + +```bash +pnpm exec vitest run packages/node-sdk/test/config.test.ts packages/agent-core/test/harness/reload-session.test.ts packages/agent-core/test/session/cron-stop-on-close.test.ts apps/kimi-code/test/tui/config.test.ts apps/kimi-code/test/tui/commands/registry.test.ts apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts apps/kimi-code/test/tui/commands/resolve.test.ts +``` + +- [ ] **Step 2:typecheck** + +```bash +pnpm --filter @moonshot-ai/agent-core run typecheck +pnpm --filter @moonshot-ai/kimi-code-sdk run typecheck +pnpm --filter @moonshot-ai/kimi-code run typecheck +``` + +- [ ] **Step 3:touched-file lint** + +```bash +pnpm exec oxlint --type-aware packages/agent-core/src/rpc/core-api.ts packages/agent-core/src/rpc/core-impl.ts packages/agent-core/src/session/index.ts packages/node-sdk/src/types.ts packages/node-sdk/src/rpc.ts packages/node-sdk/src/kimi-harness.ts packages/node-sdk/src/session.ts apps/kimi-code/src/tui/commands/reload.ts apps/kimi-code/src/tui/commands/dispatch.ts apps/kimi-code/src/tui/commands/registry.ts apps/kimi-code/src/tui/commands/index.ts +``` + +预期:0 errors。`dispatch.ts` 上既有的 `invalid` exhaustive warning 不在本任务修复范围;如果只剩该 warning,记录但不改。 + +- [ ] **Step 4:diff check** + +```bash +git diff --check +``` + +--- + +## 手测方案 + +1. 修改 `tui.toml` 的 `theme`,执行 `/reload-tui`,确认主题立即变化且 transcript 不清空。 +2. 修改 `tui.toml` 的 `editor.command`,执行 `/reload-tui`,再用 Ctrl-G 确认新 editor 生效。 +3. 修改 `config.toml` 中当前 model alias 的 `max_context_size`,执行 `/reload`,确认 context 上限变化。 +4. 修改 provider `base_url` / `api_key`,执行 `/reload`,下一轮请求应使用新 provider config。 +5. 新增一个 skill,执行 `/reload`,确认 slash skill commands 刷新。 +6. 删除一个 skill,执行 `/reload`,确认 slash skill commands 移除。 +7. 修改 `mcp.json`,执行 `/reload`,确认 MCP 重新连接。 +8. 创建 cron job,执行 `/reload`,再让模型调用 `CronList`,确认任务仍存在。 +9. streaming 中执行 `/reload`,确认被 idle-only 阻止。 +10. streaming 中执行 `/reload-tui`,确认可以应用 TUI config。 +11. 故意写坏 plugin state,执行 `/reload`,确认 reload 失败并保留旧 UI session 可见状态。 + +--- + +## 风险与处理 + +- **`/reload-tui` 误触发 session reload。** + - 处理:`handleReloadTuiCommand()` 不访问 `host.session.reloadSession()`,不访问 `host.harness.getConfig()`。 +- **SDK wrapper reload 后仍持旧 resume state。** + - 处理:`Session.reloadSession()` 必须更新 wrapper 内的 `summary` 和 `resumeState`。 +- **旧 wrapper close 误关新 Core session。** + - 处理:TUI `/reload` 不调用 `setSession()`,不 close wrapper,只在同一 wrapper 上 reload。 +- **background task 被 reload 当成 exit 杀掉。** + - 处理:reload 使用 `closeForReload()`,不调用 `stopBackgroundTasksOnExit()`。 +- **cron 停掉后不能恢复。** + - 处理:cron task 已持久化,旧 CronManager stop 后,新 Agent 构造新 CronManager 并 `loadFromDisk()`。 +- **services 仍用旧配置。** + - 处理:`KimiCore.reloadSession()` 必须 `this.runtime = undefined`。 +- **plugin state 没刷新。** + - 处理:`reloadSession()` 在 resume 前调用 `plugins.reload()`;失败则本次 reload 失败。 +- **reload 失败后 session 被删。** + - 处理:优先 reload config/plugins 这类会失败的步骤,再 close old session;close 后失败时给出明确错误并允许普通 resume 恢复。 + +--- + +## 推荐实现顺序 + +1. Core API 类型。 +2. `Session.closeForReload()`。 +3. `KimiCore.reloadSession()`。 +4. SDK `reloadSession()`。 +5. TUI `/reload-tui`。 +6. TUI `/reload`。 +7. 旧命名清理。 +8. 聚焦测试、typecheck、lint。 +9. 手测。 + +--- + +## 建议 Commit Message + +```text +feat(tui): add runtime and tui reload commands +```