Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/types/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js"
* ExperimentId
*/

export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const
export const experimentIds = [
"preventFocusDisruption",
"imageGeneration",
"runSlashCommand",
"customTools",
"workspaceProfileOverrides",
] as const

export const experimentIdsSchema = z.enum(experimentIds)

Expand All @@ -21,6 +27,7 @@ export const experimentsSchema = z.object({
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
customTools: z.boolean().optional(),
workspaceProfileOverrides: z.boolean().optional(),
})

export type Experiments = z.infer<typeof experimentsSchema>
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export type ExtensionState = Pick<
| "disabledTools"
> & {
lockApiConfigAcrossModes?: boolean
workspaceModeApiConfigs?: Record<string, string>
version: string
clineMessages: ClineMessage[]
currentTaskId?: string
Expand Down Expand Up @@ -499,6 +500,8 @@ export interface WebviewMessage {
| "toggleApiConfigPin"
| "hasOpenedModeSelector"
| "lockApiConfigAcrossModes"
| "setWorkspaceModeApiConfig"
| "clearWorkspaceModeApiConfig"
| "clearCloudAuthSkipModel"
| "cloudButtonClicked"
| "rooCloudSignIn"
Expand Down
32 changes: 29 additions & 3 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
Expand Down Expand Up @@ -999,7 +999,18 @@ export class ClineProvider
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)

if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) {
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
// Check workspace-level override first (if experiment enabled), then fall back to global mode config.
const { experiments: experimentsState } = await this.getState()
const workspaceOverridesEnabled = experimentsUtil.isEnabled(
experimentsState ?? experimentDefault,
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
)
const workspaceModeApiConfigs = workspaceOverridesEnabled
? (this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {})
: {}
const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode]
const savedConfigId =
workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode))
const listApiConfig = await this.providerSettingsManager.listConfig()

// Update listApiConfigMeta first to ensure UI has latest data.
Expand Down Expand Up @@ -1433,8 +1444,19 @@ export class ClineProvider
return
}

// Check for workspace-level mode-to-profile override first (if experiment enabled), then fall back to global.
const { experiments: experimentsState } = await this.getState()
const workspaceOverridesEnabled = experimentsUtil.isEnabled(
experimentsState ?? experimentDefault,
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
)
const workspaceModeApiConfigs = workspaceOverridesEnabled
? (this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {})
: {}
const workspaceConfigId = workspaceModeApiConfigs[newMode]

// Load the saved API config for the new mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const savedConfigId = workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(newMode))
const listApiConfig = await this.providerSettingsManager.listConfig()

// Update listApiConfigMeta first to ensure UI has latest data.
Expand Down Expand Up @@ -2563,6 +2585,10 @@ export class ClineProvider
},
profileThresholds: stateValues.profileThresholds ?? {},
lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false),
workspaceModeApiConfigs: this.context.workspaceState.get<Record<string, string>>(
"workspaceModeApiConfigs",
{},
),
includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true,
maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50,
includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// npx vitest run core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts

import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"

describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
let mockProvider: {
context: {
workspaceState: {
get: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
getState: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
providerSettingsManager: {
setModeConfig: ReturnType<typeof vi.fn>
}
postMessageToWebview: ReturnType<typeof vi.fn>
getCurrentTask: ReturnType<typeof vi.fn>
}

let workspaceStateStore: Record<string, unknown>

beforeEach(() => {
vi.clearAllMocks()

workspaceStateStore = {}

mockProvider = {
context: {
workspaceState: {
get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => {
return key in workspaceStateStore ? workspaceStateStore[key] : defaultValue
}),
update: vi.fn().mockImplementation((key: string, value: unknown) => {
workspaceStateStore[key] = value
return Promise.resolve()
}),
},
},
getState: vi.fn().mockResolvedValue({
currentApiConfigName: "test-config",
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
customModes: [],
experiments: { workspaceProfileOverrides: true },
}),
postStateToWebview: vi.fn(),
providerSettingsManager: {
setModeConfig: vi.fn(),
},
postMessageToWebview: vi.fn(),
getCurrentTask: vi.fn(),
}
})

it("does nothing when experiment is disabled", async () => {
mockProvider.getState.mockResolvedValueOnce({
currentApiConfigName: "test-config",
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
customModes: [],
experiments: { workspaceProfileOverrides: false },
})

await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
text: "config-123",
})

expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled()
})

it("sets a workspace mode API config for a specific mode", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
text: "config-123",
})

expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
code: "config-123",
})
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})

it("clears a workspace mode API config when text is undefined", async () => {
// Pre-populate with an existing mapping
workspaceStateStore["workspaceModeApiConfigs"] = { code: "config-123", architect: "config-456" }

await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
// text is undefined - clears the override
})

expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
architect: "config-456",
})
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})

it("preserves existing workspace configs when adding a new one", async () => {
workspaceStateStore["workspaceModeApiConfigs"] = { architect: "config-456" }

await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
text: "config-789",
})

expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
architect: "config-456",
code: "config-789",
})
})

it("does nothing if mode is not provided", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
// mode is undefined
text: "config-123",
})

expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled()
})
})

describe("webviewMessageHandler - clearWorkspaceModeApiConfig", () => {
let mockProvider: {
context: {
workspaceState: {
get: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
getState: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
providerSettingsManager: {
setModeConfig: ReturnType<typeof vi.fn>
}
postMessageToWebview: ReturnType<typeof vi.fn>
getCurrentTask: ReturnType<typeof vi.fn>
}

beforeEach(() => {
vi.clearAllMocks()

mockProvider = {
context: {
workspaceState: {
get: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
},
},
getState: vi.fn().mockResolvedValue({
currentApiConfigName: "test-config",
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
customModes: [],
}),
postStateToWebview: vi.fn(),
providerSettingsManager: {
setModeConfig: vi.fn(),
},
postMessageToWebview: vi.fn(),
getCurrentTask: vi.fn(),
}
})

it("clears all workspace mode API configs", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "clearWorkspaceModeApiConfig",
})

expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {})
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})
})
43 changes: 42 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { MessageEnhancer } from "./messageEnhancer"

import { CodeIndexManager } from "../../services/code-index/manager"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
Expand Down Expand Up @@ -1650,6 +1650,47 @@ export const webviewMessageHandler = async (
break
}

case "setWorkspaceModeApiConfig": {
// Set a workspace-level mode-to-profile override.
// message.mode contains the mode slug, message.text contains the profile config ID.
// Only proceed if the workspace profile overrides experiment is enabled.
const { experiments: expState } = await provider.getState()
const wsOverridesEnabled = experimentsUtil.isEnabled(
expState ?? experimentDefault,
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
)
if (!wsOverridesEnabled) {
break
}

const modeSlug = message.mode
const configId = message.text

if (modeSlug) {
const workspaceModeApiConfigs = provider.context.workspaceState.get<Record<string, string>>(
"workspaceModeApiConfigs",
{},
)

if (configId) {
workspaceModeApiConfigs[modeSlug] = configId
} else {
delete workspaceModeApiConfigs[modeSlug]
}

await provider.context.workspaceState.update("workspaceModeApiConfigs", workspaceModeApiConfigs)
await provider.postStateToWebview()
}
break
}

case "clearWorkspaceModeApiConfig": {
// Clear all workspace-level mode-to-profile overrides.
await provider.context.workspaceState.update("workspaceModeApiConfigs", {})
await provider.postStateToWebview()
break
}

case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}
Expand Down
3 changes: 3 additions & 0 deletions src/shared/__tests__/experiments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
workspaceProfileOverrides: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
})
Expand All @@ -31,6 +32,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
workspaceProfileOverrides: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true)
})
Expand All @@ -41,6 +43,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
workspaceProfileOverrides: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
})
Expand Down
2 changes: 2 additions & 0 deletions src/shared/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = {
IMAGE_GENERATION: "imageGeneration",
RUN_SLASH_COMMAND: "runSlashCommand",
CUSTOM_TOOLS: "customTools",
WORKSPACE_PROFILE_OVERRIDES: "workspaceProfileOverrides",
} as const satisfies Record<string, ExperimentId>

type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
Expand All @@ -20,6 +21,7 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
IMAGE_GENERATION: { enabled: false },
RUN_SLASH_COMMAND: { enabled: false },
CUSTOM_TOOLS: { enabled: false },
WORKSPACE_PROFILE_OVERRIDES: { enabled: false },
}

export const experimentDefault = Object.fromEntries(
Expand Down
Loading
Loading