Skip to content

Commit b8dc315

Browse files
roomote[bot]roomotedaniel-lxs
authored
feat: add symlink support for AGENTS.md file loading (#6326)
* feat: add symlink support for AGENTS.md file loading - Add safeReadFileFollowingSymlinks function to handle symlink resolution - Update loadAgentRulesFile to use the new symlink-aware function - Add comprehensive tests for both symlink and regular file scenarios - Ensures AGENTS.md can be a symlink pointing to actual rules file * refactor: use existing symlink resolution pattern for AGENTS.md - Extracted resolveSymlinkPath function to handle symlink resolution - Removed duplicate safeReadFileFollowingSymlinks function - Updated loadAgentRulesFile to use resolveSymlinkPath + safeReadFile - Updated tests to match new implementation - Maintains same functionality while reusing existing patterns * fix: simplify symlink resolution for AGENTS.md to fix Windows compatibility - Remove duplicate resolveSymlinkPath function as suggested by @mrubens - Use simpler inline symlink resolution in loadAgentRulesFile - Update tests to match simplified implementation - This should fix the failing Windows unit tests while maintaining functionality * refactor: use existing resolveSymLink function for AGENTS.md symlink support - Remove duplicate inline symlink resolution logic - Reuse existing resolveSymLink function with MAX_DEPTH protection - Adapt loadAgentRulesFile to work with resolveSymLink's fileInfo interface - Fix test to properly mock fs.stat for resolved symlink targets - All tests pass (36/36) --------- Co-authored-by: Roo Code <[email protected]> Co-authored-by: Daniel Riccio <[email protected]>
1 parent ded0180 commit b8dc315

File tree

2 files changed

+171
-1
lines changed

2 files changed

+171
-1
lines changed

src/core/prompts/sections/__tests__/custom-instructions.spec.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ const readFileMock = vi.fn()
5454
const statMock = vi.fn()
5555
const readdirMock = vi.fn()
5656
const readlinkMock = vi.fn()
57+
const lstatMock = vi.fn()
5758

5859
// Replace fs functions with our mocks
5960
fs.readFile = readFileMock as any
6061
fs.stat = statMock as any
6162
fs.readdir = readdirMock as any
6263
fs.readlink = readlinkMock as any
64+
fs.lstat = lstatMock as any
6365

6466
// Mock process.cwd
6567
const 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,118 @@ 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 stat to indicate the resolved target is a file
691+
statMock.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({
696+
isFile: vi.fn().mockReturnValue(true),
697+
})
698+
}
699+
return Promise.reject({ code: "ENOENT" })
700+
})
701+
702+
// Mock readFile to return content from the resolved path
703+
readFileMock.mockImplementation((filePath: PathLike) => {
704+
const pathStr = filePath.toString()
705+
const normalizedPath = pathStr.replace(/\\/g, "/")
706+
if (normalizedPath.endsWith("actual-agents-file.md")) {
707+
return Promise.resolve("Agent rules from symlinked file")
708+
}
709+
return Promise.reject({ code: "ENOENT" })
710+
})
711+
712+
const result = await addCustomInstructions(
713+
"mode instructions",
714+
"global instructions",
715+
"/fake/path",
716+
"test-mode",
717+
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
718+
)
719+
720+
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
721+
expect(result).toContain("Agent rules from symlinked file")
722+
723+
// Verify lstat was called to check if it's a symlink
724+
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
725+
726+
// Verify readlink was called to resolve the symlink
727+
expect(readlinkMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
728+
729+
// Verify the resolved path was read
730+
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("actual-agents-file.md"), "utf-8")
731+
})
732+
733+
it("should handle AGENTS.md as a regular file when not a symlink", async () => {
734+
// Simulate no .roo/rules-test-mode directory
735+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
736+
737+
// Mock lstat to indicate AGENTS.md is NOT a symlink
738+
lstatMock.mockImplementation((filePath: PathLike) => {
739+
const pathStr = filePath.toString()
740+
if (pathStr.endsWith("AGENTS.md")) {
741+
return Promise.resolve({
742+
isSymbolicLink: vi.fn().mockReturnValue(false),
743+
})
744+
}
745+
return Promise.reject({ code: "ENOENT" })
746+
})
747+
748+
// Mock readFile to return content directly from AGENTS.md
749+
readFileMock.mockImplementation((filePath: PathLike) => {
750+
const pathStr = filePath.toString()
751+
if (pathStr.endsWith("AGENTS.md")) {
752+
return Promise.resolve("Agent rules from regular file")
753+
}
754+
return Promise.reject({ code: "ENOENT" })
755+
})
756+
757+
const result = await addCustomInstructions(
758+
"mode instructions",
759+
"global instructions",
760+
"/fake/path",
761+
"test-mode",
762+
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
763+
)
764+
765+
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
766+
expect(result).toContain("Agent rules from regular file")
767+
768+
// Verify lstat was called
769+
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
770+
771+
// Verify readlink was NOT called since it's not a symlink
772+
expect(readlinkMock).not.toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
773+
774+
// Verify the file was read directly
775+
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"), "utf-8")
776+
})
777+
631778
it("should return empty string when no instructions provided", async () => {
632779
// Simulate no .roo/rules directory
633780
statMock.mockRejectedValueOnce({ code: "ENOENT" })

src/core/prompts/sections/custom-instructions.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,30 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
222222
async function loadAgentRulesFile(cwd: string): Promise<string> {
223223
try {
224224
const agentsPath = path.join(cwd, "AGENTS.md")
225-
const content = await safeReadFile(agentsPath)
225+
let resolvedPath = agentsPath
226+
227+
// Check if AGENTS.md exists and handle symlinks
228+
try {
229+
const stats = await fs.lstat(agentsPath)
230+
if (stats.isSymbolicLink()) {
231+
// Create a temporary fileInfo array to use with resolveSymLink
232+
const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = []
233+
234+
// Use the existing resolveSymLink function to handle symlink resolution
235+
await resolveSymLink(agentsPath, fileInfo, 0)
236+
237+
// Extract the resolved path from fileInfo
238+
if (fileInfo.length > 0) {
239+
resolvedPath = fileInfo[0].resolvedPath
240+
}
241+
}
242+
} catch (err) {
243+
// If lstat fails (file doesn't exist), return empty
244+
return ""
245+
}
246+
247+
// Read the content from the resolved path
248+
const content = await safeReadFile(resolvedPath)
226249
if (content) {
227250
return `# Agent Rules Standard (AGENTS.md):\n${content}`
228251
}

0 commit comments

Comments
 (0)