mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 765699e1ec | |||
| 79cc27439d | |||
| e99b47c22e | |||
| 4195168d4d | |||
| 9d600b9e8f | |||
| 1ed9a04d71 | |||
| c578567488 | |||
| 39a7d59b27 | |||
| 985c5953c6 | |||
| 625b53ef39 |
@@ -3711,7 +3711,8 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
|
||||
config.setActiveExtensionContext('ext-plan');
|
||||
expect(config.getPlansDir()).toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from active extension when user has specified one', async () => {
|
||||
|
||||
@@ -609,9 +609,12 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
const extensionPlanDirs: Record<string, string> = {};
|
||||
for (const ext of extensionManager.getExtensions()) {
|
||||
if (ext.isActive && ext.plan?.directory) {
|
||||
extensionPlanDirs[ext.name] = ext.plan.directory;
|
||||
}
|
||||
}
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
@@ -969,9 +972,8 @@ export async function loadCliConfig(
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
planSettings: settings.general?.plan,
|
||||
extensionPlanDirs,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -23,6 +23,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): 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),
|
||||
|
||||
@@ -3182,9 +3182,7 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overflow Hint Handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -1308,6 +1308,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
|
||||
const parsedCommand = parseSlashCommand(
|
||||
submittedValue,
|
||||
slashCommands ?? [],
|
||||
);
|
||||
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
const isAgentRunning =
|
||||
@@ -1315,10 +1320,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isToolExecuting(pendingHistoryItems);
|
||||
|
||||
if (isSlash && isAgentRunning) {
|
||||
const { commandToExecute } = parseSlashCommand(
|
||||
submittedValue,
|
||||
slashCommands ?? [],
|
||||
);
|
||||
const commandToExecute = parsedCommand.commandToExecute;
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
void handleSlashCommand(submittedValue);
|
||||
addInput(submittedValue);
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('clearCommand', () => {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
setActiveExtensionContext: vi.fn(),
|
||||
injectionService: {
|
||||
clear: mockHintClear,
|
||||
},
|
||||
|
||||
@@ -30,8 +30,9 @@ export const clearCommand: SlashCommand = {
|
||||
await hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
|
||||
}
|
||||
|
||||
// Reset user steering hints
|
||||
// Reset user steering hints and extension context
|
||||
config?.injectionService.clear();
|
||||
config?.setActiveExtensionContext(undefined);
|
||||
|
||||
// Start a new conversation recording with a new session ID
|
||||
// We MUST do this before calling resetChat() so the new ChatRecordingService
|
||||
|
||||
@@ -993,4 +993,155 @@ describe('useSlashCommandProcessor', () => {
|
||||
expect(result.current.slashCommands).toEqual([newCommand]),
|
||||
);
|
||||
});
|
||||
describe('Active Extension Context Switching', () => {
|
||||
it('sets active extension context when a command with a plan dir is executed', async () => {
|
||||
const extensionCommand = createTestCommand({
|
||||
name: 'conductor:setup',
|
||||
extensionName: 'conductor',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spyHasPlanDir = vi
|
||||
.spyOn(mockConfig, 'hasExtensionPlanDir')
|
||||
.mockReturnValue(true);
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [extensionCommand],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/conductor:setup');
|
||||
});
|
||||
|
||||
expect(spyHasPlanDir).toHaveBeenCalledWith('conductor');
|
||||
expect(spySetContext).toHaveBeenCalledWith('conductor');
|
||||
});
|
||||
|
||||
it('clears active extension context when a command WITHOUT a plan dir is executed', async () => {
|
||||
const extensionCommand = createTestCommand({
|
||||
name: 'other:cmd',
|
||||
extensionName: 'other',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spyHasPlanDir = vi
|
||||
.spyOn(mockConfig, 'hasExtensionPlanDir')
|
||||
.mockReturnValue(false);
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [extensionCommand],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/other:cmd');
|
||||
});
|
||||
|
||||
expect(spyHasPlanDir).toHaveBeenCalledWith('other');
|
||||
expect(spySetContext).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('clears active extension context when the canonical /plan command is executed', async () => {
|
||||
const planCommand = createTestCommand({
|
||||
name: 'plan',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [planCommand],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/plan my task');
|
||||
});
|
||||
|
||||
expect(spySetContext).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('clears active extension context when a /plan alias or subcommand is executed', async () => {
|
||||
const planCommand = createTestCommand({
|
||||
name: 'plan',
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'create',
|
||||
}),
|
||||
],
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [planCommand],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/plan create');
|
||||
});
|
||||
|
||||
expect(spySetContext).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('handles a sequence of context switches between extensions and default plan mode', async () => {
|
||||
const extA = createTestCommand({
|
||||
name: 'extA',
|
||||
extensionName: 'extA',
|
||||
action: vi.fn(),
|
||||
});
|
||||
const extB = createTestCommand({
|
||||
name: 'extB',
|
||||
extensionName: 'extB',
|
||||
action: vi.fn(),
|
||||
});
|
||||
const planCmd = createTestCommand({
|
||||
name: 'plan',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
vi.spyOn(mockConfig, 'hasExtensionPlanDir').mockReturnValue(true);
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [extA, extB, planCmd],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(3));
|
||||
|
||||
// 1. Run Ext A
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/extA');
|
||||
});
|
||||
expect(spySetContext).toHaveBeenLastCalledWith('extA');
|
||||
|
||||
// 2. Run Ext B
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/extB');
|
||||
});
|
||||
expect(spySetContext).toHaveBeenLastCalledWith('extB');
|
||||
|
||||
// 3. Run /plan (Default)
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/plan my task');
|
||||
});
|
||||
expect(spySetContext).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
// 4. Run /clear (Global)
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/help');
|
||||
});
|
||||
// Context should still be undefined
|
||||
expect(spySetContext).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,8 +368,21 @@ export const useSlashCommandProcessor = (
|
||||
commandToExecute,
|
||||
args,
|
||||
canonicalPath: resolvedCommandPath,
|
||||
extensionContext,
|
||||
} = parseSlashCommand(trimmed, commands);
|
||||
|
||||
if (config) {
|
||||
if (extensionContext) {
|
||||
if (config.hasExtensionPlanDir(extensionContext)) {
|
||||
config.setActiveExtensionContext(extensionContext);
|
||||
} else {
|
||||
config.setActiveExtensionContext(undefined);
|
||||
}
|
||||
} else if (resolvedCommandPath?.[0] === 'plan') {
|
||||
config.setActiveExtensionContext(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// If the input doesn't match any known command, check if MCP servers
|
||||
// are still loading (the command might come from an MCP server).
|
||||
// Otherwise, treat it as regular text input (e.g. file paths like
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ParsedSlashCommand = {
|
||||
commandToExecute: SlashCommand | undefined;
|
||||
args: string;
|
||||
canonicalPath: string[];
|
||||
extensionContext?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +70,8 @@ export const parseSlashCommand = (
|
||||
|
||||
const args = parts.slice(pathIndex).join(' ');
|
||||
|
||||
const extensionContext = commandToExecute?.extensionName;
|
||||
|
||||
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
|
||||
// AND the parent command is capable of handling them.
|
||||
if (
|
||||
@@ -82,8 +85,9 @@ export const parseSlashCommand = (
|
||||
commandToExecute: parentCommand,
|
||||
args: parts.slice(pathIndex - 1).join(' '),
|
||||
canonicalPath: canonicalPath.slice(0, -1),
|
||||
extensionContext: parentCommand.extensionName,
|
||||
};
|
||||
}
|
||||
|
||||
return { commandToExecute, args, canonicalPath };
|
||||
return { commandToExecute, args, canonicalPath, extensionContext };
|
||||
};
|
||||
|
||||
@@ -3266,6 +3266,9 @@ describe('Plans Directory Initialization', () => {
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
planSettings: {
|
||||
directory: 'plans',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -3274,11 +3277,12 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(fs.promises.mkdir).mockRestore();
|
||||
vi.mocked(fs.promises.access).mockRestore?.();
|
||||
vi.mocked(fs.mkdirSync).mockRestore?.();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // Reset to default mock behavior
|
||||
});
|
||||
|
||||
it('should add plans directory to workspace context if it exists', async () => {
|
||||
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
|
||||
it('should not eagerly create plans directory during initialization', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
@@ -3286,18 +3290,99 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
// Should NOT create the directory eagerly
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
// Should check if it exists
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
|
||||
// Using storage directly to avoid triggering creation
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).not.toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should create plans directory and add it to workspace context when getPlansDir is called', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
const plansDir = config.getPlansDir();
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should NOT add plans directory to workspace context if it does not exist', async () => {
|
||||
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
|
||||
it('should gracefully handle existing directories by relying on mkdirSync recursive: true', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
const plansDir = config.getPlansDir();
|
||||
|
||||
// mkdirSync should be called unconditionally
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, { recursive: true });
|
||||
|
||||
// It MUST still register the directory
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should log a warning if the plan directory path is blocked by an existing file (EEXIST)', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
||||
const err = new Error('File exists') as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
throw err;
|
||||
});
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
config.getPlansDir();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/Failed to initialize active plan directory.*File exists/,
|
||||
),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES)', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
||||
const err = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
});
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
config.getPlansDir();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/Failed to initialize active plan directory.*Permission denied/,
|
||||
),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should deduplicate and cache when multiple extensions (or default) use the same directory', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
@@ -3305,15 +3390,31 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
// 1. Call for Default Plan Dir
|
||||
const defaultDir = config.getPlansDir();
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).not.toContain(plansDir);
|
||||
// 2. Mock an extension that happens to use the SAME directory string
|
||||
vi.spyOn(
|
||||
config as unknown as {
|
||||
getActiveExtensionPlanDir: () => string | undefined;
|
||||
},
|
||||
'getActiveExtensionPlanDir',
|
||||
).mockReturnValue(
|
||||
'plans', // This will resolve to the same path as the default in our mock setup
|
||||
);
|
||||
|
||||
const extDir = config.getPlansDir();
|
||||
|
||||
// It should be the same path
|
||||
expect(extDir).toBe(defaultDir);
|
||||
|
||||
// It should NOT have called mkdirSync a second time
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: false,
|
||||
@@ -3321,10 +3422,12 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
// Even if getPlansDir is called manually, it should NOT create the directory
|
||||
const plansDir = config.getPlansDir();
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
|
||||
plansDir,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -462,6 +462,7 @@ import { A2AClientManager } from '../agents/a2a-client-manager.js';
|
||||
import { type McpContext } from '../tools/mcp-client.js';
|
||||
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { LRUCache } from 'mnemonist';
|
||||
|
||||
export type { FileFilteringOptions };
|
||||
export {
|
||||
@@ -716,6 +717,7 @@ export interface ConfigParameters {
|
||||
plan?: boolean;
|
||||
tracker?: boolean;
|
||||
planSettings?: PlanSettings;
|
||||
extensionPlanDirs?: Record<string, string>;
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
modelSteering?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
@@ -779,6 +781,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
private activeExtensionContext?: string;
|
||||
private initializedPlanDirs = new LRUCache<string, boolean>(20);
|
||||
private plansDirCache = new LRUCache<string | undefined, string>(20);
|
||||
private readonly extensionPlanDirs: Record<string, string>;
|
||||
private userMemory: string | HierarchicalMemory;
|
||||
private geminiMdFileCount: number;
|
||||
private geminiMdFilePaths: string[];
|
||||
@@ -1030,6 +1036,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||
this.extensionPlanDirs = params.extensionPlanDirs ?? {};
|
||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||
this.extensionsEnabled = params.extensionsEnabled ?? true;
|
||||
this.allowedMcpServers = params.allowedMcpServers ?? [];
|
||||
@@ -1391,20 +1398,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.workspaceContext.addDirectory(dir);
|
||||
}
|
||||
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
const plansDir = this.storage.getPlansDir();
|
||||
try {
|
||||
await fs.promises.access(plansDir);
|
||||
this.workspaceContext.addDirectory(plansDir);
|
||||
} catch {
|
||||
// Directory does not exist yet, so we don't add it to the workspace context.
|
||||
// It will be created when the first plan is written. Since custom plan
|
||||
// directories must be within the project root, they are automatically
|
||||
// covered by the project-wide file discovery once created.
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize centralized FileDiscoveryService
|
||||
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
||||
this.getFileService();
|
||||
@@ -2215,6 +2208,56 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpEnabled;
|
||||
}
|
||||
|
||||
getActiveExtensionContext(): string | undefined {
|
||||
return this.activeExtensionContext;
|
||||
}
|
||||
|
||||
setActiveExtensionContext(context: string | undefined): void {
|
||||
this.activeExtensionContext = context;
|
||||
}
|
||||
|
||||
hasExtensionPlanDir(name: string): boolean {
|
||||
return !!this.extensionPlanDirs[name];
|
||||
}
|
||||
|
||||
getActiveExtensionPlanDir(): string | undefined {
|
||||
if (this.activeExtensionContext) {
|
||||
return this.extensionPlanDirs[this.activeExtensionContext];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
const context = this.getActiveExtensionContext();
|
||||
// Cache key: undefined means default context, string means extension context
|
||||
const cacheKey = context === undefined ? 'default' : context;
|
||||
|
||||
let plansDir = this.plansDirCache.get(cacheKey);
|
||||
if (plansDir === undefined) {
|
||||
plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
|
||||
this.plansDirCache.set(cacheKey, plansDir);
|
||||
}
|
||||
|
||||
if (!this.planEnabled || this.initializedPlanDirs.has(plansDir)) {
|
||||
return plansDir;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(plansDir, { recursive: true });
|
||||
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
this.workspaceContext.addDirectory(realPlansDir);
|
||||
this.initializedPlanDirs.set(plansDir, true);
|
||||
} catch (e: unknown) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
return plansDir;
|
||||
}
|
||||
|
||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||
return this.mcpEnablementCallbacks;
|
||||
}
|
||||
|
||||
@@ -358,6 +358,48 @@ 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'),
|
||||
},
|
||||
{
|
||||
name: 'security escape via symbolic link with non-existent dir throws',
|
||||
customDir: 'link-to-outside/new-dir',
|
||||
setup: () => {
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
const pStr = p.toString();
|
||||
if (pStr.includes('link-to-outside/new-dir')) {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
if (pStr.includes('link-to-outside')) {
|
||||
return '/outside/project/root';
|
||||
}
|
||||
return pStr;
|
||||
});
|
||||
return () => vi.mocked(fs.realpathSync).mockRestore();
|
||||
},
|
||||
expected: '',
|
||||
expectedError:
|
||||
"Custom plans directory 'link-to-outside/new-dir' resolves to '/outside/project/root/new-dir', which is outside the project root '/tmp/project'.",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
|
||||
|
||||
@@ -32,10 +32,12 @@ export class Storage {
|
||||
private projectIdentifier: string | undefined;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private customPlansDir: string | undefined;
|
||||
private readonly realProjectRoot: string;
|
||||
|
||||
constructor(targetDir: string, sessionId?: string) {
|
||||
this.targetDir = targetDir;
|
||||
this.sessionId = sessionId;
|
||||
this.realProjectRoot = resolveToRealPath(targetDir);
|
||||
}
|
||||
|
||||
setCustomPlansDir(dir: string | undefined): void {
|
||||
@@ -313,18 +315,15 @@ export class Storage {
|
||||
return path.join(this.getProjectTempDir(), 'tracker');
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
if (this.customPlansDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customPlansDir,
|
||||
);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
getPlansDir(extensionPlanDir?: string): string {
|
||||
const customDir = extensionPlanDir || this.customPlansDir;
|
||||
if (customDir) {
|
||||
const resolvedPath = path.resolve(this.getProjectRoot(), customDir);
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
if (!isSubpath(this.realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
`Custom plans directory '${customDir}' resolves to '${realResolvedPath}', which is outside the project root '${this.realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -567,7 +567,7 @@ For example:
|
||||
|
||||
# Active Approval Mode: Plan
|
||||
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
|
||||
|
||||
## Available Tools
|
||||
The following tools are available in Plan Mode:
|
||||
@@ -583,8 +583,8 @@ The following tools are available in Plan Mode:
|
||||
</available_tools>
|
||||
|
||||
## Rules
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
|
||||
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
|
||||
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
|
||||
@@ -607,7 +607,7 @@ The depth of your consultation should be proportional to the task's complexity.
|
||||
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
|
||||
|
||||
### 3. Draft
|
||||
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
|
||||
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
|
||||
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
|
||||
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
|
||||
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
|
||||
|
||||
@@ -90,9 +90,10 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
},
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
@@ -420,6 +421,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('PromptProvider', () => {
|
||||
topicState: new TopicState(),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
|
||||
@@ -178,7 +178,7 @@ export class PromptProvider {
|
||||
() => ({
|
||||
interactive: interactiveMode,
|
||||
planModeToolsList,
|
||||
plansDir: context.config.storage.getPlansDir(),
|
||||
plansDir: context.config.getPlansDir(),
|
||||
approvedPlanPath: context.config.getApprovedPlanPath(),
|
||||
taskTracker: context.config.isTrackerEnabled(),
|
||||
}),
|
||||
|
||||
@@ -132,9 +132,10 @@ describe('EditTool', () => {
|
||||
getDisableLLMCorrection: vi.fn(() => true),
|
||||
getExperiments: () => {},
|
||||
isPlanMode: vi.fn(() => false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
|
||||
},
|
||||
isPathAllowed(this: Config, absolutePath: string): boolean {
|
||||
const workspaceContext = this.getWorkspaceContext();
|
||||
@@ -1314,6 +1315,7 @@ function doIt() {
|
||||
fs.mkdirSync(plansDir);
|
||||
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getPlansDir).mockReturnValue(plansDir);
|
||||
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
|
||||
|
||||
const filePath = path.join(rootDir, 'test-file.txt');
|
||||
|
||||
@@ -465,10 +465,7 @@ class EditToolInvocation
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
if (result.success) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -39,6 +38,7 @@ describe('EnterPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -119,7 +119,6 @@ describe('EnterPlanModeTool', () => {
|
||||
describe('execute', () => {
|
||||
it('should set approval mode to PLAN and return message', async () => {
|
||||
const invocation = tool.build({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
@@ -130,21 +129,9 @@ describe('EnterPlanModeTool', () => {
|
||||
expect(result.returnDisplay).toBe('Switching to Plan mode');
|
||||
});
|
||||
|
||||
it('should create plans directory if it does not exist', async () => {
|
||||
const invocation = tool.build({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include optional reason in output display but not in llmContent', async () => {
|
||||
const reason = 'Design new database schema';
|
||||
const invocation = tool.build({ reason });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -19,7 +18,6 @@ import { ENTER_PLAN_MODE_TOOL_NAME } from './tool-names.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { ENTER_PLAN_MODE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface EnterPlanModeParams {
|
||||
reason?: string;
|
||||
@@ -124,19 +122,6 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
this.config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
// Ensure plans directory exists so that the agent can write the plan file.
|
||||
// In sandboxed environments, the plans directory must exist on the host
|
||||
// before it can be bound/allowed in the sandbox.
|
||||
const plansDir = this.config.storage.getPlansDir();
|
||||
if (!fs.existsSync(plansDir)) {
|
||||
try {
|
||||
fs.mkdirSync(plansDir, { recursive: true });
|
||||
} catch (e) {
|
||||
// Log error but don't fail; write_file will try again later
|
||||
debugLogger.error(`Failed to create plans directory: ${plansDir}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: 'Switching to Plan mode.',
|
||||
returnDisplay: this.params.reason
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('ExitPlanModeTool', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
setApprovalMode: vi.fn(),
|
||||
setApprovedPlanPath: vi.fn(),
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
} as unknown as Config['storage'],
|
||||
|
||||
@@ -60,11 +60,8 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
}
|
||||
|
||||
const safeFilename = path.basename(params.plan_filename);
|
||||
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
|
||||
const resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
const plansDir = resolveToRealPath(this.config.getPlansDir());
|
||||
const resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
|
||||
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
@@ -120,7 +117,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
const pathError = await validatePlanPath(
|
||||
this.params.plan_filename,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
if (pathError) {
|
||||
this.planValidationError = pathError;
|
||||
@@ -170,7 +167,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Requesting plan approval for: ${path.join(this.config.storage.getPlansDir(), this.params.plan_filename)}`;
|
||||
return `Requesting plan approval for: ${path.join(this.config.getPlansDir(), this.params.plan_filename)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +176,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
*/
|
||||
private getResolvedPlanPath(): string {
|
||||
const safeFilename = path.basename(this.params.plan_filename);
|
||||
return path.join(this.config.storage.getPlansDir(), safeFilename);
|
||||
return path.join(this.config.getPlansDir(), safeFilename);
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
|
||||
@@ -617,7 +617,7 @@ export class ToolRegistry {
|
||||
*/
|
||||
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
const plansDir = this.config.storage.getPlansDir();
|
||||
const plansDir = this.config.getPlansDir();
|
||||
|
||||
const declarations: FunctionDeclaration[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
@@ -107,6 +107,7 @@ const mockConfigInternal = {
|
||||
getDisableLLMCorrection: vi.fn(() => true),
|
||||
isPlanMode: vi.fn(() => false),
|
||||
getActiveModel: () => 'test-model',
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
|
||||
@@ -168,10 +168,7 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
|
||||
Reference in New Issue
Block a user