diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 3f2d688c68..7237b63192 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -551,6 +551,65 @@ describe('handleAtCommand', () => { }); }); + it('should skip recursive glob fallback for bare @ names', async () => { + const buildAndExecute = vi.fn().mockResolvedValue({ + llmContent: 'No files found', + returnDisplay: 'No files found', + }); + vi.mocked(mockConfig.getToolRegistry).mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'glob' ? { buildAndExecute } : undefined, + ), + } as unknown as ToolRegistry); + + const query = 'Ask @teammate for review'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 134, + signal: abortController.signal, + }); + + expect(buildAndExecute).not.toHaveBeenCalled(); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + 'Path teammate not found directly and does not look like a path. Skipping recursive glob search.', + ); + expect(result).toEqual({ + processedQuery: [{ text: query }], + }); + }); + + it('should not treat @ in the middle of a word as an @ command', async () => { + const buildAndExecute = vi.fn().mockResolvedValue({ + llmContent: 'No files found', + returnDisplay: 'No files found', + }); + vi.mocked(mockConfig.getToolRegistry).mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'glob' ? { buildAndExecute } : undefined, + ), + } as unknown as ToolRegistry); + + const query = 'Connect to user@host before continuing.'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 135, + signal: abortController.signal, + }); + + expect(buildAndExecute).not.toHaveBeenCalled(); + expect(result).toEqual({ + processedQuery: [{ text: query }], + }); + }); + describe('git-aware filtering', () => { beforeEach(async () => { await fsPromises.mkdir(path.join(testRootDir, '.git'), { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 3d00b68652..3be7da60ea 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -61,6 +61,12 @@ export function unescapeLiteralAt(text: string): string { export const AT_COMMAND_PATH_REGEX_SOURCE = '(?:(?:"(?:[^"]*)")|(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r])))+'; +/** + * An @ reference can start at the beginning of input or after punctuation/space, + * but not in the middle of a word such as user@host or hello@file. + */ +export const AT_COMMAND_START_REGEX_SOURCE = '(? { // referenced files are pre-loaded before the query is sent to the model. expect(isAtCommand('check:@file.py')).toBe(true); expect(isAtCommand('analyze(@file.py)')).toBe(true); - expect(isAtCommand('hello@file')).toBe(true); - expect(isAtCommand('text@path/to/file')).toBe(true); - expect(isAtCommand('user@host')).toBe(true); + }); + + it('should return false when @ is in the middle of a word', () => { + expect(isAtCommand('hello@file')).toBe(false); + expect(isAtCommand('text@path/to/file')).toBe(false); + expect(isAtCommand('user@host')).toBe(false); }); it('should return false when query does not contain any @ pattern', () => { diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index d6fdb99f0f..0e31199bca 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -10,13 +10,16 @@ import type { SlashCommand } from '../commands/types.js'; import fs from 'node:fs'; import type { Writable } from 'node:stream'; import type { Settings } from '../../config/settingsSchema.js'; -import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js'; +import { + AT_COMMAND_PATH_REGEX_SOURCE, + AT_COMMAND_START_REGEX_SOURCE, +} from '../hooks/atCommandProcessor.js'; // Pre-compiled regex for detecting @ patterns consistent with parseAllAtCommands. // Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever // parseAllAtCommands would find at least one atPath part. const AT_COMMAND_DETECT_REGEX = new RegExp( - `(?