diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 6561ac1db0..c12b5b8a49 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 d78b56e11d..6c69aa332f 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -3195,6 +3195,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 2974242a81..556aa34073 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1348,9 +1348,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 822e1c70be..665097447b 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', () => { expected: '', expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, }, + { + 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 065b19e78c..1766cc60d9 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -340,6 +340,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)) {