diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1fa7bdbf1..67f854be9 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import type { ConfigParameters, SandboxConfig } from './config.js'; import { Config, ApprovalMode } from './config.js'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js'; import { @@ -57,6 +58,9 @@ vi.mock('node:fs', async (importOriginal) => { isDirectory: vi.fn().mockReturnValue(true), }), realpathSync: vi.fn((path) => path), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi.fn(), }; return { ...mocked, @@ -1240,6 +1244,53 @@ describe('setApprovalMode with folder trust', () => { }); }); + describe('plan file persistence', () => { + it('should save plan to disk', () => { + const config = new Config(baseParams); + + config.savePlan('# My Plan\n1. Step one\n2. Step two'); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('plans'), + { recursive: true }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('.md'), + '# My Plan\n1. Step one\n2. Step two', + 'utf-8', + ); + }); + + it('should load plan from disk', () => { + const config = new Config(baseParams); + (fs.readFileSync as Mock).mockReturnValue('# Saved Plan'); + + const plan = config.loadPlan(); + expect(plan).toBe('# Saved Plan'); + }); + + it('should return undefined when no plan file exists', () => { + const config = new Config(baseParams); + (fs.readFileSync as Mock).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const plan = config.loadPlan(); + expect(plan).toBeUndefined(); + }); + + it('should use session ID in plan file path', () => { + const config = new Config({ + ...baseParams, + sessionId: 'test-session-123', + }); + + const filePath = config.getPlanFilePath(); + expect(filePath).toContain('test-session-123'); + expect(filePath).toMatch(/\.md$/); + }); + }); + describe('registerCoreTools', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 27b33cd06..62076887a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -6,6 +6,7 @@ // Node built-ins import type { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import process from 'node:process'; @@ -1665,6 +1666,35 @@ export class Config { this.approvalMode = mode; } + /** + * Returns the file path for this session's plan file. + */ + getPlanFilePath(): string { + return Storage.getPlanFilePath(this.sessionId); + } + + /** + * Saves a plan to disk for the current session. + */ + savePlan(plan: string): void { + const filePath = this.getPlanFilePath(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, plan, 'utf-8'); + } + + /** + * Loads the plan for the current session, or returns undefined if none exists. + */ + loadPlan(): string | undefined { + const filePath = this.getPlanFilePath(); + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return undefined; + } + } + getInputFormat(): 'text' | 'stream-json' { return this.inputFormat; } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index e29cefa62..d14998dec 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -18,6 +18,7 @@ const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; const IDE_DIR_NAME = 'ide'; +const PLANS_DIR_NAME = 'plans'; const DEBUG_DIR_NAME = 'debug'; const ARENA_DIR_NAME = 'arena'; @@ -165,6 +166,14 @@ export class Storage { return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME); } + static getPlansDir(): string { + return path.join(Storage.getGlobalQwenDir(), PLANS_DIR_NAME); + } + + static getPlanFilePath(sessionId: string): string { + return path.join(Storage.getPlansDir(), `${sessionId}.md`); + } + static getGlobalBinDir(): string { return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); } diff --git a/packages/core/src/tools/exitPlanMode.test.ts b/packages/core/src/tools/exitPlanMode.test.ts index 721069429..022a78fd6 100644 --- a/packages/core/src/tools/exitPlanMode.test.ts +++ b/packages/core/src/tools/exitPlanMode.test.ts @@ -22,6 +22,7 @@ describe('ExitPlanModeTool', () => { setApprovalMode: vi.fn((mode: ApprovalMode) => { approvalMode = mode; }), + savePlan: vi.fn(), } as unknown as Config; tool = new ExitPlanModeTool(mockConfig); @@ -148,6 +149,9 @@ describe('ExitPlanModeTool', () => { ApprovalMode.DEFAULT, ); expect(approvalMode).toBe(ApprovalMode.DEFAULT); + + // Plan should be saved to disk + expect(mockConfig.savePlan).toHaveBeenCalledWith(params.plan); }); it('should request confirmation with plan details', async () => { @@ -222,6 +226,9 @@ describe('ExitPlanModeTool', () => { ApprovalMode.PLAN, ); expect(approvalMode).toBe(ApprovalMode.PLAN); + + // Plan should NOT be saved when rejected + expect(mockConfig.savePlan).not.toHaveBeenCalled(); }); it('should have correct description', () => { diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index 123760cf9..b6dd1cdfd 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -147,6 +147,15 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation< }; } + // Persist the approved plan to disk + try { + this.config.savePlan(plan); + } catch (error) { + debugLogger.warn( + `[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const llmMessage = `User has approved your plan. You can now start coding. Start with updating your todo list if applicable.`; const displayMessage = 'User approved the plan.';