diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 01574406b2..d015748315 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -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" }) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index ccd36b2662..22fb6122ec 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -222,7 +222,30 @@ export async function loadRuleFiles(cwd: string): Promise { async function loadAgentRulesFile(cwd: string): Promise { 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}` }