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 <roomote@roocode.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
roomote[bot] 2025-07-30 18:57:15 -04:00 committed by GitHub
parent ded0180112
commit b8dc31581c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 171 additions and 1 deletions

View file

@ -54,12 +54,14 @@ const readFileMock = vi.fn()
const statMock = vi.fn()
const readdirMock = vi.fn()
const readlinkMock = vi.fn()
const lstatMock = vi.fn()
// Replace fs functions with our mocks
fs.readFile = readFileMock as any
fs.stat = statMock as any
fs.readdir = readdirMock as any
fs.readlink = readlinkMock as any
fs.lstat = lstatMock as any
// Mock process.cwd
const originalCwd = process.cwd
@ -509,6 +511,17 @@ describe("addCustomInstructions", () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate AGENTS.md is NOT a symlink
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
@ -558,6 +571,17 @@ describe("addCustomInstructions", () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate AGENTS.md is NOT a symlink
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
@ -602,6 +626,17 @@ describe("addCustomInstructions", () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate AGENTS.md is NOT a symlink
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
@ -628,6 +663,118 @@ describe("addCustomInstructions", () => {
expect(result).toContain("Roo rules content")
})
it("should follow symlinks when loading AGENTS.md", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate AGENTS.md is a symlink
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(true),
})
}
return Promise.reject({ code: "ENOENT" })
})
// Mock readlink to return the symlink target
readlinkMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("../actual-agents-file.md")
}
return Promise.reject({ code: "ENOENT" })
})
// Mock stat to indicate the resolved target is a file
statMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
const normalizedPath = pathStr.replace(/\\/g, "/")
if (normalizedPath.endsWith("actual-agents-file.md")) {
return Promise.resolve({
isFile: vi.fn().mockReturnValue(true),
})
}
return Promise.reject({ code: "ENOENT" })
})
// Mock readFile to return content from the resolved path
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
const normalizedPath = pathStr.replace(/\\/g, "/")
if (normalizedPath.endsWith("actual-agents-file.md")) {
return Promise.resolve("Agent rules from symlinked file")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
)
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Agent rules from symlinked file")
// Verify lstat was called to check if it's a symlink
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
// Verify readlink was called to resolve the symlink
expect(readlinkMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
// Verify the resolved path was read
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("actual-agents-file.md"), "utf-8")
})
it("should handle AGENTS.md as a regular file when not a symlink", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate AGENTS.md is NOT a symlink
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
// Mock readFile to return content directly from AGENTS.md
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("Agent rules from regular file")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
)
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Agent rules from regular file")
// Verify lstat was called
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
// Verify readlink was NOT called since it's not a symlink
expect(readlinkMock).not.toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
// Verify the file was read directly
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"), "utf-8")
})
it("should return empty string when no instructions provided", async () => {
// Simulate no .roo/rules directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })

View file

@ -222,7 +222,30 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
async function loadAgentRulesFile(cwd: string): Promise<string> {
try {
const agentsPath = path.join(cwd, "AGENTS.md")
const content = await safeReadFile(agentsPath)
let resolvedPath = agentsPath
// Check if AGENTS.md exists and handle symlinks
try {
const stats = await fs.lstat(agentsPath)
if (stats.isSymbolicLink()) {
// Create a temporary fileInfo array to use with resolveSymLink
const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = []
// Use the existing resolveSymLink function to handle symlink resolution
await resolveSymLink(agentsPath, fileInfo, 0)
// Extract the resolved path from fileInfo
if (fileInfo.length > 0) {
resolvedPath = fileInfo[0].resolvedPath
}
}
} catch (err) {
// If lstat fails (file doesn't exist), return empty
return ""
}
// Read the content from the resolved path
const content = await safeReadFile(resolvedPath)
if (content) {
return `# Agent Rules Standard (AGENTS.md):\n${content}`
}