mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Support @ suggestions for subagenets (#16201)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -163,6 +163,7 @@ export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
FILE = 'file',
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
AGENT = 'agent',
|
||||
}
|
||||
|
||||
// The standardized contract for any command in the system.
|
||||
|
||||
@@ -60,8 +60,13 @@ export function SuggestionsDisplay({
|
||||
);
|
||||
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
|
||||
|
||||
const COMMAND_KIND_SUFFIX: Partial<Record<CommandKind, string>> = {
|
||||
[CommandKind.MCP_PROMPT]: ' [MCP]',
|
||||
[CommandKind.AGENT]: ' [Agent]',
|
||||
};
|
||||
|
||||
const getFullLabel = (s: Suggestion) =>
|
||||
s.label + (s.commandKind === CommandKind.MCP_PROMPT ? ' [MCP]' : '');
|
||||
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
|
||||
|
||||
const maxLabelLength = Math.max(
|
||||
...suggestions.map((s) => getFullLabel(s).length),
|
||||
@@ -98,9 +103,12 @@ export function SuggestionsDisplay({
|
||||
>
|
||||
<Box>
|
||||
{labelElement}
|
||||
{suggestion.commandKind === CommandKind.MCP_PROMPT && (
|
||||
<Text color={textColor}> [MCP]</Text>
|
||||
)}
|
||||
{suggestion.commandKind &&
|
||||
COMMAND_KIND_SUFFIX[suggestion.commandKind] && (
|
||||
<Text color={textColor}>
|
||||
{COMMAND_KIND_SUFFIX[suggestion.commandKind]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export async function handleAtCommand({
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const resourceAttachments: DiscoveredMCPResource[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const agentsFound: string[] = [];
|
||||
const fileLabelsForDisplay: string[] = [];
|
||||
const absoluteToRelativePathMap = new Map<string, string>();
|
||||
const ignoredByReason: Record<string, string[]> = {
|
||||
@@ -208,6 +209,14 @@ export async function handleAtCommand({
|
||||
return { processedQuery: null, error: errMsg };
|
||||
}
|
||||
|
||||
// Check if this is an Agent reference
|
||||
const agentRegistry = config.getAgentRegistry?.();
|
||||
if (agentRegistry?.getDefinition(pathName)) {
|
||||
agentsFound.push(pathName);
|
||||
atPathToResolvedSpecMap.set(originalAtPath, pathName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an MCP resource reference (serverName:uri format)
|
||||
const resourceMatch = resourceRegistry.findResourceByUri(pathName);
|
||||
if (resourceMatch) {
|
||||
@@ -420,7 +429,11 @@ export async function handleAtCommand({
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
if (pathSpecsToRead.length === 0 && resourceAttachments.length === 0) {
|
||||
if (
|
||||
pathSpecsToRead.length === 0 &&
|
||||
resourceAttachments.length === 0 &&
|
||||
agentsFound.length === 0
|
||||
) {
|
||||
onDebugMessage('No valid file paths found in @ commands to read.');
|
||||
if (initialQueryText === '@' && query.trim() === '@') {
|
||||
// If the only thing was a lone @, pass original query (which might have spaces)
|
||||
@@ -435,6 +448,13 @@ export async function handleAtCommand({
|
||||
|
||||
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
|
||||
|
||||
if (agentsFound.length > 0) {
|
||||
const agentNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentsFound.join(
|
||||
', ',
|
||||
)}. Please use the 'delegate_to_agent' tool to delegate the task to the selected agent(s).\n</system_note>\n`;
|
||||
processedQueryParts.push({ text: agentNudge });
|
||||
}
|
||||
|
||||
const resourcePromises = resourceAttachments.map(async (resource) => {
|
||||
const uri = resource.uri;
|
||||
const client = mcpClientManager?.getClient(resource.serverName);
|
||||
|
||||
241
packages/cli/src/ui/hooks/atCommandProcessor_agents.test.ts
Normal file
241
packages/cli/src/ui/hooks/atCommandProcessor_agents.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
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,
|
||||
} 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.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: () => true,
|
||||
getDirectories: () => [testRootDir],
|
||||
}),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpServerCommand: () => undefined,
|
||||
getPromptRegistry: () => ({
|
||||
getPromptsByServer: () => [],
|
||||
}),
|
||||
getDebugMode: () => false,
|
||||
getFileExclusions: () => ({
|
||||
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
|
||||
getDefaultExcludePatterns: () => [],
|
||||
getGlobExcludes: () => [],
|
||||
buildExcludePatterns: () => [],
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getEnableExtensionReloading: () => false,
|
||||
getResourceRegistry: () => ({
|
||||
findResourceByUri: () => undefined,
|
||||
getAllResources: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getClient: () => undefined,
|
||||
}),
|
||||
getAgentRegistry: () => mockAgentRegistry,
|
||||
getMessageBus: () => mockMessageBus,
|
||||
} 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');
|
||||
}
|
||||
|
||||
// Check if the query text is preserved
|
||||
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');
|
||||
}
|
||||
|
||||
// Check if the nudge message is added
|
||||
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 () => {
|
||||
// Mock another agent
|
||||
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';
|
||||
// This should fail to resolve and fallback or error depending on file search
|
||||
// Since it's not a file, handleAtCommand logic for files will run.
|
||||
// It will likely log debug message about not finding file/glob.
|
||||
// But critical for this test: it should NOT add the agent nudge.
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { Config, FileSearch } from '@google/gemini-cli-core';
|
||||
import { FileSearchFactory, escapePath } from '@google/gemini-cli-core';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
export enum AtCompletionStatus {
|
||||
@@ -127,6 +128,18 @@ function buildResourceCandidates(
|
||||
return resources;
|
||||
}
|
||||
|
||||
function buildAgentCandidates(config?: Config): Suggestion[] {
|
||||
const registry = config?.getAgentRegistry?.();
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
return registry.getAllDefinitions().map((def) => ({
|
||||
label: def.name,
|
||||
value: def.name,
|
||||
commandKind: CommandKind.AGENT,
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchResourceCandidates(
|
||||
pattern: string,
|
||||
candidates: ResourceSuggestionCandidate[],
|
||||
@@ -153,6 +166,26 @@ async function searchResourceCandidates(
|
||||
);
|
||||
}
|
||||
|
||||
async function searchAgentCandidates(
|
||||
pattern: string,
|
||||
candidates: Suggestion[],
|
||||
): Promise<Suggestion[]> {
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const normalizedPattern = pattern.toLowerCase();
|
||||
if (!normalizedPattern) {
|
||||
return candidates.slice(0, MAX_SUGGESTIONS_TO_SHOW);
|
||||
}
|
||||
const fzf = new AsyncFzf(candidates, {
|
||||
selector: (s: Suggestion) => s.label,
|
||||
});
|
||||
const results = await fzf.find(normalizedPattern, {
|
||||
limit: MAX_SUGGESTIONS_TO_SHOW,
|
||||
});
|
||||
return results.map((r: { item: Suggestion }) => r.item);
|
||||
}
|
||||
|
||||
export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
const {
|
||||
enabled,
|
||||
@@ -283,7 +316,14 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
value: suggestion.value.replace(/^@/, ''),
|
||||
}));
|
||||
|
||||
const agentCandidates = buildAgentCandidates(config);
|
||||
const agentSuggestions = await searchAgentCandidates(
|
||||
state.pattern ?? '',
|
||||
agentCandidates,
|
||||
);
|
||||
|
||||
const combinedSuggestions = [
|
||||
...agentSuggestions,
|
||||
...fileSuggestions,
|
||||
...resourceSuggestions,
|
||||
];
|
||||
|
||||
117
packages/cli/src/ui/hooks/useAtCompletion_agents.test.ts
Normal file
117
packages/cli/src/ui/hooks/useAtCompletion_agents.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import type { Config, AgentDefinition } from '@google/gemini-cli-core';
|
||||
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
|
||||
// Test harness to capture the state from the hook's callbacks.
|
||||
function useTestHarnessForAtCompletion(
|
||||
enabled: boolean,
|
||||
pattern: string,
|
||||
config: Config | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
|
||||
useAtCompletion({
|
||||
enabled,
|
||||
pattern,
|
||||
config,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
});
|
||||
|
||||
return { suggestions, isLoadingSuggestions };
|
||||
}
|
||||
|
||||
describe('useAtCompletion with Agents', () => {
|
||||
let testRootDir: string;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockAgentRegistry = {
|
||||
getAllDefinitions: vi.fn(() => [
|
||||
{
|
||||
name: 'CodebaseInvestigator',
|
||||
description: 'Investigates codebase',
|
||||
kind: 'local',
|
||||
} as AgentDefinition,
|
||||
{
|
||||
name: 'OtherAgent',
|
||||
description: 'Another agent',
|
||||
kind: 'local',
|
||||
} as AgentDefinition,
|
||||
]),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
getFileFilteringOptions: vi.fn(() => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
})),
|
||||
getEnableRecursiveFileSearch: () => true,
|
||||
getFileFilteringDisableFuzzySearch: () => false,
|
||||
getResourceRegistry: vi.fn().mockReturnValue({
|
||||
getAllResources: () => [],
|
||||
}),
|
||||
getAgentRegistry: () => mockAgentRegistry,
|
||||
} as unknown as Config;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testRootDir) {
|
||||
await cleanupTmpDir(testRootDir);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should include agent suggestions', async () => {
|
||||
testRootDir = await createTmpDir({});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const agentSuggestion = result.current.suggestions.find(
|
||||
(s) => s.value === 'CodebaseInvestigator',
|
||||
);
|
||||
expect(agentSuggestion).toBeDefined();
|
||||
expect(agentSuggestion?.commandKind).toBe(CommandKind.AGENT);
|
||||
});
|
||||
|
||||
it('should filter agent suggestions', async () => {
|
||||
testRootDir = await createTmpDir({});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, 'Code', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.suggestions.map((s) => s.value)).toContain(
|
||||
'CodebaseInvestigator',
|
||||
);
|
||||
expect(result.current.suggestions.map((s) => s.value)).not.toContain(
|
||||
'OtherAgent',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,9 @@ export * from './hooks/index.js';
|
||||
// Export hook types
|
||||
export * from './hooks/types.js';
|
||||
|
||||
// Export agent types
|
||||
export * from './agents/types.js';
|
||||
|
||||
// Export stdio utils
|
||||
export * from './utils/stdio.js';
|
||||
export * from './utils/terminal.js';
|
||||
|
||||
Reference in New Issue
Block a user