Skip to content

Commit 3d0e054

Browse files
authored
Merge pull request #1249 from RooVetGit/custom_system_prompt
Allow users to set custom system prompts
2 parents 203b1ad + 1f0211e commit 3d0e054

File tree

6 files changed

+317
-1
lines changed

6 files changed

+317
-1
lines changed

src/__mocks__/fs/promises.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,6 @@ const mockFs = {
140140
currentPath += "/" + parts[parts.length - 1]
141141
mockDirectories.add(currentPath)
142142
return Promise.resolve()
143-
return Promise.resolve()
144143
}),
145144

146145
access: jest.fn().mockImplementation(async (path: string) => {

src/__mocks__/jest.setup.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({
1515
}),
1616
},
1717
}))
18+
19+
// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts
20+
// This is needed because the production code expects strings to have this method
21+
// Note: In production, this is added via import in the entry point (extension.ts)
22+
export {}
23+
24+
declare global {
25+
interface String {
26+
toPosix(): string
27+
}
28+
}
29+
30+
// Implementation that matches src/utils/path.ts
31+
function toPosixPath(p: string) {
32+
// Extended-Length Paths in Windows start with "\\?\" to allow longer paths
33+
// and bypass usual parsing. If detected, we return the path unmodified.
34+
const isExtendedLengthPath = p.startsWith("\\\\?\\")
35+
36+
if (isExtendedLengthPath) {
37+
return p
38+
}
39+
40+
return p.replace(/\\/g, "/")
41+
}
42+
43+
if (!String.prototype.toPosix) {
44+
String.prototype.toPosix = function (this: string): string {
45+
return toPosixPath(this)
46+
}
47+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { SYSTEM_PROMPT } from "../system"
2+
import { defaultModeSlug, modes } from "../../../shared/modes"
3+
import * as vscode from "vscode"
4+
import * as fs from "fs/promises"
5+
6+
// Mock the fs/promises module
7+
jest.mock("fs/promises", () => ({
8+
readFile: jest.fn(),
9+
mkdir: jest.fn().mockResolvedValue(undefined),
10+
access: jest.fn().mockResolvedValue(undefined),
11+
}))
12+
13+
// Get the mocked fs module
14+
const mockedFs = fs as jest.Mocked<typeof fs>
15+
16+
// Mock the fileExistsAtPath function
17+
jest.mock("../../../utils/fs", () => ({
18+
fileExistsAtPath: jest.fn().mockResolvedValue(true),
19+
createDirectoriesForFile: jest.fn().mockResolvedValue([]),
20+
}))
21+
22+
// Create a mock ExtensionContext with relative paths instead of absolute paths
23+
const mockContext = {
24+
extensionPath: "mock/extension/path",
25+
globalStoragePath: "mock/storage/path",
26+
storagePath: "mock/storage/path",
27+
logPath: "mock/log/path",
28+
subscriptions: [],
29+
workspaceState: {
30+
get: () => undefined,
31+
update: () => Promise.resolve(),
32+
},
33+
globalState: {
34+
get: () => undefined,
35+
update: () => Promise.resolve(),
36+
setKeysForSync: () => {},
37+
},
38+
extensionUri: { fsPath: "mock/extension/path" },
39+
globalStorageUri: { fsPath: "mock/settings/path" },
40+
asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`,
41+
extension: {
42+
packageJSON: {
43+
version: "1.0.0",
44+
},
45+
},
46+
} as unknown as vscode.ExtensionContext
47+
48+
describe("File-Based Custom System Prompt", () => {
49+
const experiments = {}
50+
51+
beforeEach(() => {
52+
// Reset mocks before each test
53+
jest.clearAllMocks()
54+
55+
// Default behavior: file doesn't exist
56+
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
57+
})
58+
59+
it("should use default generation when no file-based system prompt is found", async () => {
60+
const customModePrompts = {
61+
[defaultModeSlug]: {
62+
roleDefinition: "Test role definition",
63+
},
64+
}
65+
66+
const prompt = await SYSTEM_PROMPT(
67+
mockContext,
68+
"test/path", // Using a relative path without leading slash
69+
false,
70+
undefined,
71+
undefined,
72+
undefined,
73+
defaultModeSlug,
74+
customModePrompts,
75+
undefined,
76+
undefined,
77+
undefined,
78+
undefined,
79+
experiments,
80+
true,
81+
)
82+
83+
// Should contain default sections
84+
expect(prompt).toContain("TOOL USE")
85+
expect(prompt).toContain("CAPABILITIES")
86+
expect(prompt).toContain("MODES")
87+
expect(prompt).toContain("Test role definition")
88+
})
89+
90+
it("should use file-based custom system prompt when available", async () => {
91+
// Mock the readFile to return content from a file
92+
const fileCustomSystemPrompt = "Custom system prompt from file"
93+
// When called with utf-8 encoding, return a string
94+
mockedFs.readFile.mockImplementation((filePath, options) => {
95+
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
96+
return Promise.resolve(fileCustomSystemPrompt)
97+
}
98+
return Promise.reject({ code: "ENOENT" })
99+
})
100+
101+
const prompt = await SYSTEM_PROMPT(
102+
mockContext,
103+
"test/path", // Using a relative path without leading slash
104+
false,
105+
undefined,
106+
undefined,
107+
undefined,
108+
defaultModeSlug,
109+
undefined,
110+
undefined,
111+
undefined,
112+
undefined,
113+
undefined,
114+
experiments,
115+
true,
116+
)
117+
118+
// Should contain role definition and file-based system prompt
119+
expect(prompt).toContain(modes[0].roleDefinition)
120+
expect(prompt).toContain(fileCustomSystemPrompt)
121+
122+
// Should not contain any of the default sections
123+
expect(prompt).not.toContain("TOOL USE")
124+
expect(prompt).not.toContain("CAPABILITIES")
125+
expect(prompt).not.toContain("MODES")
126+
})
127+
128+
it("should combine file-based system prompt with role definition and custom instructions", async () => {
129+
// Mock the readFile to return content from a file
130+
const fileCustomSystemPrompt = "Custom system prompt from file"
131+
mockedFs.readFile.mockImplementation((filePath, options) => {
132+
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
133+
return Promise.resolve(fileCustomSystemPrompt)
134+
}
135+
return Promise.reject({ code: "ENOENT" })
136+
})
137+
138+
// Define custom role definition
139+
const customRoleDefinition = "Custom role definition"
140+
const customModePrompts = {
141+
[defaultModeSlug]: {
142+
roleDefinition: customRoleDefinition,
143+
},
144+
}
145+
146+
const prompt = await SYSTEM_PROMPT(
147+
mockContext,
148+
"test/path", // Using a relative path without leading slash
149+
false,
150+
undefined,
151+
undefined,
152+
undefined,
153+
defaultModeSlug,
154+
customModePrompts,
155+
undefined,
156+
undefined,
157+
undefined,
158+
undefined,
159+
experiments,
160+
true,
161+
)
162+
163+
// Should contain custom role definition and file-based system prompt
164+
expect(prompt).toContain(customRoleDefinition)
165+
expect(prompt).toContain(fileCustomSystemPrompt)
166+
167+
// Should not contain any of the default sections
168+
expect(prompt).not.toContain("TOOL USE")
169+
expect(prompt).not.toContain("CAPABILITIES")
170+
expect(prompt).not.toContain("MODES")
171+
})
172+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import fs from "fs/promises"
2+
import path from "path"
3+
import { Mode } from "../../../shared/modes"
4+
import { fileExistsAtPath } from "../../../utils/fs"
5+
6+
/**
7+
* Safely reads a file, returning an empty string if the file doesn't exist
8+
*/
9+
async function safeReadFile(filePath: string): Promise<string> {
10+
try {
11+
const content = await fs.readFile(filePath, "utf-8")
12+
// When reading with "utf-8" encoding, content should be a string
13+
return content.trim()
14+
} catch (err) {
15+
const errorCode = (err as NodeJS.ErrnoException).code
16+
if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
17+
throw err
18+
}
19+
return ""
20+
}
21+
}
22+
23+
/**
24+
* Get the path to a system prompt file for a specific mode
25+
*/
26+
export function getSystemPromptFilePath(cwd: string, mode: Mode): string {
27+
return path.join(cwd, ".roo", `system-prompt-${mode}`)
28+
}
29+
30+
/**
31+
* Loads custom system prompt from a file at .roo/system-prompt-[mode slug]
32+
* If the file doesn't exist, returns an empty string
33+
*/
34+
export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise<string> {
35+
const filePath = getSystemPromptFilePath(cwd, mode)
36+
return safeReadFile(filePath)
37+
}
38+
39+
/**
40+
* Ensures the .roo directory exists, creating it if necessary
41+
*/
42+
export async function ensureRooDirectory(cwd: string): Promise<void> {
43+
const rooDir = path.join(cwd, ".roo")
44+
45+
// Check if directory already exists
46+
if (await fileExistsAtPath(rooDir)) {
47+
return
48+
}
49+
50+
// Create the directory
51+
try {
52+
await fs.mkdir(rooDir, { recursive: true })
53+
} catch (err) {
54+
// If directory already exists (race condition), ignore the error
55+
const errorCode = (err as NodeJS.ErrnoException).code
56+
if (errorCode !== "EEXIST") {
57+
throw err
58+
}
59+
}
60+
}

src/core/prompts/system.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getModesSection,
2424
addCustomInstructions,
2525
} from "./sections"
26+
import { loadSystemPromptFile } from "./sections/custom-system-prompt"
2627
import fs from "fs/promises"
2728
import path from "path"
2829

@@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async (
119120
return undefined
120121
}
121122

123+
// Try to load custom system prompt from file
124+
const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode)
125+
122126
// Check if it's a custom mode
123127
const promptComponent = getPromptComponent(customModePrompts?.[mode])
128+
124129
// Get full mode config from custom modes or fall back to built-in modes
125130
const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0]
126131

132+
// If a file-based custom system prompt exists, use it
133+
if (fileCustomSystemPrompt) {
134+
const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition
135+
return `${roleDefinition}
136+
137+
${fileCustomSystemPrompt}
138+
139+
${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
140+
}
141+
127142
// If diff is disabled, don't pass the diffStrategy
128143
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
129144

webview-ui/src/components/prompts/PromptsView.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
8888
const [showConfigMenu, setShowConfigMenu] = useState(false)
8989
const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
9090
const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
91+
const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false)
9192

9293
// Direct update functions
9394
const updateAgentPrompt = useCallback(
@@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
971972
<span className="codicon codicon-copy"></span>
972973
</VSCodeButton>
973974
</div>
975+
976+
{/* Custom System Prompt Disclosure */}
977+
<div className="mb-3 mt-12">
978+
<button
979+
onClick={() => setIsSystemPromptDisclosureOpen(!isSystemPromptDisclosureOpen)}
980+
className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
981+
aria-expanded={isSystemPromptDisclosureOpen}>
982+
<span
983+
className={`codicon codicon-${isSystemPromptDisclosureOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
984+
<span>Advanced: Override System Prompt</span>
985+
</button>
986+
987+
{isSystemPromptDisclosureOpen && (
988+
<div className="text-xs text-vscode-descriptionForeground mt-2 ml-5">
989+
You can completely replace the system prompt for this mode (aside from the role
990+
definition and custom instructions) by creating a file at{" "}
991+
<span
992+
className="text-vscode-textLink-foreground cursor-pointer underline"
993+
onClick={() => {
994+
const currentMode = getCurrentMode()
995+
if (!currentMode) return
996+
997+
// Open or create an empty file
998+
vscode.postMessage({
999+
type: "openFile",
1000+
text: `./.roo/system-prompt-${currentMode.slug}`,
1001+
values: {
1002+
create: true,
1003+
content: "",
1004+
},
1005+
})
1006+
}}>
1007+
.roo/system-prompt-{getCurrentMode()?.slug || "code"}
1008+
</span>{" "}
1009+
in your workspace. This is a very advanced feature that bypasses built-in safeguards and
1010+
consistency checks (especially around tool usage), so be careful!
1011+
</div>
1012+
)}
1013+
</div>
9741014
</div>
9751015

9761016
<div

0 commit comments

Comments
 (0)