Download packages/core/src/tools/exit-plan-mode.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 18.7 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/tools/exit-plan-mode.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/tools/exit-plan-mode.test.ts
-
curl -L -o exit-plan-mode.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/tools/exit-plan-mode.test.ts
18.7 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| import { ExitPlanModeTool, ExitPlanModeInvocation } from './exit-plan-mode.js'; | |
| import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; | |
| import path from 'node:path'; | |
| 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 * as fs from 'node:fs'; | |
| import os from 'node:os'; | |
| import { validatePlanPath } from '../utils/planUtils.js'; | |
| import * as loggers from '../telemetry/loggers.js'; | |
| vi.mock('../telemetry/loggers.js', () => ({ | |
| logPlanExecution: vi.fn(), | |
| })); | |
| describe('ExitPlanModeTool', () => { | |
| let tool: ExitPlanModeTool; | |
| let mockMessageBus: ReturnType<typeof createMockMessageBus>; | |
| let mockConfig: Partial<Config>; | |
| let tempRootDir: string; | |
| let mockPlansDir: string; | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| mockMessageBus = createMockMessageBus(); | |
| vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined); | |
| tempRootDir = fs.realpathSync( | |
| fs.mkdtempSync(path.join(os.tmpdir(), 'exit-plan-test-')), | |
| ); | |
| const plansDirRaw = path.join(tempRootDir, 'plans'); | |
| fs.mkdirSync(plansDirRaw, { recursive: true }); | |
| mockPlansDir = fs.realpathSync(plansDirRaw); | |
| mockConfig = { | |
| getTargetDir: vi.fn().mockReturnValue(tempRootDir), | |
| getProjectRoot: vi.fn().mockReturnValue(tempRootDir), | |
| setApprovalMode: vi.fn(), | |
| setApprovedPlanPath: vi.fn(), | |
| storage: { | |
| getPlansDir: vi.fn().mockReturnValue(mockPlansDir), | |
| } as unknown as Config['storage'], | |
| isInteractive: vi.fn().mockReturnValue(true), | |
| }; | |
| tool = new ExitPlanModeTool( | |
| mockConfig as Config, | |
| mockMessageBus as unknown as MessageBus, | |
| ); | |
| // Mock getMessageBusDecision on the invocation prototype | |
| vi.spyOn( | |
| ExitPlanModeInvocation.prototype as unknown as { | |
| getMessageBusDecision: () => Promise<string>; | |
| }, | |
| 'getMessageBusDecision', | |
| ).mockResolvedValue('ask_user'); | |
| }); | |
| afterEach(() => { | |
| if (fs.existsSync(tempRootDir)) { | |
| fs.rmSync(tempRootDir, { recursive: true, force: true }); | |
| } | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| const createPlanFile = (name: string, content: string) => { | |
| const filePath = path.join(mockPlansDir, name); | |
| // Ensure parent directory exists for nested tests | |
| fs.mkdirSync(path.dirname(filePath), { recursive: true }); | |
| fs.writeFileSync(filePath, content); | |
| return name; | |
| }; | |
| describe('shouldConfirmExecute', () => { | |
| it('should return plan approval confirmation details when plan has content', async () => { | |
| const planRelativePath = createPlanFile('test-plan.md', '# My Plan'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const result = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(result).not.toBe(false); | |
| if (result === false) return; | |
| expect(result.type).toBe('exit_plan_mode'); | |
| expect(result.title).toBe('Plan Approval'); | |
| if (result.type === 'exit_plan_mode') { | |
| expect(result.planPath).toBe(path.join(mockPlansDir, 'test-plan.md')); | |
| } | |
| expect(typeof result.onConfirm).toBe('function'); | |
| }); | |
| it('should return false when plan file is empty', async () => { | |
| const planRelativePath = createPlanFile('empty.md', ' '); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const result = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(result).toBe(false); | |
| }); | |
| it('should return false when plan file cannot be read', async () => { | |
| const planRelativePath = path.join('plans', 'non-existent.md'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const result = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(result).toBe(false); | |
| }); | |
| it('should auto-approve when policy decision is ALLOW', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| vi.spyOn( | |
| invocation as unknown as { | |
| getMessageBusDecision: () => Promise<string>; | |
| }, | |
| 'getMessageBusDecision', | |
| ).mockResolvedValue('allow'); | |
| const result = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(result).toBe(false); | |
| // Verify it auto-approved internally | |
| const executeResult = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(executeResult.llmContent).toContain('Plan approved'); | |
| }); | |
| it('should throw error when policy decision is DENY', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| vi.spyOn( | |
| invocation as unknown as { | |
| getMessageBusDecision: () => Promise<string>; | |
| }, | |
| 'getMessageBusDecision', | |
| ).mockResolvedValue('deny'); | |
| await expect( | |
| invocation.shouldConfirmExecute(new AbortController().signal), | |
| ).rejects.toThrow(/denied by policy/); | |
| }); | |
| }); | |
| describe('execute with invalid plan', () => { | |
| it('should return error when plan file is empty', async () => { | |
| const planRelativePath = createPlanFile('empty.md', ''); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| await invocation.shouldConfirmExecute(new AbortController().signal); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(result.llmContent).toContain('Plan file is empty'); | |
| expect(result.llmContent).toContain('write content to the plan'); | |
| }); | |
| it('should return error when plan file cannot be read', async () => { | |
| const planRelativePath = 'ghost.md'; | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| await invocation.shouldConfirmExecute(new AbortController().signal); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(result.llmContent).toContain('Plan file does not exist'); | |
| }); | |
| }); | |
| describe('execute', () => { | |
| it('should return approval message when plan is approved with DEFAULT mode', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(confirmDetails).not.toBe(false); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: true, | |
| approvalMode: ApprovalMode.DEFAULT, | |
| }); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| const expectedPath = path.join(mockPlansDir, 'test.md'); | |
| expect(result).toEqual({ | |
| llmContent: `Plan approved. Switching to Default mode (edits will require confirmation). | |
| The approved implementation plan is stored at: ${expectedPath} | |
| Read and follow the plan strictly during implementation.`, | |
| returnDisplay: `Plan approved: ${expectedPath}`, | |
| }); | |
| expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath); | |
| }); | |
| it('should return approval message when plan is approved with AUTO_EDIT mode', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(confirmDetails).not.toBe(false); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: true, | |
| approvalMode: ApprovalMode.AUTO_EDIT, | |
| }); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| const expectedPath = path.join(mockPlansDir, 'test.md'); | |
| expect(result).toEqual({ | |
| llmContent: `Plan approved. Switching to Auto-Edit mode (edits will be applied automatically). | |
| The approved implementation plan is stored at: ${expectedPath} | |
| Read and follow the plan strictly during implementation.`, | |
| returnDisplay: `Plan approved: ${expectedPath}`, | |
| }); | |
| expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( | |
| ApprovalMode.AUTO_EDIT, | |
| ); | |
| expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath); | |
| }); | |
| it('should return feedback message when plan is rejected with feedback', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(confirmDetails).not.toBe(false); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: false, | |
| feedback: 'Please add more details.', | |
| }); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| const expectedPath = path.join(mockPlansDir, 'test.md'); | |
| expect(result).toEqual({ | |
| llmContent: `Plan rejected. User feedback: Please add more details. | |
| The plan is stored at: ${expectedPath} | |
| Revise the plan based on the feedback.`, | |
| returnDisplay: 'Feedback: Please add more details.', | |
| }); | |
| }); | |
| it('should handle rejection without feedback gracefully', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(confirmDetails).not.toBe(false); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: false, | |
| }); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| const expectedPath = path.join(mockPlansDir, 'test.md'); | |
| expect(result).toEqual({ | |
| llmContent: `Plan rejected. No feedback provided. | |
| The plan is stored at: ${expectedPath} | |
| Ask the user for specific feedback on how to improve the plan.`, | |
| returnDisplay: 'Rejected (no feedback)', | |
| }); | |
| }); | |
| it('should log plan execution event when plan is approved', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: true, | |
| approvalMode: ApprovalMode.AUTO_EDIT, | |
| }); | |
| await invocation.execute({ abortSignal: new AbortController().signal }); | |
| expect(loggers.logPlanExecution).toHaveBeenCalledWith( | |
| mockConfig, | |
| expect.objectContaining({ | |
| approval_mode: ApprovalMode.AUTO_EDIT, | |
| }), | |
| ); | |
| }); | |
| it('should return cancellation message when cancelled', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| expect(confirmDetails).not.toBe(false); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.Cancel); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(result).toEqual({ | |
| llmContent: | |
| 'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.', | |
| returnDisplay: 'Cancelled', | |
| }); | |
| }); | |
| }); | |
| describe('execute when shouldConfirmExecute is never called', () => { | |
| it('should approve with DEFAULT mode when approvalPayload is null (policy ALLOW skips confirmation)', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| // Simulate the scheduler's policy ALLOW path: execute() is called | |
| // directly without ever calling shouldConfirmExecute(), leaving | |
| // approvalPayload null. | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| const expectedPath = path.join(mockPlansDir, 'test.md'); | |
| expect(result.llmContent).toContain('Plan approved'); | |
| expect(result.returnDisplay).toContain('Plan approved'); | |
| expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( | |
| ApprovalMode.DEFAULT, | |
| ); | |
| expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath); | |
| }); | |
| }); | |
| describe('getAllowApprovalMode (internal)', () => { | |
| it('should return YOLO when config.isInteractive() is false', async () => { | |
| mockConfig.isInteractive = vi.fn().mockReturnValue(false); | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| // Directly call execute to trigger the internal getAllowApprovalMode | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(result.llmContent).toContain('YOLO mode'); | |
| expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( | |
| ApprovalMode.YOLO, | |
| ); | |
| }); | |
| it('should return DEFAULT when config.isInteractive() is true', async () => { | |
| mockConfig.isInteractive = vi.fn().mockReturnValue(true); | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| // Directly call execute to trigger the internal getAllowApprovalMode | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(result.llmContent).toContain('Default mode'); | |
| expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( | |
| ApprovalMode.DEFAULT, | |
| ); | |
| }); | |
| }); | |
| describe('getApprovalModeDescription (internal)', () => { | |
| it('should handle all valid approval modes', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const testMode = async (mode: ApprovalMode, expected: string) => { | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: true, | |
| approvalMode: mode, | |
| }); | |
| const result = await invocation.execute({ | |
| abortSignal: new AbortController().signal, | |
| }); | |
| expect(result.llmContent).toContain(expected); | |
| }; | |
| await testMode( | |
| ApprovalMode.AUTO_EDIT, | |
| 'Auto-Edit mode (edits will be applied automatically)', | |
| ); | |
| await testMode( | |
| ApprovalMode.DEFAULT, | |
| 'Default mode (edits will require confirmation)', | |
| ); | |
| await testMode( | |
| ApprovalMode.YOLO, | |
| 'YOLO mode (all tool calls auto-approved)', | |
| ); | |
| }); | |
| it('should throw for invalid post-planning modes', async () => { | |
| const planRelativePath = createPlanFile('test.md', '# Content'); | |
| const invocation = tool.build({ plan_filename: planRelativePath }); | |
| const testInvalidMode = async (mode: ApprovalMode) => { | |
| const confirmDetails = await invocation.shouldConfirmExecute( | |
| new AbortController().signal, | |
| ); | |
| if (confirmDetails === false) return; | |
| await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { | |
| approved: true, | |
| approvalMode: mode, | |
| }); | |
| await expect( | |
| invocation.execute({ abortSignal: new AbortController().signal }), | |
| ).rejects.toThrow(/Unexpected approval mode/); | |
| }; | |
| await testInvalidMode(ApprovalMode.PLAN); | |
| }); | |
| }); | |
| describe('validateToolParams', () => { | |
| it('should reject empty plan_filename', () => { | |
| const result = tool.validateToolParams({ plan_filename: '' }); | |
| expect(result).toBe('plan_filename is required.'); | |
| }); | |
| it('should reject whitespace-only plan_filename', () => { | |
| const result = tool.validateToolParams({ plan_filename: ' ' }); | |
| expect(result).toBe('plan_filename is required.'); | |
| }); | |
| it('should reject non-existent plan file', async () => { | |
| const result = await validatePlanPath( | |
| 'ghost.md', | |
| mockPlansDir, | |
| tempRootDir, | |
| ); | |
| expect(result).toContain('Plan file does not exist'); | |
| }); | |
| it('should reject symbolic links pointing outside the plans directory', () => { | |
| const outsideFile = path.join(tempRootDir, 'outside.txt'); | |
| fs.writeFileSync(outsideFile, 'secret'); | |
| const maliciousPath = path.join(mockPlansDir, 'malicious.md'); | |
| fs.symlinkSync(outsideFile, maliciousPath); | |
| const result = tool.validateToolParams({ | |
| plan_filename: 'malicious.md', | |
| }); | |
| expect(result).toBe( | |
| `Access denied: plan path (malicious.md) must be within the designated plans directory (${mockPlansDir}).`, | |
| ); | |
| }); | |
| it('should accept valid path within plans directory', () => { | |
| createPlanFile('valid.md', '# Content'); | |
| const result = tool.validateToolParams({ | |
| plan_filename: 'valid.md', | |
| }); | |
| expect(result).toBeNull(); | |
| }); | |
| }); | |
| }); | |