mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add global system prompt override support
- Users can now define global system prompts in ~/.roo/system-prompt-{mode}
- Local project prompts still take precedence over global ones
- Updated tests to cover the new functionality
- Fixes #6813
This commit is contained in:
parent
6b4ac52d00
commit
36af94e000
3 changed files with 95 additions and 22 deletions
|
|
@ -1,16 +1,19 @@
|
|||
// Mocks must come first, before imports
|
||||
|
||||
vi.mock("fs/promises")
|
||||
vi.mock("os")
|
||||
|
||||
// Then imports
|
||||
import type { Mock } from "vitest"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { readFile } from "fs/promises"
|
||||
import type { Mode } from "../../../../shared/modes" // Type-only import
|
||||
import { loadSystemPromptFile, PromptVariables } from "../custom-system-prompt"
|
||||
import { loadSystemPromptFile, PromptVariables, getGlobalSystemPromptFilePath } from "../custom-system-prompt"
|
||||
|
||||
// Cast the mocked readFile to the correct Mock type
|
||||
const mockedReadFile = readFile as Mock<typeof readFile>
|
||||
const mockedHomedir = os.homedir as Mock<typeof os.homedir>
|
||||
|
||||
describe("loadSystemPromptFile", () => {
|
||||
// Corrected PromptVariables type and added mockMode
|
||||
|
|
@ -19,15 +22,18 @@ describe("loadSystemPromptFile", () => {
|
|||
}
|
||||
const mockCwd = "/mock/cwd"
|
||||
const mockMode: Mode = "test" // Use Mode type, e.g., 'test'
|
||||
const mockHomeDir = "/home/user"
|
||||
// Corrected expected file path format
|
||||
const expectedFilePath = path.join(mockCwd, ".roo", `system-prompt-${mockMode}`)
|
||||
const expectedLocalFilePath = path.join(mockCwd, ".roo", `system-prompt-${mockMode}`)
|
||||
const expectedGlobalFilePath = path.join(mockHomeDir, ".roo", `system-prompt-${mockMode}`)
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mocks before each test
|
||||
mockedReadFile.mockClear()
|
||||
mockedHomedir.mockReturnValue(mockHomeDir)
|
||||
})
|
||||
|
||||
it("should return an empty string if the file does not exist (ENOENT)", async () => {
|
||||
it("should return an empty string if neither local nor global file exists (ENOENT)", async () => {
|
||||
const error: NodeJS.ErrnoException = new Error("File not found")
|
||||
error.code = "ENOENT"
|
||||
mockedReadFile.mockRejectedValue(error)
|
||||
|
|
@ -36,8 +42,9 @@ describe("loadSystemPromptFile", () => {
|
|||
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
|
||||
|
||||
expect(result).toBe("")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedGlobalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
// Updated test: should re-throw unexpected errors
|
||||
|
|
@ -50,18 +57,23 @@ describe("loadSystemPromptFile", () => {
|
|||
|
||||
// Verify readFile was still called correctly
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
it("should return an empty string if the file content is empty", async () => {
|
||||
mockedReadFile.mockResolvedValue("")
|
||||
it("should return an empty string if the local file content is empty and check global", async () => {
|
||||
const error: NodeJS.ErrnoException = new Error("File not found")
|
||||
error.code = "ENOENT"
|
||||
|
||||
// Local file is empty, global file doesn't exist
|
||||
mockedReadFile.mockResolvedValueOnce("").mockRejectedValueOnce(error)
|
||||
|
||||
// Added mockMode argument
|
||||
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
|
||||
|
||||
expect(result).toBe("")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockedReadFile).toHaveBeenNthCalledWith(1, expectedLocalFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenNthCalledWith(2, expectedGlobalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
// Updated test to only check workspace interpolation
|
||||
|
|
@ -74,7 +86,7 @@ describe("loadSystemPromptFile", () => {
|
|||
|
||||
expect(result).toBe("Workspace is: /path/to/workspace")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
// Updated test for multiple occurrences of workspace
|
||||
|
|
@ -87,7 +99,7 @@ describe("loadSystemPromptFile", () => {
|
|||
|
||||
expect(result).toBe("Path: /path/to/workspace//path/to/workspace")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
// Updated test for mixed used/unused
|
||||
|
|
@ -101,7 +113,7 @@ describe("loadSystemPromptFile", () => {
|
|||
// Unused variables should remain untouched
|
||||
expect(result).toBe("Workspace: /path/to/workspace, Unused: {{unusedVar}}, Another: {{another}}")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
// Test remains valid, just needs the mode argument and updated template
|
||||
|
|
@ -114,7 +126,7 @@ describe("loadSystemPromptFile", () => {
|
|||
|
||||
expect(result).toBe("Workspace: /path/to/workspace, Missing: {{missingPlaceholder}}")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
// Removed the test for extra keys as PromptVariables is simple now
|
||||
|
|
@ -129,6 +141,39 @@ describe("loadSystemPromptFile", () => {
|
|||
|
||||
expect(result).toBe("This is a static prompt.")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
it("should use global system prompt when local file doesn't exist", async () => {
|
||||
const error: NodeJS.ErrnoException = new Error("File not found")
|
||||
error.code = "ENOENT"
|
||||
const globalTemplate = "Global system prompt: {{workspace}}"
|
||||
|
||||
// First call (local) fails, second call (global) succeeds
|
||||
mockedReadFile.mockRejectedValueOnce(error).mockResolvedValueOnce(globalTemplate)
|
||||
|
||||
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
|
||||
|
||||
expect(result).toBe("Global system prompt: /path/to/workspace")
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockedReadFile).toHaveBeenNthCalledWith(1, expectedLocalFilePath, "utf-8")
|
||||
expect(mockedReadFile).toHaveBeenNthCalledWith(2, expectedGlobalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
it("should prefer local system prompt over global when both exist", async () => {
|
||||
const localTemplate = "Local system prompt: {{workspace}}"
|
||||
mockedReadFile.mockResolvedValueOnce(localTemplate)
|
||||
|
||||
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
|
||||
|
||||
expect(result).toBe("Local system prompt: /path/to/workspace")
|
||||
// Should only read the local file, not the global one
|
||||
expect(mockedReadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
|
||||
})
|
||||
|
||||
it("should correctly generate global system prompt file path", () => {
|
||||
const result = getGlobalSystemPromptFilePath(mockMode)
|
||||
expect(result).toBe(expectedGlobalFilePath)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Mode } from "../../../shared/modes"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
|
|
@ -50,15 +51,34 @@ export function getSystemPromptFilePath(cwd: string, mode: Mode): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Loads custom system prompt from a file at .roo/system-prompt-[mode slug]
|
||||
* If the file doesn't exist, returns an empty string
|
||||
* Get the path to a global system prompt file for a specific mode
|
||||
* Located in the user's home directory under .roo/system-prompt-[mode slug]
|
||||
*/
|
||||
export function getGlobalSystemPromptFilePath(mode: Mode): string {
|
||||
return path.join(os.homedir(), ".roo", `system-prompt-${mode}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads custom system prompt from a file, checking in the following order:
|
||||
* 1. Local project: .roo/system-prompt-[mode slug]
|
||||
* 2. Global (home directory): ~/.roo/system-prompt-[mode slug]
|
||||
* If neither file exists, returns an empty string
|
||||
*/
|
||||
export async function loadSystemPromptFile(cwd: string, mode: Mode, variables: PromptVariables): Promise<string> {
|
||||
const filePath = getSystemPromptFilePath(cwd, mode)
|
||||
const rawContent = await safeReadFile(filePath)
|
||||
// First, check for local project-specific system prompt
|
||||
const localFilePath = getSystemPromptFilePath(cwd, mode)
|
||||
let rawContent = await safeReadFile(localFilePath)
|
||||
|
||||
// If no local file exists, check for global system prompt
|
||||
if (!rawContent) {
|
||||
const globalFilePath = getGlobalSystemPromptFilePath(mode)
|
||||
rawContent = await safeReadFile(globalFilePath)
|
||||
}
|
||||
|
||||
if (!rawContent) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const interpolatedContent = interpolatePromptContent(rawContent, variables)
|
||||
return interpolatedContent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ import { ContextProxy } from "../config/ContextProxy"
|
|||
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
|
||||
import { CustomModesManager } from "../config/CustomModesManager"
|
||||
import { Task, TaskOptions } from "../task/Task"
|
||||
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
|
||||
import { getSystemPromptFilePath, getGlobalSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
|
||||
|
||||
import { webviewMessageHandler } from "./webviewMessageHandler"
|
||||
import { getNonce } from "./getNonce"
|
||||
|
|
@ -1480,10 +1480,18 @@ export class ClineProvider
|
|||
|
||||
/**
|
||||
* Checks if there is a file-based system prompt override for the given mode
|
||||
* Checks both local project directory and global home directory
|
||||
*/
|
||||
async hasFileBasedSystemPromptOverride(mode: Mode): Promise<boolean> {
|
||||
const promptFilePath = getSystemPromptFilePath(this.cwd, mode)
|
||||
return await fileExistsAtPath(promptFilePath)
|
||||
// Check local project directory first
|
||||
const localPromptFilePath = getSystemPromptFilePath(this.cwd, mode)
|
||||
if (await fileExistsAtPath(localPromptFilePath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check global home directory
|
||||
const globalPromptFilePath = getGlobalSystemPromptFilePath(mode)
|
||||
return await fileExistsAtPath(globalPromptFilePath)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue