From 7ca9bdfed516d148b063229a9686a28f9e29aaef Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 17:41:25 +0800 Subject: [PATCH] fix(tui): skip re-entering plan mode on resume and scope startup flags to startup (#692) Resuming a session that was already in plan mode with --plan crashed with "Already in plan mode": the resume path called setPlanMode(true) unconditionally while session replay had already restored the active plan state. Check the session status first and only enable plan mode when it is not active yet, in both the resume startup path and the startup session picker. The /sessions picker shared the same onSelect callback, so startup flags were also re-applied on every mid-session switch, overriding the picked session's own persisted modes. Gate the flag application behind an applyStartupModes option that only the startup picker enables, and surface post-switch setup errors instead of leaving them as unhandled rejections. --- ...eplan-on-resume-and-scope-startup-flags.md | 5 + apps/kimi-code/src/tui/kimi-tui.ts | 102 ++++++++++-------- .../test/tui/kimi-tui-startup.test.ts | 102 ++++++++++++++++++ 3 files changed, 166 insertions(+), 43 deletions(-) create mode 100644 .changeset/skip-replan-on-resume-and-scope-startup-flags.md diff --git a/.changeset/skip-replan-on-resume-and-scope-startup-flags.md b/.changeset/skip-replan-on-resume-and-scope-startup-flags.md new file mode 100644 index 000000000..ec7e549e7 --- /dev/null +++ b/.changeset/skip-replan-on-resume-and-scope-startup-flags.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 87c0b4cb2..0337785f0 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -588,14 +588,7 @@ export class KimiTUI { session = await this.harness.createSession(createSessionOptions); } if (session !== undefined && shouldReplayHistory) { - if (startup.auto) { - await session.setPermission('auto'); - } else if (startup.yolo) { - await session.setPermission('yolo'); - } - if (startup.plan) { - await session.setPlanMode(true); - } + await this.applyStartupModesToResumedSession(session); if (startup.model !== undefined) { await session.setModel(startup.model); } @@ -1097,6 +1090,25 @@ export class KimiTUI { }); } + // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed + // session may already be in plan mode from its persisted records, and + // re-entering plan mode throws, so only enable it when it is not active yet. + // setPermission is idempotent and needs no such guard. + private async applyStartupModesToResumedSession(session: Session): Promise { + const { startup } = this.options; + if (startup.auto) { + await session.setPermission('auto'); + } else if (startup.yolo) { + await session.setPermission('yolo'); + } + if (startup.plan) { + const status = await session.getStatus(); + if (!status.planMode) { + await session.setPlanMode(true); + } + } + } + // Re-apply startup flags that the user explicitly passed on the command line. // syncRuntimeState and session-replay hydration can both read stale persisted // values, so this guarantees the footer reflects the CLI intent. @@ -1830,27 +1842,28 @@ export class KimiTUI { async showSessionPicker(): Promise { await this.fetchSessions(); - this.mountSessionPicker(() => { - this.hideSessionPicker(); + this.mountSessionPicker({ + onCancel: () => { + this.hideSessionPicker(); + }, }); } private async bootstrapFromPicker(): Promise { await this.fetchSessions(); - this.mountSessionPicker( - () => { + this.mountSessionPicker({ + applyStartupModes: true, + onCancel: () => { this.hideSessionPicker(); void this.stop(); }, - { - onCtrlC: () => { - this.state.editor.onCtrlC?.(); - }, - onCtrlD: () => { - this.state.editor.onCtrlD?.(); - }, + onCtrlC: () => { + this.state.editor.onCtrlC?.(); }, - ); + onCtrlD: () => { + this.state.editor.onCtrlD?.(); + }, + }); } hideSessionPicker(): void { @@ -1859,10 +1872,15 @@ export class KimiTUI { this.restoreEditor(); } - private mountSessionPicker( - onCancel: () => void, - shortcuts: { readonly onCtrlC?: () => void; readonly onCtrlD?: () => void } = {}, - ): void { + private mountSessionPicker(options: { + readonly onCancel: () => void; + readonly onCtrlC?: () => void; + readonly onCtrlD?: () => void; + // CLI mode flags (--auto/--yolo/--plan) target the session picked at + // startup (bare --session); later /sessions switches keep the picked + // session's own persisted modes. + readonly applyStartupModes?: boolean; + }): void { this.state.activeDialog = 'session-picker'; this.mountEditorReplacement( new SessionPickerComponent({ @@ -1870,26 +1888,24 @@ export class KimiTUI { loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, onSelect: (sessionId: string) => { - void this.resumeSession(sessionId).then(async (switched) => { - if (!switched) { - return; - } - const session = this.requireSession(); - if (this.options.startup.auto) { - await session.setPermission('auto'); - } else if (this.options.startup.yolo) { - await session.setPermission('yolo'); - } - if (this.options.startup.plan) { - await session.setPlanMode(true); - } - this.applyStartupPermissionAndPlanToAppState(); - this.hideSessionPicker(); - }); + void this.resumeSession(sessionId) + .then(async (switched) => { + if (!switched) { + return; + } + if (options.applyStartupModes === true) { + await this.applyStartupModesToResumedSession(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); + } + this.hideSessionPicker(); + }) + .catch((error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }); }, - onCancel, - onCtrlC: shortcuts.onCtrlC, - onCtrlD: shortcuts.onCtrlD, + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, }), ); } diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index a46d67466..bed844af8 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -363,6 +363,33 @@ describe('KimiTUI startup', () => { expect(driver.state.appState.planMode).toBe(true); }); + it('skips setPlanMode when the resumed session is already in plan mode', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + it('forces footer state to reflect --auto even if getStatus lags behind', async () => { const session = makeSession({ id: 'ses-latest', @@ -627,6 +654,81 @@ describe('KimiTUI startup', () => { expect(driver.state.appState.permissionMode).toBe('auto'); }); + it('skips setPlanMode after picking a session already in plan mode', async () => { + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '', plan: true })); + + await (driver as unknown as { initMainTui(): Promise }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('does not apply startup flags when switching sessions via the /sessions picker', async () => { + const initial = makeSession({ id: 'ses-1' }); + const picked = makeSession({ + id: 'ses-2', + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(initial, { + resumeSession: vi.fn(async () => picked), + listSessions: vi.fn(async () => [ + { + id: 'ses-2', + title: 'Other session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ auto: true, plan: true })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.state.appState.sessionId).toBe('ses-2'); + expect(picked.setPermission).not.toHaveBeenCalled(); + expect(picked.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.permissionMode).toBe('manual'); + expect(driver.state.appState.planMode).toBe(false); + }); + it('clears startup picker exit confirmation before resuming a selected session', async () => { const session = makeSession({ id: 'ses-picked' }); const harness = makeHarness(session, {