Simplify the process of setting the "active" provider profile (#3366)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2025-05-08 20:59:59 -07:00 committed by hannesrudolph
parent 791f4e2742
commit a12fb2a736
8 changed files with 155 additions and 222 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ out
out-*
node_modules
coverage/
mock/
.DS_Store

0
git
View file

View file

@ -265,52 +265,60 @@ export class ProviderSettingsManager {
}
}
/**
* Load a config by name and set it as the current config.
*/
public async loadConfig(name: string) {
public async getProfile(params: { name: string } | { id: string }) {
try {
return await this.lock(async () => {
const providerProfiles = await this.load()
const providerSettings = providerProfiles.apiConfigs[name]
let name: string
let providerSettings: ProviderSettingsWithId
if (!providerSettings) {
throw new Error(`Config '${name}' not found`)
if ("name" in params) {
name = params.name
if (!providerProfiles.apiConfigs[name]) {
throw new Error(`Config with name '${name}' not found`)
}
providerSettings = providerProfiles.apiConfigs[name]
} else {
const id = params.id
const entry = Object.entries(providerProfiles.apiConfigs).find(
([_, apiConfig]) => apiConfig.id === id,
)
if (!entry) {
throw new Error(`Config with ID '${id}' not found`)
}
name = entry[0]
providerSettings = entry[1]
}
providerProfiles.currentApiConfigName = name
await this.store(providerProfiles)
return providerSettings
return { name, ...providerSettings }
})
} catch (error) {
throw new Error(`Failed to load config: ${error}`)
throw new Error(`Failed to get profile: ${error instanceof Error ? error.message : error}`)
}
}
/**
* Load a config by ID and set it as the current config.
* Activate a profile by name or ID.
*/
public async loadConfigById(id: string) {
public async activateProfile(
params: { name: string } | { id: string },
): Promise<ProviderSettingsWithId & { name: string }> {
const { name, ...providerSettings } = await this.getProfile(params)
try {
return await this.lock(async () => {
const providerProfiles = await this.load()
const providerSettings = Object.entries(providerProfiles.apiConfigs).find(
([_, apiConfig]) => apiConfig.id === id,
)
if (!providerSettings) {
throw new Error(`Config with ID '${id}' not found`)
}
const [name, apiConfig] = providerSettings
providerProfiles.currentApiConfigName = name
await this.store(providerProfiles)
return { config: apiConfig, name }
return { name, ...providerSettings }
})
} catch (error) {
throw new Error(`Failed to load config by ID: ${error}`)
throw new Error(`Failed to activate profile: ${error instanceof Error ? error.message : error}`)
}
}

View file

@ -438,17 +438,15 @@ describe("ProviderSettingsManager", () => {
mockGlobalState.get.mockResolvedValue(42)
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const config = await providerSettingsManager.loadConfig("test")
const { name, ...providerSettings } = await providerSettingsManager.activateProfile({ name: "test" })
expect(config).toEqual({
apiProvider: "anthropic",
apiKey: "test-key",
id: "test-id",
})
expect(name).toBe("test")
expect(providerSettings).toEqual({ apiProvider: "anthropic", apiKey: "test-key", id: "test-id" })
// Get the stored config to check the structure
// Get the stored config to check the structure.
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[1][1])
expect(storedConfig.currentApiConfigName).toBe("test")
expect(storedConfig.apiConfigs.test).toEqual({
apiProvider: "anthropic",
apiKey: "test-key",
@ -460,17 +458,12 @@ describe("ProviderSettingsManager", () => {
mockSecrets.get.mockResolvedValue(
JSON.stringify({
currentApiConfigName: "default",
apiConfigs: {
default: {
config: {},
id: "default",
},
},
apiConfigs: { default: { config: {}, id: "default" } },
}),
)
await expect(providerSettingsManager.loadConfig("nonexistent")).rejects.toThrow(
"Config 'nonexistent' not found",
await expect(providerSettingsManager.activateProfile({ name: "nonexistent" })).rejects.toThrow(
"Config with name 'nonexistent' not found",
)
})
@ -478,20 +471,13 @@ describe("ProviderSettingsManager", () => {
mockSecrets.get.mockResolvedValue(
JSON.stringify({
currentApiConfigName: "default",
apiConfigs: {
test: {
config: {
apiProvider: "anthropic",
},
id: "test-id",
},
},
apiConfigs: { test: { config: { apiProvider: "anthropic" }, id: "test-id" } },
}),
)
mockSecrets.store.mockRejectedValueOnce(new Error("Storage failed"))
await expect(providerSettingsManager.loadConfig("test")).rejects.toThrow(
"Failed to load config: Error: Failed to write provider profiles to secrets: Error: Storage failed",
await expect(providerSettingsManager.activateProfile({ name: "test" })).rejects.toThrow(
"Failed to activate profile: Failed to write provider profiles to secrets: Error: Storage failed",
)
})
@ -541,12 +527,7 @@ describe("ProviderSettingsManager", () => {
mockSecrets.get.mockResolvedValue(
JSON.stringify({
currentApiConfigName: "test",
apiConfigs: {
test: {
apiProvider: "anthropic",
id: "test-id",
},
},
apiConfigs: { test: { apiProvider: "anthropic", id: "test-id" } },
}),
)
@ -561,18 +542,8 @@ describe("ProviderSettingsManager", () => {
it("should return true for existing config", async () => {
const existingConfig: ProviderProfiles = {
currentApiConfigName: "default",
apiConfigs: {
default: {
id: "default",
},
test: {
apiProvider: "anthropic",
id: "test-id",
},
},
migrations: {
rateLimitSecondsMigrated: false,
},
apiConfigs: { default: { id: "default" }, test: { apiProvider: "anthropic", id: "test-id" } },
migrations: { rateLimitSecondsMigrated: false },
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
@ -583,10 +554,7 @@ describe("ProviderSettingsManager", () => {
it("should return false for non-existent config", async () => {
mockSecrets.get.mockResolvedValue(
JSON.stringify({
currentApiConfigName: "default",
apiConfigs: { default: {} },
}),
JSON.stringify({ currentApiConfigName: "default", apiConfigs: { default: {} } }),
)
const hasConfig = await providerSettingsManager.hasConfig("nonexistent")

View file

@ -762,7 +762,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
* @param newMode The mode to switch to
*/
public async handleModeSwitch(newMode: Mode) {
// Capture mode switch telemetry event
const cline = this.getCurrentCline()
if (cline) {
@ -779,24 +778,19 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
// Update listApiConfigMeta first to ensure UI has latest data
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// If this mode has a saved config, use it
// If this mode has a saved config, use it.
if (savedConfigId) {
const config = listApiConfig?.find((c) => c.id === savedConfigId)
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
if (config?.name) {
const apiConfig = await this.providerSettingsManager.loadConfig(config.name)
await Promise.all([
this.updateGlobalState("currentApiConfigName", config.name),
this.updateApiConfiguration(apiConfig),
])
if (profile?.name) {
await this.activateProviderProfile({ name: profile.name })
}
} else {
// If no saved config for this mode, save current config as default
// If no saved config for this mode, save current config as default.
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
if (currentApiConfigName) {
const config = listApiConfig?.find((c) => c.name === currentApiConfigName)
const config = listApiConfig.find((c) => c.name === currentApiConfigName)
if (config?.id) {
await this.providerSettingsManager.setModeConfig(newMode, config.id)
@ -807,6 +801,18 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
await this.postStateToWebview()
}
async activateProviderProfile(args: { name: string } | { id: string }) {
const { name, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)
await Promise.all([
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
this.contextProxy.setValue("currentApiConfigName", name),
])
await this.updateApiConfiguration(providerSettings)
await this.postStateToWebview()
}
async updateApiConfiguration(providerSettings: ProviderSettings) {
// Update mode's default config.
const { mode } = await this.getState()

View file

@ -5,7 +5,7 @@ import * as vscode from "vscode"
import axios from "axios"
import { ClineProvider } from "../ClineProvider"
import { ClineMessage, ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
import { ApiConfigMeta, ClineMessage, ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
import { setSoundEnabled } from "../../../utils/sound"
import { setTtsEnabled } from "../../../utils/tts"
import { defaultModeSlug } from "../../../shared/modes"
@ -227,12 +227,6 @@ jest.mock("../../../integrations/misc/extract-text", () => ({
}),
}))
// Spy on console.error and console.log to suppress expected messages
beforeAll(() => {
jest.spyOn(console, "error").mockImplementation(() => {})
jest.spyOn(console, "log").mockImplementation(() => {})
})
afterAll(() => {
jest.restoreAllMocks()
})
@ -596,14 +590,16 @@ describe("ClineProvider", () => {
expect(state.alwaysApproveResubmit).toBe(false)
})
test("loads saved API config when switching modes", async () => {
it("loads saved API config when switching modes", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
const profile: ApiConfigMeta = { name: "test-config", id: "test-id", apiProvider: "anthropic" }
;(provider as any).providerSettingsManager = {
getModeConfigId: jest.fn().mockResolvedValue("test-id"),
listConfig: jest.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }),
listConfig: jest.fn().mockResolvedValue([profile]),
activateProfile: jest.fn().mockResolvedValue(profile),
setModeConfig: jest.fn(),
} as any
@ -612,7 +608,7 @@ describe("ClineProvider", () => {
// Should load the saved config for architect mode
expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect")
expect(provider.providerSettingsManager.loadConfig).toHaveBeenCalledWith("test-config")
expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "test-config" })
expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config")
})
@ -642,8 +638,7 @@ describe("ClineProvider", () => {
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
;(provider as any).providerSettingsManager = {
loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic", id: "new-id" }),
loadConfigById: jest
activateProfile: jest
.fn()
.mockResolvedValue({ config: { apiProvider: "anthropic", id: "new-id" }, name: "new-config" }),
listConfig: jest.fn().mockResolvedValue([{ name: "new-config", id: "new-id", apiProvider: "anthropic" }]),
@ -666,7 +661,7 @@ describe("ClineProvider", () => {
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
;(provider as any).providerSettingsManager = {
loadConfigById: jest.fn().mockResolvedValue({
activateProfile: jest.fn().mockResolvedValue({
config: { apiProvider: "anthropic", id: "config-id-123" },
name: "config-by-id",
}),
@ -686,8 +681,8 @@ describe("ClineProvider", () => {
// Should save new config as default for architect mode
expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "config-id-123")
// Ensure the loadConfigById method was called with the correct ID
expect(provider.providerSettingsManager.loadConfigById).toHaveBeenCalledWith("config-id-123")
// Ensure the `activateProfile` method was called with the correct ID
expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ id: "config-id-123" })
})
test("handles browserToolEnabled setting", async () => {
@ -1542,13 +1537,13 @@ describe("ClineProvider", () => {
await provider.resolveWebviewView(mockWebviewView)
})
test("loads saved API config when switching modes", async () => {
it("loads saved API config when switching modes", async () => {
const profile: ApiConfigMeta = { name: "saved-config", id: "saved-config-id", apiProvider: "anthropic" }
;(provider as any).providerSettingsManager = {
getModeConfigId: jest.fn().mockResolvedValue("saved-config-id"),
listConfig: jest
.fn()
.mockResolvedValue([{ name: "saved-config", id: "saved-config-id", apiProvider: "anthropic" }]),
loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }),
listConfig: jest.fn().mockResolvedValue([profile]),
activateProfile: jest.fn().mockResolvedValue(profile),
setModeConfig: jest.fn(),
} as any
@ -1560,7 +1555,7 @@ describe("ClineProvider", () => {
// Verify saved config was loaded
expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect")
expect(provider.providerSettingsManager.loadConfig).toHaveBeenCalledWith("saved-config")
expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "saved-config" })
expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "saved-config")
// Verify state was posted to webview

View file

@ -4,9 +4,9 @@ import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { ClineProvider } from "./ClineProvider"
import { Language, ApiConfigMeta } from "../../schemas"
import { Language, ApiConfigMeta, ProviderSettings } from "../../schemas"
import { changeLanguage, t } from "../../i18n"
import { ApiConfiguration, RouterName, toRouterName } from "../../shared/api"
import { RouterName, toRouterName } from "../../shared/api"
import { supportPrompt } from "../../shared/support-prompt"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
@ -89,19 +89,12 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
if (currentConfigName) {
if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) {
// current config name not valid, get first config in list
await updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
if (listApiConfig?.[0]?.name) {
const apiConfig = await provider.providerSettingsManager.loadConfig(
listApiConfig?.[0]?.name,
)
// Current config name not valid, get first config in list.
const name = listApiConfig[0]?.name
await updateGlobalState("currentApiConfigName", name)
await Promise.all([
updateGlobalState("listApiConfigMeta", listApiConfig),
provider.postMessageToWebview({ type: "listApiConfig", listApiConfig }),
provider.updateApiConfiguration(apiConfig),
])
await provider.postStateToWebview()
if (name) {
await provider.activateProviderProfile({ name })
return
}
}
@ -939,45 +932,39 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
const { apiConfiguration, customSupportPrompts, listApiConfigMeta, enhancementApiConfigId } =
await provider.getState()
// Try to get enhancement config first, fall back to current config
let configToUse: ApiConfiguration = apiConfiguration
if (enhancementApiConfigId) {
const config = listApiConfigMeta?.find((c: ApiConfigMeta) => c.id === enhancementApiConfigId)
if (config?.name) {
const loadedConfig = await provider.providerSettingsManager.loadConfig(config.name)
if (loadedConfig.apiProvider) {
configToUse = loadedConfig
}
// Try to get enhancement config first, fall back to current config.
let configToUse: ProviderSettings = apiConfiguration
if (
enhancementApiConfigId &&
!!listApiConfigMeta.find((c: ApiConfigMeta) => c.id === enhancementApiConfigId)
) {
const { name: _, ...providerSettings } = await provider.providerSettingsManager.getProfile({
id: enhancementApiConfigId,
})
if (providerSettings.apiProvider) {
configToUse = providerSettings
}
}
const enhancedPrompt = await singleCompletionHandler(
configToUse,
supportPrompt.create(
"ENHANCE",
{
userInput: message.text,
},
customSupportPrompts,
),
supportPrompt.create("ENHANCE", { userInput: message.text }, customSupportPrompts),
)
// Capture telemetry for prompt enhancement
// Capture telemetry for prompt enhancement.
const currentCline = provider.getCurrentCline()
telemetryService.capturePromptEnhanced(currentCline?.taskId)
await provider.postMessageToWebview({
type: "enhancedPrompt",
text: enhancedPrompt,
})
await provider.postMessageToWebview({ type: "enhancedPrompt", text: enhancedPrompt })
} catch (error) {
provider.log(
`Error enhancing prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(t("common:errors.enhance_prompt"))
await provider.postMessageToWebview({
type: "enhancedPrompt",
})
await provider.postMessageToWebview({ type: "enhancedPrompt" })
}
}
break
@ -1096,30 +1083,23 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
break
}
// Load the old configuration to get its ID
const oldConfig = await provider.providerSettingsManager.loadConfig(oldName)
// Load the old configuration to get its ID.
const { id } = await provider.providerSettingsManager.getProfile({ name: oldName })
// Create a new configuration with the same ID
const newConfig = {
...message.apiConfiguration,
id: oldConfig.id, // Preserve the ID
}
// Create a new configuration with the new name and old ID.
await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id })
// Save with the new name but same ID
await provider.providerSettingsManager.saveConfig(newName, newConfig)
// Delete the old configuration.
await provider.providerSettingsManager.deleteConfig(oldName)
const listApiConfig = await provider.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data
await updateGlobalState("listApiConfigMeta", listApiConfig)
await updateGlobalState("currentApiConfigName", newName)
await provider.postStateToWebview()
// Re-activate to update the global settings related to the
// currently activated provider profile.
await provider.activateProviderProfile({ name: newName })
} catch (error) {
provider.log(
`Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(t("common:errors.rename_api_config"))
}
}
@ -1127,16 +1107,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
case "loadApiConfiguration":
if (message.text) {
try {
const apiConfig = await provider.providerSettingsManager.loadConfig(message.text)
const listApiConfig = await provider.providerSettingsManager.listConfig()
await Promise.all([
updateGlobalState("listApiConfigMeta", listApiConfig),
updateGlobalState("currentApiConfigName", message.text),
provider.updateApiConfiguration(apiConfig),
])
await provider.postStateToWebview()
await provider.activateProviderProfile({ name: message.text })
} catch (error) {
provider.log(
`Error load api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@ -1148,18 +1119,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
case "loadApiConfigurationById":
if (message.text) {
try {
const { config: apiConfig, name } = await provider.providerSettingsManager.loadConfigById(
message.text,
)
const listApiConfig = await provider.providerSettingsManager.listConfig()
await Promise.all([
updateGlobalState("listApiConfigMeta", listApiConfig),
updateGlobalState("currentApiConfigName", name),
provider.updateApiConfiguration(apiConfig),
])
await provider.postStateToWebview()
await provider.activateProviderProfile({ id: message.text })
} catch (error) {
provider.log(
`Error load api configuration by ID: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@ -1180,29 +1140,25 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
break
}
const oldName = message.text
const newName = (await provider.providerSettingsManager.listConfig()).filter(
(c) => c.name !== oldName,
)[0]?.name
if (!newName) {
vscode.window.showErrorMessage(t("common:errors.delete_api_config"))
return
}
try {
await provider.providerSettingsManager.deleteConfig(message.text)
const listApiConfig = await provider.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data
await updateGlobalState("listApiConfigMeta", listApiConfig)
// If this was the current config, switch to first available
const currentApiConfigName = getGlobalState("currentApiConfigName")
if (message.text === currentApiConfigName && listApiConfig?.[0]?.name) {
const apiConfig = await provider.providerSettingsManager.loadConfig(listApiConfig[0].name)
await Promise.all([
updateGlobalState("currentApiConfigName", listApiConfig[0].name),
provider.updateApiConfiguration(apiConfig),
])
}
await provider.postStateToWebview()
await provider.providerSettingsManager.deleteConfig(oldName)
await provider.activateProviderProfile({ name: newName })
} catch (error) {
provider.log(
`Error delete api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(t("common:errors.delete_api_config"))
}
}

View file

@ -217,21 +217,24 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
return id
}
private getProfilesMeta() {
return this.getConfiguration().listApiConfigMeta || []
}
public getProfiles() {
return (this.getConfiguration().listApiConfigMeta || []).map((profile) => profile.name)
return this.getProfilesMeta().map((profile) => profile.name)
}
public hasProfile(name: string): boolean {
return !!(this.getConfiguration().listApiConfigMeta || []).find((profile) => profile.name === name)
}
public async setActiveProfile(name: string) {
const currentSettings = this.getConfiguration()
const profiles = currentSettings.listApiConfigMeta || []
const profile = profiles.find((p) => p.name === name)
if (!profile) {
if (!this.hasProfile(name)) {
throw new Error(`Profile with name "${name}" does not exist`)
}
await this.setConfiguration({ ...currentSettings, currentApiConfigName: profile.name })
await this.sidebarProvider.activateProviderProfile({ name })
}
public getActiveProfile() {
@ -240,27 +243,23 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
public async deleteProfile(name: string) {
const currentSettings = this.getConfiguration()
const profiles = currentSettings.listApiConfigMeta || []
const targetIndex = profiles.findIndex((p) => p.name === name)
const listApiConfigMeta = this.getProfilesMeta()
const targetIndex = listApiConfigMeta.findIndex((p) => p.name === name)
if (targetIndex === -1) {
throw new Error(`Profile with name "${name}" does not exist`)
}
const profileToDelete = profiles[targetIndex]
profiles.splice(targetIndex, 1)
const profileToDelete = listApiConfigMeta[targetIndex]
listApiConfigMeta.splice(targetIndex, 1)
// If we're deleting the active profile, clear the currentApiConfigName.
const newSettings: RooCodeSettings = {
...currentSettings,
listApiConfigMeta: profiles,
currentApiConfigName:
currentSettings.currentApiConfigName === profileToDelete.name
? undefined
: currentSettings.currentApiConfigName,
}
const currentApiConfigName =
currentSettings.currentApiConfigName === profileToDelete.name
? undefined
: currentSettings.currentApiConfigName
await this.setConfiguration(newSettings)
await this.setConfiguration({ ...currentSettings, listApiConfigMeta, currentApiConfigName })
}
public isReady() {