diff --git a/src/__mocks__/fs/promises.ts b/src/__mocks__/fs/promises.ts index d5f076247a..e496a7fa51 100644 --- a/src/__mocks__/fs/promises.ts +++ b/src/__mocks__/fs/promises.ts @@ -140,7 +140,6 @@ const mockFs = { currentPath += "/" + parts[parts.length - 1] mockDirectories.add(currentPath) return Promise.resolve() - return Promise.resolve() }), access: jest.fn().mockImplementation(async (path: string) => { diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 6bd00e9567..836279bfe4 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({ }), }, })) + +// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts +// This is needed because the production code expects strings to have this method +// Note: In production, this is added via import in the entry point (extension.ts) +export {} + +declare global { + interface String { + toPosix(): string + } +} + +// Implementation that matches src/utils/path.ts +function toPosixPath(p: string) { + // Extended-Length Paths in Windows start with "\\?\" to allow longer paths + // and bypass usual parsing. If detected, we return the path unmodified. + const isExtendedLengthPath = p.startsWith("\\\\?\\") + + if (isExtendedLengthPath) { + return p + } + + return p.replace(/\\/g, "/") +} + +if (!String.prototype.toPosix) { + String.prototype.toPosix = function (this: string): string { + return toPosixPath(this) + } +} diff --git a/src/core/prompts/__tests__/custom-system-prompt.test.ts b/src/core/prompts/__tests__/custom-system-prompt.test.ts new file mode 100644 index 0000000000..7594c13e6d --- /dev/null +++ b/src/core/prompts/__tests__/custom-system-prompt.test.ts @@ -0,0 +1,172 @@ +import { SYSTEM_PROMPT } from "../system" +import { defaultModeSlug, modes } from "../../../shared/modes" +import * as vscode from "vscode" +import * as fs from "fs/promises" + +// Mock the fs/promises module +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), + mkdir: jest.fn().mockResolvedValue(undefined), + access: jest.fn().mockResolvedValue(undefined), +})) + +// Get the mocked fs module +const mockedFs = fs as jest.Mocked + +// Mock the fileExistsAtPath function +jest.mock("../../../utils/fs", () => ({ + fileExistsAtPath: jest.fn().mockResolvedValue(true), + createDirectoriesForFile: jest.fn().mockResolvedValue([]), +})) + +// Create a mock ExtensionContext with relative paths instead of absolute paths +const mockContext = { + extensionPath: "mock/extension/path", + globalStoragePath: "mock/storage/path", + storagePath: "mock/storage/path", + logPath: "mock/log/path", + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + setKeysForSync: () => {}, + }, + extensionUri: { fsPath: "mock/extension/path" }, + globalStorageUri: { fsPath: "mock/settings/path" }, + asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, +} as unknown as vscode.ExtensionContext + +describe("File-Based Custom System Prompt", () => { + const experiments = {} + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks() + + // Default behavior: file doesn't exist + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + }) + + it("should use default generation when no file-based system prompt is found", async () => { + const customModePrompts = { + [defaultModeSlug]: { + roleDefinition: "Test role definition", + }, + } + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + customModePrompts, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain default sections + expect(prompt).toContain("TOOL USE") + expect(prompt).toContain("CAPABILITIES") + expect(prompt).toContain("MODES") + expect(prompt).toContain("Test role definition") + }) + + it("should use file-based custom system prompt when available", async () => { + // Mock the readFile to return content from a file + const fileCustomSystemPrompt = "Custom system prompt from file" + // When called with utf-8 encoding, return a string + mockedFs.readFile.mockImplementation((filePath, options) => { + if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") { + return Promise.resolve(fileCustomSystemPrompt) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + undefined, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain role definition and file-based system prompt + expect(prompt).toContain(modes[0].roleDefinition) + expect(prompt).toContain(fileCustomSystemPrompt) + + // Should not contain any of the default sections + expect(prompt).not.toContain("TOOL USE") + expect(prompt).not.toContain("CAPABILITIES") + expect(prompt).not.toContain("MODES") + }) + + it("should combine file-based system prompt with role definition and custom instructions", async () => { + // Mock the readFile to return content from a file + const fileCustomSystemPrompt = "Custom system prompt from file" + mockedFs.readFile.mockImplementation((filePath, options) => { + if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") { + return Promise.resolve(fileCustomSystemPrompt) + } + return Promise.reject({ code: "ENOENT" }) + }) + + // Define custom role definition + const customRoleDefinition = "Custom role definition" + const customModePrompts = { + [defaultModeSlug]: { + roleDefinition: customRoleDefinition, + }, + } + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + customModePrompts, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain custom role definition and file-based system prompt + expect(prompt).toContain(customRoleDefinition) + expect(prompt).toContain(fileCustomSystemPrompt) + + // Should not contain any of the default sections + expect(prompt).not.toContain("TOOL USE") + expect(prompt).not.toContain("CAPABILITIES") + expect(prompt).not.toContain("MODES") + }) +}) diff --git a/src/core/prompts/sections/custom-system-prompt.ts b/src/core/prompts/sections/custom-system-prompt.ts new file mode 100644 index 0000000000..eca2b98b8d --- /dev/null +++ b/src/core/prompts/sections/custom-system-prompt.ts @@ -0,0 +1,60 @@ +import fs from "fs/promises" +import path from "path" +import { Mode } from "../../../shared/modes" +import { fileExistsAtPath } from "../../../utils/fs" + +/** + * Safely reads a file, returning an empty string if the file doesn't exist + */ +async function safeReadFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + // When reading with "utf-8" encoding, content should be a string + return content.trim() + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code + if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { + throw err + } + return "" + } +} + +/** + * Get the path to a system prompt file for a specific mode + */ +export function getSystemPromptFilePath(cwd: string, mode: Mode): string { + return path.join(cwd, ".roo", `system-prompt-${mode}`) +} + +/** + * Loads custom system prompt from a file at .roo/system-prompt-[mode slug] + * If the file doesn't exist, returns an empty string + */ +export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise { + const filePath = getSystemPromptFilePath(cwd, mode) + return safeReadFile(filePath) +} + +/** + * Ensures the .roo directory exists, creating it if necessary + */ +export async function ensureRooDirectory(cwd: string): Promise { + const rooDir = path.join(cwd, ".roo") + + // Check if directory already exists + if (await fileExistsAtPath(rooDir)) { + return + } + + // Create the directory + try { + await fs.mkdir(rooDir, { recursive: true }) + } catch (err) { + // If directory already exists (race condition), ignore the error + const errorCode = (err as NodeJS.ErrnoException).code + if (errorCode !== "EEXIST") { + throw err + } + } +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 91bbd07387..90791f6358 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -23,6 +23,7 @@ import { getModesSection, addCustomInstructions, } from "./sections" +import { loadSystemPromptFile } from "./sections/custom-system-prompt" import fs from "fs/promises" import path from "path" @@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async ( return undefined } + // Try to load custom system prompt from file + const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode) + // Check if it's a custom mode const promptComponent = getPromptComponent(customModePrompts?.[mode]) + // Get full mode config from custom modes or fall back to built-in modes const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0] + // If a file-based custom system prompt exists, use it + if (fileCustomSystemPrompt) { + const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition + return `${roleDefinition} + +${fileCustomSystemPrompt} + +${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}` + } + // If diff is disabled, don't pass the diffStrategy const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 061fa789de..2bfafeff5c 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -88,6 +88,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { const [showConfigMenu, setShowConfigMenu] = useState(false) const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false) const [activeSupportTab, setActiveSupportTab] = useState("ENHANCE") + const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false) // Direct update functions const updateAgentPrompt = useCallback( @@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { + + {/* Custom System Prompt Disclosure */} +
+ + + {isSystemPromptDisclosureOpen && ( +
+ You can completely replace the system prompt for this mode (aside from the role + definition and custom instructions) by creating a file at{" "} + { + const currentMode = getCurrentMode() + if (!currentMode) return + + // Open or create an empty file + vscode.postMessage({ + type: "openFile", + text: `./.roo/system-prompt-${currentMode.slug}`, + values: { + create: true, + content: "", + }, + }) + }}> + .roo/system-prompt-{getCurrentMode()?.slug || "code"} + {" "} + in your workspace. This is a very advanced feature that bypasses built-in safeguards and + consistency checks (especially around tool usage), so be careful! +
+ )} +