2025-05-20 13:02:41 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-08-26 00:04:53 +02:00
import type { Mock } from 'vitest' ;
import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
2025-05-20 13:02:41 -07:00
import { handleAtCommand } from './atCommandProcessor.js' ;
2025-12-09 03:43:12 +01:00
import type { Config , DiscoveredMCPResource } from '@google/gemini-cli-core' ;
2025-07-22 17:18:57 -07:00
import {
FileDiscoveryService ,
GlobTool ,
ReadManyFilesTool ,
2025-08-18 16:29:45 -06:00
StandardFileSystemService ,
2025-07-22 17:18:57 -07:00
ToolRegistry ,
2025-08-23 13:35:00 +09:00
COMMON_IGNORE_PATTERNS ,
2026-01-27 17:19:13 -08:00
GEMINI_IGNORE_FILE_NAME ,
2025-09-06 01:39:02 -04:00
// DEFAULT_FILE_EXCLUDES,
2025-07-22 17:18:57 -07:00
} from '@google/gemini-cli-core' ;
2025-12-13 06:31:12 +05:30
import * as core from '@google/gemini-cli-core' ;
2025-08-25 22:11:27 +02:00
import * as os from 'node:os' ;
2025-05-28 17:08:05 -07:00
import { ToolCallStatus } from '../types.js' ;
2025-08-26 00:04:53 +02:00
import type { UseHistoryManagerReturn } from './useHistoryManager.js' ;
2025-08-25 22:11:27 +02:00
import * as fsPromises from 'node:fs/promises' ;
import * as path from 'node:path' ;
2025-06-03 21:40:46 -07:00
2025-05-20 13:02:41 -07:00
describe ( 'handleAtCommand' , ( ) = > {
2025-07-22 17:18:57 -07:00
let testRootDir : string ;
let mockConfig : Config ;
const mockAddItem : Mock < UseHistoryManagerReturn [ 'addItem' ] > = vi . fn ( ) ;
const mockOnDebugMessage : Mock < ( message : string ) = > void > = vi . fn ( ) ;
2025-05-20 13:02:41 -07:00
let abortController : AbortController ;
2025-07-22 17:18:57 -07:00
async function createTestFile ( fullPath : string , fileContents : string ) {
await fsPromises . mkdir ( path . dirname ( fullPath ) , { recursive : true } ) ;
await fsPromises . writeFile ( fullPath , fileContents ) ;
return path . resolve ( testRootDir , fullPath ) ;
}
2025-10-29 10:13:04 +08:00
function getRelativePath ( absolutePath : string ) : string {
return path . relative ( testRootDir , absolutePath ) ;
}
2025-07-22 17:18:57 -07:00
beforeEach ( async ( ) = > {
2026-01-29 18:24:35 +00:00
vi . restoreAllMocks ( ) ;
2025-05-20 13:02:41 -07:00
vi . resetAllMocks ( ) ;
2025-06-03 21:40:46 -07:00
2025-07-22 17:18:57 -07:00
testRootDir = await fsPromises . mkdtemp (
path . join ( os . tmpdir ( ) , 'folder-structure-test-' ) ,
2025-06-03 21:40:46 -07:00
) ;
2025-07-22 17:18:57 -07:00
abortController = new AbortController ( ) ;
const getToolRegistry = vi . fn ( ) ;
2026-01-04 17:11:43 -05:00
const mockMessageBus = {
publish : vi.fn ( ) ,
subscribe : vi.fn ( ) ,
unsubscribe : vi.fn ( ) ,
} as unknown as core . MessageBus ;
2025-07-22 17:18:57 -07:00
mockConfig = {
getToolRegistry ,
getTargetDir : ( ) = > testRootDir ,
isSandboxed : ( ) = > false ,
2025-11-07 12:18:35 -08:00
getExcludeTools : vi.fn ( ) ,
2025-07-22 17:18:57 -07:00
getFileService : ( ) = > new FileDiscoveryService ( testRootDir ) ,
getFileFilteringRespectGitIgnore : ( ) = > true ,
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
2025-08-18 16:29:45 -06:00
getFileSystemService : ( ) = > new StandardFileSystemService ( ) ,
2025-07-22 17:18:57 -07:00
getEnableRecursiveFileSearch : vi.fn ( ( ) = > true ) ,
2025-07-31 05:38:20 +09:00
getWorkspaceContext : ( ) = > ( {
2026-01-27 13:17:40 -08:00
isPathWithinWorkspace : ( p : string ) = >
p . startsWith ( testRootDir ) || p . startsWith ( '/private' + testRootDir ) ,
2025-07-31 05:38:20 +09:00
getDirectories : ( ) = > [ testRootDir ] ,
} ) ,
2026-01-27 13:17:40 -08:00
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 } ` ;
} ,
2025-08-19 21:03:19 +02:00
getMcpServers : ( ) = > ( { } ) ,
getMcpServerCommand : ( ) = > undefined ,
getPromptRegistry : ( ) = > ( {
getPromptsByServer : ( ) = > [ ] ,
} ) ,
getDebugMode : ( ) = > false ,
2026-01-27 13:17:40 -08:00
getWorkingDir : ( ) = > '/working/dir' ,
2025-08-23 13:35:00 +09:00
getFileExclusions : ( ) = > ( {
getCoreIgnorePatterns : ( ) = > COMMON_IGNORE_PATTERNS ,
2025-09-06 01:39:02 -04:00
getDefaultExcludePatterns : ( ) = > [ ] ,
getGlobExcludes : ( ) = > [ ] ,
buildExcludePatterns : ( ) = > [ ] ,
getReadManyFilesExcludes : ( ) = > [ ] ,
2025-08-23 13:35:00 +09:00
} ) ,
2025-08-22 17:47:32 +05:30
getUsageStatisticsEnabled : ( ) = > false ,
2025-10-30 11:05:49 -07:00
getEnableExtensionReloading : ( ) = > false ,
2025-12-09 03:43:12 +01:00
getResourceRegistry : ( ) = > ( {
findResourceByUri : ( ) = > undefined ,
getAllResources : ( ) = > [ ] ,
} ) ,
getMcpClientManager : ( ) = > ( {
getClient : ( ) = > undefined ,
} ) ,
2026-01-04 17:11:43 -05:00
getMessageBus : ( ) = > mockMessageBus ,
2025-07-22 17:18:57 -07:00
} as unknown as Config ;
2026-01-04 17:11:43 -05:00
const registry = new ToolRegistry ( mockConfig , mockMessageBus ) ;
registry . registerTool ( new ReadManyFilesTool ( mockConfig , mockMessageBus ) ) ;
registry . registerTool ( new GlobTool ( mockConfig , mockMessageBus ) ) ;
2025-07-22 17:18:57 -07:00
getToolRegistry . mockReturnValue ( registry ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-07-22 17:18:57 -07:00
afterEach ( async ( ) = > {
2025-05-28 17:08:05 -07:00
abortController . abort ( ) ;
2025-07-22 17:18:57 -07:00
await fsPromises . rm ( testRootDir , { recursive : true , force : true } ) ;
2025-05-20 13:02:41 -07:00
} ) ;
it ( 'should pass through query if no @ command is present' , async ( ) = > {
const query = 'regular user query' ;
2025-07-22 17:18:57 -07:00
2025-05-20 13:02:41 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 123 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [ { text : query } ] ,
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-05-28 17:08:05 -07:00
it ( 'should pass through original query if only a lone @ symbol is present' , async ( ) = > {
2025-05-20 13:02:41 -07:00
const queryWithSpaces = ' @ ' ;
2025-07-22 17:18:57 -07:00
2025-05-20 13:02:41 -07:00
const result = await handleAtCommand ( {
2025-05-28 17:08:05 -07:00
query : queryWithSpaces ,
2025-05-20 13:02:41 -07:00
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 124 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [ { text : queryWithSpaces } ] ,
} ) ;
2025-05-20 13:02:41 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-05-28 17:08:05 -07:00
'Lone @ detected, will be treated as text in the modified query.' ,
2025-05-20 13:02:41 -07:00
) ;
} ) ;
it ( 'should process a valid text file path' , async ( ) = > {
const fileContent = 'This is the file content.' ;
2025-07-22 17:18:57 -07:00
const filePath = await createTestFile (
path . join ( testRootDir , 'path' , 'to' , 'file.txt' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const relativePath = getRelativePath ( filePath ) ;
2025-07-22 17:18:57 -07:00
const query = ` @ ${ filePath } ` ;
2025-05-20 13:02:41 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 125 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` @ ${ relativePath } ` } ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ relativePath } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-05-20 13:02:41 -07:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'tool_group' ,
2025-05-28 17:08:05 -07:00
tools : [ expect . objectContaining ( { status : ToolCallStatus.Success } ) ] ,
2025-05-20 13:02:41 -07:00
} ) ,
125 ,
) ;
} ) ;
it ( 'should process a valid directory path and convert to glob' , async ( ) = > {
2025-07-22 17:18:57 -07:00
const fileContent = 'This is the file content.' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'path' , 'to' , 'file.txt' ) ,
fileContent ,
) ;
const dirPath = path . dirname ( filePath ) ;
2025-10-29 10:13:04 +08:00
const relativeDirPath = getRelativePath ( dirPath ) ;
const relativeFilePath = getRelativePath ( filePath ) ;
2025-05-20 13:02:41 -07:00
const query = ` @ ${ dirPath } ` ;
2025-10-29 14:11:39 -07:00
const resolvedGlob = path . join ( relativeDirPath , '**' ) ;
2025-05-20 13:02:41 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 126 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
{ text : ` @ ${ resolvedGlob } ` } ,
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ relativeFilePath } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-05-20 13:02:41 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-05-28 17:08:05 -07:00
` Path ${ dirPath } resolved to directory, using glob: ${ resolvedGlob } ` ,
2025-05-20 13:02:41 -07:00
) ;
} ) ;
it ( 'should handle query with text before and after @command' , async ( ) = > {
2025-07-22 17:18:57 -07:00
const fileContent = 'Markdown content.' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'doc.md' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const relativePath = getRelativePath ( filePath ) ;
2025-05-28 17:08:05 -07:00
const textBefore = 'Explain this: ' ;
const textAfter = ' in detail.' ;
const query = ` ${ textBefore } @ ${ filePath } ${ textAfter } ` ;
2025-05-20 13:02:41 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 128 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` ${ textBefore } @ ${ relativePath } ${ textAfter } ` } ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ relativePath } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;
it ( 'should correctly unescape paths with escaped spaces' , async ( ) = > {
2025-07-22 17:18:57 -07:00
const fileContent = 'This is the file content.' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'path' , 'to' , 'my file.txt' ) ,
fileContent ,
) ;
const escapedpath = path . join ( testRootDir , 'path' , 'to' , 'my\\ file.txt' ) ;
const query = ` @ ${ escapedpath } ` ;
2025-05-20 13:02:41 -07:00
2025-07-22 17:18:57 -07:00
const result = await handleAtCommand ( {
2025-05-20 13:02:41 -07:00
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
2025-07-22 17:18:57 -07:00
messageId : 125 ,
2025-05-20 13:02:41 -07:00
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` @ ${ getRelativePath ( filePath ) } ` } ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'tool_group' ,
tools : [ expect . objectContaining ( { status : ToolCallStatus.Success } ) ] ,
} ) ,
125 ,
2025-05-28 17:08:05 -07:00
) ;
2025-11-10 18:31:00 -07:00
} , 10000 ) ;
2025-05-28 17:08:05 -07:00
it ( 'should handle multiple @file references' , async ( ) = > {
const content1 = 'Content file1' ;
2025-07-22 17:18:57 -07:00
const file1Path = await createTestFile (
path . join ( testRootDir , 'file1.txt' ) ,
content1 ,
) ;
2025-05-28 17:08:05 -07:00
const content2 = 'Content file2' ;
2025-07-22 17:18:57 -07:00
const file2Path = await createTestFile (
path . join ( testRootDir , 'file2.md' ) ,
content2 ,
) ;
const query = ` @ ${ file1Path } @ ${ file2Path } ` ;
2025-05-28 17:08:05 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 130 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` @ ${ getRelativePath ( file1Path ) } @ ${ getRelativePath ( file2Path ) } ` ,
} ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file1Path ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : content1 } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file2Path ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : content2 } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-05-28 17:08:05 -07:00
} ) ;
it ( 'should handle multiple @file references with interleaved text' , async ( ) = > {
const text1 = 'Check ' ;
const content1 = 'C1' ;
2025-07-22 17:18:57 -07:00
const file1Path = await createTestFile (
path . join ( testRootDir , 'f1.txt' ) ,
content1 ,
) ;
2025-05-28 17:08:05 -07:00
const text2 = ' and ' ;
const content2 = 'C2' ;
2025-07-22 17:18:57 -07:00
const file2Path = await createTestFile (
path . join ( testRootDir , 'f2.md' ) ,
content2 ,
) ;
2025-05-28 17:08:05 -07:00
const text3 = ' please.' ;
2025-07-22 17:18:57 -07:00
const query = ` ${ text1 } @ ${ file1Path } ${ text2 } @ ${ file2Path } ${ text3 } ` ;
2025-05-28 17:08:05 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 131 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` ${ text1 } @ ${ getRelativePath ( file1Path ) } ${ text2 } @ ${ getRelativePath ( file2Path ) } ${ text3 } ` ,
} ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file1Path ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : content1 } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file2Path ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : content2 } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-05-28 17:08:05 -07:00
} ) ;
it ( 'should handle a mix of valid, invalid, and lone @ references' , async ( ) = > {
const content1 = 'Valid content 1' ;
2025-07-22 17:18:57 -07:00
const file1Path = await createTestFile (
path . join ( testRootDir , 'valid1.txt' ) ,
content1 ,
) ;
2025-05-28 17:08:05 -07:00
const invalidFile = 'nonexistent.txt' ;
const content2 = 'Globbed content' ;
2025-07-22 17:18:57 -07:00
const file2Path = await createTestFile (
path . join ( testRootDir , 'resolved' , 'valid2.actual' ) ,
content2 ,
) ;
const query = ` Look at @ ${ file1Path } then @ ${ invalidFile } and also just @ symbol, then @ ${ file2Path } ` ;
2025-05-28 17:08:05 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 132 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
{
2025-10-29 10:13:04 +08:00
text : ` Look at @ ${ getRelativePath ( file1Path ) } then @ ${ invalidFile } and also just @ symbol, then @ ${ getRelativePath ( file2Path ) } ` ,
2025-07-20 00:55:33 -07:00
} ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file2Path ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : content2 } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file1Path ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : content1 } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-05-28 17:08:05 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ invalidFile } not found directly, attempting glob search. ` ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Glob search for '**/* ${ invalidFile } *' found no files or an error. Path ${ invalidFile } will be skipped. ` ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
'Lone @ detected, will be treated as text in the modified query.' ,
) ;
} ) ;
it ( 'should return original query if all @paths are invalid or lone @' , async ( ) = > {
const query = 'Check @nonexistent.txt and @ also' ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 133 ,
signal : abortController.signal ,
} ) ;
2025-05-31 16:19:14 -07:00
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [ { text : 'Check @nonexistent.txt and @ also' } ] ,
2025-05-31 16:19:14 -07:00
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-06-03 21:40:46 -07:00
describe ( 'git-aware filtering' , ( ) = > {
2025-07-22 17:18:57 -07:00
beforeEach ( async ( ) = > {
await fsPromises . mkdir ( path . join ( testRootDir , '.git' ) , {
recursive : true ,
} ) ;
} ) ;
2025-06-03 21:40:46 -07:00
2025-07-22 17:18:57 -07:00
it ( 'should skip git-ignored files in @ commands' , async ( ) = > {
await createTestFile (
path . join ( testRootDir , '.gitignore' ) ,
'node_modules/package.json' ,
2025-06-03 21:40:46 -07:00
) ;
2025-07-22 17:18:57 -07:00
const gitIgnoredFile = await createTestFile (
path . join ( testRootDir , 'node_modules' , 'package.json' ) ,
'the file contents' ,
) ;
const query = ` @ ${ gitIgnoredFile } ` ;
2025-06-03 21:40:46 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 200 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [ { text : query } ] ,
} ) ;
2025-06-03 21:40:46 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ gitIgnoredFile } is git-ignored and will be skipped. ` ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-07-22 17:18:57 -07:00
` Ignored 1 files: \ nGit-ignored: ${ gitIgnoredFile } ` ,
2025-06-03 21:40:46 -07:00
) ;
} ) ;
it ( 'should process non-git-ignored files normally' , async ( ) = > {
2025-07-22 17:18:57 -07:00
await createTestFile (
path . join ( testRootDir , '.gitignore' ) ,
'node_modules/package.json' ,
2025-07-20 00:55:33 -07:00
) ;
2025-07-22 17:18:57 -07:00
const validFile = await createTestFile (
path . join ( testRootDir , 'src' , 'index.ts' ) ,
'console.log("Hello world");' ,
) ;
const query = ` @ ${ validFile } ` ;
2025-06-03 21:40:46 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 201 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` @ ${ getRelativePath ( validFile ) } ` } ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( validFile ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : 'console.log("Hello world");' } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-06-03 21:40:46 -07:00
} ) ;
it ( 'should handle mixed git-ignored and valid files' , async ( ) = > {
2025-07-22 17:18:57 -07:00
await createTestFile ( path . join ( testRootDir , '.gitignore' ) , '.env' ) ;
const validFile = await createTestFile (
path . join ( testRootDir , 'README.md' ) ,
'# Project README' ,
2025-06-03 21:40:46 -07:00
) ;
2025-07-22 17:18:57 -07:00
const gitIgnoredFile = await createTestFile (
path . join ( testRootDir , '.env' ) ,
'SECRET=123' ,
) ;
const query = ` @ ${ validFile } @ ${ gitIgnoredFile } ` ;
2025-06-03 21:40:46 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 202 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` @ ${ getRelativePath ( validFile ) } @ ${ gitIgnoredFile } ` } ,
2025-07-22 17:18:57 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( validFile ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : '# Project README' } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
2025-06-03 21:40:46 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ gitIgnoredFile } is git-ignored and will be skipped. ` ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-07-22 17:18:57 -07:00
` Ignored 1 files: \ nGit-ignored: ${ gitIgnoredFile } ` ,
2025-06-03 21:40:46 -07:00
) ;
} ) ;
it ( 'should always ignore .git directory files' , async ( ) = > {
2025-07-22 17:18:57 -07:00
const gitFile = await createTestFile (
path . join ( testRootDir , '.git' , 'config' ) ,
'[core]\n\trepositoryformatversion = 0\n' ,
2025-07-20 00:55:33 -07:00
) ;
2025-07-22 17:18:57 -07:00
const query = ` @ ${ gitFile } ` ;
2025-06-03 21:40:46 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 203 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [ { text : query } ] ,
} ) ;
2025-06-03 21:40:46 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ gitFile } is git-ignored and will be skipped. ` ,
) ;
2025-07-20 00:55:33 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-07-22 17:18:57 -07:00
` Ignored 1 files: \ nGit-ignored: ${ gitFile } ` ,
2025-07-20 00:55:33 -07:00
) ;
2025-06-03 21:40:46 -07:00
} ) ;
} ) ;
2025-06-23 23:48:26 -07:00
describe ( 'when recursive file search is disabled' , ( ) = > {
beforeEach ( ( ) = > {
vi . mocked ( mockConfig . getEnableRecursiveFileSearch ) . mockReturnValue ( false ) ;
} ) ;
it ( 'should not use glob search for a nonexistent file' , async ( ) = > {
const invalidFile = 'nonexistent.txt' ;
const query = ` @ ${ invalidFile } ` ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 300 ,
signal : abortController.signal ,
} ) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Glob tool not found. Path ${ invalidFile } will be skipped. ` ,
) ;
expect ( result . processedQuery ) . toEqual ( [ { text : query } ] ) ;
2025-12-13 06:31:12 +05:30
expect ( result . processedQuery ) . not . toBeNull ( ) ;
expect ( result . error ) . toBeUndefined ( ) ;
2025-06-23 23:48:26 -07:00
} ) ;
} ) ;
2025-07-20 00:55:33 -07:00
describe ( 'gemini-ignore filtering' , ( ) = > {
it ( 'should skip gemini-ignored files in @ commands' , async ( ) = > {
2025-07-22 17:18:57 -07:00
await createTestFile (
2026-01-27 17:19:13 -08:00
path . join ( testRootDir , GEMINI_IGNORE_FILE_NAME ) ,
2025-07-22 17:18:57 -07:00
'build/output.js' ,
) ;
const geminiIgnoredFile = await createTestFile (
path . join ( testRootDir , 'build' , 'output.js' ) ,
'console.log("Hello");' ,
2025-07-20 00:55:33 -07:00
) ;
2025-07-22 17:18:57 -07:00
const query = ` @ ${ geminiIgnoredFile } ` ;
2025-07-20 00:55:33 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 204 ,
signal : abortController.signal ,
} ) ;
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [ { text : query } ] ,
} ) ;
2025-07-20 00:55:33 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ geminiIgnoredFile } is gemini-ignored and will be skipped. ` ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-07-22 17:18:57 -07:00
` Ignored 1 files: \ nGemini-ignored: ${ geminiIgnoredFile } ` ,
2025-07-20 00:55:33 -07:00
) ;
} ) ;
2025-07-22 17:18:57 -07:00
} ) ;
it ( 'should process non-ignored files when .geminiignore is present' , async ( ) = > {
await createTestFile (
2026-01-27 17:19:13 -08:00
path . join ( testRootDir , GEMINI_IGNORE_FILE_NAME ) ,
2025-07-22 17:18:57 -07:00
'build/output.js' ,
) ;
const validFile = await createTestFile (
path . join ( testRootDir , 'src' , 'index.ts' ) ,
'console.log("Hello world");' ,
) ;
const query = ` @ ${ validFile } ` ;
2025-07-20 00:55:33 -07:00
2025-07-22 17:18:57 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 205 ,
signal : abortController.signal ,
} ) ;
2025-07-20 00:55:33 -07:00
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` @ ${ getRelativePath ( validFile ) } ` } ,
2025-07-20 00:55:33 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( validFile ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : 'console.log("Hello world");' } ,
2025-07-20 00:55:33 -07:00
{ text : '\n--- End of content ---' } ,
2025-07-22 17:18:57 -07:00
] ,
2025-07-20 00:55:33 -07:00
} ) ;
2025-07-22 17:18:57 -07:00
} ) ;
2025-07-20 00:55:33 -07:00
2025-07-22 17:18:57 -07:00
it ( 'should handle mixed gemini-ignored and valid files' , async ( ) = > {
await createTestFile (
2026-01-27 17:19:13 -08:00
path . join ( testRootDir , GEMINI_IGNORE_FILE_NAME ) ,
2025-07-22 17:18:57 -07:00
'dist/bundle.js' ,
) ;
const validFile = await createTestFile (
path . join ( testRootDir , 'src' , 'main.ts' ) ,
'// Main application entry' ,
) ;
const geminiIgnoredFile = await createTestFile (
path . join ( testRootDir , 'dist' , 'bundle.js' ) ,
'console.log("bundle");' ,
) ;
const query = ` @ ${ validFile } @ ${ geminiIgnoredFile } ` ;
2025-07-20 00:55:33 -07:00
2025-07-22 17:18:57 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 206 ,
signal : abortController.signal ,
} ) ;
2025-07-20 00:55:33 -07:00
2025-07-22 17:18:57 -07:00
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` @ ${ getRelativePath ( validFile ) } @ ${ geminiIgnoredFile } ` } ,
2025-07-20 00:55:33 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( validFile ) } : \ n ` } ,
2025-07-22 17:18:57 -07:00
{ text : '// Main application entry' } ,
2025-07-20 00:55:33 -07:00
{ text : '\n--- End of content ---' } ,
2025-07-22 17:18:57 -07:00
] ,
2025-07-20 00:55:33 -07:00
} ) ;
2025-07-22 17:18:57 -07:00
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ geminiIgnoredFile } is gemini-ignored and will be skipped. ` ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Ignored 1 files: \ nGemini-ignored: ${ geminiIgnoredFile } ` ,
) ;
2025-07-20 00:55:33 -07:00
} ) ;
2025-08-04 10:49:15 -07:00
describe ( 'punctuation termination in @ commands' , ( ) = > {
const punctuationTestCases = [
{
name : 'comma' ,
fileName : 'test.txt' ,
fileContent : 'File content here' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Look at @ ${ getRelativePath ( filePath ) } , then explain it. ` ,
2025-08-04 10:49:15 -07:00
messageId : 400 ,
} ,
{
name : 'period' ,
fileName : 'readme.md' ,
fileContent : 'File content here' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Check @ ${ getRelativePath ( filePath ) } . What does it say? ` ,
2025-08-04 10:49:15 -07:00
messageId : 401 ,
} ,
{
name : 'semicolon' ,
fileName : 'example.js' ,
fileContent : 'Code example' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Review @ ${ getRelativePath ( filePath ) } ; check for bugs. ` ,
2025-08-04 10:49:15 -07:00
messageId : 402 ,
} ,
{
name : 'exclamation mark' ,
fileName : 'important.txt' ,
fileContent : 'Important content' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Look at @ ${ getRelativePath ( filePath ) } ! This is critical. ` ,
2025-08-04 10:49:15 -07:00
messageId : 403 ,
} ,
{
name : 'question mark' ,
fileName : 'config.json' ,
fileContent : 'Config settings' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` What is in @ ${ getRelativePath ( filePath ) } ? Please explain. ` ,
2025-08-04 10:49:15 -07:00
messageId : 404 ,
} ,
{
name : 'opening parenthesis' ,
fileName : 'func.ts' ,
fileContent : 'Function definition' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Analyze @ ${ getRelativePath ( filePath ) } (the main function). ` ,
2025-08-04 10:49:15 -07:00
messageId : 405 ,
} ,
{
name : 'closing parenthesis' ,
fileName : 'data.json' ,
fileContent : 'Test data' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Use data from @ ${ getRelativePath ( filePath ) } ) for testing. ` ,
2025-08-04 10:49:15 -07:00
messageId : 406 ,
} ,
{
name : 'opening square bracket' ,
fileName : 'array.js' ,
fileContent : 'Array data' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Check @ ${ getRelativePath ( filePath ) } [0] for the first element. ` ,
2025-08-04 10:49:15 -07:00
messageId : 407 ,
} ,
{
name : 'closing square bracket' ,
fileName : 'list.md' ,
fileContent : 'List content' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Review item @ ${ getRelativePath ( filePath ) } ] from the list. ` ,
2025-08-04 10:49:15 -07:00
messageId : 408 ,
} ,
{
name : 'opening curly brace' ,
fileName : 'object.ts' ,
fileContent : 'Object definition' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Parse @ ${ getRelativePath ( filePath ) } {prop1: value1}. ` ,
2025-08-04 10:49:15 -07:00
messageId : 409 ,
} ,
{
name : 'closing curly brace' ,
fileName : 'config.yaml' ,
fileContent : 'Configuration' ,
queryTemplate : ( filePath : string ) = >
2025-10-29 10:13:04 +08:00
` Use settings from @ ${ getRelativePath ( filePath ) } } for deployment. ` ,
2025-08-04 10:49:15 -07:00
messageId : 410 ,
} ,
] ;
it . each ( punctuationTestCases ) (
'should terminate @path at $name' ,
async ( { fileName , fileContent , queryTemplate , messageId } ) = > {
const filePath = await createTestFile (
path . join ( testRootDir , fileName ) ,
fileContent ,
) ;
const query = queryTemplate ( filePath ) ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
{ text : query } ,
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ,
) ;
it ( 'should handle multiple @paths terminated by different punctuation' , async ( ) = > {
const content1 = 'First file' ;
const file1Path = await createTestFile (
path . join ( testRootDir , 'first.txt' ) ,
content1 ,
) ;
const content2 = 'Second file' ;
const file2Path = await createTestFile (
path . join ( testRootDir , 'second.txt' ) ,
content2 ,
) ;
const query = ` Compare @ ${ file1Path } , @ ${ file2Path } ; what's different? ` ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 411 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` Compare @ ${ getRelativePath ( file1Path ) } , @ ${ getRelativePath ( file2Path ) } ; what's different? ` ,
} ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file1Path ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : content1 } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( file2Path ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : content2 } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should still handle escaped spaces in paths before punctuation' , async ( ) = > {
const fileContent = 'Spaced file content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'spaced file.txt' ) ,
fileContent ,
) ;
const escapedPath = path . join ( testRootDir , 'spaced\\ file.txt' ) ;
const query = ` Check @ ${ escapedPath } , it has spaces. ` ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 412 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` Check @ ${ getRelativePath ( filePath ) } , it has spaces. ` } ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should not break file paths with periods in extensions' , async ( ) = > {
const fileContent = 'TypeScript content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'example.d.ts' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Analyze @ ${ getRelativePath ( filePath ) } for type definitions. ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 413 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` Analyze @ ${ getRelativePath ( filePath ) } for type definitions. ` ,
} ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should handle file paths ending with period followed by space' , async ( ) = > {
const fileContent = 'Config content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'config.json' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Check @ ${ getRelativePath ( filePath ) } . This file contains settings. ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 414 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` Check @ ${ getRelativePath ( filePath ) } . This file contains settings. ` ,
} ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should handle comma termination with complex file paths' , async ( ) = > {
const fileContent = 'Package info' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'package.json' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Review @ ${ getRelativePath ( filePath ) } , then check dependencies. ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 415 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` Review @ ${ getRelativePath ( filePath ) } , then check dependencies. ` ,
} ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
2025-12-13 06:31:12 +05:30
it ( 'should correctly handle file paths with multiple periods' , async ( ) = > {
2025-08-04 10:49:15 -07:00
const fileContent = 'Version info' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'version.1.2.3.txt' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Check @ ${ getRelativePath ( filePath ) } contains version information. ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 416 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{
text : ` Check @ ${ getRelativePath ( filePath ) } contains version information. ` ,
} ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should handle end of string termination for period and comma' , async ( ) = > {
const fileContent = 'End file content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'end.txt' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Show me @ ${ getRelativePath ( filePath ) } . ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 417 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` Show me @ ${ getRelativePath ( filePath ) } . ` } ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should handle files with special characters in names' , async ( ) = > {
const fileContent = 'File with special chars content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'file$with&special#chars.txt' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Check @ ${ getRelativePath ( filePath ) } for content. ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 418 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` Check @ ${ getRelativePath ( filePath ) } for content. ` } ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
it ( 'should handle basic file names without special characters' , async ( ) = > {
const fileContent = 'Basic file content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'basicfile.txt' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` Check @ ${ getRelativePath ( filePath ) } please. ` ;
2025-08-04 10:49:15 -07:00
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 421 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
2025-10-29 10:13:04 +08:00
{ text : ` Check @ ${ getRelativePath ( filePath ) } please. ` } ,
2025-08-04 10:49:15 -07:00
{ text : '\n--- Content from referenced files ---' } ,
2025-10-29 10:13:04 +08:00
{ text : ` \ nContent from @ ${ getRelativePath ( filePath ) } : \ n ` } ,
2025-08-04 10:49:15 -07:00
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
} ) ;
} ) ;
2025-08-20 15:51:31 -04:00
2025-10-29 10:13:04 +08:00
describe ( 'absolute path handling' , ( ) = > {
it ( 'should handle absolute file paths correctly' , async ( ) = > {
const fileContent = 'console.log("This is an absolute path test");' ;
const relativePath = path . join ( 'src' , 'absolute-test.ts' ) ;
const absolutePath = await createTestFile (
path . join ( testRootDir , relativePath ) ,
fileContent ,
) ;
const query = ` Check @ ${ absolutePath } please. ` ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 500 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [
{ text : ` Check @ ${ relativePath } please. ` } ,
{ text : '\n--- Content from referenced files ---' } ,
{ text : ` \ nContent from @ ${ relativePath } : \ n ` } ,
{ text : fileContent } ,
{ text : '\n--- End of content ---' } ,
] ,
} ) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
expect . stringContaining ( ` using relative path: ${ relativePath } ` ) ,
) ;
} ) ;
it ( 'should handle absolute directory paths correctly' , async ( ) = > {
const fileContent =
'export default function test() { return "absolute dir test"; }' ;
2025-10-29 14:11:39 -07:00
const subDirPath = path . join ( 'src' , 'utils' ) ;
2025-10-29 10:13:04 +08:00
const fileName = 'helper.ts' ;
await createTestFile (
path . join ( testRootDir , subDirPath , fileName ) ,
fileContent ,
) ;
const absoluteDirPath = path . join ( testRootDir , subDirPath ) ;
const query = ` Check @ ${ absoluteDirPath } please. ` ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 501 ,
signal : abortController.signal ,
} ) ;
2025-12-13 06:31:12 +05:30
expect ( result . processedQuery ) . not . toBeNull ( ) ;
expect ( result . error ) . toBeUndefined ( ) ;
2025-10-29 10:13:04 +08:00
expect ( result . processedQuery ) . toEqual (
expect . arrayContaining ( [
2025-10-29 14:11:39 -07:00
{ text : ` Check @ ${ path . join ( subDirPath , '**' ) } please. ` } ,
2025-10-29 10:13:04 +08:00
expect . objectContaining ( {
text : '\n--- Content from referenced files ---' ,
} ) ,
] ) ,
) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
2025-10-29 14:11:39 -07:00
expect . stringContaining ( ` using glob: ${ path . join ( subDirPath , '**' ) } ` ) ,
2025-10-29 10:13:04 +08:00
) ;
} ) ;
it ( 'should skip absolute paths outside workspace' , async ( ) = > {
const outsidePath = '/tmp/outside-workspace.txt' ;
const query = ` Check @ ${ outsidePath } please. ` ;
const mockWorkspaceContext = {
isPathWithinWorkspace : vi.fn ( ( path : string ) = >
path . startsWith ( testRootDir ) ,
) ,
getDirectories : ( ) = > [ testRootDir ] ,
addDirectory : vi.fn ( ) ,
getInitialDirectories : ( ) = > [ testRootDir ] ,
setDirectories : vi.fn ( ) ,
onDirectoriesChanged : vi.fn ( ( ) = > ( ) = > { } ) ,
} as unknown as ReturnType < typeof mockConfig.getWorkspaceContext > ;
mockConfig . getWorkspaceContext = ( ) = > mockWorkspaceContext ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 502 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : [ { text : ` Check @ ${ outsidePath } please. ` } ] ,
} ) ;
expect ( mockOnDebugMessage ) . toHaveBeenCalledWith (
` Path ${ outsidePath } is not in the workspace and will be skipped. ` ,
) ;
} ) ;
} ) ;
2025-08-20 15:51:31 -04:00
it ( "should not add the user's turn to history, as that is the caller's responsibility" , async ( ) = > {
// Arrange
const fileContent = 'This is the file content.' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'path' , 'to' , 'another-file.txt' ) ,
fileContent ,
) ;
2025-10-29 10:13:04 +08:00
const query = ` A query with @ ${ getRelativePath ( filePath ) } ` ;
2025-08-20 15:51:31 -04:00
// Act
await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 999 ,
signal : abortController.signal ,
} ) ;
// Assert
// It SHOULD be called for the tool_group
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( { type : 'tool_group' } ) ,
999 ,
) ;
// It should NOT have been called for the user turn
const userTurnCalls = mockAddItem . mock . calls . filter (
( call ) = > call [ 0 ] . type === 'user' ,
) ;
expect ( userTurnCalls ) . toHaveLength ( 0 ) ;
} ) ;
2025-12-09 03:43:12 +01:00
describe ( 'MCP resource attachments' , ( ) = > {
it ( 'attaches MCP resource content when @serverName:uri matches registry' , async ( ) = > {
const serverName = 'server-1' ;
const resourceUri = 'resource://server-1/logs' ;
const prefixedUri = ` ${ serverName } : ${ resourceUri } ` ;
const resource = {
serverName ,
uri : resourceUri ,
name : 'logs' ,
discoveredAt : Date.now ( ) ,
} as DiscoveredMCPResource ;
vi . spyOn ( mockConfig , 'getResourceRegistry' ) . mockReturnValue ( {
findResourceByUri : ( identifier : string ) = >
identifier === prefixedUri ? resource : undefined ,
getAllResources : ( ) = > [ ] ,
} as never ) ;
const readResource = vi . fn ( ) . mockResolvedValue ( {
contents : [ { text : 'mcp resource body' } ] ,
} ) ;
vi . spyOn ( mockConfig , 'getMcpClientManager' ) . mockReturnValue ( {
getClient : ( ) = > ( { readResource } ) ,
} as never ) ;
const result = await handleAtCommand ( {
query : ` @ ${ prefixedUri } ` ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 42 ,
signal : abortController.signal ,
} ) ;
expect ( readResource ) . toHaveBeenCalledWith ( resourceUri ) ;
const processedParts = Array . isArray ( result . processedQuery )
? result . processedQuery
: [ ] ;
const containsResourceText = processedParts . some ( ( part ) = > {
const text = typeof part === 'string' ? part : part?.text ;
return typeof text === 'string' && text . includes ( 'mcp resource body' ) ;
} ) ;
expect ( containsResourceText ) . toBe ( true ) ;
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( { type : 'tool_group' } ) ,
expect . any ( Number ) ,
) ;
} ) ;
it ( 'returns an error if MCP client is unavailable' , async ( ) = > {
const serverName = 'server-1' ;
const resourceUri = 'resource://server-1/logs' ;
const prefixedUri = ` ${ serverName } : ${ resourceUri } ` ;
vi . spyOn ( mockConfig , 'getResourceRegistry' ) . mockReturnValue ( {
findResourceByUri : ( identifier : string ) = >
identifier === prefixedUri
? ( {
serverName ,
uri : resourceUri ,
discoveredAt : Date.now ( ) ,
} as DiscoveredMCPResource )
: undefined ,
getAllResources : ( ) = > [ ] ,
} as never ) ;
vi . spyOn ( mockConfig , 'getMcpClientManager' ) . mockReturnValue ( {
getClient : ( ) = > undefined ,
} as never ) ;
const result = await handleAtCommand ( {
query : ` @ ${ prefixedUri } ` ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 42 ,
signal : abortController.signal ,
} ) ;
2025-12-13 06:31:12 +05:30
expect ( result . processedQuery ) . toBeNull ( ) ;
expect ( result . error ) . toBeDefined ( ) ;
2025-12-09 03:43:12 +01:00
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'tool_group' ,
tools : expect.arrayContaining ( [
expect . objectContaining ( {
resultDisplay : expect.stringContaining (
"MCP client for server 'server-1' is not available or not connected." ,
) ,
} ) ,
] ) ,
} ) ,
expect . any ( Number ) ,
) ;
} ) ;
} ) ;
2025-12-13 06:31:12 +05:30
it ( 'should return error if the read_many_files tool is cancelled by user' , async ( ) = > {
const fileContent = 'Some content' ;
const filePath = await createTestFile (
path . join ( testRootDir , 'file.txt' ) ,
fileContent ,
) ;
const query = ` @ ${ filePath } ` ;
// Simulate user cancellation
const mockToolInstance = {
buildAndExecute : vi
. fn ( )
. mockRejectedValue ( new Error ( 'User cancelled operation' ) ) ,
displayName : 'Read Many Files' ,
build : vi.fn ( ( ) = > ( {
execute : mockToolInstance.buildAndExecute ,
getDescription : vi.fn ( ( ) = > 'Mocked tool description' ) ,
} ) ) ,
} ;
const viSpy = vi . spyOn ( core , 'ReadManyFilesTool' ) ;
viSpy . mockImplementation (
( ) = > mockToolInstance as unknown as core . ReadManyFilesTool ,
) ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 134 ,
signal : abortController.signal ,
} ) ;
expect ( result ) . toEqual ( {
processedQuery : null ,
error : ` Exiting due to an error processing the @ command: Error reading files (file.txt): User cancelled operation ` ,
} ) ;
expect ( mockAddItem ) . toHaveBeenCalledWith (
expect . objectContaining ( {
type : 'tool_group' ,
tools : [ expect . objectContaining ( { status : ToolCallStatus.Error } ) ] ,
} ) ,
134 ,
) ;
} ) ;
2026-01-29 18:24:35 +00:00
it ( 'should include agent nudge when agents are found' , async ( ) = > {
const agentName = 'my-agent' ;
const otherAgent = 'other-agent' ;
// Mock getAgentRegistry on the config
mockConfig . getAgentRegistry = vi . fn ( ) . mockReturnValue ( {
getDefinition : ( name : string ) = >
name === agentName || name === otherAgent ? { name } : undefined ,
} ) ;
const query = ` @ ${ agentName } @ ${ otherAgent } ` ;
const result = await handleAtCommand ( {
query ,
config : mockConfig ,
addItem : mockAddItem ,
onDebugMessage : mockOnDebugMessage ,
messageId : 600 ,
signal : abortController.signal ,
} ) ;
const expectedNudge = ` \ n<system_note> \ nThe user has explicitly selected the following agent(s): ${ agentName } , ${ otherAgent } . Please use the following tool(s) to delegate the task: ' ${ agentName } ', ' ${ otherAgent } '. \ n</system_note> \ n ` ;
expect ( result . processedQuery ) . toContainEqual (
expect . objectContaining ( { text : expectedNudge } ) ,
) ;
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;