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, {