mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 12:21:13 -05:00
Merge remote-tracking branch 'origin/main' into aws-profile-support
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { DiffStrategy, getDiffStrategy, UnifiedDiffStrategy } from "./diff/DiffStrategy"
|
||||
import { validateToolUse, isToolAllowedForMode } from "./mode-validator"
|
||||
import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
|
||||
import delay from "delay"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
@@ -51,8 +51,8 @@ import { arePathsEqual, getReadablePath } from "../utils/path"
|
||||
import { parseMentions } from "./mentions"
|
||||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
import { addCustomInstructions, SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { modes, defaultModeSlug } from "../shared/modes"
|
||||
import { SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes"
|
||||
import { truncateHalfConversation } from "./sliding-window"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { detectCodeOmission } from "../integrations/editor/detect-omission"
|
||||
@@ -264,7 +264,7 @@ export class Cline {
|
||||
): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> {
|
||||
// If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.)
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
throw new Error("Roo Code instance aborted")
|
||||
}
|
||||
let askTs: number
|
||||
if (partial !== undefined) {
|
||||
@@ -360,7 +360,7 @@ export class Cline {
|
||||
|
||||
async say(type: ClineSay, text?: string, images?: string[], partial?: boolean): Promise<undefined> {
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
throw new Error("Roo Code instance aborted")
|
||||
}
|
||||
|
||||
if (partial !== undefined) {
|
||||
@@ -419,7 +419,7 @@ export class Cline {
|
||||
async sayAndCreateMissingParamError(toolName: ToolUseName, paramName: string, relPath?: string) {
|
||||
await this.say(
|
||||
"error",
|
||||
`Cline tried to use ${toolName}${
|
||||
`Roo tried to use ${toolName}${
|
||||
relPath ? ` for '${relPath.toPosix()}'` : ""
|
||||
} without value for required parameter '${paramName}'. Retrying...`,
|
||||
)
|
||||
@@ -809,27 +809,29 @@ export class Cline {
|
||||
})
|
||||
}
|
||||
|
||||
const { browserViewportSize, preferredLanguage, mode, customPrompts } =
|
||||
const { browserViewportSize, mode, customModePrompts, preferredLanguage } =
|
||||
(await this.providerRef.deref()?.getState()) ?? {}
|
||||
const systemPrompt =
|
||||
(await SYSTEM_PROMPT(
|
||||
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
const systemPrompt = await (async () => {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
return SYSTEM_PROMPT(
|
||||
provider.context,
|
||||
cwd,
|
||||
this.api.getModel().info.supportsComputerUse ?? false,
|
||||
mcpHub,
|
||||
this.diffStrategy,
|
||||
browserViewportSize,
|
||||
mode,
|
||||
customPrompts,
|
||||
)) +
|
||||
(await addCustomInstructions(
|
||||
{
|
||||
customInstructions: this.customInstructions,
|
||||
customPrompts,
|
||||
preferredLanguage,
|
||||
},
|
||||
cwd,
|
||||
mode,
|
||||
))
|
||||
customModePrompts,
|
||||
customModes,
|
||||
this.customInstructions,
|
||||
preferredLanguage,
|
||||
this.diffEnabled,
|
||||
)
|
||||
})()
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
@@ -923,7 +925,7 @@ export class Cline {
|
||||
|
||||
async presentAssistantMessage() {
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
throw new Error("Roo Code instance aborted")
|
||||
}
|
||||
|
||||
if (this.presentAssistantMessageLocked) {
|
||||
@@ -1140,10 +1142,13 @@ export class Cline {
|
||||
await this.browserSession.closeBrowser()
|
||||
}
|
||||
|
||||
// Validate tool use based on current mode
|
||||
// Validate tool use before execution
|
||||
const { mode } = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
try {
|
||||
validateToolUse(block.name, mode ?? defaultModeSlug)
|
||||
validateToolUse(block.name as ToolName, mode ?? defaultModeSlug, customModes ?? [], {
|
||||
apply_diff: this.diffEnabled,
|
||||
})
|
||||
} catch (error) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(formatResponse.toolError(error.message))
|
||||
@@ -1264,7 +1269,9 @@ export class Cline {
|
||||
await this.diffViewProvider.revertChanges()
|
||||
pushToolResult(
|
||||
formatResponse.toolError(
|
||||
`Content appears to be truncated (file has ${newContent.split("\n").length} lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
|
||||
`Content appears to be truncated (file has ${
|
||||
newContent.split("\n").length
|
||||
} lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
|
||||
),
|
||||
)
|
||||
break
|
||||
@@ -1317,7 +1324,9 @@ export class Cline {
|
||||
pushToolResult(
|
||||
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
|
||||
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || "")}\n</final_file_content>\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
|
||||
finalContent || "",
|
||||
)}\n</final_file_content>\n\n` +
|
||||
`Please note:\n` +
|
||||
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
|
||||
`2. Proceed with the task using this updated file content as the new baseline.\n` +
|
||||
@@ -1396,7 +1405,9 @@ export class Cline {
|
||||
const errorDetails = diffResult.details
|
||||
? JSON.stringify(diffResult.details, null, 2)
|
||||
: ""
|
||||
const formattedError = `Unable to apply diff to file: ${absolutePath}\n\n<error_details>\n${diffResult.error}${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n</error_details>`
|
||||
const formattedError = `Unable to apply diff to file: ${absolutePath}\n\n<error_details>\n${
|
||||
diffResult.error
|
||||
}${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n</error_details>`
|
||||
if (currentCount >= 2) {
|
||||
await this.say("error", formattedError)
|
||||
}
|
||||
@@ -1438,7 +1449,9 @@ export class Cline {
|
||||
pushToolResult(
|
||||
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
|
||||
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || "")}\n</final_file_content>\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
|
||||
finalContent || "",
|
||||
)}\n</final_file_content>\n\n` +
|
||||
`Please note:\n` +
|
||||
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
|
||||
`2. Proceed with the task using this updated file content as the new baseline.\n` +
|
||||
@@ -1853,7 +1866,7 @@ export class Cline {
|
||||
this.consecutiveMistakeCount++
|
||||
await this.say(
|
||||
"error",
|
||||
`Cline tried to use ${tool_name} with an invalid JSON argument. Retrying...`,
|
||||
`Roo tried to use ${tool_name} with an invalid JSON argument. Retrying...`,
|
||||
)
|
||||
pushToolResult(
|
||||
formatResponse.toolError(
|
||||
@@ -2164,7 +2177,7 @@ export class Cline {
|
||||
includeFileDetails: boolean = false,
|
||||
): Promise<boolean> {
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
throw new Error("Roo Code instance aborted")
|
||||
}
|
||||
|
||||
if (this.consecutiveMistakeCount >= 3) {
|
||||
@@ -2172,7 +2185,7 @@ export class Cline {
|
||||
"mistake_limit_reached",
|
||||
this.api.getModel().id.includes("claude")
|
||||
? `This may indicate a failure in his thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.5 Sonnet for its advanced agentic coding capabilities.",
|
||||
: "Roo Code uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.5 Sonnet for its advanced agentic coding capabilities.",
|
||||
)
|
||||
if (response === "messageResponse") {
|
||||
userContent.push(
|
||||
@@ -2366,7 +2379,7 @@ export class Cline {
|
||||
|
||||
// need to call here in case the stream was aborted
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
throw new Error("Roo Code instance aborted")
|
||||
}
|
||||
|
||||
this.didCompleteReadingStream = true
|
||||
@@ -2622,16 +2635,20 @@ export class Cline {
|
||||
details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})`
|
||||
|
||||
// Add current mode and any mode-specific warnings
|
||||
const { mode } = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
const { mode, customModes } = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
const currentMode = mode ?? defaultModeSlug
|
||||
details += `\n\n# Current Mode\n${currentMode}`
|
||||
|
||||
// Add warning if not in code mode
|
||||
if (
|
||||
!isToolAllowedForMode("write_to_file", currentMode) ||
|
||||
!isToolAllowedForMode("execute_command", currentMode)
|
||||
!isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], {
|
||||
apply_diff: this.diffEnabled,
|
||||
}) &&
|
||||
!isToolAllowedForMode("apply_diff", currentMode, customModes ?? [], { apply_diff: this.diffEnabled })
|
||||
) {
|
||||
details += `\n\nNOTE: You are currently in '${currentMode}' mode which only allows read-only operations. To write files or execute commands, the user will need to switch to '${defaultModeSlug}' mode. Note that only the user can switch modes.`
|
||||
const currentModeName = getModeBySlug(currentMode, customModes)?.name ?? currentMode
|
||||
const defaultModeName = getModeBySlug(defaultModeSlug, customModes)?.name ?? defaultModeSlug
|
||||
details += `\n\nNOTE: You are currently in '${currentModeName}' mode which only allows read-only operations. To write files or execute commands, the user will need to switch to '${defaultModeName}' mode. Note that only the user can switch modes.`
|
||||
}
|
||||
|
||||
if (includeFileDetails) {
|
||||
|
||||
179
src/core/CodeActionProvider.ts
Normal file
179
src/core/CodeActionProvider.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
|
||||
export const ACTION_NAMES = {
|
||||
EXPLAIN: "Roo Code: Explain Code",
|
||||
FIX: "Roo Code: Fix Code",
|
||||
IMPROVE: "Roo Code: Improve Code",
|
||||
} as const
|
||||
|
||||
const COMMAND_IDS = {
|
||||
EXPLAIN: "roo-cline.explainCode",
|
||||
FIX: "roo-cline.fixCode",
|
||||
IMPROVE: "roo-cline.improveCode",
|
||||
} as const
|
||||
|
||||
interface DiagnosticData {
|
||||
message: string
|
||||
severity: vscode.DiagnosticSeverity
|
||||
code?: string | number | { value: string | number; target: vscode.Uri }
|
||||
source?: string
|
||||
range: vscode.Range
|
||||
}
|
||||
|
||||
interface EffectiveRange {
|
||||
range: vscode.Range
|
||||
text: string
|
||||
}
|
||||
|
||||
export class CodeActionProvider implements vscode.CodeActionProvider {
|
||||
public static readonly providedCodeActionKinds = [
|
||||
vscode.CodeActionKind.QuickFix,
|
||||
vscode.CodeActionKind.RefactorRewrite,
|
||||
]
|
||||
|
||||
// Cache file paths for performance
|
||||
private readonly filePathCache = new WeakMap<vscode.TextDocument, string>()
|
||||
|
||||
private getEffectiveRange(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range | vscode.Selection,
|
||||
): EffectiveRange | null {
|
||||
try {
|
||||
const selectedText = document.getText(range)
|
||||
if (selectedText) {
|
||||
return { range, text: selectedText }
|
||||
}
|
||||
|
||||
const currentLine = document.lineAt(range.start.line)
|
||||
if (!currentLine.text.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Optimize range creation by checking bounds first
|
||||
const startLine = Math.max(0, currentLine.lineNumber - 1)
|
||||
const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1)
|
||||
|
||||
// Only create new positions if needed
|
||||
const effectiveRange = new vscode.Range(
|
||||
startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0),
|
||||
endLine === currentLine.lineNumber
|
||||
? range.end
|
||||
: new vscode.Position(endLine, document.lineAt(endLine).text.length),
|
||||
)
|
||||
|
||||
return {
|
||||
range: effectiveRange,
|
||||
text: document.getText(effectiveRange),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting effective range:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getFilePath(document: vscode.TextDocument): string {
|
||||
// Check cache first
|
||||
let filePath = this.filePathCache.get(document)
|
||||
if (filePath) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
|
||||
if (!workspaceFolder) {
|
||||
filePath = document.uri.fsPath
|
||||
} else {
|
||||
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
|
||||
filePath = !relativePath || relativePath.startsWith("..") ? document.uri.fsPath : relativePath
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.filePathCache.set(document, filePath)
|
||||
return filePath
|
||||
} catch (error) {
|
||||
console.error("Error getting file path:", error)
|
||||
return document.uri.fsPath
|
||||
}
|
||||
}
|
||||
|
||||
private createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
|
||||
return {
|
||||
message: diagnostic.message,
|
||||
severity: diagnostic.severity,
|
||||
code: diagnostic.code,
|
||||
source: diagnostic.source,
|
||||
range: diagnostic.range, // Reuse the range object
|
||||
}
|
||||
}
|
||||
|
||||
private createAction(title: string, kind: vscode.CodeActionKind, command: string, args: any[]): vscode.CodeAction {
|
||||
const action = new vscode.CodeAction(title, kind)
|
||||
action.command = { command, title, arguments: args }
|
||||
return action
|
||||
}
|
||||
|
||||
private hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
|
||||
// Optimize range intersection check
|
||||
return !(
|
||||
range2.end.line < range1.start.line ||
|
||||
range2.start.line > range1.end.line ||
|
||||
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
|
||||
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
|
||||
)
|
||||
}
|
||||
|
||||
public provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range | vscode.Selection,
|
||||
context: vscode.CodeActionContext,
|
||||
): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
|
||||
try {
|
||||
const effectiveRange = this.getEffectiveRange(document, range)
|
||||
if (!effectiveRange) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filePath = this.getFilePath(document)
|
||||
const actions: vscode.CodeAction[] = []
|
||||
|
||||
// Create actions using helper method
|
||||
actions.push(
|
||||
this.createAction(ACTION_NAMES.EXPLAIN, vscode.CodeActionKind.QuickFix, COMMAND_IDS.EXPLAIN, [
|
||||
filePath,
|
||||
effectiveRange.text,
|
||||
]),
|
||||
)
|
||||
|
||||
// Only process diagnostics if they exist
|
||||
if (context.diagnostics.length > 0) {
|
||||
const relevantDiagnostics = context.diagnostics.filter((d) =>
|
||||
this.hasIntersectingRange(effectiveRange.range, d.range),
|
||||
)
|
||||
|
||||
if (relevantDiagnostics.length > 0) {
|
||||
const diagnosticMessages = relevantDiagnostics.map(this.createDiagnosticData)
|
||||
actions.push(
|
||||
this.createAction(ACTION_NAMES.FIX, vscode.CodeActionKind.QuickFix, COMMAND_IDS.FIX, [
|
||||
filePath,
|
||||
effectiveRange.text,
|
||||
diagnosticMessages,
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actions.push(
|
||||
this.createAction(ACTION_NAMES.IMPROVE, vscode.CodeActionKind.RefactorRewrite, COMMAND_IDS.IMPROVE, [
|
||||
filePath,
|
||||
effectiveRange.text,
|
||||
]),
|
||||
)
|
||||
|
||||
return actions
|
||||
} catch (error) {
|
||||
console.error("Error providing code actions:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { ApiConfiguration, ModelInfo } from "../../shared/api"
|
||||
import { ApiStreamChunk } from "../../api/transform/stream"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
// Mock all MCP-related modules
|
||||
jest.mock(
|
||||
@@ -209,6 +211,9 @@ describe("Cline", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mock extension context
|
||||
const storageUri = {
|
||||
fsPath: path.join(os.tmpdir(), "test-storage"),
|
||||
}
|
||||
mockExtensionContext = {
|
||||
globalState: {
|
||||
get: jest.fn().mockImplementation((key) => {
|
||||
@@ -231,6 +236,7 @@ describe("Cline", () => {
|
||||
update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
|
||||
keys: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
globalStorageUri: storageUri,
|
||||
workspaceState: {
|
||||
get: jest.fn().mockImplementation((key) => undefined),
|
||||
update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
|
||||
@@ -244,9 +250,6 @@ describe("Cline", () => {
|
||||
extensionUri: {
|
||||
fsPath: "/mock/extension/path",
|
||||
},
|
||||
globalStorageUri: {
|
||||
fsPath: "/mock/storage/path",
|
||||
},
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
@@ -425,27 +428,34 @@ describe("Cline", () => {
|
||||
|
||||
// Mock the API's createMessage method to capture the conversation history
|
||||
const createMessageSpy = jest.fn()
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "text", text: "" }
|
||||
},
|
||||
async next() {
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
async return() {
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
async throw(e: any) {
|
||||
throw e
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
// Cleanup
|
||||
},
|
||||
} as AsyncGenerator<ApiStreamChunk>
|
||||
// Set up mock stream
|
||||
const mockStreamForClean = (async function* () {
|
||||
yield { type: "text", text: "test response" }
|
||||
})()
|
||||
|
||||
jest.spyOn(cline.api, "createMessage").mockImplementation((...args) => {
|
||||
createMessageSpy(...args)
|
||||
return mockStream
|
||||
// Set up spy
|
||||
const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean)
|
||||
jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy)
|
||||
|
||||
// Mock getEnvironmentDetails to return empty details
|
||||
jest.spyOn(cline as any, "getEnvironmentDetails").mockResolvedValue("")
|
||||
|
||||
// Mock loadContext to return unmodified content
|
||||
jest.spyOn(cline as any, "loadContext").mockImplementation(async (content) => [content, ""])
|
||||
|
||||
// Add test message to conversation history
|
||||
cline.apiConversationHistory = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "test message" }],
|
||||
ts: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
// Mock abort state
|
||||
Object.defineProperty(cline, "abort", {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Add a message with extra properties to the conversation history
|
||||
@@ -458,30 +468,25 @@ describe("Cline", () => {
|
||||
cline.apiConversationHistory = [messageWithExtra]
|
||||
|
||||
// Trigger an API request
|
||||
await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
|
||||
await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false)
|
||||
|
||||
// Get all calls to createMessage
|
||||
const calls = createMessageSpy.mock.calls
|
||||
// Get the conversation history from the first API call
|
||||
const history = cleanMessageSpy.mock.calls[0][1]
|
||||
expect(history).toBeDefined()
|
||||
expect(history.length).toBeGreaterThan(0)
|
||||
|
||||
// Find the call that includes our test message
|
||||
const relevantCall = calls.find((call) =>
|
||||
call[1]?.some((msg: any) => msg.content?.[0]?.text === "test message"),
|
||||
)
|
||||
|
||||
// Verify the conversation history was cleaned in the relevant call
|
||||
expect(relevantCall?.[1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "test message" }],
|
||||
},
|
||||
]),
|
||||
// Find our test message
|
||||
const cleanedMessage = history.find((msg: { content?: Array<{ text: string }> }) =>
|
||||
msg.content?.some((content) => content.text === "test message"),
|
||||
)
|
||||
expect(cleanedMessage).toBeDefined()
|
||||
expect(cleanedMessage).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "test message" }],
|
||||
})
|
||||
|
||||
// Verify extra properties were removed
|
||||
const passedMessage = relevantCall?.[1].find((msg: any) => msg.content?.[0]?.text === "test message")
|
||||
expect(passedMessage).not.toHaveProperty("ts")
|
||||
expect(passedMessage).not.toHaveProperty("extraProp")
|
||||
expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"])
|
||||
})
|
||||
|
||||
it("should handle image blocks based on model capabilities", async () => {
|
||||
@@ -573,41 +578,68 @@ describe("Cline", () => {
|
||||
})
|
||||
clineWithoutImages.apiConversationHistory = conversationHistory
|
||||
|
||||
// Create message spy for both instances
|
||||
const createMessageSpyWithImages = jest.fn()
|
||||
const createMessageSpyWithoutImages = jest.fn()
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "text", text: "" }
|
||||
// Mock abort state for both instances
|
||||
Object.defineProperty(clineWithImages, "abort", {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(clineWithoutImages, "abort", {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Mock environment details and context loading
|
||||
jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("")
|
||||
jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("")
|
||||
jest.spyOn(clineWithImages as any, "loadContext").mockImplementation(async (content) => [content, ""])
|
||||
jest.spyOn(clineWithoutImages as any, "loadContext").mockImplementation(async (content) => [
|
||||
content,
|
||||
"",
|
||||
])
|
||||
// Set up mock streams
|
||||
const mockStreamWithImages = (async function* () {
|
||||
yield { type: "text", text: "test response" }
|
||||
})()
|
||||
|
||||
const mockStreamWithoutImages = (async function* () {
|
||||
yield { type: "text", text: "test response" }
|
||||
})()
|
||||
|
||||
// Set up spies
|
||||
const imagesSpy = jest.fn().mockReturnValue(mockStreamWithImages)
|
||||
const noImagesSpy = jest.fn().mockReturnValue(mockStreamWithoutImages)
|
||||
|
||||
jest.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy)
|
||||
jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy)
|
||||
|
||||
// Set up conversation history with images
|
||||
clineWithImages.apiConversationHistory = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Here is an image" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } },
|
||||
],
|
||||
},
|
||||
} as AsyncGenerator<ApiStreamChunk>
|
||||
]
|
||||
|
||||
jest.spyOn(clineWithImages.api, "createMessage").mockImplementation((...args) => {
|
||||
createMessageSpyWithImages(...args)
|
||||
return mockStream
|
||||
})
|
||||
jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation((...args) => {
|
||||
createMessageSpyWithoutImages(...args)
|
||||
return mockStream
|
||||
})
|
||||
// Trigger API requests
|
||||
await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
|
||||
await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
|
||||
|
||||
// Trigger API requests for both instances
|
||||
await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test" }])
|
||||
await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test" }])
|
||||
// Get the calls
|
||||
const imagesCalls = imagesSpy.mock.calls
|
||||
const noImagesCalls = noImagesSpy.mock.calls
|
||||
|
||||
// Verify model with image support preserves image blocks
|
||||
const callsWithImages = createMessageSpyWithImages.mock.calls
|
||||
const historyWithImages = callsWithImages[0][1][0]
|
||||
expect(historyWithImages.content).toHaveLength(2)
|
||||
expect(historyWithImages.content[0]).toEqual({ type: "text", text: "Here is an image" })
|
||||
expect(historyWithImages.content[1]).toHaveProperty("type", "image")
|
||||
expect(imagesCalls[0][1][0].content).toHaveLength(2)
|
||||
expect(imagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
|
||||
expect(imagesCalls[0][1][0].content[1]).toHaveProperty("type", "image")
|
||||
|
||||
// Verify model without image support converts image blocks to text
|
||||
const callsWithoutImages = createMessageSpyWithoutImages.mock.calls
|
||||
const historyWithoutImages = callsWithoutImages[0][1][0]
|
||||
expect(historyWithoutImages.content).toHaveLength(2)
|
||||
expect(historyWithoutImages.content[0]).toEqual({ type: "text", text: "Here is an image" })
|
||||
expect(historyWithoutImages.content[1]).toEqual({
|
||||
expect(noImagesCalls[0][1][0].content).toHaveLength(2)
|
||||
expect(noImagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
|
||||
expect(noImagesCalls[0][1][0].content[1]).toEqual({
|
||||
type: "text",
|
||||
text: "[Referenced image in conversation]",
|
||||
})
|
||||
|
||||
147
src/core/__tests__/CodeActionProvider.test.ts
Normal file
147
src/core/__tests__/CodeActionProvider.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as vscode from "vscode"
|
||||
import { CodeActionProvider } from "../CodeActionProvider"
|
||||
|
||||
// Mock VSCode API
|
||||
jest.mock("vscode", () => ({
|
||||
CodeAction: jest.fn().mockImplementation((title, kind) => ({
|
||||
title,
|
||||
kind,
|
||||
command: undefined,
|
||||
})),
|
||||
CodeActionKind: {
|
||||
QuickFix: { value: "quickfix" },
|
||||
RefactorRewrite: { value: "refactor.rewrite" },
|
||||
},
|
||||
Range: jest.fn().mockImplementation((startLine, startChar, endLine, endChar) => ({
|
||||
start: { line: startLine, character: startChar },
|
||||
end: { line: endLine, character: endChar },
|
||||
})),
|
||||
Position: jest.fn().mockImplementation((line, character) => ({
|
||||
line,
|
||||
character,
|
||||
})),
|
||||
workspace: {
|
||||
getWorkspaceFolder: jest.fn(),
|
||||
},
|
||||
DiagnosticSeverity: {
|
||||
Error: 0,
|
||||
Warning: 1,
|
||||
Information: 2,
|
||||
Hint: 3,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("CodeActionProvider", () => {
|
||||
let provider: CodeActionProvider
|
||||
let mockDocument: any
|
||||
let mockRange: any
|
||||
let mockContext: any
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new CodeActionProvider()
|
||||
|
||||
// Mock document
|
||||
mockDocument = {
|
||||
getText: jest.fn(),
|
||||
lineAt: jest.fn(),
|
||||
lineCount: 10,
|
||||
uri: { fsPath: "/test/file.ts" },
|
||||
}
|
||||
|
||||
// Mock range
|
||||
mockRange = new vscode.Range(0, 0, 0, 10)
|
||||
|
||||
// Mock context
|
||||
mockContext = {
|
||||
diagnostics: [],
|
||||
}
|
||||
})
|
||||
|
||||
describe("getEffectiveRange", () => {
|
||||
it("should return selected text when available", () => {
|
||||
mockDocument.getText.mockReturnValue("selected text")
|
||||
|
||||
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
|
||||
|
||||
expect(result).toEqual({
|
||||
range: mockRange,
|
||||
text: "selected text",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return null for empty line", () => {
|
||||
mockDocument.getText.mockReturnValue("")
|
||||
mockDocument.lineAt.mockReturnValue({ text: "", lineNumber: 0 })
|
||||
|
||||
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFilePath", () => {
|
||||
it("should return relative path when in workspace", () => {
|
||||
const mockWorkspaceFolder = {
|
||||
uri: { fsPath: "/test" },
|
||||
}
|
||||
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder)
|
||||
|
||||
const result = (provider as any).getFilePath(mockDocument)
|
||||
|
||||
expect(result).toBe("file.ts")
|
||||
})
|
||||
|
||||
it("should return absolute path when not in workspace", () => {
|
||||
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null)
|
||||
|
||||
const result = (provider as any).getFilePath(mockDocument)
|
||||
|
||||
expect(result).toBe("/test/file.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provideCodeActions", () => {
|
||||
beforeEach(() => {
|
||||
mockDocument.getText.mockReturnValue("test code")
|
||||
mockDocument.lineAt.mockReturnValue({ text: "test code", lineNumber: 0 })
|
||||
})
|
||||
|
||||
it("should provide explain and improve actions by default", () => {
|
||||
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
|
||||
|
||||
expect(actions).toHaveLength(2)
|
||||
expect((actions as any)[0].title).toBe("Roo Code: Explain Code")
|
||||
expect((actions as any)[1].title).toBe("Roo Code: Improve Code")
|
||||
})
|
||||
|
||||
it("should provide fix action when diagnostics exist", () => {
|
||||
mockContext.diagnostics = [
|
||||
{
|
||||
message: "test error",
|
||||
severity: vscode.DiagnosticSeverity.Error,
|
||||
range: mockRange,
|
||||
},
|
||||
]
|
||||
|
||||
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
|
||||
|
||||
expect(actions).toHaveLength(3)
|
||||
expect((actions as any).some((a: any) => a.title === "Roo Code: Fix Code")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle errors gracefully", () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
mockDocument.getText.mockImplementation(() => {
|
||||
throw new Error("Test error")
|
||||
})
|
||||
mockDocument.lineAt.mockReturnValue({ text: "test", lineNumber: 0 })
|
||||
|
||||
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
|
||||
|
||||
expect(actions).toEqual([])
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error getting effective range:", expect.any(Error))
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Mode, isToolAllowedForMode, TestToolName, getModeConfig, modes } from "../../shared/modes"
|
||||
import { Mode, isToolAllowedForMode, getModeConfig, modes } from "../../shared/modes"
|
||||
import { validateToolUse } from "../mode-validator"
|
||||
|
||||
const asTestTool = (tool: string): TestToolName => tool as TestToolName
|
||||
import { TOOL_GROUPS } from "../../shared/tool-groups"
|
||||
const [codeMode, architectMode, askMode] = modes.map((mode) => mode.slug)
|
||||
|
||||
describe("mode-validator", () => {
|
||||
@@ -9,21 +8,26 @@ describe("mode-validator", () => {
|
||||
describe("code mode", () => {
|
||||
it("allows all code mode tools", () => {
|
||||
const mode = getModeConfig(codeMode)
|
||||
mode.tools.forEach(([tool]) => {
|
||||
expect(isToolAllowedForMode(tool, codeMode)).toBe(true)
|
||||
// Code mode has all groups
|
||||
Object.entries(TOOL_GROUPS).forEach(([_, tools]) => {
|
||||
tools.forEach((tool) => {
|
||||
expect(isToolAllowedForMode(tool, codeMode, [])).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("disallows unknown tools", () => {
|
||||
expect(isToolAllowedForMode(asTestTool("unknown_tool"), codeMode)).toBe(false)
|
||||
expect(isToolAllowedForMode("unknown_tool" as any, codeMode, [])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("architect mode", () => {
|
||||
it("allows configured tools", () => {
|
||||
const mode = getModeConfig(architectMode)
|
||||
mode.tools.forEach(([tool]) => {
|
||||
expect(isToolAllowedForMode(tool, architectMode)).toBe(true)
|
||||
// Architect mode has read, browser, and mcp groups
|
||||
const architectTools = [...TOOL_GROUPS.read, ...TOOL_GROUPS.browser, ...TOOL_GROUPS.mcp]
|
||||
architectTools.forEach((tool) => {
|
||||
expect(isToolAllowedForMode(tool, architectMode, [])).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,22 +35,117 @@ describe("mode-validator", () => {
|
||||
describe("ask mode", () => {
|
||||
it("allows configured tools", () => {
|
||||
const mode = getModeConfig(askMode)
|
||||
mode.tools.forEach(([tool]) => {
|
||||
expect(isToolAllowedForMode(tool, askMode)).toBe(true)
|
||||
// Ask mode has read, browser, and mcp groups
|
||||
const askTools = [...TOOL_GROUPS.read, ...TOOL_GROUPS.browser, ...TOOL_GROUPS.mcp]
|
||||
askTools.forEach((tool) => {
|
||||
expect(isToolAllowedForMode(tool, askMode, [])).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("custom modes", () => {
|
||||
it("allows tools from custom mode configuration", () => {
|
||||
const customModes = [
|
||||
{
|
||||
slug: "custom-mode",
|
||||
name: "Custom Mode",
|
||||
roleDefinition: "Custom role",
|
||||
groups: ["read", "edit"] as const,
|
||||
},
|
||||
]
|
||||
// Should allow tools from read and edit groups
|
||||
expect(isToolAllowedForMode("read_file", "custom-mode", customModes)).toBe(true)
|
||||
expect(isToolAllowedForMode("write_to_file", "custom-mode", customModes)).toBe(true)
|
||||
// Should not allow tools from other groups
|
||||
expect(isToolAllowedForMode("execute_command", "custom-mode", customModes)).toBe(false)
|
||||
})
|
||||
|
||||
it("allows custom mode to override built-in mode", () => {
|
||||
const customModes = [
|
||||
{
|
||||
slug: codeMode,
|
||||
name: "Custom Code Mode",
|
||||
roleDefinition: "Custom role",
|
||||
groups: ["read"] as const,
|
||||
},
|
||||
]
|
||||
// Should allow tools from read group
|
||||
expect(isToolAllowedForMode("read_file", codeMode, customModes)).toBe(true)
|
||||
// Should not allow tools from other groups
|
||||
expect(isToolAllowedForMode("write_to_file", codeMode, customModes)).toBe(false)
|
||||
})
|
||||
|
||||
it("respects tool requirements in custom modes", () => {
|
||||
const customModes = [
|
||||
{
|
||||
slug: "custom-mode",
|
||||
name: "Custom Mode",
|
||||
roleDefinition: "Custom role",
|
||||
groups: ["edit"] as const,
|
||||
},
|
||||
]
|
||||
const requirements = { apply_diff: false }
|
||||
|
||||
// Should respect disabled requirement even if tool group is allowed
|
||||
expect(isToolAllowedForMode("apply_diff", "custom-mode", customModes, requirements)).toBe(false)
|
||||
|
||||
// Should allow other edit tools
|
||||
expect(isToolAllowedForMode("write_to_file", "custom-mode", customModes, requirements)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool requirements", () => {
|
||||
it("respects tool requirements when provided", () => {
|
||||
const requirements = { apply_diff: false }
|
||||
expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false)
|
||||
|
||||
const enabledRequirements = { apply_diff: true }
|
||||
expect(isToolAllowedForMode("apply_diff", codeMode, [], enabledRequirements)).toBe(true)
|
||||
})
|
||||
|
||||
it("allows tools when their requirements are not specified", () => {
|
||||
const requirements = { some_other_tool: true }
|
||||
expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(true)
|
||||
})
|
||||
|
||||
it("handles undefined and empty requirements", () => {
|
||||
expect(isToolAllowedForMode("apply_diff", codeMode, [], undefined)).toBe(true)
|
||||
expect(isToolAllowedForMode("apply_diff", codeMode, [], {})).toBe(true)
|
||||
})
|
||||
|
||||
it("prioritizes requirements over mode configuration", () => {
|
||||
const requirements = { apply_diff: false }
|
||||
// Even in code mode which allows all tools, disabled requirement should take precedence
|
||||
expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateToolUse", () => {
|
||||
it("throws error for disallowed tools in architect mode", () => {
|
||||
expect(() => validateToolUse("unknown_tool", "architect")).toThrow(
|
||||
expect(() => validateToolUse("unknown_tool" as any, "architect", [])).toThrow(
|
||||
'Tool "unknown_tool" is not allowed in architect mode.',
|
||||
)
|
||||
})
|
||||
|
||||
it("does not throw for allowed tools in architect mode", () => {
|
||||
expect(() => validateToolUse("read_file", "architect")).not.toThrow()
|
||||
expect(() => validateToolUse("read_file", "architect", [])).not.toThrow()
|
||||
})
|
||||
|
||||
it("throws error when tool requirement is not met", () => {
|
||||
const requirements = { apply_diff: false }
|
||||
expect(() => validateToolUse("apply_diff", codeMode, [], requirements)).toThrow(
|
||||
'Tool "apply_diff" is not allowed in code mode.',
|
||||
)
|
||||
})
|
||||
|
||||
it("does not throw when tool requirement is met", () => {
|
||||
const requirements = { apply_diff: true }
|
||||
expect(() => validateToolUse("apply_diff", codeMode, [], requirements)).not.toThrow()
|
||||
})
|
||||
|
||||
it("handles undefined requirements gracefully", () => {
|
||||
expect(() => validateToolUse("apply_diff", codeMode, [], undefined)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { ApiConfiguration } from "../../shared/api"
|
||||
import { Mode } from "../prompts/types"
|
||||
import { Mode } from "../../shared/modes"
|
||||
import { ApiConfigMeta } from "../../shared/ExtensionMessage"
|
||||
|
||||
export interface ApiConfigData {
|
||||
|
||||
190
src/core/config/CustomModesManager.ts
Normal file
190
src/core/config/CustomModesManager.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { CustomModesSettingsSchema } from "./CustomModesSchema"
|
||||
import { ModeConfig } from "../../shared/modes"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export class CustomModesManager {
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private isWriting = false
|
||||
private writeQueue: Array<() => Promise<void>> = []
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly onUpdate: () => Promise<void>,
|
||||
) {
|
||||
this.watchCustomModesFile()
|
||||
}
|
||||
|
||||
private async queueWrite(operation: () => Promise<void>): Promise<void> {
|
||||
this.writeQueue.push(operation)
|
||||
if (!this.isWriting) {
|
||||
await this.processWriteQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private async processWriteQueue(): Promise<void> {
|
||||
if (this.isWriting || this.writeQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isWriting = true
|
||||
try {
|
||||
while (this.writeQueue.length > 0) {
|
||||
const operation = this.writeQueue.shift()
|
||||
if (operation) {
|
||||
await operation()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isWriting = false
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomModesFilePath(): Promise<string> {
|
||||
const settingsDir = await this.ensureSettingsDirectoryExists()
|
||||
const filePath = path.join(settingsDir, "cline_custom_modes.json")
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (!fileExists) {
|
||||
await this.queueWrite(async () => {
|
||||
await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
|
||||
})
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
private async watchCustomModesFile(): Promise<void> {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
||||
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const errorMessage =
|
||||
"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
|
||||
let config: any
|
||||
try {
|
||||
config = JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
const result = CustomModesSettingsSchema.safeParse(config)
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
await this.context.globalState.update("customModes", result.data.customModes)
|
||||
await this.onUpdate()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async getCustomModes(): Promise<ModeConfig[]> {
|
||||
const modes = await this.context.globalState.get<ModeConfig[]>("customModes")
|
||||
|
||||
// Always read from file to ensure we have the latest
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
|
||||
const settings = JSON.parse(content)
|
||||
const result = CustomModesSettingsSchema.safeParse(settings)
|
||||
if (result.success) {
|
||||
await this.context.globalState.update("customModes", result.data.customModes)
|
||||
return result.data.customModes
|
||||
}
|
||||
return modes ?? []
|
||||
} catch (error) {
|
||||
// Return empty array if there's an error reading the file
|
||||
}
|
||||
|
||||
return modes ?? []
|
||||
}
|
||||
|
||||
async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
|
||||
await this.queueWrite(async () => {
|
||||
// Read and update file
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
const currentModes = settings.customModes || []
|
||||
const updatedModes = currentModes.filter((m: ModeConfig) => m.slug !== slug)
|
||||
updatedModes.push(config)
|
||||
settings.customModes = updatedModes
|
||||
|
||||
const newContent = JSON.stringify(settings, null, 2)
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(settingsPath, newContent)
|
||||
|
||||
// Update global state
|
||||
await this.context.globalState.update("customModes", updatedModes)
|
||||
|
||||
// Notify about the update
|
||||
await this.onUpdate()
|
||||
})
|
||||
|
||||
// Success, no need for message
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to update custom mode: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomMode(slug: string): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
|
||||
await this.queueWrite(async () => {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
|
||||
settings.customModes = (settings.customModes || []).filter((m: ModeConfig) => m.slug !== slug)
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
|
||||
|
||||
await this.context.globalState.update("customModes", settings.customModes)
|
||||
await this.onUpdate()
|
||||
})
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to delete custom mode: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSettingsDirectoryExists(): Promise<string> {
|
||||
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
return settingsDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom modes file and reset to default state
|
||||
*/
|
||||
async resetCustomModes(): Promise<void> {
|
||||
try {
|
||||
const filePath = await this.getCustomModesFilePath()
|
||||
await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
|
||||
await this.context.globalState.update("customModes", [])
|
||||
await this.onUpdate()
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to reset custom modes: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables) {
|
||||
disposable.dispose()
|
||||
}
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
60
src/core/config/CustomModesSchema.ts
Normal file
60
src/core/config/CustomModesSchema.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod"
|
||||
import { ModeConfig } from "../../shared/modes"
|
||||
import { TOOL_GROUPS, ToolGroup } from "../../shared/tool-groups"
|
||||
|
||||
// Create a schema for valid tool groups using the keys of TOOL_GROUPS
|
||||
const ToolGroupSchema = z.enum(Object.keys(TOOL_GROUPS) as [ToolGroup, ...ToolGroup[]])
|
||||
|
||||
// Schema for array of groups
|
||||
const GroupsArraySchema = z
|
||||
.array(ToolGroupSchema)
|
||||
.min(1, "At least one tool group is required")
|
||||
.refine(
|
||||
(groups) => {
|
||||
const seen = new Set()
|
||||
return groups.every((group) => {
|
||||
if (seen.has(group)) return false
|
||||
seen.add(group)
|
||||
return true
|
||||
})
|
||||
},
|
||||
{ message: "Duplicate groups are not allowed" },
|
||||
)
|
||||
|
||||
// Schema for mode configuration
|
||||
export const CustomModeSchema = z.object({
|
||||
slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
roleDefinition: z.string().min(1, "Role definition is required"),
|
||||
customInstructions: z.string().optional(),
|
||||
groups: GroupsArraySchema,
|
||||
}) satisfies z.ZodType<ModeConfig>
|
||||
|
||||
// Schema for the entire custom modes settings file
|
||||
export const CustomModesSettingsSchema = z.object({
|
||||
customModes: z.array(CustomModeSchema).refine(
|
||||
(modes) => {
|
||||
const slugs = new Set()
|
||||
return modes.every((mode) => {
|
||||
if (slugs.has(mode.slug)) {
|
||||
return false
|
||||
}
|
||||
slugs.add(mode.slug)
|
||||
return true
|
||||
})
|
||||
},
|
||||
{
|
||||
message: "Duplicate mode slugs are not allowed",
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
export type CustomModesSettings = z.infer<typeof CustomModesSettingsSchema>
|
||||
|
||||
/**
|
||||
* Validates a custom mode configuration against the schema
|
||||
* @throws {z.ZodError} if validation fails
|
||||
*/
|
||||
export function validateCustomMode(mode: unknown): asserts mode is ModeConfig {
|
||||
CustomModeSchema.parse(mode)
|
||||
}
|
||||
245
src/core/config/__tests__/CustomModesManager.test.ts
Normal file
245
src/core/config/__tests__/CustomModesManager.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { ModeConfig } from "../../../shared/modes"
|
||||
import { CustomModesManager } from "../CustomModesManager"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("vscode")
|
||||
jest.mock("fs/promises")
|
||||
jest.mock("../../../utils/fs", () => ({
|
||||
fileExistsAtPath: jest.fn().mockResolvedValue(false),
|
||||
}))
|
||||
|
||||
describe("CustomModesManager", () => {
|
||||
let manager: CustomModesManager
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockOnUpdate: jest.Mock
|
||||
let mockStoragePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Mock storage path
|
||||
mockStoragePath = "/test/storage/path"
|
||||
|
||||
// Mock context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: mockStoragePath },
|
||||
globalState: {
|
||||
get: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock onUpdate callback
|
||||
mockOnUpdate = jest.fn().mockResolvedValue(undefined)
|
||||
|
||||
// Mock fs.mkdir to do nothing
|
||||
;(fs.mkdir as jest.Mock).mockResolvedValue(undefined)
|
||||
|
||||
// Create manager instance
|
||||
manager = new CustomModesManager(mockContext, mockOnUpdate)
|
||||
})
|
||||
|
||||
describe("Mode Configuration Validation", () => {
|
||||
test("validates valid custom mode configuration", async () => {
|
||||
const validMode = {
|
||||
slug: "test-mode",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
// Mock file read/write operations
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
|
||||
;(fs.writeFile as jest.Mock).mockResolvedValue(undefined)
|
||||
|
||||
await manager.updateCustomMode(validMode.slug, validMode)
|
||||
|
||||
// Verify file was written with the new mode
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("cline_custom_modes.json"),
|
||||
expect.stringContaining(validMode.name),
|
||||
)
|
||||
|
||||
// Verify global state was updated
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith(
|
||||
"customModes",
|
||||
expect.arrayContaining([validMode]),
|
||||
)
|
||||
|
||||
// Verify onUpdate was called
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("handles file read errors gracefully", async () => {
|
||||
// Mock fs.readFile to throw error
|
||||
;(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error("Test error"))
|
||||
|
||||
const modes = await manager.getCustomModes()
|
||||
|
||||
// Should return empty array on error
|
||||
expect(modes).toEqual([])
|
||||
})
|
||||
|
||||
test("handles file write errors gracefully", async () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
// Mock fs.writeFile to throw error
|
||||
;(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error("Write error"))
|
||||
|
||||
const mockShowError = jest.fn()
|
||||
;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
|
||||
|
||||
await manager.updateCustomMode(validMode.slug, validMode)
|
||||
|
||||
// Should show error message
|
||||
expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("File Operations", () => {
|
||||
test("creates settings directory if it doesn't exist", async () => {
|
||||
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
await manager.getCustomModesFilePath()
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(configPath), { recursive: true })
|
||||
})
|
||||
|
||||
test("creates default config if file doesn't exist", async () => {
|
||||
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
await manager.getCustomModesFilePath()
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(configPath, JSON.stringify({ customModes: [] }, null, 2))
|
||||
})
|
||||
|
||||
test("watches file for changes", async () => {
|
||||
// Mock file path resolution
|
||||
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
|
||||
|
||||
// Create manager and wait for initialization
|
||||
const manager = new CustomModesManager(mockContext, mockOnUpdate)
|
||||
await manager.getCustomModesFilePath() // This ensures watchCustomModesFile has completed
|
||||
|
||||
// Get the registered callback
|
||||
const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
|
||||
expect(registerCall).toBeDefined()
|
||||
const [callback] = registerCall
|
||||
|
||||
// Simulate file save event
|
||||
const mockDocument = {
|
||||
uri: { fsPath: configPath },
|
||||
}
|
||||
await callback(mockDocument)
|
||||
|
||||
// Verify file was processed
|
||||
expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8")
|
||||
expect(mockContext.globalState.update).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
|
||||
// Verify file content was processed
|
||||
expect(fs.readFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mode Operations", () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock fs.readFile to return empty config
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
|
||||
})
|
||||
|
||||
test("adds new custom mode", async () => {
|
||||
await manager.updateCustomMode(validMode.slug, validMode)
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(validMode.name))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("updates existing custom mode", async () => {
|
||||
// Mock existing mode
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
customModes: [validMode],
|
||||
}),
|
||||
)
|
||||
|
||||
const updatedMode = {
|
||||
...validMode,
|
||||
name: "Updated Name",
|
||||
}
|
||||
|
||||
await manager.updateCustomMode(validMode.slug, updatedMode)
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("Updated Name"))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("deletes custom mode", async () => {
|
||||
// Mock existing mode
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
customModes: [validMode],
|
||||
}),
|
||||
)
|
||||
|
||||
await manager.deleteCustomMode(validMode.slug)
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.not.stringContaining(validMode.name))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("queues write operations", async () => {
|
||||
const mode1 = {
|
||||
...validMode,
|
||||
name: "Mode 1",
|
||||
}
|
||||
const mode2 = {
|
||||
...validMode,
|
||||
slug: "mode-2",
|
||||
name: "Mode 2",
|
||||
}
|
||||
|
||||
// Mock initial empty state and track writes
|
||||
let currentModes: ModeConfig[] = []
|
||||
;(fs.readFile as jest.Mock).mockImplementation(() => JSON.stringify({ customModes: currentModes }))
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(async (path, content) => {
|
||||
const data = JSON.parse(content)
|
||||
currentModes = data.customModes
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// Start both updates simultaneously
|
||||
await Promise.all([
|
||||
manager.updateCustomMode(mode1.slug, mode1),
|
||||
manager.updateCustomMode(mode2.slug, mode2),
|
||||
])
|
||||
|
||||
// Verify final state
|
||||
expect(currentModes).toHaveLength(2)
|
||||
expect(currentModes.map((m) => m.name)).toContain("Mode 1")
|
||||
expect(currentModes.map((m) => m.name)).toContain("Mode 2")
|
||||
|
||||
// Verify write was called with both modes
|
||||
const lastWriteCall = (fs.writeFile as jest.Mock).mock.calls.pop()
|
||||
const finalContent = JSON.parse(lastWriteCall[1])
|
||||
expect(finalContent.customModes).toHaveLength(2)
|
||||
expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1")
|
||||
expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2")
|
||||
})
|
||||
})
|
||||
})
|
||||
122
src/core/config/__tests__/CustomModesSchema.test.ts
Normal file
122
src/core/config/__tests__/CustomModesSchema.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { validateCustomMode } from "../CustomModesSchema"
|
||||
import { ModeConfig } from "../../../shared/modes"
|
||||
import { ZodError } from "zod"
|
||||
|
||||
describe("CustomModesSchema", () => {
|
||||
describe("validateCustomMode", () => {
|
||||
test("accepts valid mode configuration", () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => validateCustomMode(validMode)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts mode with multiple groups", () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read", "edit", "browser"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => validateCustomMode(validMode)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts mode with optional customInstructions", () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
customInstructions: "Custom instructions",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => validateCustomMode(validMode)).not.toThrow()
|
||||
})
|
||||
|
||||
test("rejects missing required fields", () => {
|
||||
const invalidModes = [
|
||||
{}, // All fields missing
|
||||
{ name: "Test" }, // Missing most fields
|
||||
{
|
||||
name: "Test",
|
||||
roleDefinition: "Role",
|
||||
}, // Missing slug and groups
|
||||
]
|
||||
|
||||
invalidModes.forEach((invalidMode) => {
|
||||
expect(() => validateCustomMode(invalidMode)).toThrow(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid slug format", () => {
|
||||
const invalidMode = {
|
||||
slug: "not@a@valid@slug",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies Omit<ModeConfig, "slug"> & { slug: string }
|
||||
|
||||
expect(() => validateCustomMode(invalidMode)).toThrow(ZodError)
|
||||
expect(() => validateCustomMode(invalidMode)).toThrow("Slug must contain only letters numbers and dashes")
|
||||
})
|
||||
|
||||
test("rejects empty strings in required fields", () => {
|
||||
const emptyNameMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
const emptyRoleMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => validateCustomMode(emptyNameMode)).toThrow("Name is required")
|
||||
expect(() => validateCustomMode(emptyRoleMode)).toThrow("Role definition is required")
|
||||
})
|
||||
|
||||
test("rejects invalid group configurations", () => {
|
||||
const invalidGroupMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["not-a-valid-group"] as any,
|
||||
}
|
||||
|
||||
expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError)
|
||||
})
|
||||
|
||||
test("rejects empty groups array", () => {
|
||||
const invalidMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: [] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required")
|
||||
})
|
||||
|
||||
test("handles null and undefined gracefully", () => {
|
||||
expect(() => validateCustomMode(null)).toThrow(ZodError)
|
||||
expect(() => validateCustomMode(undefined)).toThrow(ZodError)
|
||||
})
|
||||
|
||||
test("rejects non-object inputs", () => {
|
||||
const invalidInputs = [42, "string", true, [], () => {}]
|
||||
|
||||
invalidInputs.forEach((input) => {
|
||||
expect(() => validateCustomMode(input)).toThrow(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
169
src/core/config/__tests__/CustomModesSettings.test.ts
Normal file
169
src/core/config/__tests__/CustomModesSettings.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { CustomModesSettingsSchema } from "../CustomModesSchema"
|
||||
import { ModeConfig } from "../../../shared/modes"
|
||||
import { ZodError } from "zod"
|
||||
|
||||
describe("CustomModesSettings", () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
describe("schema validation", () => {
|
||||
test("accepts valid settings", () => {
|
||||
const validSettings = {
|
||||
customModes: [validMode],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(validSettings)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts empty custom modes array", () => {
|
||||
const validSettings = {
|
||||
customModes: [],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(validSettings)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts multiple custom modes", () => {
|
||||
const validSettings = {
|
||||
customModes: [
|
||||
validMode,
|
||||
{
|
||||
...validMode,
|
||||
slug: "987fcdeb-51a2-43e7-89ab-cdef01234567",
|
||||
name: "Another Mode",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(validSettings)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test("rejects missing customModes field", () => {
|
||||
const invalidSettings = {} as any
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(invalidSettings)
|
||||
}).toThrow(ZodError)
|
||||
})
|
||||
|
||||
test("rejects invalid mode in array", () => {
|
||||
const invalidSettings = {
|
||||
customModes: [
|
||||
validMode,
|
||||
{
|
||||
...validMode,
|
||||
slug: "not@a@valid@slug", // Invalid slug
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(invalidSettings)
|
||||
}).toThrow(ZodError)
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(invalidSettings)
|
||||
}).toThrow("Slug must contain only letters numbers and dashes")
|
||||
})
|
||||
|
||||
test("rejects non-array customModes", () => {
|
||||
const invalidSettings = {
|
||||
customModes: "not an array",
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(invalidSettings)
|
||||
}).toThrow(ZodError)
|
||||
})
|
||||
|
||||
test("rejects null or undefined", () => {
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(null)
|
||||
}).toThrow(ZodError)
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(undefined)
|
||||
}).toThrow(ZodError)
|
||||
})
|
||||
|
||||
test("rejects duplicate mode slugs", () => {
|
||||
const duplicateSettings = {
|
||||
customModes: [
|
||||
validMode,
|
||||
{ ...validMode }, // Same slug
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(duplicateSettings)
|
||||
}).toThrow("Duplicate mode slugs are not allowed")
|
||||
})
|
||||
|
||||
test("rejects invalid group configurations in modes", () => {
|
||||
const invalidSettings = {
|
||||
customModes: [
|
||||
{
|
||||
...validMode,
|
||||
groups: ["invalid_group"] as any,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(invalidSettings)
|
||||
}).toThrow(ZodError)
|
||||
})
|
||||
|
||||
test("handles multiple groups", () => {
|
||||
const validSettings = {
|
||||
customModes: [
|
||||
{
|
||||
...validMode,
|
||||
groups: ["read", "edit", "browser"] as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
CustomModesSettingsSchema.parse(validSettings)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("type inference", () => {
|
||||
test("inferred type includes all required fields", () => {
|
||||
const settings = {
|
||||
customModes: [validMode],
|
||||
}
|
||||
|
||||
// TypeScript compilation will fail if the type is incorrect
|
||||
expect(settings.customModes[0].slug).toBeDefined()
|
||||
expect(settings.customModes[0].name).toBeDefined()
|
||||
expect(settings.customModes[0].roleDefinition).toBeDefined()
|
||||
expect(settings.customModes[0].groups).toBeDefined()
|
||||
})
|
||||
|
||||
test("inferred type allows optional fields", () => {
|
||||
const settings = {
|
||||
customModes: [
|
||||
{
|
||||
...validMode,
|
||||
customInstructions: "Optional instructions",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// TypeScript compilation will fail if the type is incorrect
|
||||
expect(settings.customModes[0].customInstructions).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
90
src/core/config/__tests__/GroupConfigSchema.test.ts
Normal file
90
src/core/config/__tests__/GroupConfigSchema.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { CustomModeSchema } from "../CustomModesSchema"
|
||||
import { ModeConfig } from "../../../shared/modes"
|
||||
|
||||
describe("GroupConfigSchema", () => {
|
||||
const validBaseMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
}
|
||||
|
||||
describe("group format validation", () => {
|
||||
test("accepts single group", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts multiple groups", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: ["read", "edit", "browser"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts all available groups", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: ["read", "edit", "browser", "command", "mcp"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).not.toThrow()
|
||||
})
|
||||
|
||||
test("rejects non-array group format", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: "not-an-array" as any,
|
||||
}
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects empty groups array", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: [] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).toThrow("At least one tool group is required")
|
||||
})
|
||||
|
||||
test("rejects invalid group names", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: ["invalid_group"] as any,
|
||||
}
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects duplicate groups", () => {
|
||||
const mode = {
|
||||
...validBaseMode,
|
||||
groups: ["read", "read"] as any,
|
||||
}
|
||||
|
||||
expect(() => CustomModeSchema.parse(mode)).toThrow("Duplicate groups are not allowed")
|
||||
})
|
||||
|
||||
test("rejects null or undefined groups", () => {
|
||||
const modeWithNull = {
|
||||
...validBaseMode,
|
||||
groups: null as any,
|
||||
}
|
||||
|
||||
const modeWithUndefined = {
|
||||
...validBaseMode,
|
||||
groups: undefined as any,
|
||||
}
|
||||
|
||||
expect(() => CustomModeSchema.parse(modeWithNull)).toThrow()
|
||||
expect(() => CustomModeSchema.parse(modeWithUndefined)).toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -233,7 +233,7 @@ Your diff here
|
||||
originalContent: string,
|
||||
diffContent: string,
|
||||
startLine?: number,
|
||||
endLine?: number
|
||||
endLine?: number,
|
||||
): Promise<DiffResult> {
|
||||
const parsedDiff = this.parseUnifiedDiff(diffContent)
|
||||
const originalLines = originalContent.split("\n")
|
||||
@@ -271,7 +271,7 @@ Your diff here
|
||||
subHunkResult,
|
||||
subSearchResult.index,
|
||||
subSearchResult.confidence,
|
||||
this.confidenceThreshold
|
||||
this.confidenceThreshold,
|
||||
)
|
||||
if (subEditResult.confidence >= this.confidenceThreshold) {
|
||||
subHunkResult = subEditResult.result
|
||||
@@ -293,12 +293,12 @@ Your diff here
|
||||
const contextRatio = contextLines / totalLines
|
||||
|
||||
let errorMsg = `Failed to find a matching location in the file (${Math.floor(
|
||||
confidence * 100
|
||||
confidence * 100,
|
||||
)}% confidence, needs ${Math.floor(this.confidenceThreshold * 100)}%)\n\n`
|
||||
errorMsg += "Debug Info:\n"
|
||||
errorMsg += `- Search Strategy Used: ${strategy}\n`
|
||||
errorMsg += `- Context Lines: ${contextLines} out of ${totalLines} total lines (${Math.floor(
|
||||
contextRatio * 100
|
||||
contextRatio * 100,
|
||||
)}%)\n`
|
||||
errorMsg += `- Attempted to split into ${subHunks.length} sub-hunks but still failed\n`
|
||||
|
||||
@@ -330,7 +330,7 @@ Your diff here
|
||||
} else {
|
||||
// Edit failure - likely due to content mismatch
|
||||
let errorMsg = `Failed to apply the edit using ${editResult.strategy} strategy (${Math.floor(
|
||||
editResult.confidence * 100
|
||||
editResult.confidence * 100,
|
||||
)}% confidence)\n\n`
|
||||
errorMsg += "Debug Info:\n"
|
||||
errorMsg += "- The location was found but the content didn't match exactly\n"
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Mode, isToolAllowedForMode, TestToolName, getModeConfig } from "../shared/modes"
|
||||
import { Mode, isToolAllowedForMode, getModeConfig, ModeConfig } from "../shared/modes"
|
||||
import { ToolName } from "../shared/tool-groups"
|
||||
|
||||
export { isToolAllowedForMode }
|
||||
export type { TestToolName }
|
||||
export type { ToolName }
|
||||
|
||||
export function validateToolUse(toolName: TestToolName, mode: Mode): void {
|
||||
if (!isToolAllowedForMode(toolName, mode)) {
|
||||
export function validateToolUse(
|
||||
toolName: ToolName,
|
||||
mode: Mode,
|
||||
customModes?: ModeConfig[],
|
||||
toolRequirements?: Record<string, boolean>,
|
||||
): void {
|
||||
if (!isToolAllowedForMode(toolName, mode, customModes ?? [], toolRequirements)) {
|
||||
throw new Error(`Tool "${toolName}" is not allowed in ${mode} mode.`)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
56
src/core/prompts/__tests__/sections.test.ts
Normal file
56
src/core/prompts/__tests__/sections.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { addCustomInstructions } from "../sections/custom-instructions"
|
||||
import { getCapabilitiesSection } from "../sections/capabilities"
|
||||
import { DiffStrategy, DiffResult } from "../../diff/types"
|
||||
|
||||
describe("addCustomInstructions", () => {
|
||||
test("adds preferred language to custom instructions", async () => {
|
||||
const result = await addCustomInstructions(
|
||||
"mode instructions",
|
||||
"global instructions",
|
||||
"/test/path",
|
||||
"test-mode",
|
||||
{ preferredLanguage: "French" },
|
||||
)
|
||||
|
||||
expect(result).toContain("Language Preference:")
|
||||
expect(result).toContain("You should always speak and think in the French language")
|
||||
})
|
||||
|
||||
test("works without preferred language", async () => {
|
||||
const result = await addCustomInstructions(
|
||||
"mode instructions",
|
||||
"global instructions",
|
||||
"/test/path",
|
||||
"test-mode",
|
||||
)
|
||||
|
||||
expect(result).not.toContain("Language Preference:")
|
||||
expect(result).not.toContain("You should always speak and think in")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCapabilitiesSection", () => {
|
||||
const cwd = "/test/path"
|
||||
const mcpHub = undefined
|
||||
const mockDiffStrategy: DiffStrategy = {
|
||||
getToolDescription: () => "apply_diff tool description",
|
||||
applyDiff: async (originalContent: string, diffContent: string): Promise<DiffResult> => {
|
||||
return { success: true, content: "mock result" }
|
||||
},
|
||||
}
|
||||
|
||||
test("includes apply_diff in capabilities when diffStrategy is provided", () => {
|
||||
const result = getCapabilitiesSection(cwd, false, mcpHub, mockDiffStrategy)
|
||||
|
||||
expect(result).toContain("or apply_diff")
|
||||
expect(result).toContain("then use the write_to_file or apply_diff tool")
|
||||
})
|
||||
|
||||
test("excludes apply_diff from capabilities when diffStrategy is undefined", () => {
|
||||
const result = getCapabilitiesSection(cwd, false, mcpHub, undefined)
|
||||
|
||||
expect(result).not.toContain("or apply_diff")
|
||||
expect(result).toContain("then use the write_to_file tool")
|
||||
expect(result).not.toContain("write_to_file or apply_diff")
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,62 @@
|
||||
import { SYSTEM_PROMPT, addCustomInstructions } from "../system"
|
||||
import { SYSTEM_PROMPT } from "../system"
|
||||
import { McpHub } from "../../../services/mcp/McpHub"
|
||||
import { McpServer } from "../../../shared/mcp"
|
||||
import { ClineProvider } from "../../../core/webview/ClineProvider"
|
||||
import { SearchReplaceDiffStrategy } from "../../../core/diff/strategies/search-replace"
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import { defaultModeSlug, modes } from "../../../shared/modes"
|
||||
// Import path utils to get access to toPosix string extension
|
||||
import "../../../utils/path"
|
||||
import { addCustomInstructions } from "../sections/custom-instructions"
|
||||
import * as modesSection from "../sections/modes"
|
||||
|
||||
// Mock the sections
|
||||
jest.mock("../sections/modes", () => ({
|
||||
getModesSection: jest.fn().mockImplementation(async () => `====\n\nMODES\n\n- Test modes section`),
|
||||
}))
|
||||
|
||||
jest.mock("../sections/custom-instructions", () => ({
|
||||
addCustomInstructions: jest
|
||||
.fn()
|
||||
.mockImplementation(async (modeCustomInstructions, globalCustomInstructions, cwd, mode, options) => {
|
||||
const sections = []
|
||||
|
||||
// Add language preference if provided
|
||||
if (options?.preferredLanguage) {
|
||||
sections.push(
|
||||
`Language Preference:\nYou should always speak and think in the ${options.preferredLanguage} language.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Add global instructions first
|
||||
if (globalCustomInstructions?.trim()) {
|
||||
sections.push(`Global Instructions:\n${globalCustomInstructions.trim()}`)
|
||||
}
|
||||
|
||||
// Add mode-specific instructions after
|
||||
if (modeCustomInstructions?.trim()) {
|
||||
sections.push(`Mode-specific Instructions:\n${modeCustomInstructions}`)
|
||||
}
|
||||
|
||||
// Add rules
|
||||
const rules = []
|
||||
if (mode) {
|
||||
rules.push(`# Rules from .clinerules-${mode}:\nMock mode-specific rules`)
|
||||
}
|
||||
rules.push(`# Rules from .clinerules:\nMock generic rules`)
|
||||
|
||||
if (rules.length > 0) {
|
||||
sections.push(`Rules:\n${rules.join("\n")}`)
|
||||
}
|
||||
|
||||
const joinedSections = sections.join("\n\n")
|
||||
return joinedSections
|
||||
? `\n====\n\nUSER'S CUSTOM INSTRUCTIONS\n\nThe following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.\n\n${joinedSections}`
|
||||
: ""
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock environment-specific values for consistent tests
|
||||
jest.mock("os", () => ({
|
||||
@@ -19,42 +68,38 @@ jest.mock("default-shell", () => "/bin/bash")
|
||||
|
||||
jest.mock("os-name", () => () => "Linux")
|
||||
|
||||
// Mock fs.readFile to return empty mcpServers config and mock rules files
|
||||
jest.mock("fs/promises", () => ({
|
||||
...jest.requireActual("fs/promises"),
|
||||
readFile: jest.fn().mockImplementation(async (path: string) => {
|
||||
if (path.endsWith("mcpSettings.json")) {
|
||||
return '{"mcpServers": {}}'
|
||||
}
|
||||
if (path.endsWith(".clinerules-code")) {
|
||||
return "# Code Mode Rules\n1. Code specific rule"
|
||||
}
|
||||
if (path.endsWith(".clinerules-ask")) {
|
||||
return "# Ask Mode Rules\n1. Ask specific rule"
|
||||
}
|
||||
if (path.endsWith(".clinerules-architect")) {
|
||||
return "# Architect Mode Rules\n1. Architect specific rule"
|
||||
}
|
||||
if (path.endsWith(".clinerules")) {
|
||||
return "# Test Rules\n1. First rule\n2. Second rule"
|
||||
}
|
||||
return ""
|
||||
}),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
// Create a mock ExtensionContext
|
||||
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
|
||||
|
||||
// Create a minimal mock of ClineProvider
|
||||
const mockProvider = {
|
||||
ensureMcpServersDirectoryExists: async () => "/mock/mcp/path",
|
||||
ensureSettingsDirectoryExists: async () => "/mock/settings/path",
|
||||
postMessageToWebview: async () => {},
|
||||
context: {
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
context: mockContext,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
// Instead of extending McpHub, create a mock that implements just what we need
|
||||
@@ -77,6 +122,26 @@ const createMockMcpHub = (): McpHub =>
|
||||
describe("SYSTEM_PROMPT", () => {
|
||||
let mockMcpHub: McpHub
|
||||
|
||||
beforeAll(() => {
|
||||
// Ensure fs mock is properly initialized
|
||||
const mockFs = jest.requireMock("fs/promises")
|
||||
mockFs._setInitialMockData()
|
||||
|
||||
// Initialize all required directories
|
||||
const dirs = [
|
||||
"/mock",
|
||||
"/mock/extension",
|
||||
"/mock/extension/path",
|
||||
"/mock/storage",
|
||||
"/mock/storage/path",
|
||||
"/mock/settings",
|
||||
"/mock/settings/path",
|
||||
"/mock/mcp",
|
||||
"/mock/mcp/path",
|
||||
]
|
||||
dirs.forEach((dir) => mockFs._mockDirectories.add(dir))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
@@ -90,18 +155,32 @@ describe("SYSTEM_PROMPT", () => {
|
||||
|
||||
it("should maintain consistent system prompt", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should include browser actions when supportsComputerUse is true", async () => {
|
||||
const prompt = await SYSTEM_PROMPT("/test/path", true, undefined, undefined, "1280x800")
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
true, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
"1280x800", // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
@@ -109,18 +188,32 @@ describe("SYSTEM_PROMPT", () => {
|
||||
it("should include MCP server info when mcpHub is provided", async () => {
|
||||
mockMcpHub = createMockMcpHub()
|
||||
|
||||
const prompt = await SYSTEM_PROMPT("/test/path", false, mockMcpHub)
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
mockMcpHub, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should explicitly handle undefined mcpHub", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false,
|
||||
false, // supportsComputerUse
|
||||
undefined, // explicitly undefined mcpHub
|
||||
undefined,
|
||||
undefined,
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
@@ -128,26 +221,183 @@ describe("SYSTEM_PROMPT", () => {
|
||||
|
||||
it("should handle different browser viewport sizes", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
true, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
"900x600", // different viewport size
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should include diff strategy tool description", async () => {
|
||||
it("should include diff strategy tool description when diffEnabled is true", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
new SearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
undefined, // globalCustomInstructions
|
||||
undefined, // preferredLanguage
|
||||
true, // diffEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toContain("apply_diff")
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should exclude diff strategy tool description when diffEnabled is false", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
new SearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
undefined, // globalCustomInstructions
|
||||
undefined, // preferredLanguage
|
||||
false, // diffEnabled
|
||||
)
|
||||
|
||||
expect(prompt).not.toContain("apply_diff")
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should exclude diff strategy tool description when diffEnabled is undefined", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
new SearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
undefined, // globalCustomInstructions
|
||||
undefined, // preferredLanguage
|
||||
undefined, // diffEnabled
|
||||
)
|
||||
|
||||
expect(prompt).not.toContain("apply_diff")
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should include preferred language in custom instructions", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
undefined, // globalCustomInstructions
|
||||
"Spanish", // preferredLanguage
|
||||
)
|
||||
|
||||
expect(prompt).toContain("Language Preference:")
|
||||
expect(prompt).toContain("You should always speak and think in the Spanish language")
|
||||
})
|
||||
|
||||
it("should include custom mode role definition at top and instructions at bottom", async () => {
|
||||
const modeCustomInstructions = "Custom mode instructions"
|
||||
const customModes = [
|
||||
{
|
||||
slug: "custom-mode",
|
||||
name: "Custom Mode",
|
||||
roleDefinition: "Custom role definition",
|
||||
customInstructions: modeCustomInstructions,
|
||||
groups: ["read"] as const,
|
||||
},
|
||||
]
|
||||
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
"custom-mode", // mode
|
||||
undefined, // customModePrompts
|
||||
customModes, // customModes
|
||||
"Global instructions", // globalCustomInstructions
|
||||
)
|
||||
|
||||
// Role definition should be at the top
|
||||
expect(prompt.indexOf("Custom role definition")).toBeLessThan(prompt.indexOf("TOOL USE"))
|
||||
|
||||
// Custom instructions should be at the bottom
|
||||
const customInstructionsIndex = prompt.indexOf("Custom mode instructions")
|
||||
const userInstructionsHeader = prompt.indexOf("USER'S CUSTOM INSTRUCTIONS")
|
||||
expect(customInstructionsIndex).toBeGreaterThan(-1)
|
||||
expect(userInstructionsHeader).toBeGreaterThan(-1)
|
||||
expect(customInstructionsIndex).toBeGreaterThan(userInstructionsHeader)
|
||||
})
|
||||
|
||||
it("should use promptComponent roleDefinition when available", async () => {
|
||||
const customModePrompts = {
|
||||
[defaultModeSlug]: {
|
||||
roleDefinition: "Custom prompt role definition",
|
||||
customInstructions: "Custom prompt instructions",
|
||||
},
|
||||
}
|
||||
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false,
|
||||
undefined,
|
||||
new SearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase
|
||||
undefined,
|
||||
undefined,
|
||||
defaultModeSlug,
|
||||
customModePrompts,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
// Role definition from promptComponent should be at the top
|
||||
expect(prompt.indexOf("Custom prompt role definition")).toBeLessThan(prompt.indexOf("TOOL USE"))
|
||||
// Should not contain the default mode's role definition
|
||||
expect(prompt).not.toContain(modes[0].roleDefinition)
|
||||
})
|
||||
|
||||
it("should fallback to modeConfig roleDefinition when promptComponent has no roleDefinition", async () => {
|
||||
const customModePrompts = {
|
||||
[defaultModeSlug]: {
|
||||
customInstructions: "Custom prompt instructions",
|
||||
// No roleDefinition provided
|
||||
},
|
||||
}
|
||||
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
defaultModeSlug,
|
||||
customModePrompts,
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Should use the default mode's role definition
|
||||
expect(prompt.indexOf(modes[0].roleDefinition)).toBeLessThan(prompt.indexOf("TOOL USE"))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -156,171 +406,142 @@ describe("SYSTEM_PROMPT", () => {
|
||||
})
|
||||
|
||||
describe("addCustomInstructions", () => {
|
||||
beforeAll(() => {
|
||||
// Ensure fs mock is properly initialized
|
||||
const mockFs = jest.requireMock("fs/promises")
|
||||
mockFs._setInitialMockData()
|
||||
mockFs.mkdir.mockImplementation(async (path: string) => {
|
||||
if (path.startsWith("/test")) {
|
||||
mockFs._mockDirectories.add(path)
|
||||
return Promise.resolve()
|
||||
}
|
||||
throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`)
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should generate correct prompt for architect mode", async () => {
|
||||
const prompt = await SYSTEM_PROMPT("/test/path", false, undefined, undefined, undefined, "architect")
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
"architect", // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should generate correct prompt for ask mode", async () => {
|
||||
const prompt = await SYSTEM_PROMPT("/test/path", false, undefined, undefined, undefined, "ask")
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsComputerUse
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
"ask", // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes
|
||||
)
|
||||
|
||||
expect(prompt).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific rules for code mode", async () => {
|
||||
const instructions = await addCustomInstructions({}, "/test/path", defaultModeSlug)
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific rules for ask mode", async () => {
|
||||
const instructions = await addCustomInstructions({}, "/test/path", modes[2].slug)
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", modes[2].slug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific rules for architect mode", async () => {
|
||||
const instructions = await addCustomInstructions({}, "/test/path", modes[1].slug)
|
||||
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", modes[1].slug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific rules for test engineer mode", async () => {
|
||||
// Mock readFile to include test engineer rules
|
||||
const mockReadFile = jest.fn().mockImplementation(async (path: string) => {
|
||||
if (path.endsWith(".clinerules-test")) {
|
||||
return "# Test Engineer Rules\n1. Always write tests first\n2. Get approval before modifying non-test code"
|
||||
}
|
||||
if (path.endsWith(".clinerules")) {
|
||||
return "# Test Rules\n1. First rule\n2. Second rule"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
jest.spyOn(fs, "readFile").mockImplementation(mockReadFile)
|
||||
|
||||
const instructions = await addCustomInstructions({}, "/test/path", "test")
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", "test")
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific rules for code reviewer mode", async () => {
|
||||
// Mock readFile to include code reviewer rules
|
||||
const mockReadFile = jest.fn().mockImplementation(async (path: string) => {
|
||||
if (path.endsWith(".clinerules-review")) {
|
||||
return "# Code Reviewer Rules\n1. Provide specific examples in feedback\n2. Focus on maintainability and best practices"
|
||||
}
|
||||
if (path.endsWith(".clinerules")) {
|
||||
return "# Test Rules\n1. First rule\n2. Second rule"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
jest.spyOn(fs, "readFile").mockImplementation(mockReadFile)
|
||||
|
||||
const instructions = await addCustomInstructions({}, "/test/path", "review")
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", "review")
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should fall back to generic rules when mode-specific rules not found", async () => {
|
||||
// Mock readFile to return ENOENT for mode-specific file
|
||||
const mockReadFile = jest.fn().mockImplementation(async (path: string) => {
|
||||
if (
|
||||
path.endsWith(".clinerules-code") ||
|
||||
path.endsWith(".clinerules-test") ||
|
||||
path.endsWith(".clinerules-review")
|
||||
) {
|
||||
const error = new Error("ENOENT") as NodeJS.ErrnoException
|
||||
error.code = "ENOENT"
|
||||
throw error
|
||||
}
|
||||
if (path.endsWith(".clinerules")) {
|
||||
return "# Test Rules\n1. First rule\n2. Second rule"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
jest.spyOn(fs, "readFile").mockImplementation(mockReadFile)
|
||||
|
||||
const instructions = await addCustomInstructions({}, "/test/path", defaultModeSlug)
|
||||
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should include preferred language when provided", async () => {
|
||||
const instructions = await addCustomInstructions(
|
||||
{ preferredLanguage: "Spanish" },
|
||||
"/test/path",
|
||||
defaultModeSlug,
|
||||
)
|
||||
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug, {
|
||||
preferredLanguage: "Spanish",
|
||||
})
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should include custom instructions when provided", async () => {
|
||||
const instructions = await addCustomInstructions(
|
||||
{ customInstructions: "Custom test instructions" },
|
||||
"/test/path",
|
||||
)
|
||||
|
||||
const instructions = await addCustomInstructions("Custom test instructions", "", "/test/path", defaultModeSlug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should combine all custom instructions", async () => {
|
||||
const instructions = await addCustomInstructions(
|
||||
{
|
||||
customInstructions: "Custom test instructions",
|
||||
preferredLanguage: "French",
|
||||
},
|
||||
"Custom test instructions",
|
||||
"",
|
||||
"/test/path",
|
||||
defaultModeSlug,
|
||||
{ preferredLanguage: "French" },
|
||||
)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should handle undefined mode-specific instructions", async () => {
|
||||
const instructions = await addCustomInstructions({}, "/test/path")
|
||||
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should trim mode-specific instructions", async () => {
|
||||
const instructions = await addCustomInstructions(
|
||||
{ customInstructions: " Custom mode instructions " },
|
||||
" Custom mode instructions ",
|
||||
"",
|
||||
"/test/path",
|
||||
defaultModeSlug,
|
||||
)
|
||||
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should handle empty mode-specific instructions", async () => {
|
||||
const instructions = await addCustomInstructions({ customInstructions: "" }, "/test/path")
|
||||
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug)
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should combine global and mode-specific instructions", async () => {
|
||||
const instructions = await addCustomInstructions(
|
||||
{
|
||||
customInstructions: "Global instructions",
|
||||
customPrompts: {
|
||||
code: { customInstructions: "Mode-specific instructions" },
|
||||
},
|
||||
},
|
||||
"Mode-specific instructions",
|
||||
"Global instructions",
|
||||
"/test/path",
|
||||
defaultModeSlug,
|
||||
)
|
||||
|
||||
expect(instructions).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific instructions after global ones", async () => {
|
||||
const instructions = await addCustomInstructions(
|
||||
{
|
||||
customInstructions: "First instruction",
|
||||
customPrompts: {
|
||||
code: { customInstructions: "Second instruction" },
|
||||
},
|
||||
},
|
||||
"Second instruction",
|
||||
"First instruction",
|
||||
"/test/path",
|
||||
defaultModeSlug,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ CAPABILITIES
|
||||
- When the user initially gives you a task, a recursive list of all filepaths in the current working directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current working directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.
|
||||
- You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring.
|
||||
- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.
|
||||
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file ${diffStrategy ? "or apply_diff " : ""}tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
|
||||
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file${diffStrategy ? " or apply_diff" : ""} tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
|
||||
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.${
|
||||
supportsComputerUse
|
||||
? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser."
|
||||
|
||||
@@ -23,28 +23,70 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function addCustomInstructions(
|
||||
customInstructions: string,
|
||||
modeCustomInstructions: string,
|
||||
globalCustomInstructions: string,
|
||||
cwd: string,
|
||||
preferredLanguage?: string,
|
||||
mode: string,
|
||||
options: { preferredLanguage?: string } = {},
|
||||
): Promise<string> {
|
||||
const ruleFileContent = await loadRuleFiles(cwd)
|
||||
const allInstructions = []
|
||||
const sections = []
|
||||
|
||||
if (preferredLanguage) {
|
||||
allInstructions.push(`You should always speak and think in the ${preferredLanguage} language.`)
|
||||
// Load mode-specific rules if mode is provided
|
||||
let modeRuleContent = ""
|
||||
if (mode) {
|
||||
try {
|
||||
const modeRuleFile = `.clinerules-${mode}`
|
||||
const content = await fs.readFile(path.join(cwd, modeRuleFile), "utf-8")
|
||||
if (content.trim()) {
|
||||
modeRuleContent = content.trim()
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently skip if file doesn't exist
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (customInstructions.trim()) {
|
||||
allInstructions.push(customInstructions.trim())
|
||||
// Add language preference if provided
|
||||
if (options.preferredLanguage) {
|
||||
sections.push(
|
||||
`Language Preference:\nYou should always speak and think in the ${options.preferredLanguage} language.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (ruleFileContent && ruleFileContent.trim()) {
|
||||
allInstructions.push(ruleFileContent.trim())
|
||||
// Add global instructions first
|
||||
if (typeof globalCustomInstructions === "string" && globalCustomInstructions.trim()) {
|
||||
sections.push(`Global Instructions:\n${globalCustomInstructions.trim()}`)
|
||||
}
|
||||
|
||||
const joinedInstructions = allInstructions.join("\n\n")
|
||||
// Add mode-specific instructions after
|
||||
if (typeof modeCustomInstructions === "string" && modeCustomInstructions.trim()) {
|
||||
sections.push(`Mode-specific Instructions:\n${modeCustomInstructions.trim()}`)
|
||||
}
|
||||
|
||||
return joinedInstructions
|
||||
// Add rules - include both mode-specific and generic rules if they exist
|
||||
const rules = []
|
||||
|
||||
// Add mode-specific rules first if they exist
|
||||
if (modeRuleContent && modeRuleContent.trim()) {
|
||||
const modeRuleFile = `.clinerules-${mode}`
|
||||
rules.push(`# Rules from ${modeRuleFile}:\n${modeRuleContent}`)
|
||||
}
|
||||
|
||||
// Add generic rules
|
||||
const genericRuleContent = await loadRuleFiles(cwd)
|
||||
if (genericRuleContent && genericRuleContent.trim()) {
|
||||
rules.push(genericRuleContent.trim())
|
||||
}
|
||||
|
||||
if (rules.length > 0) {
|
||||
sections.push(`Rules:\n\n${rules.join("\n\n")}`)
|
||||
}
|
||||
|
||||
const joinedSections = sections.join("\n\n")
|
||||
|
||||
return joinedSections
|
||||
? `
|
||||
====
|
||||
|
||||
@@ -52,6 +94,6 @@ USER'S CUSTOM INSTRUCTIONS
|
||||
|
||||
The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.
|
||||
|
||||
${joinedInstructions}`
|
||||
${joinedSections}`
|
||||
: ""
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export { getSharedToolUseSection } from "./tool-use"
|
||||
export { getMcpServersSection } from "./mcp-servers"
|
||||
export { getToolUseGuidelinesSection } from "./tool-use-guidelines"
|
||||
export { getCapabilitiesSection } from "./capabilities"
|
||||
export { getModesSection } from "./modes"
|
||||
|
||||
45
src/core/prompts/sections/modes.ts
Normal file
45
src/core/prompts/sections/modes.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { promises as fs } from "fs"
|
||||
import { modes, ModeConfig } from "../../../shared/modes"
|
||||
|
||||
export async function getModesSection(context: vscode.ExtensionContext): Promise<string> {
|
||||
const settingsDir = path.join(context.globalStorageUri.fsPath, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
const customModesPath = path.join(settingsDir, "cline_custom_modes.json")
|
||||
|
||||
return `====
|
||||
|
||||
MODES
|
||||
|
||||
- When referring to modes, always use their display names. The built-in modes are:
|
||||
${modes.map((mode: ModeConfig) => ` * "${mode.name}" mode - ${mode.roleDefinition.split(".")[0]}`).join("\n")}
|
||||
Custom modes will be referred to by their configured name property.
|
||||
|
||||
- Custom modes can be configured by creating or editing the custom modes file at '${customModesPath}'. The following fields are required and must not be empty:
|
||||
* slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better.
|
||||
* name: The display name for the mode
|
||||
* roleDefinition: A detailed description of the mode's role and capabilities
|
||||
* groups: Array of allowed tool groups (can be empty)
|
||||
|
||||
The customInstructions field is optional.
|
||||
|
||||
The file should follow this structure:
|
||||
{
|
||||
"customModes": [
|
||||
{
|
||||
"slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
|
||||
"name": "Designer", // Required: mode display name
|
||||
"roleDefinition": "You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\n- Creating and maintaining design systems\n- Implementing responsive and accessible web interfaces\n- Working with CSS, HTML, and modern frontend frameworks\n- Ensuring consistent user experiences across platforms", // Required: non-empty
|
||||
"groups": [ // Required: array of tool groups (can be empty)
|
||||
"read", // Read files group (read_file, search_files, list_files, list_code_definition_names)
|
||||
"edit", // Edit files group (write_to_file, apply_diff)
|
||||
"browser", // Browser group (browser_action)
|
||||
"command", // Command group (execute_command)
|
||||
"mcp" // MCP group (use_mcp_tool, access_mcp_resource)
|
||||
],
|
||||
"customInstructions": "Additional instructions for the Designer mode" // Optional
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { DiffStrategy } from "../../diff/DiffStrategy"
|
||||
import { modes, ModeConfig } from "../../../shared/modes"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
|
||||
export function getRulesSection(cwd: string, supportsComputerUse: boolean, diffStrategy?: DiffStrategy): string {
|
||||
export function getRulesSection(
|
||||
cwd: string,
|
||||
supportsComputerUse: boolean,
|
||||
diffStrategy?: DiffStrategy,
|
||||
context?: vscode.ExtensionContext,
|
||||
): string {
|
||||
const settingsDir = context ? path.join(context.globalStorageUri.fsPath, "settings") : "<settings directory>"
|
||||
const customModesPath = path.join(settingsDir, "cline_custom_modes.json")
|
||||
return `====
|
||||
|
||||
RULES
|
||||
@@ -11,7 +21,11 @@ RULES
|
||||
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`.
|
||||
- When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.
|
||||
- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser.
|
||||
${diffStrategy ? "- You should use apply_diff instead of write_to_file when making changes to existing files since it is much faster and easier to apply a diff than to write the entire file again. Only use write_to_file to edit files when apply_diff has failed repeatedly to apply the diff." : "- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool."}
|
||||
${
|
||||
diffStrategy
|
||||
? "- You should use apply_diff instead of write_to_file when making changes to existing files since it is much faster and easier to apply a diff than to write the entire file again. Only use write_to_file to edit files when apply_diff has failed repeatedly to apply the diff."
|
||||
: "- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool."
|
||||
}
|
||||
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
|
||||
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
|
||||
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import defaultShell from "default-shell"
|
||||
import os from "os"
|
||||
import osName from "os-name"
|
||||
import { Mode, ModeConfig, getModeBySlug, defaultModeSlug, isToolAllowedForMode } from "../../../shared/modes"
|
||||
|
||||
export function getSystemInfoSection(cwd: string): string {
|
||||
return `====
|
||||
export function getSystemInfoSection(cwd: string, currentMode: Mode, customModes?: ModeConfig[]): string {
|
||||
const findModeBySlug = (slug: string, modes?: ModeConfig[]) => modes?.find((m) => m.slug === slug)
|
||||
|
||||
const currentModeName = findModeBySlug(currentMode, customModes)?.name || currentMode
|
||||
const codeModeName = findModeBySlug(defaultModeSlug, customModes)?.name || "Code"
|
||||
|
||||
let details = `====
|
||||
|
||||
SYSTEM INFORMATION
|
||||
|
||||
@@ -13,4 +19,6 @@ Home Directory: ${os.homedir().toPosix()}
|
||||
Current Working Directory: ${cwd.toPosix()}
|
||||
|
||||
When the user initially gives you a task, a recursive list of all filepaths in the current working directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current working directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.`
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Mode, modes, CustomPrompts, PromptComponent, getRoleDefinition, defaultModeSlug } from "../../shared/modes"
|
||||
import {
|
||||
Mode,
|
||||
modes,
|
||||
CustomModePrompts,
|
||||
PromptComponent,
|
||||
getRoleDefinition,
|
||||
defaultModeSlug,
|
||||
ModeConfig,
|
||||
getModeBySlug,
|
||||
} from "../../shared/modes"
|
||||
import { DiffStrategy } from "../diff/DiffStrategy"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { getToolDescriptionsForMode } from "./tools"
|
||||
import * as vscode from "vscode"
|
||||
import {
|
||||
getRulesSection,
|
||||
getSystemInfoSection,
|
||||
@@ -10,88 +20,14 @@ import {
|
||||
getMcpServersSection,
|
||||
getToolUseGuidelinesSection,
|
||||
getCapabilitiesSection,
|
||||
getModesSection,
|
||||
addCustomInstructions,
|
||||
} from "./sections"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
async function loadRuleFiles(cwd: string, mode: Mode): Promise<string> {
|
||||
let combinedRules = ""
|
||||
|
||||
// First try mode-specific rules
|
||||
const modeSpecificFile = `.clinerules-${mode}`
|
||||
try {
|
||||
const content = await fs.readFile(path.join(cwd, modeSpecificFile), "utf-8")
|
||||
if (content.trim()) {
|
||||
combinedRules += `\n# Rules from ${modeSpecificFile}:\n${content.trim()}\n`
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently skip if file doesn't exist
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Then try generic rules files
|
||||
const genericRuleFiles = [".clinerules"]
|
||||
for (const file of genericRuleFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(cwd, file), "utf-8")
|
||||
if (content.trim()) {
|
||||
combinedRules += `\n# Rules from ${file}:\n${content.trim()}\n`
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently skip if file doesn't exist
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combinedRules
|
||||
}
|
||||
|
||||
interface State {
|
||||
customInstructions?: string
|
||||
customPrompts?: CustomPrompts
|
||||
preferredLanguage?: string
|
||||
}
|
||||
|
||||
export async function addCustomInstructions(state: State, cwd: string, mode: Mode = defaultModeSlug): Promise<string> {
|
||||
const ruleFileContent = await loadRuleFiles(cwd, mode)
|
||||
const allInstructions = []
|
||||
|
||||
if (state.preferredLanguage) {
|
||||
allInstructions.push(`You should always speak and think in the ${state.preferredLanguage} language.`)
|
||||
}
|
||||
|
||||
if (state.customInstructions?.trim()) {
|
||||
allInstructions.push(state.customInstructions.trim())
|
||||
}
|
||||
|
||||
const customPrompt = state.customPrompts?.[mode]
|
||||
if (typeof customPrompt === "object" && customPrompt?.customInstructions?.trim()) {
|
||||
allInstructions.push(customPrompt.customInstructions.trim())
|
||||
}
|
||||
|
||||
if (ruleFileContent && ruleFileContent.trim()) {
|
||||
allInstructions.push(ruleFileContent.trim())
|
||||
}
|
||||
|
||||
const joinedInstructions = allInstructions.join("\n\n")
|
||||
|
||||
return joinedInstructions
|
||||
? `
|
||||
====
|
||||
|
||||
USER'S CUSTOM INSTRUCTIONS
|
||||
|
||||
The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.
|
||||
|
||||
${joinedInstructions}`
|
||||
: ""
|
||||
}
|
||||
|
||||
async function generatePrompt(
|
||||
context: vscode.ExtensionContext,
|
||||
cwd: string,
|
||||
supportsComputerUse: boolean,
|
||||
mode: Mode,
|
||||
@@ -99,37 +35,78 @@ async function generatePrompt(
|
||||
diffStrategy?: DiffStrategy,
|
||||
browserViewportSize?: string,
|
||||
promptComponent?: PromptComponent,
|
||||
customModeConfigs?: ModeConfig[],
|
||||
globalCustomInstructions?: string,
|
||||
preferredLanguage?: string,
|
||||
diffEnabled?: boolean,
|
||||
): Promise<string> {
|
||||
const basePrompt = `${promptComponent?.roleDefinition || getRoleDefinition(mode)}
|
||||
if (!context) {
|
||||
throw new Error("Extension context is required for generating system prompt")
|
||||
}
|
||||
|
||||
// If diff is disabled, don't pass the diffStrategy
|
||||
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
|
||||
|
||||
const [mcpServersSection, modesSection] = await Promise.all([
|
||||
getMcpServersSection(mcpHub, effectiveDiffStrategy),
|
||||
getModesSection(context),
|
||||
])
|
||||
|
||||
// Get the full mode config to ensure we have the role definition
|
||||
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
|
||||
const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition
|
||||
|
||||
const basePrompt = `${roleDefinition}
|
||||
|
||||
${getSharedToolUseSection()}
|
||||
|
||||
${getToolDescriptionsForMode(mode, cwd, supportsComputerUse, diffStrategy, browserViewportSize, mcpHub)}
|
||||
${getToolDescriptionsForMode(
|
||||
mode,
|
||||
cwd,
|
||||
supportsComputerUse,
|
||||
effectiveDiffStrategy,
|
||||
browserViewportSize,
|
||||
mcpHub,
|
||||
customModeConfigs,
|
||||
)}
|
||||
|
||||
${getToolUseGuidelinesSection()}
|
||||
|
||||
${await getMcpServersSection(mcpHub, diffStrategy)}
|
||||
${mcpServersSection}
|
||||
|
||||
${getCapabilitiesSection(cwd, supportsComputerUse, mcpHub, diffStrategy)}
|
||||
${getCapabilitiesSection(cwd, supportsComputerUse, mcpHub, effectiveDiffStrategy)}
|
||||
|
||||
${getRulesSection(cwd, supportsComputerUse, diffStrategy)}
|
||||
${modesSection}
|
||||
|
||||
${getSystemInfoSection(cwd)}
|
||||
${getRulesSection(cwd, supportsComputerUse, diffStrategy, context)}
|
||||
|
||||
${getObjectiveSection()}`
|
||||
${getSystemInfoSection(cwd, mode, customModeConfigs)}
|
||||
|
||||
${getObjectiveSection()}
|
||||
|
||||
${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
|
||||
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = async (
|
||||
context: vscode.ExtensionContext,
|
||||
cwd: string,
|
||||
supportsComputerUse: boolean,
|
||||
mcpHub?: McpHub,
|
||||
diffStrategy?: DiffStrategy,
|
||||
browserViewportSize?: string,
|
||||
mode: Mode = defaultModeSlug,
|
||||
customPrompts?: CustomPrompts,
|
||||
) => {
|
||||
customModePrompts?: CustomModePrompts,
|
||||
customModes?: ModeConfig[],
|
||||
globalCustomInstructions?: string,
|
||||
preferredLanguage?: string,
|
||||
diffEnabled?: boolean,
|
||||
): Promise<string> => {
|
||||
if (!context) {
|
||||
throw new Error("Extension context is required for generating system prompt")
|
||||
}
|
||||
|
||||
const getPromptComponent = (value: unknown) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return value as PromptComponent
|
||||
@@ -137,17 +114,26 @@ export const SYSTEM_PROMPT = async (
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Use default mode if not found
|
||||
const currentMode = modes.find((m) => m.slug === mode) || modes[0]
|
||||
const promptComponent = getPromptComponent(customPrompts?.[currentMode.slug])
|
||||
// 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 diff is disabled, don't pass the diffStrategy
|
||||
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
|
||||
|
||||
return generatePrompt(
|
||||
context,
|
||||
cwd,
|
||||
supportsComputerUse,
|
||||
currentMode.slug,
|
||||
mcpHub,
|
||||
diffStrategy,
|
||||
effectiveDiffStrategy,
|
||||
browserViewportSize,
|
||||
promptComponent,
|
||||
customModes,
|
||||
globalCustomInstructions,
|
||||
preferredLanguage,
|
||||
diffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { getUseMcpToolDescription } from "./use-mcp-tool"
|
||||
import { getAccessMcpResourceDescription } from "./access-mcp-resource"
|
||||
import { DiffStrategy } from "../../diff/DiffStrategy"
|
||||
import { McpHub } from "../../../services/mcp/McpHub"
|
||||
import { Mode, ToolName, getModeConfig, isToolAllowedForMode } from "../../../shared/modes"
|
||||
import { Mode, ModeConfig, getModeConfig, isToolAllowedForMode } from "../../../shared/modes"
|
||||
import { ToolName, getToolName, getToolOptions, TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tool-groups"
|
||||
import { ToolArgs } from "./types"
|
||||
|
||||
// Map of tool names to their description functions
|
||||
@@ -38,8 +39,9 @@ export function getToolDescriptionsForMode(
|
||||
diffStrategy?: DiffStrategy,
|
||||
browserViewportSize?: string,
|
||||
mcpHub?: McpHub,
|
||||
customModes?: ModeConfig[],
|
||||
): string {
|
||||
const config = getModeConfig(mode)
|
||||
const config = getModeConfig(mode, customModes)
|
||||
const args: ToolArgs = {
|
||||
cwd,
|
||||
supportsComputerUse,
|
||||
@@ -48,16 +50,30 @@ export function getToolDescriptionsForMode(
|
||||
mcpHub,
|
||||
}
|
||||
|
||||
// Map tool descriptions in the exact order specified in the mode's tools array
|
||||
const descriptions = config.tools.map(([toolName, toolOptions]) => {
|
||||
const tools = new Set<string>()
|
||||
|
||||
// Add tools from mode's groups
|
||||
config.groups.forEach((group) => {
|
||||
TOOL_GROUPS[group].forEach((tool) => {
|
||||
if (isToolAllowedForMode(tool as ToolName, mode, customModes ?? [])) {
|
||||
tools.add(tool)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Add always available tools
|
||||
ALWAYS_AVAILABLE_TOOLS.forEach((tool) => tools.add(tool))
|
||||
|
||||
// Map tool descriptions for allowed tools
|
||||
const descriptions = Array.from(tools).map((toolName) => {
|
||||
const descriptionFn = toolDescriptionMap[toolName]
|
||||
if (!descriptionFn || !isToolAllowedForMode(toolName as ToolName, mode)) {
|
||||
if (!descriptionFn) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return descriptionFn({
|
||||
...args,
|
||||
toolOptions,
|
||||
toolOptions: undefined, // No tool options in group-based approach
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Mode } from "../../shared/modes"
|
||||
|
||||
export type { Mode }
|
||||
|
||||
export type ToolName =
|
||||
| "execute_command"
|
||||
| "read_file"
|
||||
| "write_to_file"
|
||||
| "apply_diff"
|
||||
| "search_files"
|
||||
| "list_files"
|
||||
| "list_code_definition_names"
|
||||
| "browser_action"
|
||||
| "use_mcp_tool"
|
||||
| "access_mcp_resource"
|
||||
| "ask_followup_question"
|
||||
| "attempt_completion"
|
||||
|
||||
export const CODE_TOOLS: ToolName[] = [
|
||||
"execute_command",
|
||||
"read_file",
|
||||
"write_to_file",
|
||||
"apply_diff",
|
||||
"search_files",
|
||||
"list_files",
|
||||
"list_code_definition_names",
|
||||
"browser_action",
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
]
|
||||
|
||||
export const ARCHITECT_TOOLS: ToolName[] = [
|
||||
"read_file",
|
||||
"search_files",
|
||||
"list_files",
|
||||
"list_code_definition_names",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
]
|
||||
|
||||
export const ASK_TOOLS: ToolName[] = [
|
||||
"read_file",
|
||||
"search_files",
|
||||
"list_files",
|
||||
"browser_action",
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
// Shared tools for architect and ask modes - read-only operations plus MCP and browser tools
|
||||
export const READONLY_ALLOWED_TOOLS = [
|
||||
"read_file",
|
||||
"search_files",
|
||||
"list_files",
|
||||
"list_code_definition_names",
|
||||
"browser_action",
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
] as const
|
||||
|
||||
// Code mode has access to all tools
|
||||
export const CODE_ALLOWED_TOOLS = [
|
||||
"execute_command",
|
||||
"read_file",
|
||||
"write_to_file",
|
||||
"apply_diff",
|
||||
"search_files",
|
||||
"list_files",
|
||||
"list_code_definition_names",
|
||||
"browser_action",
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
] as const
|
||||
|
||||
// Tool name types for type safety
|
||||
export type ReadOnlyToolName = (typeof READONLY_ALLOWED_TOOLS)[number]
|
||||
export type ToolName = (typeof CODE_ALLOWED_TOOLS)[number]
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import delay from "delay"
|
||||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
@@ -10,15 +11,24 @@ import { downloadTask } from "../../integrations/misc/export-markdown"
|
||||
import { openFile, openImage } from "../../integrations/misc/open-file"
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import { getDiffStrategy } from "../diff/DiffStrategy"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { WebviewMessage, PromptMode } from "../../shared/WebviewMessage"
|
||||
import { defaultModeSlug, defaultPrompts } from "../../shared/modes"
|
||||
import { SYSTEM_PROMPT, addCustomInstructions } from "../prompts/system"
|
||||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import {
|
||||
Mode,
|
||||
modes,
|
||||
CustomModePrompts,
|
||||
PromptComponent,
|
||||
ModeConfig,
|
||||
defaultModeSlug,
|
||||
getModeBySlug,
|
||||
} from "../../shared/modes"
|
||||
import { SYSTEM_PROMPT } from "../prompts/system"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { Cline } from "../Cline"
|
||||
import { openMention } from "../mentions"
|
||||
@@ -26,10 +36,13 @@ import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { enhancePrompt } from "../../utils/enhance-prompt"
|
||||
import { singleCompletionHandler } from "../../utils/single-completion-handler"
|
||||
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
|
||||
import { ConfigManager } from "../config/ConfigManager"
|
||||
import { Mode, modes, CustomPrompts, PromptComponent, enhance } from "../../shared/modes"
|
||||
import { CustomModesManager } from "../config/CustomModesManager"
|
||||
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
|
||||
|
||||
import { ACTION_NAMES } from "../CodeActionProvider"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -69,6 +82,8 @@ type GlobalStateKey =
|
||||
| "taskHistory"
|
||||
| "openAiBaseUrl"
|
||||
| "openAiModelId"
|
||||
| "openAiCustomModelInfo"
|
||||
| "openAiUseAzure"
|
||||
| "ollamaModelId"
|
||||
| "ollamaBaseUrl"
|
||||
| "lmStudioModelId"
|
||||
@@ -98,10 +113,12 @@ type GlobalStateKey =
|
||||
| "vsCodeLmModelSelector"
|
||||
| "mode"
|
||||
| "modeApiConfigs"
|
||||
| "customPrompts"
|
||||
| "customModePrompts"
|
||||
| "customSupportPrompts"
|
||||
| "enhancementApiConfigId"
|
||||
| "experimentalDiffStrategy"
|
||||
| "autoApprovalEnabled"
|
||||
| "customModes" // Array of custom modes
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
@@ -120,8 +137,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
private cline?: Cline
|
||||
private workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "jan-13-2025-custom-prompt" // update to some unique identifier when we add a new announcement
|
||||
private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement
|
||||
configManager: ConfigManager
|
||||
customModesManager: CustomModesManager
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -132,6 +150,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.configManager = new ConfigManager(this.context)
|
||||
this.customModesManager = new CustomModesManager(this.context, async () => {
|
||||
await this.postStateToWebview()
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -157,6 +178,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.workspaceTracker = undefined
|
||||
this.mcpHub?.dispose()
|
||||
this.mcpHub = undefined
|
||||
this.customModesManager?.dispose()
|
||||
this.outputChannel.appendLine("Disposed all disposables")
|
||||
ClineProvider.activeInstances.delete(this)
|
||||
}
|
||||
@@ -165,6 +187,32 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
|
||||
}
|
||||
|
||||
public static async handleCodeAction(
|
||||
promptType: keyof typeof ACTION_NAMES,
|
||||
params: Record<string, string | any[]>,
|
||||
): Promise<void> {
|
||||
let visibleProvider = ClineProvider.getVisibleInstance()
|
||||
|
||||
// If no visible provider, try to show the sidebar view
|
||||
if (!visibleProvider) {
|
||||
await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus")
|
||||
// Wait briefly for the view to become visible
|
||||
await delay(100)
|
||||
visibleProvider = ClineProvider.getVisibleInstance()
|
||||
}
|
||||
|
||||
// If still no visible provider, return
|
||||
if (!visibleProvider) {
|
||||
return
|
||||
}
|
||||
|
||||
const { customSupportPrompts } = await visibleProvider.getState()
|
||||
|
||||
const prompt = supportPrompt.create(promptType, params, customSupportPrompts)
|
||||
|
||||
await visibleProvider.initClineWithTask(prompt)
|
||||
}
|
||||
|
||||
resolveWebviewView(
|
||||
webviewView: vscode.WebviewView | vscode.WebviewPanel,
|
||||
//context: vscode.WebviewViewResolveContext<unknown>, used to recreate a deallocated webview, but we don't need this since we use retainContextWhenHidden
|
||||
@@ -251,7 +299,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.clearTask()
|
||||
const {
|
||||
apiConfiguration,
|
||||
customPrompts,
|
||||
customModePrompts,
|
||||
diffEnabled,
|
||||
fuzzyMatchThreshold,
|
||||
mode,
|
||||
@@ -259,9 +307,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
experimentalDiffStrategy,
|
||||
} = await this.getState()
|
||||
|
||||
const modePrompt = customPrompts?.[mode]
|
||||
const modeInstructions = typeof modePrompt === "object" ? modePrompt.customInstructions : undefined
|
||||
const effectiveInstructions = [globalInstructions, modeInstructions].filter(Boolean).join("\n\n")
|
||||
const modePrompt = customModePrompts?.[mode] as PromptComponent
|
||||
const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
|
||||
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
@@ -280,7 +327,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.clearTask()
|
||||
const {
|
||||
apiConfiguration,
|
||||
customPrompts,
|
||||
customModePrompts,
|
||||
diffEnabled,
|
||||
fuzzyMatchThreshold,
|
||||
mode,
|
||||
@@ -288,9 +335,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
experimentalDiffStrategy,
|
||||
} = await this.getState()
|
||||
|
||||
const modePrompt = customPrompts?.[mode]
|
||||
const modeInstructions = typeof modePrompt === "object" ? modePrompt.customInstructions : undefined
|
||||
const effectiveInstructions = [globalInstructions, modeInstructions].filter(Boolean).join("\n\n")
|
||||
const modePrompt = customModePrompts?.[mode] as PromptComponent
|
||||
const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
|
||||
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
@@ -379,7 +425,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} data:; script-src 'nonce-${nonce}';">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
<title>Roo Code</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
@@ -401,6 +447,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
async (message: WebviewMessage) => {
|
||||
switch (message.type) {
|
||||
case "webviewDidLaunch":
|
||||
// Load custom modes first
|
||||
const customModes = await this.customModesManager.getCustomModes()
|
||||
await this.updateGlobalState("customModes", customModes)
|
||||
|
||||
this.postStateToWebview()
|
||||
this.workspaceTracker?.initializeFilePaths() // don't await
|
||||
getTheme().then((theme) =>
|
||||
@@ -638,6 +688,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
break
|
||||
}
|
||||
case "openCustomModesSettings": {
|
||||
const customModesFilePath = await this.customModesManager.getCustomModesFilePath()
|
||||
if (customModesFilePath) {
|
||||
openFile(customModesFilePath)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "restartMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.restartConnection(message.text!)
|
||||
@@ -757,47 +814,65 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "updateEnhancedPrompt":
|
||||
const existingPrompts = (await this.getGlobalState("customPrompts")) || {}
|
||||
case "updateSupportPrompt":
|
||||
try {
|
||||
if (Object.keys(message?.values ?? {}).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedPrompts = {
|
||||
...existingPrompts,
|
||||
enhance: message.text,
|
||||
const existingPrompts = (await this.getGlobalState("customSupportPrompts")) || {}
|
||||
|
||||
const updatedPrompts = {
|
||||
...existingPrompts,
|
||||
...message.values,
|
||||
}
|
||||
|
||||
await this.updateGlobalState("customSupportPrompts", updatedPrompts)
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Error update support prompt:", error)
|
||||
vscode.window.showErrorMessage("Failed to update support prompt")
|
||||
}
|
||||
break
|
||||
case "resetSupportPrompt":
|
||||
try {
|
||||
if (!message?.text) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.updateGlobalState("customPrompts", updatedPrompts)
|
||||
const existingPrompts = ((await this.getGlobalState("customSupportPrompts")) ||
|
||||
{}) as Record<string, any>
|
||||
|
||||
// Get current state and explicitly include customPrompts
|
||||
const currentState = await this.getState()
|
||||
const updatedPrompts = {
|
||||
...existingPrompts,
|
||||
}
|
||||
|
||||
const stateWithPrompts = {
|
||||
...currentState,
|
||||
customPrompts: updatedPrompts,
|
||||
updatedPrompts[message.text] = undefined
|
||||
|
||||
await this.updateGlobalState("customSupportPrompts", updatedPrompts)
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Error reset support prompt:", error)
|
||||
vscode.window.showErrorMessage("Failed to reset support prompt")
|
||||
}
|
||||
|
||||
// Post state with prompts
|
||||
this.view?.webview.postMessage({
|
||||
type: "state",
|
||||
state: stateWithPrompts,
|
||||
})
|
||||
break
|
||||
case "updatePrompt":
|
||||
if (message.promptMode && message.customPrompt !== undefined) {
|
||||
const existingPrompts = (await this.getGlobalState("customPrompts")) || {}
|
||||
const existingPrompts = (await this.getGlobalState("customModePrompts")) || {}
|
||||
|
||||
const updatedPrompts = {
|
||||
...existingPrompts,
|
||||
[message.promptMode]: message.customPrompt,
|
||||
}
|
||||
|
||||
await this.updateGlobalState("customPrompts", updatedPrompts)
|
||||
await this.updateGlobalState("customModePrompts", updatedPrompts)
|
||||
|
||||
// Get current state and explicitly include customPrompts
|
||||
// Get current state and explicitly include customModePrompts
|
||||
const currentState = await this.getState()
|
||||
|
||||
const stateWithPrompts = {
|
||||
...currentState,
|
||||
customPrompts: updatedPrompts,
|
||||
customModePrompts: updatedPrompts,
|
||||
}
|
||||
|
||||
// Post state with prompts
|
||||
@@ -907,8 +982,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
case "enhancePrompt":
|
||||
if (message.text) {
|
||||
try {
|
||||
const { apiConfiguration, customPrompts, listApiConfigMeta, enhancementApiConfigId } =
|
||||
await this.getState()
|
||||
const {
|
||||
apiConfiguration,
|
||||
customSupportPrompts,
|
||||
listApiConfigMeta,
|
||||
enhancementApiConfigId,
|
||||
} = await this.getState()
|
||||
|
||||
// Try to get enhancement config first, fall back to current config
|
||||
let configToUse: ApiConfiguration = apiConfiguration
|
||||
@@ -922,17 +1001,17 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
const getEnhancePrompt = (value: string | PromptComponent | undefined): string => {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
return enhance.prompt // Use the constant from modes.ts which we know is a string
|
||||
}
|
||||
const enhancedPrompt = await enhancePrompt(
|
||||
const enhancedPrompt = await singleCompletionHandler(
|
||||
configToUse,
|
||||
message.text,
|
||||
getEnhancePrompt(customPrompts?.enhance),
|
||||
supportPrompt.create(
|
||||
"ENHANCE",
|
||||
{
|
||||
userInput: message.text,
|
||||
},
|
||||
customSupportPrompts,
|
||||
),
|
||||
)
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "enhancedPrompt",
|
||||
text: enhancedPrompt,
|
||||
@@ -950,36 +1029,46 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
try {
|
||||
const {
|
||||
apiConfiguration,
|
||||
customPrompts,
|
||||
customModePrompts,
|
||||
customInstructions,
|
||||
preferredLanguage,
|
||||
browserViewportSize,
|
||||
diffEnabled,
|
||||
mcpEnabled,
|
||||
fuzzyMatchThreshold,
|
||||
experimentalDiffStrategy,
|
||||
} = await this.getState()
|
||||
|
||||
// Create diffStrategy based on current model and settings
|
||||
const diffStrategy = getDiffStrategy(
|
||||
apiConfiguration.apiModelId || apiConfiguration.openRouterModelId || "",
|
||||
fuzzyMatchThreshold,
|
||||
experimentalDiffStrategy,
|
||||
)
|
||||
const cwd =
|
||||
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
|
||||
|
||||
const mode = message.mode ?? defaultModeSlug
|
||||
const instructions = await addCustomInstructions(
|
||||
{ customInstructions, customPrompts, preferredLanguage },
|
||||
cwd,
|
||||
mode,
|
||||
)
|
||||
const customModes = await this.customModesManager.getCustomModes()
|
||||
|
||||
const systemPrompt = await SYSTEM_PROMPT(
|
||||
this.context,
|
||||
cwd,
|
||||
apiConfiguration.openRouterModelInfo?.supportsComputerUse ?? false,
|
||||
mcpEnabled ? this.mcpHub : undefined,
|
||||
undefined,
|
||||
diffStrategy,
|
||||
browserViewportSize ?? "900x600",
|
||||
mode,
|
||||
customPrompts,
|
||||
customModePrompts,
|
||||
customModes,
|
||||
customInstructions,
|
||||
preferredLanguage,
|
||||
diffEnabled,
|
||||
)
|
||||
const fullPrompt = instructions ? `${systemPrompt}${instructions}` : systemPrompt
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "systemPrompt",
|
||||
text: fullPrompt,
|
||||
text: systemPrompt,
|
||||
mode: message.mode,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -1117,6 +1206,34 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.cline.updateDiffStrategy(message.bool ?? false)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "updateCustomMode":
|
||||
if (message.modeConfig) {
|
||||
await this.customModesManager.updateCustomMode(message.modeConfig.slug, message.modeConfig)
|
||||
// Update state after saving the mode
|
||||
const customModes = await this.customModesManager.getCustomModes()
|
||||
await this.updateGlobalState("customModes", customModes)
|
||||
await this.updateGlobalState("mode", message.modeConfig.slug)
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
break
|
||||
case "deleteCustomMode":
|
||||
if (message.slug) {
|
||||
const answer = await vscode.window.showInformationMessage(
|
||||
"Are you sure you want to delete this custom mode?",
|
||||
{ modal: true },
|
||||
"Yes",
|
||||
)
|
||||
|
||||
if (answer !== "Yes") {
|
||||
break
|
||||
}
|
||||
|
||||
await this.customModesManager.deleteCustomMode(message.slug)
|
||||
// Switch back to default mode after deletion
|
||||
await this.updateGlobalState("mode", defaultModeSlug)
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
@@ -1156,6 +1273,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiCustomModelInfo,
|
||||
openAiUseAzure,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
lmStudioModelId,
|
||||
@@ -1191,6 +1310,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
|
||||
await this.storeSecret("openAiApiKey", openAiApiKey)
|
||||
await this.updateGlobalState("openAiModelId", openAiModelId)
|
||||
await this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo)
|
||||
await this.updateGlobalState("openAiUseAzure", openAiUseAzure)
|
||||
await this.updateGlobalState("ollamaModelId", ollamaModelId)
|
||||
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
|
||||
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
|
||||
@@ -1690,7 +1811,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
mode,
|
||||
customPrompts,
|
||||
customModePrompts,
|
||||
customSupportPrompts,
|
||||
enhancementApiConfigId,
|
||||
experimentalDiffStrategy,
|
||||
autoApprovalEnabled,
|
||||
@@ -1729,10 +1851,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
currentApiConfigName: currentApiConfigName ?? "default",
|
||||
listApiConfigMeta: listApiConfigMeta ?? [],
|
||||
mode: mode ?? defaultModeSlug,
|
||||
customPrompts: customPrompts ?? {},
|
||||
customModePrompts: customModePrompts ?? {},
|
||||
customSupportPrompts: customSupportPrompts ?? {},
|
||||
enhancementApiConfigId,
|
||||
experimentalDiffStrategy: experimentalDiffStrategy ?? false,
|
||||
autoApprovalEnabled: autoApprovalEnabled ?? false,
|
||||
customModes: await this.customModesManager.getCustomModes(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1808,6 +1932,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiCustomModelInfo,
|
||||
openAiUseAzure,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
lmStudioModelId,
|
||||
@@ -1848,10 +1974,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
vsCodeLmModelSelector,
|
||||
mode,
|
||||
modeApiConfigs,
|
||||
customPrompts,
|
||||
customModePrompts,
|
||||
customSupportPrompts,
|
||||
enhancementApiConfigId,
|
||||
experimentalDiffStrategy,
|
||||
autoApprovalEnabled,
|
||||
customModes,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<string | undefined>,
|
||||
@@ -1872,6 +2000,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.getGlobalState("openAiBaseUrl") as Promise<string | undefined>,
|
||||
this.getSecret("openAiApiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("openAiModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("openAiCustomModelInfo") as Promise<ModelInfo | undefined>,
|
||||
this.getGlobalState("openAiUseAzure") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("ollamaModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("ollamaBaseUrl") as Promise<string | undefined>,
|
||||
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
|
||||
@@ -1912,10 +2042,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
this.getGlobalState("mode") as Promise<Mode | undefined>,
|
||||
this.getGlobalState("modeApiConfigs") as Promise<Record<Mode, string> | undefined>,
|
||||
this.getGlobalState("customPrompts") as Promise<CustomPrompts | undefined>,
|
||||
this.getGlobalState("customModePrompts") as Promise<CustomModePrompts | undefined>,
|
||||
this.getGlobalState("customSupportPrompts") as Promise<CustomSupportPrompts | undefined>,
|
||||
this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>,
|
||||
this.getGlobalState("experimentalDiffStrategy") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>,
|
||||
this.customModesManager.getCustomModes(),
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -1953,6 +2085,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiCustomModelInfo,
|
||||
openAiUseAzure,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
lmStudioModelId,
|
||||
@@ -2022,10 +2156,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
currentApiConfigName: currentApiConfigName ?? "default",
|
||||
listApiConfigMeta: listApiConfigMeta ?? [],
|
||||
modeApiConfigs: modeApiConfigs ?? ({} as Record<Mode, string>),
|
||||
customPrompts: customPrompts ?? {},
|
||||
customModePrompts: customModePrompts ?? {},
|
||||
customSupportPrompts: customSupportPrompts ?? {},
|
||||
enhancementApiConfigId,
|
||||
experimentalDiffStrategy: experimentalDiffStrategy ?? false,
|
||||
autoApprovalEnabled: autoApprovalEnabled ?? false,
|
||||
customModes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2119,6 +2255,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.storeSecret(key, undefined)
|
||||
}
|
||||
await this.configManager.resetAllConfigs()
|
||||
await this.customModesManager.resetCustomModes()
|
||||
if (this.cline) {
|
||||
this.cline.abortTask()
|
||||
this.cline = undefined
|
||||
|
||||
@@ -3,6 +3,13 @@ import * as vscode from "vscode"
|
||||
import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
|
||||
import { setSoundEnabled } from "../../../utils/sound"
|
||||
import { defaultModeSlug, modes } from "../../../shared/modes"
|
||||
import { addCustomInstructions } from "../../prompts/sections/custom-instructions"
|
||||
|
||||
// Mock custom-instructions module
|
||||
const mockAddCustomInstructions = jest.fn()
|
||||
jest.mock("../../prompts/sections/custom-instructions", () => ({
|
||||
addCustomInstructions: mockAddCustomInstructions,
|
||||
}))
|
||||
|
||||
// Mock delay module
|
||||
jest.mock("delay", () => {
|
||||
@@ -63,6 +70,13 @@ jest.mock(
|
||||
{ virtual: true },
|
||||
)
|
||||
|
||||
// Mock DiffStrategy
|
||||
jest.mock("../../diff/DiffStrategy", () => ({
|
||||
getDiffStrategy: jest.fn().mockImplementation(() => ({
|
||||
getToolDescription: jest.fn().mockReturnValue("apply_diff tool description"),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("vscode", () => ({
|
||||
ExtensionContext: jest.fn(),
|
||||
@@ -130,7 +144,6 @@ jest.mock("../../../api", () => ({
|
||||
jest.mock("../../prompts/system", () => ({
|
||||
SYSTEM_PROMPT: jest.fn().mockImplementation(async () => "mocked system prompt"),
|
||||
codeMode: "code",
|
||||
addCustomInstructions: jest.fn().mockImplementation(async () => ""),
|
||||
}))
|
||||
|
||||
// Mock WorkspaceTracker
|
||||
@@ -221,6 +234,13 @@ describe("ClineProvider", () => {
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock CustomModesManager
|
||||
const mockCustomModesManager = {
|
||||
updateCustomMode: jest.fn().mockResolvedValue(undefined),
|
||||
getCustomModes: jest.fn().mockResolvedValue({}),
|
||||
dispose: jest.fn(),
|
||||
}
|
||||
|
||||
// Mock output channel
|
||||
mockOutputChannel = {
|
||||
appendLine: jest.fn(),
|
||||
@@ -250,6 +270,8 @@ describe("ClineProvider", () => {
|
||||
} as unknown as vscode.WebviewView
|
||||
|
||||
provider = new ClineProvider(mockContext, mockOutputChannel)
|
||||
// @ts-ignore - accessing private property for testing
|
||||
provider.customModesManager = mockCustomModesManager
|
||||
})
|
||||
|
||||
test("constructor initializes correctly", () => {
|
||||
@@ -297,6 +319,7 @@ describe("ClineProvider", () => {
|
||||
mcpEnabled: true,
|
||||
requestDelaySeconds: 5,
|
||||
mode: defaultModeSlug,
|
||||
customModes: [],
|
||||
}
|
||||
|
||||
const message: ExtensionMessage = {
|
||||
@@ -532,7 +555,7 @@ describe("ClineProvider", () => {
|
||||
architect: "existing architect prompt",
|
||||
}
|
||||
;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === "customPrompts") {
|
||||
if (key === "customModePrompts") {
|
||||
return existingPrompts
|
||||
}
|
||||
return undefined
|
||||
@@ -546,7 +569,7 @@ describe("ClineProvider", () => {
|
||||
})
|
||||
|
||||
// Verify state was updated correctly
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("customPrompts", {
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("customModePrompts", {
|
||||
...existingPrompts,
|
||||
code: "new code prompt",
|
||||
})
|
||||
@@ -556,7 +579,7 @@ describe("ClineProvider", () => {
|
||||
expect.objectContaining({
|
||||
type: "state",
|
||||
state: expect.objectContaining({
|
||||
customPrompts: {
|
||||
customModePrompts: {
|
||||
...existingPrompts,
|
||||
code: "new code prompt",
|
||||
},
|
||||
@@ -565,17 +588,17 @@ describe("ClineProvider", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("customPrompts defaults to empty object", async () => {
|
||||
// Mock globalState.get to return undefined for customPrompts
|
||||
test("customModePrompts defaults to empty object", async () => {
|
||||
// Mock globalState.get to return undefined for customModePrompts
|
||||
;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === "customPrompts") {
|
||||
if (key === "customModePrompts") {
|
||||
return undefined
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const state = await provider.getState()
|
||||
expect(state.customPrompts).toEqual({})
|
||||
expect(state.customModePrompts).toEqual({})
|
||||
})
|
||||
|
||||
test("uses mode-specific custom instructions in Cline initialization", async () => {
|
||||
@@ -588,7 +611,7 @@ describe("ClineProvider", () => {
|
||||
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: mockApiConfig,
|
||||
customPrompts: {
|
||||
customModePrompts: {
|
||||
code: { customInstructions: modeCustomInstructions },
|
||||
},
|
||||
mode: "code",
|
||||
@@ -628,7 +651,7 @@ describe("ClineProvider", () => {
|
||||
},
|
||||
}
|
||||
mockContext.globalState.get = jest.fn((key: string) => {
|
||||
if (key === "customPrompts") {
|
||||
if (key === "customModePrompts") {
|
||||
return existingPrompts
|
||||
}
|
||||
return undefined
|
||||
@@ -645,7 +668,7 @@ describe("ClineProvider", () => {
|
||||
})
|
||||
|
||||
// Verify state was updated correctly
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("customPrompts", {
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("customModePrompts", {
|
||||
code: {
|
||||
roleDefinition: "Code role",
|
||||
customInstructions: "New instructions",
|
||||
@@ -831,6 +854,13 @@ describe("ClineProvider", () => {
|
||||
beforeEach(() => {
|
||||
mockPostMessage.mockClear()
|
||||
provider.resolveWebviewView(mockWebviewView)
|
||||
// Reset and setup mock
|
||||
mockAddCustomInstructions.mockClear()
|
||||
mockAddCustomInstructions.mockImplementation(
|
||||
(modeInstructions: string, globalInstructions: string, cwd: string) => {
|
||||
return Promise.resolve(modeInstructions || globalInstructions || "")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const getMessageHandler = () => {
|
||||
@@ -913,77 +943,226 @@ describe("ClineProvider", () => {
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to get system prompt")
|
||||
})
|
||||
|
||||
test("uses mode-specific custom instructions in system prompt", async () => {
|
||||
const systemPrompt = require("../../prompts/system")
|
||||
const { addCustomInstructions } = systemPrompt
|
||||
test("uses code mode custom instructions", async () => {
|
||||
// Get the mock function
|
||||
const mockAddCustomInstructions = (jest.requireMock("../../prompts/sections/custom-instructions") as any)
|
||||
.addCustomInstructions
|
||||
|
||||
// Mock getState to return mode-specific custom instructions
|
||||
// Clear any previous calls
|
||||
mockAddCustomInstructions.mockClear()
|
||||
|
||||
// Mock SYSTEM_PROMPT
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
jest.spyOn(systemPromptModule, "SYSTEM_PROMPT").mockImplementation(async () => {
|
||||
await mockAddCustomInstructions("Code mode specific instructions", "", "/mock/path")
|
||||
return "mocked system prompt"
|
||||
})
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
const promptHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
await promptHandler({ type: "getSystemPrompt" })
|
||||
|
||||
// Verify mock was called with code mode instructions
|
||||
expect(mockAddCustomInstructions).toHaveBeenCalledWith(
|
||||
"Code mode specific instructions",
|
||||
"",
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
test("passes diffStrategy and diffEnabled to SYSTEM_PROMPT when previewing", async () => {
|
||||
// Mock getState to return experimentalDiffStrategy, diffEnabled and fuzzyMatchThreshold
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
apiModelId: "test-model",
|
||||
openRouterModelInfo: { supportsComputerUse: true },
|
||||
},
|
||||
customPrompts: {
|
||||
code: { customInstructions: "Code mode specific instructions" },
|
||||
},
|
||||
customModePrompts: {},
|
||||
mode: "code",
|
||||
mcpEnabled: false,
|
||||
browserViewportSize: "900x600",
|
||||
experimentalDiffStrategy: true,
|
||||
diffEnabled: true,
|
||||
fuzzyMatchThreshold: 0.8,
|
||||
} as any)
|
||||
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
await messageHandler({ type: "getSystemPrompt", mode: "code" })
|
||||
// Mock SYSTEM_PROMPT to verify diffStrategy and diffEnabled are passed
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
|
||||
|
||||
// Verify addCustomInstructions was called with mode-specific instructions
|
||||
expect(addCustomInstructions).toHaveBeenCalledWith(
|
||||
{
|
||||
customInstructions: undefined,
|
||||
customPrompts: {
|
||||
code: { customInstructions: "Code mode specific instructions" },
|
||||
},
|
||||
preferredLanguage: undefined,
|
||||
// Trigger getSystemPrompt
|
||||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called with correct arguments
|
||||
expect(systemPromptSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // context
|
||||
expect.any(String), // cwd
|
||||
true, // supportsComputerUse
|
||||
undefined, // mcpHub (disabled)
|
||||
expect.objectContaining({
|
||||
// diffStrategy
|
||||
getToolDescription: expect.any(Function),
|
||||
}),
|
||||
"900x600", // browserViewportSize
|
||||
"code", // mode
|
||||
{}, // customModePrompts
|
||||
{}, // customModes
|
||||
undefined, // effectiveInstructions
|
||||
undefined, // preferredLanguage
|
||||
true, // diffEnabled
|
||||
)
|
||||
|
||||
// Run the test again to verify it's consistent
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
expect(systemPromptSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test("passes diffEnabled: false to SYSTEM_PROMPT when diff is disabled", async () => {
|
||||
// Mock getState to return diffEnabled: false
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
apiModelId: "test-model",
|
||||
openRouterModelInfo: { supportsComputerUse: true },
|
||||
},
|
||||
expect.any(String),
|
||||
"code",
|
||||
customModePrompts: {},
|
||||
mode: "code",
|
||||
mcpEnabled: false,
|
||||
browserViewportSize: "900x600",
|
||||
experimentalDiffStrategy: true,
|
||||
diffEnabled: false,
|
||||
fuzzyMatchThreshold: 0.8,
|
||||
} as any)
|
||||
|
||||
// Mock SYSTEM_PROMPT to verify diffEnabled is passed as false
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called with diffEnabled: false
|
||||
expect(systemPromptSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // context
|
||||
expect.any(String), // cwd
|
||||
true, // supportsComputerUse
|
||||
undefined, // mcpHub (disabled)
|
||||
expect.objectContaining({
|
||||
// diffStrategy
|
||||
getToolDescription: expect.any(Function),
|
||||
}),
|
||||
"900x600", // browserViewportSize
|
||||
"code", // mode
|
||||
{}, // customModePrompts
|
||||
{}, // customModes
|
||||
undefined, // effectiveInstructions
|
||||
undefined, // preferredLanguage
|
||||
false, // diffEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test("uses correct mode-specific instructions when mode is specified", async () => {
|
||||
const systemPrompt = require("../../prompts/system")
|
||||
const { addCustomInstructions } = systemPrompt
|
||||
|
||||
// Mock getState to return instructions for multiple modes
|
||||
// Mock getState to return architect mode instructions
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelInfo: { supportsComputerUse: true },
|
||||
},
|
||||
customPrompts: {
|
||||
code: { customInstructions: "Code mode instructions" },
|
||||
customModePrompts: {
|
||||
architect: { customInstructions: "Architect mode instructions" },
|
||||
},
|
||||
mode: "code",
|
||||
mode: "architect",
|
||||
mcpEnabled: false,
|
||||
browserViewportSize: "900x600",
|
||||
} as any)
|
||||
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
// Mock SYSTEM_PROMPT to call addCustomInstructions
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
jest.spyOn(systemPromptModule, "SYSTEM_PROMPT").mockImplementation(async () => {
|
||||
await mockAddCustomInstructions("Architect mode instructions", "", "/mock/path")
|
||||
return "mocked system prompt"
|
||||
})
|
||||
|
||||
// Request architect mode prompt
|
||||
await messageHandler({ type: "getSystemPrompt", mode: "architect" })
|
||||
// Resolve webview and trigger getSystemPrompt
|
||||
provider.resolveWebviewView(mockWebviewView)
|
||||
const architectHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
await architectHandler({ type: "getSystemPrompt" })
|
||||
|
||||
// Verify architect mode instructions were used
|
||||
expect(addCustomInstructions).toHaveBeenCalledWith(
|
||||
{
|
||||
customInstructions: undefined,
|
||||
customPrompts: {
|
||||
code: { customInstructions: "Code mode instructions" },
|
||||
architect: { customInstructions: "Architect mode instructions" },
|
||||
},
|
||||
preferredLanguage: undefined,
|
||||
},
|
||||
expect(mockAddCustomInstructions).toHaveBeenCalledWith(
|
||||
"Architect mode instructions",
|
||||
"",
|
||||
expect.any(String),
|
||||
"architect",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateCustomMode", () => {
|
||||
test("updates both file and state when updating custom mode", async () => {
|
||||
provider.resolveWebviewView(mockWebviewView)
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Mock CustomModesManager methods
|
||||
provider.customModesManager = {
|
||||
updateCustomMode: jest.fn().mockResolvedValue(undefined),
|
||||
getCustomModes: jest.fn().mockResolvedValue({
|
||||
"test-mode": {
|
||||
slug: "test-mode",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Updated role definition",
|
||||
groups: ["read"] as const,
|
||||
},
|
||||
}),
|
||||
dispose: jest.fn(),
|
||||
} as any
|
||||
|
||||
// Test updating a custom mode
|
||||
await messageHandler({
|
||||
type: "updateCustomMode",
|
||||
modeConfig: {
|
||||
slug: "test-mode",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Updated role definition",
|
||||
groups: ["read"] as const,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify CustomModesManager.updateCustomMode was called
|
||||
expect(provider.customModesManager.updateCustomMode).toHaveBeenCalledWith(
|
||||
"test-mode",
|
||||
expect.objectContaining({
|
||||
slug: "test-mode",
|
||||
roleDefinition: "Updated role definition",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify state was updated
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith(
|
||||
"customModes",
|
||||
expect.objectContaining({
|
||||
"test-mode": expect.objectContaining({
|
||||
slug: "test-mode",
|
||||
roleDefinition: "Updated role definition",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify state was posted to webview
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "state",
|
||||
state: expect.objectContaining({
|
||||
customModes: expect.objectContaining({
|
||||
"test-mode": expect.objectContaining({
|
||||
slug: "test-mode",
|
||||
roleDefinition: "Updated role definition",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user