| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| import { handleAtCommand } from './atCommandProcessor.js'; |
| import type { |
| Config, |
| AgentDefinition, |
| MessageBus, |
| } from '@google/gemini-cli-core'; |
| import { |
| FileDiscoveryService, |
| GlobTool, |
| ReadManyFilesTool, |
| StandardFileSystemService, |
| ToolRegistry, |
| COMMON_IGNORE_PATTERNS, |
| ApprovalMode, |
| } from '@google/gemini-cli-core'; |
| import * as os from 'node:os'; |
| import type { UseHistoryManagerReturn } from './useHistoryManager.js'; |
| import * as fsPromises from 'node:fs/promises'; |
| import * as path from 'node:path'; |
|
|
| describe('handleAtCommand with Agents', () => { |
| let testRootDir: string; |
| let mockConfig: Config; |
|
|
| const mockAddItem: UseHistoryManagerReturn['addItem'] = vi.fn(); |
| const mockOnDebugMessage: (message: string) => void = vi.fn(); |
|
|
| let abortController: AbortController; |
|
|
| beforeEach(async () => { |
| vi.resetAllMocks(); |
|
|
| testRootDir = await fsPromises.realpath( |
| await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-test-')), |
| ); |
|
|
| abortController = new AbortController(); |
|
|
| const getToolRegistry = vi.fn(); |
| const mockMessageBus = { |
| publish: vi.fn(), |
| subscribe: vi.fn(), |
| unsubscribe: vi.fn(), |
| } as unknown as MessageBus; |
|
|
| const mockAgentRegistry = { |
| getDefinition: vi.fn((name: string) => { |
| if (name === 'CodebaseInvestigator') { |
| return { |
| name: 'CodebaseInvestigator', |
| description: 'Investigates codebase', |
| kind: 'local', |
| } as AgentDefinition; |
| } |
| return undefined; |
| }), |
| }; |
|
|
| mockConfig = { |
| getToolRegistry, |
| getTargetDir: () => testRootDir, |
| isSandboxed: () => false, |
| getExcludeTools: vi.fn(), |
| getFileService: () => new FileDiscoveryService(testRootDir), |
| getFileFilteringRespectGitIgnore: () => true, |
| getFileFilteringRespectGeminiIgnore: () => true, |
| getFileFilteringOptions: () => ({ |
| respectGitIgnore: true, |
| respectGeminiIgnore: true, |
| }), |
| getFileSystemService: () => new StandardFileSystemService(), |
| getEnableRecursiveFileSearch: vi.fn(() => true), |
| getWorkspaceContext: () => ({ |
| isPathWithinWorkspace: (p: string) => |
| p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir), |
| getDirectories: () => [testRootDir], |
| }), |
| storage: { |
| getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'), |
| }, |
| isPathAllowed(this: Config, absolutePath: string): boolean { |
| if (this.interactive && path.isAbsolute(absolutePath)) { |
| return true; |
| } |
|
|
| const workspaceContext = this.getWorkspaceContext(); |
| if (workspaceContext.isPathWithinWorkspace(absolutePath)) { |
| return true; |
| } |
|
|
| const projectTempDir = this.storage.getProjectTempDir(); |
| const resolvedProjectTempDir = path.resolve(projectTempDir); |
| return ( |
| absolutePath.startsWith(resolvedProjectTempDir + path.sep) || |
| absolutePath === resolvedProjectTempDir |
| ); |
| }, |
| validatePathAccess(this: Config, absolutePath: string): string | null { |
| if (this.isPathAllowed(absolutePath)) { |
| return null; |
| } |
|
|
| const workspaceDirs = this.getWorkspaceContext().getDirectories(); |
| const projectTempDir = this.storage.getProjectTempDir(); |
| return `Path validation failed: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`; |
| }, |
| getMcpServers: () => ({}), |
| getMcpServerCommand: () => undefined, |
| getPromptRegistry: () => ({ |
| getPromptsByServer: () => [], |
| }), |
| getDebugMode: () => false, |
| getWorkingDir: () => '/working/dir', |
| getFileExclusions: () => ({ |
| getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS, |
| getDefaultExcludePatterns: () => [], |
| getGlobExcludes: () => [], |
| buildExcludePatterns: () => [], |
| getReadManyFilesExcludes: () => [], |
| }), |
| getUsageStatisticsEnabled: () => false, |
| getEnableExtensionReloading: () => false, |
| getResourceRegistry: () => ({ |
| findResourceByUri: () => undefined, |
| getAllResources: () => [], |
| }), |
| getMcpClientManager: () => ({ |
| getClient: () => undefined, |
| }), |
| getMessageBus: () => mockMessageBus, |
| interactive: true, |
| getAgentRegistry: () => mockAgentRegistry, |
| getApprovalMode: () => ApprovalMode.DEFAULT, |
| } as unknown as Config; |
|
|
| const registry = new ToolRegistry(mockConfig, mockMessageBus); |
| registry.registerTool(new ReadManyFilesTool(mockConfig, mockMessageBus)); |
| registry.registerTool(new GlobTool(mockConfig, mockMessageBus)); |
| getToolRegistry.mockReturnValue(registry); |
| }); |
|
|
| afterEach(async () => { |
| abortController.abort(); |
| await fsPromises.rm(testRootDir, { recursive: true, force: true }); |
| }); |
|
|
| it('should detect agent reference and add nudge message', async () => { |
| const query = 'Please help me @CodebaseInvestigator'; |
|
|
| const result = await handleAtCommand({ |
| query, |
| config: mockConfig, |
| addItem: mockAddItem, |
| onDebugMessage: mockOnDebugMessage, |
| messageId: 123, |
| signal: abortController.signal, |
| }); |
|
|
| expect(result.processedQuery).toBeDefined(); |
| const parts = result.processedQuery; |
|
|
| if (!Array.isArray(parts)) { |
| throw new Error('processedQuery should be an array'); |
| } |
|
|
| |
| const firstPart = parts[0]; |
| if ( |
| typeof firstPart === 'object' && |
| firstPart !== null && |
| 'text' in firstPart |
| ) { |
| expect((firstPart as { text: string }).text).toContain( |
| 'Please help me @CodebaseInvestigator', |
| ); |
| } else { |
| throw new Error('First part should be a text part'); |
| } |
|
|
| |
| const nudgePart = parts.find( |
| (p) => |
| typeof p === 'object' && |
| p !== null && |
| 'text' in p && |
| (p as { text: string }).text.includes('<system_note>'), |
| ); |
| expect(nudgePart).toBeDefined(); |
| if (nudgePart && typeof nudgePart === 'object' && 'text' in nudgePart) { |
| expect((nudgePart as { text: string }).text).toContain( |
| 'The user has explicitly selected the following agent(s): CodebaseInvestigator', |
| ); |
| } |
| }); |
|
|
| it('should handle multiple agents', async () => { |
| |
| const mockAgentRegistry = mockConfig.getAgentRegistry() as { |
| getDefinition: (name: string) => AgentDefinition | undefined; |
| }; |
| mockAgentRegistry.getDefinition = vi.fn((name: string) => { |
| if (name === 'CodebaseInvestigator' || name === 'AnotherAgent') { |
| return { name, description: 'desc', kind: 'local' } as AgentDefinition; |
| } |
| return undefined; |
| }); |
|
|
| const query = '@CodebaseInvestigator and @AnotherAgent'; |
| const result = await handleAtCommand({ |
| query, |
| config: mockConfig, |
| addItem: mockAddItem, |
| onDebugMessage: mockOnDebugMessage, |
| messageId: 124, |
| signal: abortController.signal, |
| }); |
|
|
| const parts = result.processedQuery; |
| if (!Array.isArray(parts)) { |
| throw new Error('processedQuery should be an array'); |
| } |
|
|
| const nudgePart = parts.find( |
| (p) => |
| typeof p === 'object' && |
| p !== null && |
| 'text' in p && |
| (p as { text: string }).text.includes('<system_note>'), |
| ); |
| expect(nudgePart).toBeDefined(); |
| if (nudgePart && typeof nudgePart === 'object' && 'text' in nudgePart) { |
| expect((nudgePart as { text: string }).text).toContain( |
| 'CodebaseInvestigator, AnotherAgent', |
| ); |
| } |
| }); |
|
|
| it('should not treat non-agents as agents', async () => { |
| const query = '@UnknownAgent'; |
| |
| |
| |
| |
|
|
| const result = await handleAtCommand({ |
| query, |
| config: mockConfig, |
| addItem: mockAddItem, |
| onDebugMessage: mockOnDebugMessage, |
| messageId: 125, |
| signal: abortController.signal, |
| }); |
|
|
| const parts = result.processedQuery; |
| if (!Array.isArray(parts)) { |
| throw new Error('processedQuery should be an array'); |
| } |
|
|
| const nudgePart = parts.find( |
| (p) => |
| typeof p === 'object' && |
| p !== null && |
| 'text' in p && |
| (p as { text: string }).text.includes('<system_note>'), |
| ); |
| expect(nudgePart).toBeUndefined(); |
| }); |
| }); |
|
|