@@ -54,12 +54,14 @@ const readFileMock = vi.fn()
5454const statMock = vi . fn ( )
5555const readdirMock = vi . fn ( )
5656const readlinkMock = vi . fn ( )
57+ const lstatMock = vi . fn ( )
5758
5859// Replace fs functions with our mocks
5960fs . readFile = readFileMock as any
6061fs . stat = statMock as any
6162fs . readdir = readdirMock as any
6263fs . readlink = readlinkMock as any
64+ fs . lstat = lstatMock as any
6365
6466// Mock process.cwd
6567const originalCwd = process . cwd
@@ -509,6 +511,17 @@ describe("addCustomInstructions", () => {
509511 // Simulate no .roo/rules-test-mode directory
510512 statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
511513
514+ // Mock lstat to indicate AGENTS.md is NOT a symlink
515+ lstatMock . mockImplementation ( ( filePath : PathLike ) => {
516+ const pathStr = filePath . toString ( )
517+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
518+ return Promise . resolve ( {
519+ isSymbolicLink : vi . fn ( ) . mockReturnValue ( false ) ,
520+ } )
521+ }
522+ return Promise . reject ( { code : "ENOENT" } )
523+ } )
524+
512525 readFileMock . mockImplementation ( ( filePath : PathLike ) => {
513526 const pathStr = filePath . toString ( )
514527 if ( pathStr . endsWith ( "AGENTS.md" ) ) {
@@ -558,6 +571,17 @@ describe("addCustomInstructions", () => {
558571 // Simulate no .roo/rules-test-mode directory
559572 statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
560573
574+ // Mock lstat to indicate AGENTS.md is NOT a symlink
575+ lstatMock . mockImplementation ( ( filePath : PathLike ) => {
576+ const pathStr = filePath . toString ( )
577+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
578+ return Promise . resolve ( {
579+ isSymbolicLink : vi . fn ( ) . mockReturnValue ( false ) ,
580+ } )
581+ }
582+ return Promise . reject ( { code : "ENOENT" } )
583+ } )
584+
561585 readFileMock . mockImplementation ( ( filePath : PathLike ) => {
562586 const pathStr = filePath . toString ( )
563587 if ( pathStr . endsWith ( "AGENTS.md" ) ) {
@@ -602,6 +626,17 @@ describe("addCustomInstructions", () => {
602626 // Simulate no .roo/rules-test-mode directory
603627 statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
604628
629+ // Mock lstat to indicate AGENTS.md is NOT a symlink
630+ lstatMock . mockImplementation ( ( filePath : PathLike ) => {
631+ const pathStr = filePath . toString ( )
632+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
633+ return Promise . resolve ( {
634+ isSymbolicLink : vi . fn ( ) . mockReturnValue ( false ) ,
635+ } )
636+ }
637+ return Promise . reject ( { code : "ENOENT" } )
638+ } )
639+
605640 readFileMock . mockImplementation ( ( filePath : PathLike ) => {
606641 const pathStr = filePath . toString ( )
607642 if ( pathStr . endsWith ( "AGENTS.md" ) ) {
@@ -628,6 +663,106 @@ describe("addCustomInstructions", () => {
628663 expect ( result ) . toContain ( "Roo rules content" )
629664 } )
630665
666+ it ( "should follow symlinks when loading AGENTS.md" , async ( ) => {
667+ // Simulate no .roo/rules-test-mode directory
668+ statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
669+
670+ // Mock lstat to indicate AGENTS.md is a symlink
671+ lstatMock . mockImplementation ( ( filePath : PathLike ) => {
672+ const pathStr = filePath . toString ( )
673+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
674+ return Promise . resolve ( {
675+ isSymbolicLink : vi . fn ( ) . mockReturnValue ( true ) ,
676+ } )
677+ }
678+ return Promise . reject ( { code : "ENOENT" } )
679+ } )
680+
681+ // Mock readlink to return the symlink target
682+ readlinkMock . mockImplementation ( ( filePath : PathLike ) => {
683+ const pathStr = filePath . toString ( )
684+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
685+ return Promise . resolve ( "../actual-agents-file.md" )
686+ }
687+ return Promise . reject ( { code : "ENOENT" } )
688+ } )
689+
690+ // Mock readFile to return content from the resolved path
691+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
692+ const pathStr = filePath . toString ( )
693+ const normalizedPath = pathStr . replace ( / \\ / g, "/" )
694+ if ( normalizedPath . endsWith ( "actual-agents-file.md" ) ) {
695+ return Promise . resolve ( "Agent rules from symlinked file" )
696+ }
697+ return Promise . reject ( { code : "ENOENT" } )
698+ } )
699+
700+ const result = await addCustomInstructions (
701+ "mode instructions" ,
702+ "global instructions" ,
703+ "/fake/path" ,
704+ "test-mode" ,
705+ { settings : { maxConcurrentFileReads : 5 , todoListEnabled : true , useAgentRules : true } } ,
706+ )
707+
708+ expect ( result ) . toContain ( "# Agent Rules Standard (AGENTS.md):" )
709+ expect ( result ) . toContain ( "Agent rules from symlinked file" )
710+
711+ // Verify lstat was called to check if it's a symlink
712+ expect ( lstatMock ) . toHaveBeenCalledWith ( expect . stringContaining ( "AGENTS.md" ) )
713+
714+ // Verify readlink was called to resolve the symlink
715+ expect ( readlinkMock ) . toHaveBeenCalledWith ( expect . stringContaining ( "AGENTS.md" ) )
716+
717+ // Verify the resolved path was read
718+ expect ( readFileMock ) . toHaveBeenCalledWith ( expect . stringContaining ( "actual-agents-file.md" ) , "utf-8" )
719+ } )
720+
721+ it ( "should handle AGENTS.md as a regular file when not a symlink" , async ( ) => {
722+ // Simulate no .roo/rules-test-mode directory
723+ statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
724+
725+ // Mock lstat to indicate AGENTS.md is NOT a symlink
726+ lstatMock . mockImplementation ( ( filePath : PathLike ) => {
727+ const pathStr = filePath . toString ( )
728+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
729+ return Promise . resolve ( {
730+ isSymbolicLink : vi . fn ( ) . mockReturnValue ( false ) ,
731+ } )
732+ }
733+ return Promise . reject ( { code : "ENOENT" } )
734+ } )
735+
736+ // Mock readFile to return content directly from AGENTS.md
737+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
738+ const pathStr = filePath . toString ( )
739+ if ( pathStr . endsWith ( "AGENTS.md" ) ) {
740+ return Promise . resolve ( "Agent rules from regular file" )
741+ }
742+ return Promise . reject ( { code : "ENOENT" } )
743+ } )
744+
745+ const result = await addCustomInstructions (
746+ "mode instructions" ,
747+ "global instructions" ,
748+ "/fake/path" ,
749+ "test-mode" ,
750+ { settings : { maxConcurrentFileReads : 5 , todoListEnabled : true , useAgentRules : true } } ,
751+ )
752+
753+ expect ( result ) . toContain ( "# Agent Rules Standard (AGENTS.md):" )
754+ expect ( result ) . toContain ( "Agent rules from regular file" )
755+
756+ // Verify lstat was called
757+ expect ( lstatMock ) . toHaveBeenCalledWith ( expect . stringContaining ( "AGENTS.md" ) )
758+
759+ // Verify readlink was NOT called since it's not a symlink
760+ expect ( readlinkMock ) . not . toHaveBeenCalledWith ( expect . stringContaining ( "AGENTS.md" ) )
761+
762+ // Verify the file was read directly
763+ expect ( readFileMock ) . toHaveBeenCalledWith ( expect . stringContaining ( "AGENTS.md" ) , "utf-8" )
764+ } )
765+
631766 it ( "should return empty string when no instructions provided" , async ( ) => {
632767 // Simulate no .roo/rules directory
633768 statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
0 commit comments