feat: implement experimental features system

- Add experiments.ts to manage experimental features
- Refactor experimental diff strategy into experiments system
- Add UI components for managing experimental features
- Add tests for experimental tools
- Update system prompts to handle experiments
This commit is contained in:
sam hoang
2025-01-27 03:48:24 +07:00
parent 2c97b59ed1
commit ad552ea026
14 changed files with 429 additions and 69 deletions

View File

@@ -61,6 +61,7 @@ import { OpenRouterHandler } from "../api/providers/openrouter"
import { McpHub } from "../services/mcp/McpHub" import { McpHub } from "../services/mcp/McpHub"
import crypto from "crypto" import crypto from "crypto"
import { insertGroups } from "./diff/insert-groups" import { insertGroups } from "./diff/insert-groups"
import { EXPERIMENT_IDS } from "../shared/experiments"
const cwd = const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -151,9 +152,8 @@ export class Cline {
async updateDiffStrategy(experimentalDiffStrategy?: boolean) { async updateDiffStrategy(experimentalDiffStrategy?: boolean) {
// If not provided, get from current state // If not provided, get from current state
if (experimentalDiffStrategy === undefined) { if (experimentalDiffStrategy === undefined) {
const { experimentalDiffStrategy: stateExperimentalDiffStrategy } = const { experiments: stateExperimental } = (await this.providerRef.deref()?.getState()) ?? {}
(await this.providerRef.deref()?.getState()) ?? {} experimentalDiffStrategy = stateExperimental?.[EXPERIMENT_IDS.DIFF_STRATEGY] ?? false
experimentalDiffStrategy = stateExperimentalDiffStrategy ?? false
} }
this.diffStrategy = getDiffStrategy(this.api.getModel().id, this.fuzzyMatchThreshold, experimentalDiffStrategy) this.diffStrategy = getDiffStrategy(this.api.getModel().id, this.fuzzyMatchThreshold, experimentalDiffStrategy)
} }
@@ -810,7 +810,7 @@ export class Cline {
}) })
} }
const { browserViewportSize, mode, customModePrompts, preferredLanguage } = const { browserViewportSize, mode, customModePrompts, preferredLanguage, experiments } =
(await this.providerRef.deref()?.getState()) ?? {} (await this.providerRef.deref()?.getState()) ?? {}
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {} const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
const systemPrompt = await (async () => { const systemPrompt = await (async () => {
@@ -831,6 +831,7 @@ export class Cline {
this.customInstructions, this.customInstructions,
preferredLanguage, preferredLanguage,
this.diffEnabled, this.diffEnabled,
experiments,
) )
})() })()

View File

@@ -11,6 +11,7 @@ import { defaultModeSlug, modes } from "../../../shared/modes"
import "../../../utils/path" import "../../../utils/path"
import { addCustomInstructions } from "../sections/custom-instructions" import { addCustomInstructions } from "../sections/custom-instructions"
import * as modesSection from "../sections/modes" import * as modesSection from "../sections/modes"
import { EXPERIMENT_IDS } from "../../../shared/experiments"
// Mock the sections // Mock the sections
jest.mock("../sections/modes", () => ({ jest.mock("../sections/modes", () => ({
@@ -121,6 +122,7 @@ const createMockMcpHub = (): McpHub =>
describe("SYSTEM_PROMPT", () => { describe("SYSTEM_PROMPT", () => {
let mockMcpHub: McpHub let mockMcpHub: McpHub
let experiments: Record<string, boolean>
beforeAll(() => { beforeAll(() => {
// Ensure fs mock is properly initialized // Ensure fs mock is properly initialized
@@ -140,6 +142,10 @@ describe("SYSTEM_PROMPT", () => {
"/mock/mcp/path", "/mock/mcp/path",
] ]
dirs.forEach((dir) => mockFs._mockDirectories.add(dir)) dirs.forEach((dir) => mockFs._mockDirectories.add(dir))
experiments = {
[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: false,
[EXPERIMENT_IDS.INSERT_BLOCK]: false,
}
}) })
beforeEach(() => { beforeEach(() => {
@@ -164,6 +170,10 @@ describe("SYSTEM_PROMPT", () => {
defaultModeSlug, // mode defaultModeSlug, // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()
@@ -179,7 +189,11 @@ describe("SYSTEM_PROMPT", () => {
"1280x800", // browserViewportSize "1280x800", // browserViewportSize
defaultModeSlug, // mode defaultModeSlug, // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes,
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()
@@ -197,7 +211,11 @@ describe("SYSTEM_PROMPT", () => {
undefined, // browserViewportSize undefined, // browserViewportSize
defaultModeSlug, // mode defaultModeSlug, // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes,
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()
@@ -213,7 +231,11 @@ describe("SYSTEM_PROMPT", () => {
undefined, // browserViewportSize undefined, // browserViewportSize
defaultModeSlug, // mode defaultModeSlug, // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes,
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()
@@ -229,7 +251,11 @@ describe("SYSTEM_PROMPT", () => {
"900x600", // different viewport size "900x600", // different viewport size
defaultModeSlug, // mode defaultModeSlug, // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes,
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()
@@ -249,6 +275,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // globalCustomInstructions undefined, // globalCustomInstructions
undefined, // preferredLanguage undefined, // preferredLanguage
true, // diffEnabled true, // diffEnabled
experiments,
) )
expect(prompt).toContain("apply_diff") expect(prompt).toContain("apply_diff")
@@ -269,6 +296,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // globalCustomInstructions undefined, // globalCustomInstructions
undefined, // preferredLanguage undefined, // preferredLanguage
false, // diffEnabled false, // diffEnabled
experiments,
) )
expect(prompt).not.toContain("apply_diff") expect(prompt).not.toContain("apply_diff")
@@ -289,6 +317,7 @@ describe("SYSTEM_PROMPT", () => {
undefined, // globalCustomInstructions undefined, // globalCustomInstructions
undefined, // preferredLanguage undefined, // preferredLanguage
undefined, // diffEnabled undefined, // diffEnabled
experiments,
) )
expect(prompt).not.toContain("apply_diff") expect(prompt).not.toContain("apply_diff")
@@ -308,6 +337,8 @@ describe("SYSTEM_PROMPT", () => {
undefined, // customModes undefined, // customModes
undefined, // globalCustomInstructions undefined, // globalCustomInstructions
"Spanish", // preferredLanguage "Spanish", // preferredLanguage
undefined, // diffEnabled
experiments,
) )
expect(prompt).toContain("Language Preference:") expect(prompt).toContain("Language Preference:")
@@ -337,6 +368,9 @@ describe("SYSTEM_PROMPT", () => {
undefined, // customModePrompts undefined, // customModePrompts
customModes, // customModes customModes, // customModes
"Global instructions", // globalCustomInstructions "Global instructions", // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
) )
// Role definition should be at the top // Role definition should be at the top
@@ -368,6 +402,10 @@ describe("SYSTEM_PROMPT", () => {
defaultModeSlug, defaultModeSlug,
customModePrompts, customModePrompts,
undefined, undefined,
undefined,
undefined,
undefined,
experiments,
) )
// Role definition from promptComponent should be at the top // Role definition from promptComponent should be at the top
@@ -394,18 +432,101 @@ describe("SYSTEM_PROMPT", () => {
defaultModeSlug, defaultModeSlug,
customModePrompts, customModePrompts,
undefined, undefined,
undefined,
undefined,
undefined,
experiments,
) )
// Should use the default mode's role definition // Should use the default mode's role definition
expect(prompt.indexOf(modes[0].roleDefinition)).toBeLessThan(prompt.indexOf("TOOL USE")) expect(prompt.indexOf(modes[0].roleDefinition)).toBeLessThan(prompt.indexOf("TOOL USE"))
}) })
describe("experimental tools", () => {
it("should disable experimental tools by default", 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
undefined, // preferredLanguage
undefined, // diffEnabled
experiments, // experiments - undefined should disable all experimental tools
)
// Verify experimental tools are not included in the prompt
expect(prompt).not.toContain(EXPERIMENT_IDS.SEARCH_AND_REPLACE)
expect(prompt).not.toContain(EXPERIMENT_IDS.INSERT_BLOCK)
})
it("should enable experimental tools when explicitly enabled", async () => {
const experiments = {
[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: true,
[EXPERIMENT_IDS.INSERT_BLOCK]: true,
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
)
// Verify experimental tools are included in the prompt when enabled
expect(prompt).toContain(EXPERIMENT_IDS.SEARCH_AND_REPLACE)
expect(prompt).toContain(EXPERIMENT_IDS.INSERT_BLOCK)
})
it("should selectively enable experimental tools", async () => {
const experiments = {
[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: true,
[EXPERIMENT_IDS.INSERT_BLOCK]: false,
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
undefined, // globalCustomInstructions
undefined, // preferredLanguage
undefined, // diffEnabled
experiments,
)
// Verify only enabled experimental tools are included
expect(prompt).toContain(EXPERIMENT_IDS.SEARCH_AND_REPLACE)
expect(prompt).not.toContain(EXPERIMENT_IDS.INSERT_BLOCK)
})
})
afterAll(() => { afterAll(() => {
jest.restoreAllMocks() jest.restoreAllMocks()
}) })
}) })
describe("addCustomInstructions", () => { describe("addCustomInstructions", () => {
let experiments: Record<string, boolean>
beforeAll(() => { beforeAll(() => {
// Ensure fs mock is properly initialized // Ensure fs mock is properly initialized
const mockFs = jest.requireMock("fs/promises") const mockFs = jest.requireMock("fs/promises")
@@ -417,6 +538,11 @@ describe("addCustomInstructions", () => {
} }
throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`) throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`)
}) })
experiments = {
[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: false,
[EXPERIMENT_IDS.INSERT_BLOCK]: false,
}
}) })
beforeEach(() => { beforeEach(() => {
@@ -434,6 +560,10 @@ describe("addCustomInstructions", () => {
"architect", // mode "architect", // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes
undefined,
undefined,
undefined,
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()
@@ -450,6 +580,10 @@ describe("addCustomInstructions", () => {
"ask", // mode "ask", // mode
undefined, // customModePrompts undefined, // customModePrompts
undefined, // customModes undefined, // customModes
undefined,
undefined,
undefined,
experiments,
) )
expect(prompt).toMatchSnapshot() expect(prompt).toMatchSnapshot()

View File

@@ -39,6 +39,7 @@ async function generatePrompt(
globalCustomInstructions?: string, globalCustomInstructions?: string,
preferredLanguage?: string, preferredLanguage?: string,
diffEnabled?: boolean, diffEnabled?: boolean,
experiments?: Record<string, boolean>,
): Promise<string> { ): Promise<string> {
if (!context) { if (!context) {
throw new Error("Extension context is required for generating system prompt") throw new Error("Extension context is required for generating system prompt")
@@ -68,6 +69,7 @@ ${getToolDescriptionsForMode(
browserViewportSize, browserViewportSize,
mcpHub, mcpHub,
customModeConfigs, customModeConfigs,
experiments,
)} )}
${getToolUseGuidelinesSection()} ${getToolUseGuidelinesSection()}
@@ -102,6 +104,7 @@ export const SYSTEM_PROMPT = async (
globalCustomInstructions?: string, globalCustomInstructions?: string,
preferredLanguage?: string, preferredLanguage?: string,
diffEnabled?: boolean, diffEnabled?: boolean,
experiments?: Record<string, boolean>,
): Promise<string> => { ): Promise<string> => {
if (!context) { if (!context) {
throw new Error("Extension context is required for generating system prompt") throw new Error("Extension context is required for generating system prompt")
@@ -135,5 +138,6 @@ export const SYSTEM_PROMPT = async (
globalCustomInstructions, globalCustomInstructions,
preferredLanguage, preferredLanguage,
diffEnabled, diffEnabled,
experiments,
) )
} }

View File

@@ -46,6 +46,7 @@ export function getToolDescriptionsForMode(
browserViewportSize?: string, browserViewportSize?: string,
mcpHub?: McpHub, mcpHub?: McpHub,
customModes?: ModeConfig[], customModes?: ModeConfig[],
experiments?: Record<string, boolean>,
): string { ): string {
const config = getModeConfig(mode, customModes) const config = getModeConfig(mode, customModes)
const args: ToolArgs = { const args: ToolArgs = {
@@ -64,7 +65,7 @@ export function getToolDescriptionsForMode(
const toolGroup = TOOL_GROUPS[groupName] const toolGroup = TOOL_GROUPS[groupName]
if (toolGroup) { if (toolGroup) {
toolGroup.forEach((tool) => { toolGroup.forEach((tool) => {
if (isToolAllowedForMode(tool as ToolName, mode, customModes ?? [])) { if (isToolAllowedForMode(tool as ToolName, mode, customModes ?? [], experiments ?? {})) {
tools.add(tool) tools.add(tool)
} }
}) })

View File

@@ -40,6 +40,12 @@ import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
import { ConfigManager } from "../config/ConfigManager" import { ConfigManager } from "../config/ConfigManager"
import { CustomModesManager } from "../config/CustomModesManager" import { CustomModesManager } from "../config/CustomModesManager"
import {
EXPERIMENT_IDS,
experimentConfigs,
experiments as Experiments,
experimentDefault,
} from "../../shared/experiments"
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt" import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
import { ACTION_NAMES } from "../CodeActionProvider" import { ACTION_NAMES } from "../CodeActionProvider"
@@ -118,7 +124,7 @@ type GlobalStateKey =
| "customModePrompts" | "customModePrompts"
| "customSupportPrompts" | "customSupportPrompts"
| "enhancementApiConfigId" | "enhancementApiConfigId"
| "experimentalDiffStrategy" | "experiments" // Map of experiment IDs to their enabled state
| "autoApprovalEnabled" | "autoApprovalEnabled"
| "customModes" // Array of custom modes | "customModes" // Array of custom modes
@@ -339,7 +345,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
fuzzyMatchThreshold, fuzzyMatchThreshold,
mode, mode,
customInstructions: globalInstructions, customInstructions: globalInstructions,
experimentalDiffStrategy, experiments,
} = await this.getState() } = await this.getState()
const modePrompt = customModePrompts?.[mode] as PromptComponent const modePrompt = customModePrompts?.[mode] as PromptComponent
@@ -354,7 +360,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
task, task,
images, images,
undefined, undefined,
experimentalDiffStrategy, Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY),
) )
} }
@@ -367,7 +373,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
fuzzyMatchThreshold, fuzzyMatchThreshold,
mode, mode,
customInstructions: globalInstructions, customInstructions: globalInstructions,
experimentalDiffStrategy, experiments,
} = await this.getState() } = await this.getState()
const modePrompt = customModePrompts?.[mode] as PromptComponent const modePrompt = customModePrompts?.[mode] as PromptComponent
@@ -382,7 +388,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
undefined, undefined,
undefined, undefined,
historyItem, historyItem,
experimentalDiffStrategy, Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY),
) )
} }
@@ -1044,14 +1050,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled, diffEnabled,
mcpEnabled, mcpEnabled,
fuzzyMatchThreshold, fuzzyMatchThreshold,
experimentalDiffStrategy, experiments,
} = await this.getState() } = await this.getState()
// Create diffStrategy based on current model and settings // Create diffStrategy based on current model and settings
const diffStrategy = getDiffStrategy( const diffStrategy = getDiffStrategy(
apiConfiguration.apiModelId || apiConfiguration.openRouterModelId || "", apiConfiguration.apiModelId || apiConfiguration.openRouterModelId || "",
fuzzyMatchThreshold, fuzzyMatchThreshold,
experimentalDiffStrategy, Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY),
) )
const cwd = const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || "" vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
@@ -1072,6 +1078,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
customInstructions, customInstructions,
preferredLanguage, preferredLanguage,
diffEnabled, diffEnabled,
experiments,
) )
await this.postMessageToWebview({ await this.postMessageToWebview({
@@ -1207,14 +1214,28 @@ export class ClineProvider implements vscode.WebviewViewProvider {
vscode.window.showErrorMessage("Failed to get list api configuration") vscode.window.showErrorMessage("Failed to get list api configuration")
} }
break break
case "experimentalDiffStrategy": case "updateExperimental": {
await this.updateGlobalState("experimentalDiffStrategy", message.bool ?? false) if (!message.values) {
// Update diffStrategy in current Cline instance if it exists break
if (this.cline) {
await this.cline.updateDiffStrategy(message.bool ?? false)
} }
const updatedExperiments = {
...((await this.getGlobalState("experiments")) ?? experimentDefault),
...message.values,
}
await this.updateGlobalState("experiments", updatedExperiments)
// Update diffStrategy in current Cline instance if it exists
if (message.values[EXPERIMENT_IDS.DIFF_STRATEGY] !== undefined && this.cline) {
await this.cline.updateDiffStrategy(
Experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.DIFF_STRATEGY),
)
}
await this.postStateToWebview() await this.postStateToWebview()
break break
}
case "updateMcpTimeout": case "updateMcpTimeout":
if (message.serverName && typeof message.timeout === "number") { if (message.serverName && typeof message.timeout === "number") {
try { try {
@@ -1873,8 +1894,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
customModePrompts, customModePrompts,
customSupportPrompts, customSupportPrompts,
enhancementApiConfigId, enhancementApiConfigId,
experimentalDiffStrategy,
autoApprovalEnabled, autoApprovalEnabled,
experiments,
} = await this.getState() } = await this.getState()
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || [] const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
@@ -1914,9 +1935,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
customModePrompts: customModePrompts ?? {}, customModePrompts: customModePrompts ?? {},
customSupportPrompts: customSupportPrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {},
enhancementApiConfigId, enhancementApiConfigId,
experimentalDiffStrategy: experimentalDiffStrategy ?? false,
autoApprovalEnabled: autoApprovalEnabled ?? false, autoApprovalEnabled: autoApprovalEnabled ?? false,
customModes: await this.customModesManager.getCustomModes(), customModes: await this.customModesManager.getCustomModes(),
experiments: experiments ?? experimentDefault,
} }
} }
@@ -2039,9 +2060,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
customModePrompts, customModePrompts,
customSupportPrompts, customSupportPrompts,
enhancementApiConfigId, enhancementApiConfigId,
experimentalDiffStrategy,
autoApprovalEnabled, autoApprovalEnabled,
customModes, customModes,
experiments,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2109,9 +2130,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("customModePrompts") as Promise<CustomModePrompts | undefined>, this.getGlobalState("customModePrompts") as Promise<CustomModePrompts | undefined>,
this.getGlobalState("customSupportPrompts") as Promise<CustomSupportPrompts | undefined>, this.getGlobalState("customSupportPrompts") as Promise<CustomSupportPrompts | undefined>,
this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>, this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>,
this.getGlobalState("experimentalDiffStrategy") as Promise<boolean | undefined>,
this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>, this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>,
this.customModesManager.getCustomModes(), this.customModesManager.getCustomModes(),
this.getGlobalState("experiments") as Promise<Record<string, boolean> | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -2225,7 +2246,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
customModePrompts: customModePrompts ?? {}, customModePrompts: customModePrompts ?? {},
customSupportPrompts: customSupportPrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {},
enhancementApiConfigId, enhancementApiConfigId,
experimentalDiffStrategy: experimentalDiffStrategy ?? false, experiments: experiments ?? experimentDefault,
autoApprovalEnabled: autoApprovalEnabled ?? false, autoApprovalEnabled: autoApprovalEnabled ?? false,
customModes, customModes,
} }

View File

@@ -4,6 +4,7 @@ import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessa
import { setSoundEnabled } from "../../../utils/sound" import { setSoundEnabled } from "../../../utils/sound"
import { defaultModeSlug, modes } from "../../../shared/modes" import { defaultModeSlug, modes } from "../../../shared/modes"
import { addCustomInstructions } from "../../prompts/sections/custom-instructions" import { addCustomInstructions } from "../../prompts/sections/custom-instructions"
import { experimentDefault, experiments } from "../../../shared/experiments"
// Mock custom-instructions module // Mock custom-instructions module
const mockAddCustomInstructions = jest.fn() const mockAddCustomInstructions = jest.fn()
@@ -320,6 +321,7 @@ describe("ClineProvider", () => {
requestDelaySeconds: 5, requestDelaySeconds: 5,
mode: defaultModeSlug, mode: defaultModeSlug,
customModes: [], customModes: [],
experiments: experimentDefault,
} }
const message: ExtensionMessage = { const message: ExtensionMessage = {
@@ -617,6 +619,7 @@ describe("ClineProvider", () => {
mode: "code", mode: "code",
diffEnabled: true, diffEnabled: true,
fuzzyMatchThreshold: 1.0, fuzzyMatchThreshold: 1.0,
experiments: experimentDefault,
} as any) } as any)
// Reset Cline mock // Reset Cline mock
@@ -636,7 +639,7 @@ describe("ClineProvider", () => {
"Test task", "Test task",
undefined, undefined,
undefined, undefined,
undefined, false,
) )
}) })
test("handles mode-specific custom instructions updates", async () => { test("handles mode-specific custom instructions updates", async () => {
@@ -887,6 +890,7 @@ describe("ClineProvider", () => {
}, },
mcpEnabled: true, mcpEnabled: true,
mode: "code" as const, mode: "code" as const,
experiments: experimentDefault,
} as any) } as any)
const handler1 = getMessageHandler() const handler1 = getMessageHandler()
@@ -918,6 +922,7 @@ describe("ClineProvider", () => {
}, },
mcpEnabled: false, mcpEnabled: false,
mode: "code" as const, mode: "code" as const,
experiments: experimentDefault,
} as any) } as any)
const handler2 = getMessageHandler() const handler2 = getMessageHandler()
@@ -985,6 +990,7 @@ describe("ClineProvider", () => {
experimentalDiffStrategy: true, experimentalDiffStrategy: true,
diffEnabled: true, diffEnabled: true,
fuzzyMatchThreshold: 0.8, fuzzyMatchThreshold: 0.8,
experiments: experimentDefault,
} as any) } as any)
// Mock SYSTEM_PROMPT to verify diffStrategy and diffEnabled are passed // Mock SYSTEM_PROMPT to verify diffStrategy and diffEnabled are passed
@@ -1012,6 +1018,7 @@ describe("ClineProvider", () => {
undefined, // effectiveInstructions undefined, // effectiveInstructions
undefined, // preferredLanguage undefined, // preferredLanguage
true, // diffEnabled true, // diffEnabled
experimentDefault,
) )
// Run the test again to verify it's consistent // Run the test again to verify it's consistent
@@ -1034,6 +1041,7 @@ describe("ClineProvider", () => {
experimentalDiffStrategy: true, experimentalDiffStrategy: true,
diffEnabled: false, diffEnabled: false,
fuzzyMatchThreshold: 0.8, fuzzyMatchThreshold: 0.8,
experiments: experimentDefault,
} as any) } as any)
// Mock SYSTEM_PROMPT to verify diffEnabled is passed as false // Mock SYSTEM_PROMPT to verify diffEnabled is passed as false
@@ -1061,6 +1069,7 @@ describe("ClineProvider", () => {
undefined, // effectiveInstructions undefined, // effectiveInstructions
undefined, // preferredLanguage undefined, // preferredLanguage
false, // diffEnabled false, // diffEnabled
experimentDefault,
) )
}) })
@@ -1077,6 +1086,7 @@ describe("ClineProvider", () => {
mode: "architect", mode: "architect",
mcpEnabled: false, mcpEnabled: false,
browserViewportSize: "900x600", browserViewportSize: "900x600",
experiments: experimentDefault,
} as any) } as any)
// Mock SYSTEM_PROMPT to call addCustomInstructions // Mock SYSTEM_PROMPT to call addCustomInstructions

View File

@@ -108,7 +108,7 @@ export interface ExtensionState {
mode: Mode mode: Mode
modeApiConfigs?: Record<Mode, string> modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string enhancementApiConfigId?: string
experimentalDiffStrategy?: boolean experiments: Record<string, boolean> // Map of experiment IDs to their enabled state
autoApprovalEnabled?: boolean autoApprovalEnabled?: boolean
customModes: ModeConfig[] customModes: ModeConfig[]
toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled) toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)

View File

@@ -75,7 +75,7 @@ export interface WebviewMessage {
| "getSystemPrompt" | "getSystemPrompt"
| "systemPrompt" | "systemPrompt"
| "enhancementApiConfigId" | "enhancementApiConfigId"
| "experimentalDiffStrategy" | "updateExperimental"
| "autoApprovalEnabled" | "autoApprovalEnabled"
| "updateCustomMode" | "updateCustomMode"
| "deleteCustomMode" | "deleteCustomMode"

View File

@@ -14,6 +14,12 @@ describe("isToolAllowedForMode", () => {
roleDefinition: "You are a CSS editor", roleDefinition: "You are a CSS editor",
groups: ["read", ["edit", { fileRegex: "\\.css$" }], "browser"], groups: ["read", ["edit", { fileRegex: "\\.css$" }], "browser"],
}, },
{
slug: "test-exp-mode",
name: "Test Exp Mode",
roleDefinition: "You are an experimental tester",
groups: ["read", "edit", "browser"],
},
] ]
it("allows always available tools", () => { it("allows always available tools", () => {
@@ -240,6 +246,87 @@ describe("isToolAllowedForMode", () => {
expect(isToolAllowedForMode("write_to_file", "markdown-editor", customModes, toolRequirements)).toBe(false) expect(isToolAllowedForMode("write_to_file", "markdown-editor", customModes, toolRequirements)).toBe(false)
}) })
describe("experimental tools", () => {
it("disables tools when experiment is disabled", () => {
const experiments = {
search_and_replace: false,
insert_code_block: false,
}
expect(
isToolAllowedForMode(
"search_and_replace",
"test-exp-mode",
customModes,
undefined,
undefined,
experiments,
),
).toBe(false)
expect(
isToolAllowedForMode(
"insert_code_block",
"test-exp-mode",
customModes,
undefined,
undefined,
experiments,
),
).toBe(false)
})
it("allows tools when experiment is enabled", () => {
const experiments = {
search_and_replace: true,
insert_code_block: true,
}
expect(
isToolAllowedForMode(
"search_and_replace",
"test-exp-mode",
customModes,
undefined,
undefined,
experiments,
),
).toBe(true)
expect(
isToolAllowedForMode(
"insert_code_block",
"test-exp-mode",
customModes,
undefined,
undefined,
experiments,
),
).toBe(true)
})
it("allows non-experimental tools when experiments are disabled", () => {
const experiments = {
search_and_replace: false,
insert_code_block: false,
}
expect(
isToolAllowedForMode("read_file", "markdown-editor", customModes, undefined, undefined, experiments),
).toBe(true)
expect(
isToolAllowedForMode(
"write_to_file",
"markdown-editor",
customModes,
undefined,
{ path: "test.md" },
experiments,
),
).toBe(true)
})
})
}) })
describe("FileRestrictionError", () => { describe("FileRestrictionError", () => {

63
src/shared/experiments.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface ExperimentConfig {
id: string
name: string
description: string
enabled: boolean
}
export const EXPERIMENT_IDS = {
DIFF_STRATEGY: "experimentalDiffStrategy",
SEARCH_AND_REPLACE: "search_and_replace",
INSERT_BLOCK: "insert_code_block",
} as const
export type ExperimentId = keyof typeof EXPERIMENT_IDS
export const experimentConfigsMap: Record<ExperimentId, ExperimentConfig> = {
DIFF_STRATEGY: {
id: EXPERIMENT_IDS.DIFF_STRATEGY,
name: "Use experimental unified diff strategy",
description:
"Enable the experimental unified diff strategy. This strategy might reduce the number of retries caused by model errors but may cause unexpected behavior or incorrect edits. Only enable if you understand the risks and are willing to carefully review all changes.",
enabled: false,
},
SEARCH_AND_REPLACE: {
id: EXPERIMENT_IDS.SEARCH_AND_REPLACE,
name: "Use experimental search and replace tool",
description:
"Enable the experimental Search and Replace tool. This tool allows Roo to search and replace term. Can be run multiple search and replace in sequence at once request.",
enabled: false,
},
INSERT_BLOCK: {
id: EXPERIMENT_IDS.INSERT_BLOCK,
name: "Use experimental insert block tool",
description:
"Enable the experimental insert block tool. This tool allows Roo to insert code blocks into files. Can be insert multiple blocks at once.",
enabled: false,
},
}
// Keep the array version for backward compatibility
export const experimentConfigs = Object.values(experimentConfigsMap)
export const experimentDefault = Object.fromEntries(
Object.entries(experimentConfigsMap).map(([_, config]) => [config.id, config.enabled]),
)
export const experiments = {
get: (id: ExperimentId): ExperimentConfig | undefined => {
return experimentConfigsMap[id]
},
isEnabled: (experimentsConfig: Record<string, boolean>, id: string): boolean => {
return experimentsConfig[id] ?? experimentDefault[id]
},
} as const
// Expose experiment details for UI - pre-compute from map for better performance
export const experimentLabels = Object.fromEntries(
Object.values(experimentConfigsMap).map((config) => [config.id, config.name]),
) as Record<string, string>
export const experimentDescriptions = Object.fromEntries(
Object.values(experimentConfigsMap).map((config) => [config.id, config.description]),
) as Record<string, string>

View File

@@ -160,12 +160,19 @@ export function isToolAllowedForMode(
customModes: ModeConfig[], customModes: ModeConfig[],
toolRequirements?: Record<string, boolean>, toolRequirements?: Record<string, boolean>,
toolParams?: Record<string, any>, // All tool parameters toolParams?: Record<string, any>, // All tool parameters
experiments?: Record<string, boolean>,
): boolean { ): boolean {
// Always allow these tools // Always allow these tools
if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) {
return true return true
} }
if (experiments && tool in experiments) {
if (!experiments[tool]) {
return false
}
}
// Check tool requirements if any exist // Check tool requirements if any exist
if (toolRequirements && tool in toolRequirements) { if (toolRequirements && tool in toolRequirements) {
if (!toolRequirements[tool]) { if (!toolRequirements[tool]) {

View File

@@ -0,0 +1,37 @@
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
interface ExperimentalFeatureProps {
id: string
name: string
description: string
enabled: boolean
onChange: (value: boolean) => void
}
const ExperimentalFeature = ({ id, name, description, enabled, onChange }: ExperimentalFeatureProps) => {
return (
<div
style={{
marginTop: 10,
paddingLeft: 10,
borderLeft: "2px solid var(--vscode-button-background)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ color: "var(--vscode-errorForeground)" }}></span>
<VSCodeCheckbox checked={enabled} onChange={(e: any) => onChange(e.target.checked)}>
<span style={{ fontWeight: "500" }}>{name}</span>
</VSCodeCheckbox>
</div>
<p
style={{
fontSize: "12px",
marginBottom: 15,
color: "var(--vscode-descriptionForeground)",
}}>
{description}
</p>
</div>
)
}
export default ExperimentalFeature

View File

@@ -4,6 +4,8 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration, validateModelId } from "../../utils/validate" import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode" import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions" import ApiOptions from "./ApiOptions"
import ExperimentalFeature from "./ExperimentalFeature"
import { experimentConfigs, EXPERIMENT_IDS, experimentConfigsMap } from "../../../../src/shared/experiments"
import ApiConfigManager from "./ApiConfigManager" import ApiConfigManager from "./ApiConfigManager"
type SettingsViewProps = { type SettingsViewProps = {
@@ -51,8 +53,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setRequestDelaySeconds, setRequestDelaySeconds,
currentApiConfigName, currentApiConfigName,
listApiConfigMeta, listApiConfigMeta,
experimentalDiffStrategy, experiments,
setExperimentalDiffStrategy, setExperimentEnabled,
alwaysAllowModeSwitch, alwaysAllowModeSwitch,
setAlwaysAllowModeSwitch, setAlwaysAllowModeSwitch,
} = useExtensionState() } = useExtensionState()
@@ -94,7 +96,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
text: currentApiConfigName, text: currentApiConfigName,
apiConfiguration, apiConfiguration,
}) })
vscode.postMessage({ type: "experimentalDiffStrategy", bool: experimentalDiffStrategy })
vscode.postMessage({
type: "updateExperimental",
values: experiments,
})
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
onDone() onDone()
} }
@@ -583,7 +590,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setDiffEnabled(e.target.checked) setDiffEnabled(e.target.checked)
if (!e.target.checked) { if (!e.target.checked) {
// Reset experimental strategy when diffs are disabled // Reset experimental strategy when diffs are disabled
setExperimentalDiffStrategy(false) setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, false)
} }
}}> }}>
<span style={{ fontWeight: "500" }}>Enable editing through diffs</span> <span style={{ fontWeight: "500" }}>Enable editing through diffs</span>
@@ -599,35 +606,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p> </p>
{diffEnabled && ( {diffEnabled && (
<div <div style={{ marginTop: 10 }}>
style={{ <ExperimentalFeature
marginTop: 10, key={EXPERIMENT_IDS.DIFF_STRATEGY}
paddingLeft: 10, {...experimentConfigsMap.DIFF_STRATEGY}
borderLeft: "2px solid var(--vscode-button-background)", enabled={experiments[EXPERIMENT_IDS.DIFF_STRATEGY] ?? false}
}}> onChange={(enabled) => setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled)}
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}> />
<span style={{ color: "var(--vscode-errorForeground)" }}>⚠️</span> <div style={{ display: "flex", alignItems: "center", gap: "5px", marginTop: "15px" }}>
<VSCodeCheckbox
checked={experimentalDiffStrategy}
onChange={(e: any) => setExperimentalDiffStrategy(e.target.checked)}>
<span style={{ fontWeight: "500" }}>
Use experimental unified diff strategy
</span>
</VSCodeCheckbox>
</div>
<p
style={{
fontSize: "12px",
marginBottom: 15,
color: "var(--vscode-descriptionForeground)",
}}>
Enable the experimental unified diff strategy. This strategy might reduce the number
of retries caused by model errors but may cause unexpected behavior or incorrect
edits. Only enable if you understand the risks and are willing to carefully review
all changes.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ fontWeight: "500", minWidth: "100px" }}>Match precision</span> <span style={{ fontWeight: "500", minWidth: "100px" }}>Match precision</span>
<input <input
type="range" type="range"
@@ -660,6 +646,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p> </p>
</div> </div>
)} )}
{experimentConfigs
.filter((config) => config.id !== EXPERIMENT_IDS.DIFF_STRATEGY)
.map((config) => (
<ExperimentalFeature
key={config.id}
{...config}
enabled={experiments[config.id] ?? false}
onChange={(enabled) => setExperimentEnabled(config.id, enabled)}
/>
))}
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ import { McpServer } from "../../../src/shared/mcp"
import { checkExistKey } from "../../../src/shared/checkExistApiConfig" import { checkExistKey } from "../../../src/shared/checkExistApiConfig"
import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig } from "../../../src/shared/modes" import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig } from "../../../src/shared/modes"
import { CustomSupportPrompts } from "../../../src/shared/support-prompt" import { CustomSupportPrompts } from "../../../src/shared/support-prompt"
import { EXPERIMENT_IDS, experimentDefault } from "../../../src/shared/experiments"
export interface ExtensionStateContextType extends ExtensionState { export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
@@ -63,9 +64,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setCustomSupportPrompts: (value: CustomSupportPrompts) => void setCustomSupportPrompts: (value: CustomSupportPrompts) => void
enhancementApiConfigId?: string enhancementApiConfigId?: string
setEnhancementApiConfigId: (value: string) => void setEnhancementApiConfigId: (value: string) => void
experimentalDiffStrategy: boolean experiments: Record<string, boolean>
setExperimentalDiffStrategy: (value: boolean) => void setExperimentEnabled: (id: string, enabled: boolean) => void
autoApprovalEnabled?: boolean
setAutoApprovalEnabled: (value: boolean) => void setAutoApprovalEnabled: (value: boolean) => void
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
customModes: ModeConfig[] customModes: ModeConfig[]
@@ -98,8 +98,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
mode: defaultModeSlug, mode: defaultModeSlug,
customModePrompts: defaultPrompts, customModePrompts: defaultPrompts,
customSupportPrompts: {}, customSupportPrompts: {},
experiments: experimentDefault,
enhancementApiConfigId: "", enhancementApiConfigId: "",
experimentalDiffStrategy: false,
autoApprovalEnabled: false, autoApprovalEnabled: false,
customModes: [], customModes: [],
}) })
@@ -242,7 +242,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
fuzzyMatchThreshold: state.fuzzyMatchThreshold, fuzzyMatchThreshold: state.fuzzyMatchThreshold,
writeDelayMs: state.writeDelayMs, writeDelayMs: state.writeDelayMs,
screenshotQuality: state.screenshotQuality, screenshotQuality: state.screenshotQuality,
experimentalDiffStrategy: state.experimentalDiffStrategy ?? false, setExperimentEnabled: (id, enabled) =>
setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })),
setApiConfiguration: (value) => setApiConfiguration: (value) =>
setState((prevState) => ({ setState((prevState) => ({
...prevState, ...prevState,
@@ -279,8 +280,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })), setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })),
setEnhancementApiConfigId: (value) => setEnhancementApiConfigId: (value) =>
setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
setExperimentalDiffStrategy: (value) =>
setState((prevState) => ({ ...prevState, experimentalDiffStrategy: value })),
setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })), setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
handleInputChange, handleInputChange,
setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })), setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })),