Просмотр исходного кода

Allow users to set custom system prompts

Matt Rubens 10 месяцев назад
Родитель
Сommit
1f0211ee64

+ 0 - 1
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) => {

+ 30 - 0
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)
+	}
+}

+ 172 - 0
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<typeof fs>
+
+// 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")
+	})
+})

+ 60 - 0
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<string> {
+	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<string> {
+	const filePath = getSystemPromptFilePath(cwd, mode)
+	return safeReadFile(filePath)
+}
+
+/**
+ * Ensures the .roo directory exists, creating it if necessary
+ */
+export async function ensureRooDirectory(cwd: string): Promise<void> {
+	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
+		}
+	}
+}

+ 15 - 0
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
 

+ 40 - 0
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<SupportPromptType>("ENHANCE")
+	const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false)
 
 	// Direct update functions
 	const updateAgentPrompt = useCallback(
@@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 							<span className="codicon codicon-copy"></span>
 						</VSCodeButton>
 					</div>
+
+					{/* Custom System Prompt Disclosure */}
+					<div className="mb-3 mt-12">
+						<button
+							onClick={() => setIsSystemPromptDisclosureOpen(!isSystemPromptDisclosureOpen)}
+							className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
+							aria-expanded={isSystemPromptDisclosureOpen}>
+							<span
+								className={`codicon codicon-${isSystemPromptDisclosureOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
+							<span>Advanced: Override System Prompt</span>
+						</button>
+
+						{isSystemPromptDisclosureOpen && (
+							<div className="text-xs text-vscode-descriptionForeground mt-2 ml-5">
+								You can completely replace the system prompt for this mode (aside from the role
+								definition and custom instructions) by creating a file at{" "}
+								<span
+									className="text-vscode-textLink-foreground cursor-pointer underline"
+									onClick={() => {
+										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"}
+								</span>{" "}
+								in your workspace. This is a very advanced feature that bypasses built-in safeguards and
+								consistency checks (especially around tool usage), so be careful!
+							</div>
+						)}
+					</div>
 				</div>
 
 				<div