diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index daf109d928..3f85690d45 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -23,6 +23,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => isInteractive: vi.fn(() => false), isInitialized: vi.fn(() => true), setTerminalBackground: vi.fn(), + setActiveExtensionContext: vi.fn(), + hasExtensionPlanDir: vi.fn(() => true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), initialize: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0e436cc645..051e8834f1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -3182,6 +3182,39 @@ describe('AppContainer State Management', () => { ); unmount(); }); + + it('clears activeExtensionContext when /plan is explicitly executed', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: vi.fn(), + slashCommands: [{ name: 'plan', description: 'test', action: vi.fn() }], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + + const spySetActiveExtensionContext = vi.spyOn( + mockConfig, + 'setActiveExtensionContext', + ); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIActions).toBeTruthy(); + + await act(async () => + capturedUIActions.handleFinalSubmit('/plan my task'), + ); + + expect(spySetActiveExtensionContext).toHaveBeenCalledWith(undefined); + + unmount(); + }); }); describe('Overflow Hint Handling', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ee0d49f2be..2a34029e4d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1313,9 +1313,13 @@ Logging in with Google... Restarting Gemini CLI to continue. slashCommands ?? [], ); - if (parsedCommand.extensionContext && config) { - if (config.hasExtensionPlanDir(parsedCommand.extensionContext)) { - config.setActiveExtensionContext(parsedCommand.extensionContext); + if (config) { + if (parsedCommand.extensionContext) { + if (config.hasExtensionPlanDir(parsedCommand.extensionContext)) { + config.setActiveExtensionContext(parsedCommand.extensionContext); + } + } else if (parsedCommand.commandToExecute?.name === 'plan') { + config.setActiveExtensionContext(undefined); } } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index b5b8c26841..e695ae5d9b 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -358,6 +358,26 @@ describe('Storage – additional helpers', () => { expectedError: "Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.", }, + { + name: 'non-existent plan dir in a symlinked project root', + customDir: 'new-plans', + setup: () => { + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + const pStr = p.toString(); + if (pStr === projectRoot) { + return '/private/tmp/project'; + } + if (pStr.includes('new-plans')) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return pStr; + }); + return () => vi.mocked(fs.realpathSync).mockRestore(); + }, + expected: path.resolve(projectRoot, 'new-plans'), + }, ]; testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bd7ce303e2..63d3921c58 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -333,6 +333,8 @@ export class Storage { ) { throw e; } + // Construct the fallback path safely against the real project root + realResolvedPath = path.resolve(realProjectRoot, customDir); } if (!isSubpath(realProjectRoot, realResolvedPath)) {