mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-10 00:47:16 -07:00
925 lines
35 KiB
TypeScript
925 lines
35 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { getCoreSystemPrompt } from './prompts.js';
|
|
import { resolvePathFromEnv } from '../prompts/utils.js';
|
|
import { isGitRepository } from '../utils/gitUtils.js';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import type { Config } from '../config/config.js';
|
|
import type { AgentDefinition } from '../agents/types.js';
|
|
import { CodebaseInvestigatorAgent } from '../agents/codebase-investigator.js';
|
|
import { GEMINI_DIR } from '../utils/paths.js';
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
|
import {
|
|
PREVIEW_GEMINI_MODEL,
|
|
PREVIEW_GEMINI_FLASH_MODEL,
|
|
DEFAULT_GEMINI_MODEL_AUTO,
|
|
DEFAULT_GEMINI_MODEL,
|
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
|
} from '../config/models.js';
|
|
import { ApprovalMode } from '../policy/types.js';
|
|
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
|
import type { AnyDeclarativeTool } from '../tools/tools.js';
|
|
import type { CallableTool } from '@google/genai';
|
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
|
|
|
// Mock tool names if they are dynamically generated or complex
|
|
vi.mock('../tools/ls', () => ({ LSTool: { Name: 'list_directory' } }));
|
|
vi.mock('../tools/edit', () => ({ EditTool: { Name: 'replace' } }));
|
|
vi.mock('../tools/glob', () => ({ GlobTool: { Name: 'glob' } }));
|
|
vi.mock('../tools/grep', () => ({ GrepTool: { Name: 'grep_search' } }));
|
|
vi.mock('../tools/read-file', () => ({ ReadFileTool: { Name: 'read_file' } }));
|
|
vi.mock('../tools/read-many-files', () => ({
|
|
ReadManyFilesTool: { Name: 'read_many_files' },
|
|
}));
|
|
vi.mock('../tools/shell', () => ({
|
|
ShellTool: class {
|
|
static readonly Name = 'run_shell_command';
|
|
name = 'run_shell_command';
|
|
},
|
|
}));
|
|
vi.mock('../tools/write-file', () => ({
|
|
WriteFileTool: { Name: 'write_file' },
|
|
}));
|
|
vi.mock('../agents/codebase-investigator.js', () => ({
|
|
CodebaseInvestigatorAgent: { name: 'codebase_investigator' },
|
|
}));
|
|
vi.mock('../utils/gitUtils', () => ({
|
|
isGitRepository: vi.fn().mockReturnValue(false),
|
|
}));
|
|
vi.mock('node:fs');
|
|
vi.mock('../config/models.js', async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...(actual as object),
|
|
};
|
|
});
|
|
|
|
describe('Core System Prompt (prompts.ts)', () => {
|
|
const mockPlatform = (platform: string) => {
|
|
vi.stubGlobal(
|
|
'process',
|
|
Object.create(process, {
|
|
platform: {
|
|
get: () => platform,
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
|
|
let mockConfig: Config;
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
// Stub process.platform to 'linux' by default for deterministic snapshots across OSes
|
|
mockPlatform('linux');
|
|
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
vi.stubEnv('GEMINI_SYSTEM_MD', undefined);
|
|
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', undefined);
|
|
const mockRegistry = {
|
|
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
|
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
};
|
|
mockConfig = {
|
|
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
|
|
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
|
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
|
storage: {
|
|
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
|
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
|
getProjectTempTrackerDir: vi
|
|
.fn()
|
|
.mockReturnValue('/mock/.gemini/tmp/session/tracker'),
|
|
},
|
|
isInteractive: vi.fn().mockReturnValue(true),
|
|
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
|
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
|
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
|
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
|
getPreviewFeatures: vi.fn().mockReturnValue(true),
|
|
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
|
|
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
|
getMessageBus: vi.fn(),
|
|
getAgentRegistry: vi.fn().mockReturnValue({
|
|
getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'),
|
|
getAllDefinitions: vi.fn().mockReturnValue([
|
|
{
|
|
name: 'mock-agent',
|
|
description: 'Mock Agent Description',
|
|
},
|
|
]),
|
|
}),
|
|
getSkillManager: vi.fn().mockReturnValue({
|
|
getSkills: vi.fn().mockReturnValue([]),
|
|
}),
|
|
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
|
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
|
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
|
get config() {
|
|
return this;
|
|
},
|
|
get toolRegistry() {
|
|
return mockRegistry;
|
|
},
|
|
} as unknown as Config;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('should include available_skills when provided in config', () => {
|
|
const skills = [
|
|
{
|
|
name: 'test-skill',
|
|
description: 'A test skill description',
|
|
location: '/path/to/test-skill/SKILL.md',
|
|
body: 'Skill content',
|
|
},
|
|
];
|
|
vi.mocked(mockConfig.getSkillManager().getSkills).mockReturnValue(skills);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toContain('# Available Agent Skills');
|
|
expect(prompt).toContain(
|
|
"To activate a skill and receive its detailed instructions, you can call the `activate_skill` tool with the skill's name.",
|
|
);
|
|
expect(prompt).toContain('Skill Guidance');
|
|
expect(prompt).toContain('<available_skills>');
|
|
expect(prompt).toContain('<skill>');
|
|
expect(prompt).toContain('<name>test-skill</name>');
|
|
expect(prompt).toContain(
|
|
'<description>A test skill description</description>',
|
|
);
|
|
expect(prompt).toContain(
|
|
'<location>/path/to/test-skill/SKILL.md</location>',
|
|
);
|
|
expect(prompt).toContain('</skill>');
|
|
expect(prompt).toContain('</available_skills>');
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include available_skills with updated verbiage for preview models', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
const skills = [
|
|
{
|
|
name: 'test-skill',
|
|
description: 'A test skill description',
|
|
location: '/path/to/test-skill/SKILL.md',
|
|
body: 'Skill content',
|
|
},
|
|
];
|
|
vi.mocked(mockConfig.getSkillManager().getSkills).mockReturnValue(skills);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toContain('# Available Agent Skills');
|
|
expect(prompt).toContain(
|
|
"To activate a skill and receive its detailed instructions, call the `activate_skill` tool with the skill's name.",
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should NOT include skill guidance or available_skills when NO skills are provided', () => {
|
|
vi.mocked(mockConfig.getSkillManager().getSkills).mockReturnValue([]);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).not.toContain('# Available Agent Skills');
|
|
expect(prompt).not.toContain('Skill Guidance');
|
|
expect(prompt).not.toContain('activate_skill');
|
|
});
|
|
|
|
it('should include sub-agents in XML for preview models', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
const agents = [
|
|
{
|
|
name: 'test-agent',
|
|
displayName: 'Test Agent',
|
|
description: 'A test agent description',
|
|
},
|
|
];
|
|
vi.mocked(mockConfig.getAgentRegistry().getAllDefinitions).mockReturnValue(
|
|
agents as unknown as AgentDefinition[],
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toContain('# Available Sub-Agents');
|
|
expect(prompt).toContain('<available_subagents>');
|
|
expect(prompt).toContain('<subagent>');
|
|
expect(prompt).toContain('<name>test-agent</name>');
|
|
expect(prompt).toContain(
|
|
'<description>A test agent description</description>',
|
|
);
|
|
expect(prompt).toContain('</subagent>');
|
|
expect(prompt).toContain('</available_subagents>');
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should use legacy system prompt for non-preview model', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain(
|
|
'You are an interactive CLI agent specializing in software engineering tasks.',
|
|
);
|
|
expect(prompt).not.toContain('No sub-agents are currently available.');
|
|
expect(prompt).toContain('# Core Mandates');
|
|
expect(prompt).toContain('- **Conventions:**');
|
|
expect(prompt).toContain('- **User Hints:**');
|
|
expect(prompt).toContain('# Outside of Sandbox');
|
|
expect(prompt).toContain('# Final Reminder');
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include the TASK MANAGEMENT PROTOCOL in legacy prompt when task tracker is enabled', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
|
);
|
|
vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL');
|
|
expect(prompt).toContain(
|
|
'**PLAN MODE INTEGRATION**: If an approved plan exists, you MUST use the `tracker_create_task` tool',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include the TASK MANAGEMENT PROTOCOL when task tracker is enabled', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL');
|
|
expect(prompt).toContain(
|
|
'**PLAN MODE INTEGRATION**: If an approved plan exists, you MUST use the `tracker_create_task` tool to decompose it into discrete tasks before writing any code',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should use chatty system prompt for preview model', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('You are Gemini CLI, an interactive CLI agent'); // Check for core content
|
|
expect(prompt).toContain('- **User Hints:**');
|
|
expect(prompt).toContain('No Chitchat:');
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should use chatty system prompt for preview flash model', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
PREVIEW_GEMINI_FLASH_MODEL,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('You are Gemini CLI, an interactive CLI agent'); // Check for core content
|
|
expect(prompt).toContain('No Chitchat:');
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include mandate to distinguish between Directives and Inquiries', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toContain('Distinguish between **Directives**');
|
|
expect(prompt).toContain('and **Inquiries**');
|
|
expect(prompt).toContain(
|
|
'Assume all requests are Inquiries unless they contain an explicit instruction to perform a task.',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it.each([
|
|
['empty string', ''],
|
|
['whitespace only', ' \n \t '],
|
|
])('should return the base prompt when userMemory is %s', (_, userMemory) => {
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
const prompt = getCoreSystemPrompt(mockConfig, userMemory);
|
|
expect(prompt).not.toContain('---\n\n'); // Separator should not be present
|
|
expect(prompt).toContain('You are Gemini CLI, an interactive CLI agent'); // Check for core content
|
|
expect(prompt).toContain('No Chitchat:');
|
|
expect(prompt).toMatchSnapshot(); // Use snapshot for base prompt structure
|
|
});
|
|
|
|
it('should append userMemory with separator when provided', () => {
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
const memory = 'This is custom user memory.\nBe extra polite.';
|
|
const prompt = getCoreSystemPrompt(mockConfig, memory);
|
|
|
|
expect(prompt).toContain('# Contextual Instructions (GEMINI.md)');
|
|
expect(prompt).toContain('<loaded_context>');
|
|
expect(prompt).toContain(memory);
|
|
expect(prompt).toContain('You are Gemini CLI, an interactive CLI agent'); // Ensure base prompt follows
|
|
expect(prompt).toMatchSnapshot(); // Snapshot the combined prompt
|
|
});
|
|
|
|
it('should render hierarchical memory with XML tags', () => {
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
const memory = {
|
|
global: 'global context',
|
|
extension: 'extension context',
|
|
project: 'project context',
|
|
};
|
|
const prompt = getCoreSystemPrompt(mockConfig, memory);
|
|
|
|
expect(prompt).toContain(
|
|
'<global_context>\nglobal context\n</global_context>',
|
|
);
|
|
expect(prompt).toContain(
|
|
'<extension_context>\nextension context\n</extension_context>',
|
|
);
|
|
expect(prompt).toContain(
|
|
'<project_context>\nproject context\n</project_context>',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
// Should also include conflict resolution rules when hierarchical memory is present
|
|
expect(prompt).toContain('Conflict Resolution:');
|
|
});
|
|
|
|
it('should match snapshot on Windows', () => {
|
|
mockPlatform('win32');
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it.each([
|
|
['true', '# Sandbox', ['# macOS Seatbelt', '# Outside of Sandbox']],
|
|
['sandbox-exec', '# macOS Seatbelt', ['# Sandbox', '# Outside of Sandbox']],
|
|
[
|
|
undefined,
|
|
'You are Gemini CLI, an interactive CLI agent',
|
|
['# Sandbox', '# macOS Seatbelt'],
|
|
],
|
|
])(
|
|
'should include correct sandbox instructions for SANDBOX=%s',
|
|
(sandboxValue, expectedContains, expectedNotContains) => {
|
|
vi.stubEnv('SANDBOX', sandboxValue);
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
PREVIEW_GEMINI_MODEL,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain(expectedContains);
|
|
|
|
// modern snippets should NOT contain outside
|
|
expect(prompt).not.toContain('# Outside of Sandbox');
|
|
|
|
expectedNotContains.forEach((text) => expect(prompt).not.toContain(text));
|
|
expect(prompt).toMatchSnapshot();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[true, true],
|
|
[false, false],
|
|
])(
|
|
'should handle git instructions when isGitRepository=%s',
|
|
(isGitRepo, shouldContainGit) => {
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
vi.mocked(isGitRepository).mockReturnValue(isGitRepo);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
shouldContainGit
|
|
? expect(prompt).toContain('# Git Repository')
|
|
: expect(prompt).not.toContain('# Git Repository');
|
|
expect(prompt).toMatchSnapshot();
|
|
},
|
|
);
|
|
|
|
it('should return the interactive avoidance prompt when in non-interactive mode', () => {
|
|
vi.stubEnv('SANDBOX', undefined);
|
|
mockConfig.isInteractive = vi.fn().mockReturnValue(false);
|
|
const prompt = getCoreSystemPrompt(mockConfig, '');
|
|
expect(prompt).toContain('**Interactive Commands:**'); // Check for interactive prompt
|
|
expect(prompt).toMatchSnapshot(); // Use snapshot for base prompt structure
|
|
});
|
|
|
|
it('should redact grep and glob from the system prompt when they are disabled', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([]);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).not.toContain('`grep_search`');
|
|
expect(prompt).not.toContain('`glob`');
|
|
expect(prompt).toContain(
|
|
'Use search tools extensively to understand file structures, existing code patterns, and conventions.',
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[[CodebaseInvestigatorAgent.name, 'grep_search', 'glob'], true],
|
|
[['grep_search', 'glob'], false],
|
|
])(
|
|
'should handle CodebaseInvestigator with tools=%s',
|
|
(toolNames, expectCodebaseInvestigator) => {
|
|
const mockToolRegistry = {
|
|
getAllToolNames: vi.fn().mockReturnValue(toolNames),
|
|
};
|
|
const testConfig = {
|
|
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
|
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
|
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
|
storage: {
|
|
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
|
},
|
|
isInteractive: vi.fn().mockReturnValue(false),
|
|
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
|
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
|
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
|
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
|
getModel: vi.fn().mockReturnValue('auto'),
|
|
getActiveModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL),
|
|
getPreviewFeatures: vi.fn().mockReturnValue(true),
|
|
getAgentRegistry: vi.fn().mockReturnValue({
|
|
getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'),
|
|
getAllDefinitions: vi.fn().mockReturnValue([]),
|
|
}),
|
|
getSkillManager: vi.fn().mockReturnValue({
|
|
getSkills: vi.fn().mockReturnValue([]),
|
|
}),
|
|
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
|
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
|
get config() {
|
|
return this;
|
|
},
|
|
get toolRegistry() {
|
|
return mockToolRegistry;
|
|
},
|
|
} as unknown as Config;
|
|
|
|
const prompt = getCoreSystemPrompt(testConfig);
|
|
if (expectCodebaseInvestigator) {
|
|
expect(prompt).toContain(
|
|
`Utilize specialized sub-agents (e.g., \`codebase_investigator\`) as the primary mechanism for initial discovery`,
|
|
);
|
|
expect(prompt).not.toContain(
|
|
'Use `grep_search` and `glob` search tools extensively',
|
|
);
|
|
} else {
|
|
expect(prompt).not.toContain(
|
|
`Utilize specialized sub-agents (e.g., \`codebase_investigator\`) as the primary mechanism for initial discovery`,
|
|
);
|
|
expect(prompt).toContain(
|
|
'Use `grep_search` and `glob` search tools extensively',
|
|
);
|
|
}
|
|
expect(prompt).toMatchSnapshot();
|
|
},
|
|
);
|
|
|
|
describe('ApprovalMode in System Prompt', () => {
|
|
// Shared plan mode test fixtures
|
|
const readOnlyMcpTool = new DiscoveredMCPTool(
|
|
{} as CallableTool,
|
|
'readonly-server',
|
|
'read_data',
|
|
'A read-only MCP tool',
|
|
{},
|
|
{} as MessageBus,
|
|
false,
|
|
true, // isReadOnly
|
|
);
|
|
|
|
// Represents the full set of tools allowed by plan.toml policy
|
|
// (including a read-only MCP tool that passes annotation matching).
|
|
// Non-read-only MCP tools are excluded by the policy engine and
|
|
// never appear in getAllTools().
|
|
const planModeTools = [
|
|
{ name: 'glob' },
|
|
{ name: 'grep_search' },
|
|
{ name: 'read_file' },
|
|
{ name: 'ask_user' },
|
|
{ name: 'exit_plan_mode' },
|
|
{ name: 'write_file' },
|
|
{ name: 'replace' },
|
|
readOnlyMcpTool,
|
|
] as unknown as AnyDeclarativeTool[];
|
|
|
|
const setupPlanMode = () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
PREVIEW_GEMINI_MODEL,
|
|
);
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
|
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
|
|
planModeTools,
|
|
);
|
|
};
|
|
|
|
it('should include PLAN mode instructions', () => {
|
|
setupPlanMode();
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('# Active Approval Mode: Plan');
|
|
// Read-only MCP tool should appear with server name
|
|
expect(prompt).toContain(
|
|
'`mcp_readonly-server_read_data` (readonly-server)',
|
|
);
|
|
// Non-read-only MCP tool should not appear (excluded by policy)
|
|
expect(prompt).not.toContain(
|
|
'`mcp_nonreadonly-server_write_data` (nonreadonly-server)',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should NOT include approval mode instructions for DEFAULT mode', () => {
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(
|
|
ApprovalMode.DEFAULT,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).not.toContain('# Active Approval Mode: Plan');
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include read-only MCP tools but not non-read-only MCP tools in PLAN mode', () => {
|
|
setupPlanMode();
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toContain(
|
|
'`mcp_readonly-server_read_data` (readonly-server)',
|
|
);
|
|
expect(prompt).not.toContain(
|
|
'`mcp_nonreadonly-server_write_data` (nonreadonly-server)',
|
|
);
|
|
});
|
|
|
|
it('should only list available tools in PLAN mode', () => {
|
|
// Use a smaller subset than the full planModeTools to verify
|
|
// that only tools returned by getAllTools() appear in the prompt.
|
|
const subsetTools = [
|
|
{ name: 'glob' },
|
|
{ name: 'read_file' },
|
|
{ name: 'ask_user' },
|
|
] as unknown as AnyDeclarativeTool[];
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
PREVIEW_GEMINI_MODEL,
|
|
);
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
|
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
|
|
subsetTools,
|
|
);
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
// Should include enabled tools
|
|
expect(prompt).toContain('`glob`');
|
|
expect(prompt).toContain('`read_file`');
|
|
expect(prompt).toContain('`ask_user`');
|
|
|
|
// Should NOT include tools not in getAllTools()
|
|
expect(prompt).not.toContain('`google_web_search`');
|
|
expect(prompt).not.toContain('`list_directory`');
|
|
expect(prompt).not.toContain('`grep_search`');
|
|
});
|
|
|
|
describe('Approved Plan in Plan Mode', () => {
|
|
beforeEach(() => {
|
|
setupPlanMode();
|
|
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue('/tmp/plans');
|
|
});
|
|
|
|
it('should include approved plan path when set in config', () => {
|
|
const planPath = '/tmp/plans/feature-x.md';
|
|
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(planPath);
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should NOT include approved plan section if no plan is set in config', () => {
|
|
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(undefined);
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
});
|
|
|
|
it('should include YOLO mode instructions in interactive mode', () => {
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.YOLO);
|
|
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('# Autonomous Mode (YOLO)');
|
|
expect(prompt).toContain('Only use the `ask_user` tool if');
|
|
});
|
|
|
|
it('should NOT include YOLO mode instructions in non-interactive mode', () => {
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.YOLO);
|
|
vi.mocked(mockConfig.isInteractive).mockReturnValue(false);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).not.toContain('# Autonomous Mode (YOLO)');
|
|
});
|
|
|
|
it('should NOT include YOLO mode instructions for DEFAULT mode', () => {
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(
|
|
ApprovalMode.DEFAULT,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).not.toContain('# Autonomous Mode (YOLO)');
|
|
});
|
|
});
|
|
|
|
describe('Platform-specific and Background Process instructions', () => {
|
|
it('should include Windows-specific shell efficiency commands on win32', () => {
|
|
mockPlatform('win32');
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain(
|
|
"using commands like 'type' or 'findstr' (on CMD) and 'Get-Content' or 'Select-String' (on PowerShell)",
|
|
);
|
|
expect(prompt).not.toContain(
|
|
"using commands like 'grep', 'tail', 'head'",
|
|
);
|
|
});
|
|
|
|
it('should include generic shell efficiency commands on non-Windows', () => {
|
|
mockPlatform('linux');
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
|
);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain("using commands like 'grep', 'tail', 'head'");
|
|
expect(prompt).not.toContain(
|
|
"using commands like 'type' or 'findstr' (on CMD) and 'Get-Content' or 'Select-String' (on PowerShell)",
|
|
);
|
|
});
|
|
|
|
it('should use is_background parameter in background process instructions', () => {
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain(
|
|
'To run a command in the background, set the `is_background` parameter to true.',
|
|
);
|
|
expect(prompt).not.toContain('via `&`');
|
|
});
|
|
|
|
it("should include 'tab' instructions when interactive shell is enabled", () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
PREVIEW_GEMINI_MODEL,
|
|
);
|
|
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
|
|
vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(true);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain('tab');
|
|
});
|
|
|
|
it("should NOT include 'tab' instructions when interactive shell is disabled", () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
|
PREVIEW_GEMINI_MODEL,
|
|
);
|
|
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
|
|
vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(false);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).not.toContain('`tab`');
|
|
});
|
|
});
|
|
|
|
it('should include approved plan instructions when approvedPlanPath is set', () => {
|
|
const planPath = '/path/to/approved/plan.md';
|
|
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(planPath);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include modern approved plan instructions with completion in DEFAULT mode when approvedPlanPath is set', () => {
|
|
const planPath = '/tmp/plans/feature-x.md';
|
|
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(planPath);
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(prompt).toContain(
|
|
'2. **Strategy:** An approved plan is available for this task',
|
|
);
|
|
expect(prompt).toContain(
|
|
'provide a **final summary** of the work completed against the plan',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
it('should include planning phase suggestion when enter_plan_mode tool is enabled', () => {
|
|
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
|
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([
|
|
'enter_plan_mode',
|
|
]);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
|
|
expect(prompt).toContain(
|
|
'If the request is ambiguous, broad in scope, or involves architectural decisions or cross-cutting changes, use the `enter_plan_mode` tool to safely research and design your strategy. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries.',
|
|
);
|
|
expect(prompt).toMatchSnapshot();
|
|
});
|
|
|
|
describe('GEMINI_SYSTEM_MD environment variable', () => {
|
|
it.each(['false', '0'])(
|
|
'should use default prompt when GEMINI_SYSTEM_MD is "%s"',
|
|
(value) => {
|
|
vi.stubEnv('GEMINI_SYSTEM_MD', value);
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
expect(prompt).not.toContain('custom system prompt');
|
|
},
|
|
);
|
|
|
|
it('should throw error if GEMINI_SYSTEM_MD points to a non-existent file', () => {
|
|
const customPath = '/non/existent/path/system.md';
|
|
vi.stubEnv('GEMINI_SYSTEM_MD', customPath);
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
expect(() => getCoreSystemPrompt(mockConfig)).toThrow(
|
|
`missing system prompt file '${path.resolve(customPath)}'`,
|
|
);
|
|
});
|
|
|
|
it.each(['true', '1'])(
|
|
'should read from default path when GEMINI_SYSTEM_MD is "%s"',
|
|
(value) => {
|
|
const defaultPath = path.resolve(path.join(GEMINI_DIR, 'system.md'));
|
|
vi.stubEnv('GEMINI_SYSTEM_MD', value);
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('custom system prompt');
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(defaultPath, 'utf8');
|
|
expect(prompt).toBe('custom system prompt');
|
|
},
|
|
);
|
|
|
|
it('should read from custom path when GEMINI_SYSTEM_MD provides one, preserving case', () => {
|
|
const customPath = path.resolve('/custom/path/SyStEm.Md');
|
|
vi.stubEnv('GEMINI_SYSTEM_MD', customPath);
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('custom system prompt');
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(customPath, 'utf8');
|
|
expect(prompt).toBe('custom system prompt');
|
|
});
|
|
|
|
it('should expand tilde in custom path when GEMINI_SYSTEM_MD is set', () => {
|
|
const homeDir = '/Users/test';
|
|
vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
const customPath = '~/custom/system.md';
|
|
const expectedPath = path.join(homeDir, 'custom/system.md');
|
|
vi.stubEnv('GEMINI_SYSTEM_MD', customPath);
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('custom system prompt');
|
|
|
|
const prompt = getCoreSystemPrompt(mockConfig);
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
path.resolve(expectedPath),
|
|
'utf8',
|
|
);
|
|
expect(prompt).toBe('custom system prompt');
|
|
});
|
|
});
|
|
|
|
describe('GEMINI_WRITE_SYSTEM_MD environment variable', () => {
|
|
it.each(['false', '0'])(
|
|
'should not write to file when GEMINI_WRITE_SYSTEM_MD is "%s"',
|
|
(value) => {
|
|
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', value);
|
|
getCoreSystemPrompt(mockConfig);
|
|
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['true', '1'])(
|
|
'should write to default path when GEMINI_WRITE_SYSTEM_MD is "%s"',
|
|
(value) => {
|
|
const defaultPath = path.resolve(path.join(GEMINI_DIR, 'system.md'));
|
|
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', value);
|
|
getCoreSystemPrompt(mockConfig);
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
defaultPath,
|
|
expect.any(String),
|
|
);
|
|
},
|
|
);
|
|
|
|
it('should write to custom path when GEMINI_WRITE_SYSTEM_MD provides one', () => {
|
|
const customPath = path.resolve('/custom/path/system.md');
|
|
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', customPath);
|
|
getCoreSystemPrompt(mockConfig);
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
customPath,
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['~/custom/system.md', 'custom/system.md'],
|
|
['~', ''],
|
|
])(
|
|
'should expand tilde in custom path when GEMINI_WRITE_SYSTEM_MD is "%s"',
|
|
(customPath, relativePath) => {
|
|
const homeDir = '/Users/test';
|
|
vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
const expectedPath = relativePath
|
|
? path.join(homeDir, relativePath)
|
|
: homeDir;
|
|
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', customPath);
|
|
getCoreSystemPrompt(mockConfig);
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
path.resolve(expectedPath),
|
|
expect.any(String),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('resolvePathFromEnv helper function', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('when envVar is undefined, empty, or whitespace', () => {
|
|
it.each([
|
|
['undefined', undefined],
|
|
['empty string', ''],
|
|
['whitespace only', ' \n\t '],
|
|
])('should return null for %s', (_, input) => {
|
|
const result = resolvePathFromEnv(input);
|
|
expect(result).toEqual({
|
|
isSwitch: false,
|
|
value: null,
|
|
isDisabled: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when envVar is a boolean-like string', () => {
|
|
it.each([
|
|
['"0" as disabled switch', '0', '0', true],
|
|
['"false" as disabled switch', 'false', 'false', true],
|
|
['"1" as enabled switch', '1', '1', false],
|
|
['"true" as enabled switch', 'true', 'true', false],
|
|
['"FALSE" (case-insensitive)', 'FALSE', 'false', true],
|
|
['"TRUE" (case-insensitive)', 'TRUE', 'true', false],
|
|
])('should handle %s', (_, input, expectedValue, isDisabled) => {
|
|
const result = resolvePathFromEnv(input);
|
|
expect(result).toEqual({
|
|
isSwitch: true,
|
|
value: expectedValue,
|
|
isDisabled,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when envVar is a file path', () => {
|
|
it.each([['/absolute/path/file.txt'], ['relative/path/file.txt']])(
|
|
'should resolve path: %s',
|
|
(input) => {
|
|
const result = resolvePathFromEnv(input);
|
|
expect(result).toEqual({
|
|
isSwitch: false,
|
|
value: path.resolve(input),
|
|
isDisabled: false,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['~/documents/file.txt', 'documents/file.txt'],
|
|
['~', ''],
|
|
])('should expand tilde path: %s', (input, homeRelativePath) => {
|
|
const homeDir = '/Users/test';
|
|
vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
const result = resolvePathFromEnv(input);
|
|
expect(result).toEqual({
|
|
isSwitch: false,
|
|
value: path.resolve(
|
|
homeRelativePath ? path.join(homeDir, homeRelativePath) : homeDir,
|
|
),
|
|
isDisabled: false,
|
|
});
|
|
});
|
|
|
|
it('should handle os.homedir() errors gracefully', () => {
|
|
vi.spyOn(os, 'homedir').mockImplementation(() => {
|
|
throw new Error('Cannot resolve home directory');
|
|
});
|
|
const consoleSpy = vi
|
|
.spyOn(debugLogger, 'warn')
|
|
.mockImplementation(() => {});
|
|
|
|
const result = resolvePathFromEnv('~/documents/file.txt');
|
|
expect(result).toEqual({
|
|
isSwitch: false,
|
|
value: null,
|
|
isDisabled: false,
|
|
});
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
'Could not resolve home directory for path: ~/documents/file.txt',
|
|
expect.any(Error),
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|