From 9600da2c8fca0c6c5e3b0794502274a5b421f937 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 17 Apr 2026 03:20:36 +0800 Subject: [PATCH] fix(cli): reset plan session state on /clear (#25515) --- packages/cli/src/test-utils/mockConfig.ts | 1 + .../cli/src/ui/commands/clearCommand.test.ts | 5 +- packages/cli/src/ui/commands/clearCommand.ts | 2 +- packages/core/src/config/config.test.ts | 89 +++++++++++++++++++ packages/core/src/config/config.ts | 46 ++++++++++ packages/core/src/config/storage.test.ts | 21 +++++ packages/core/src/config/storage.ts | 10 ++- 7 files changed, 171 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 6561ac1db0..516be675c0 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getListSessions: vi.fn(() => false), getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), + resetNewSessionState: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), getWorktreeSettings: vi.fn(() => undefined), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 77f6e4854d..51e3ace2f5 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -39,7 +39,7 @@ describe('clearCommand', () => { agentContext: { config: { getEnableHooks: vi.fn().mockReturnValue(false), - setSessionId: vi.fn(), + resetNewSessionState: vi.fn(), getMessageBus: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue({ fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), @@ -74,6 +74,9 @@ describe('clearCommand', () => { expect(mockResetChat).toHaveBeenCalledTimes(1); expect(mockHintClear).toHaveBeenCalledTimes(1); + expect( + mockContext.services.agentContext?.config.resetNewSessionState, + ).toHaveBeenCalledTimes(1); expect(uiTelemetryService.clear).toHaveBeenCalled(); expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index fb032da811..8e5deafd01 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -39,7 +39,7 @@ export const clearCommand: SlashCommand = { let newSessionId: string | undefined; if (config) { newSessionId = randomUUID(); - config.setSessionId(newSessionId); + config.resetNewSessionState(newSessionId); } if (geminiClient) { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3bc1e94f8d..ab000b2691 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1774,6 +1774,95 @@ describe('Server Config (config.ts)', () => { expect(config1.topicState.getTopic()).toBe('Topic 1'); expect(config2.topicState.getTopic()).toBe('Topic 2'); }); + + it('updates storage session-scoped directories when the sessionId changes', async () => { + const config = new Config({ + ...baseParams, + sessionId: 'session-one', + plan: true, + }); + + await config.initialize(); + const tempDir = config.storage.getProjectTempDir(); + const oldPlansDir = path.join(tempDir, 'session-one', 'plans'); + const oldTrackerService = config.getTrackerService(); + + config.setSessionId('session-two'); + + expect(config.getSessionId()).toBe('session-two'); + expect(config.storage.getProjectTempPlansDir()).toBe( + path.join(tempDir, 'session-two', 'plans'), + ); + expect(config.storage.getProjectTempTrackerDir()).toBe( + path.join(tempDir, 'session-two', 'tracker'), + ); + expect(config.getTrackerService()).not.toBe(oldTrackerService); + expect(config.getTrackerService().trackerDir).toBe( + path.join(tempDir, 'session-two', 'tracker'), + ); + expect(config.getWorkspaceContext().getDirectories()).not.toContain( + oldPlansDir, + ); + }); + + it('does not throw when changing sessions before the previous plans dir exists', async () => { + const config = new Config({ + ...baseParams, + sessionId: 'session-one', + plan: true, + }); + + await config.initialize(); + const missingPlansDir = config.storage.getProjectTempPlansDir(); + const realpathMock = vi.mocked(fs.realpathSync); + const originalImplementation = realpathMock.getMockImplementation(); + + try { + realpathMock.mockImplementation((input) => { + const normalizedInput = + typeof input === 'string' || Buffer.isBuffer(input) + ? input + : input.toString(); + + if (normalizedInput === missingPlansDir) { + const error = new Error( + `ENOENT: no such file or directory, ${normalizedInput}`, + ); + Object.assign(error, { code: 'ENOENT' }); + throw error; + } + if (originalImplementation) { + return originalImplementation(input); + } + return normalizedInput; + }); + + expect(() => config.setSessionId('session-two')).not.toThrow(); + } finally { + realpathMock.mockImplementation((input) => { + if (originalImplementation) { + return originalImplementation(input); + } + return typeof input === 'string' || Buffer.isBuffer(input) + ? input + : input.toString(); + }); + } + }); + + it('clears the approved plan when starting a new session', () => { + const config = new Config({ + ...baseParams, + sessionId: 'session-one', + }); + + config.setApprovedPlanPath('/tmp/session-one/plans/approved.md'); + + expect(() => config.resetNewSessionState('session-two')).not.toThrow(); + + expect(config.getSessionId()).toBe('session-two'); + expect(config.getApprovedPlanPath()).toBeUndefined(); + }); }); describe('GemmaModelRouterSettings', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 95b1dae1d6..9dbf0f8115 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1762,7 +1762,22 @@ export class Config implements McpContext, AgentLoopContext { } setSessionId(sessionId: string): void { + const previousPlansDir = this.storage.isInitialized() + ? this.storage.getPlansDir() + : undefined; + this._sessionId = sessionId; + this.storage.setSessionId(sessionId); + this.trackerService = undefined; + + if (previousPlansDir) { + this.refreshSessionScopedPlansDirectory(previousPlansDir); + } + } + + resetNewSessionState(sessionId: string): void { + this.setSessionId(sessionId); + this.approvedPlanPath = undefined; } setTerminalBackground(terminalBackground: string | undefined): void { @@ -2051,6 +2066,37 @@ export class Config implements McpContext, AgentLoopContext { return getWorkspaceContextOverride() ?? this.workspaceContext; } + private refreshSessionScopedPlansDirectory(previousPlansDir: string): void { + const nextPlansDir = this.storage.getPlansDir(); + if (previousPlansDir === nextPlansDir) { + return; + } + + const pathsToRemove = new Set([previousPlansDir]); + try { + pathsToRemove.add(resolveToRealPath(previousPlansDir)); + } catch { + // The previous session's plans directory may never have been created. + // In that case there is nothing to resolve or remove beyond the raw path. + } + + const currentDirectories = this.workspaceContext + .getDirectories() + .filter((dir) => !pathsToRemove.has(dir)); + + this.workspaceContext.setDirectories(currentDirectories); + + try { + if (fs.existsSync(nextPlansDir)) { + this.workspaceContext.addDirectory(nextPlansDir); + } + } catch { + // Ignore invalid or unreadable plans directories here. This mirrors + // initialization behavior, which only adds the plans directory when it + // already exists and is readable. + } + } + getAgentRegistry(): AgentRegistry { return this.agentRegistry; } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 822e1c70be..6b73e0105e 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -211,6 +211,27 @@ describe('Storage – additional helpers', () => { expect(storageWithSession.getProjectTempTrackerDir()).toBe(expected); }); + it('updates session-scoped directories when the sessionId changes', async () => { + const storageWithSession = new Storage(projectRoot, 'session-one'); + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + await storageWithSession.initialize(); + const tempDir = storageWithSession.getProjectTempDir(); + + storageWithSession.setSessionId('session-two'); + + expect(storageWithSession.getProjectTempPlansDir()).toBe( + path.join(tempDir, 'session-two', 'plans'), + ); + expect(storageWithSession.getProjectTempTrackerDir()).toBe( + path.join(tempDir, 'session-two', 'tracker'), + ); + expect(storageWithSession.getProjectTempTasksDir()).toBe( + path.join(tempDir, 'session-two', 'tasks'), + ); + }); + describe('Session and JSON Loading', () => { beforeEach(async () => { await storage.initialize(); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index d49e027369..5e3aada4e5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -28,7 +28,7 @@ export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml'; export class Storage { private readonly targetDir: string; - private readonly sessionId: string | undefined; + private sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; private customPlansDir: string | undefined; @@ -42,6 +42,14 @@ export class Storage { this.customPlansDir = dir; } + setSessionId(sessionId: string | undefined): void { + this.sessionId = sessionId; + } + + isInitialized(): boolean { + return !!this.projectIdentifier; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) {