Merge pull request #1235 from samhvw8/feat/context-proxy

Feat ContextProxy to improve state management
This commit is contained in:
Matt Rubens 2025-03-05 10:16:52 -05:00 committed by GitHub
commit fcf09260b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 818 additions and 530 deletions

View file

@ -0,0 +1,243 @@
import * as vscode from "vscode"
import { ContextProxy } from "../contextProxy"
import { logger } from "../../utils/logging"
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState"
import { ApiConfiguration } from "../../shared/api"
// Mock shared/globalState
jest.mock("../../shared/globalState", () => ({
GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"],
SECRET_KEYS: ["apiKey", "openAiApiKey"],
GlobalStateKey: {},
SecretKey: {},
}))
// Mock shared/api
jest.mock("../../shared/api", () => ({
API_CONFIG_KEYS: ["apiProvider", "apiModelId"],
ApiConfiguration: {},
}))
// Mock VSCode API
jest.mock("vscode", () => ({
Uri: {
file: jest.fn((path) => ({ path })),
},
ExtensionMode: {
Development: 1,
Production: 2,
Test: 3,
},
}))
describe("ContextProxy", () => {
let proxy: ContextProxy
let mockContext: any
let mockGlobalState: any
let mockSecrets: any
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Mock globalState
mockGlobalState = {
get: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
}
// Mock secrets
mockSecrets = {
get: jest.fn().mockResolvedValue("test-secret"),
store: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
}
// Mock the extension context
mockContext = {
globalState: mockGlobalState,
secrets: mockSecrets,
extensionUri: { path: "/test/extension" },
extensionPath: "/test/extension",
globalStorageUri: { path: "/test/storage" },
logUri: { path: "/test/logs" },
extension: { packageJSON: { version: "1.0.0" } },
extensionMode: vscode.ExtensionMode.Development,
}
// Create proxy instance
proxy = new ContextProxy(mockContext)
})
describe("read-only pass-through properties", () => {
it("should return extension properties from the original context", () => {
expect(proxy.extensionUri).toBe(mockContext.extensionUri)
expect(proxy.extensionPath).toBe(mockContext.extensionPath)
expect(proxy.globalStorageUri).toBe(mockContext.globalStorageUri)
expect(proxy.logUri).toBe(mockContext.logUri)
expect(proxy.extension).toBe(mockContext.extension)
expect(proxy.extensionMode).toBe(mockContext.extensionMode)
})
})
describe("constructor", () => {
it("should initialize state cache with all global state keys", () => {
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length)
for (const key of GLOBAL_STATE_KEYS) {
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
}
})
it("should initialize secret cache with all secret keys", () => {
expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_KEYS.length)
for (const key of SECRET_KEYS) {
expect(mockSecrets.get).toHaveBeenCalledWith(key)
}
})
})
describe("getGlobalState", () => {
it("should return value from cache when it exists", async () => {
// Manually set a value in the cache
await proxy.updateGlobalState("test-key", "cached-value")
// Should return the cached value
const result = proxy.getGlobalState("test-key")
expect(result).toBe("cached-value")
// Original context should be called once during updateGlobalState
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) // Only from initialization
})
it("should handle default values correctly", async () => {
// No value in cache
const result = proxy.getGlobalState("unknown-key", "default-value")
expect(result).toBe("default-value")
})
})
describe("updateGlobalState", () => {
it("should update state directly in original context", async () => {
await proxy.updateGlobalState("test-key", "new-value")
// Should have called original context
expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value")
// Should have stored the value in cache
const storedValue = await proxy.getGlobalState("test-key")
expect(storedValue).toBe("new-value")
})
})
describe("getSecret", () => {
it("should return value from cache when it exists", async () => {
// Manually set a value in the cache
await proxy.storeSecret("api-key", "cached-secret")
// Should return the cached value
const result = proxy.getSecret("api-key")
expect(result).toBe("cached-secret")
})
})
describe("storeSecret", () => {
it("should store secret directly in original context", async () => {
await proxy.storeSecret("api-key", "new-secret")
// Should have called original context
expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret")
// Should have stored the value in cache
const storedValue = await proxy.getSecret("api-key")
expect(storedValue).toBe("new-secret")
})
it("should handle undefined value for secret deletion", async () => {
await proxy.storeSecret("api-key", undefined)
// Should have called delete on original context
expect(mockSecrets.delete).toHaveBeenCalledWith("api-key")
// Should have stored undefined in cache
const storedValue = await proxy.getSecret("api-key")
expect(storedValue).toBeUndefined()
})
describe("getApiConfiguration", () => {
it("should combine global state and secrets into a single ApiConfiguration object", async () => {
// Mock data in state cache
await proxy.updateGlobalState("apiProvider", "anthropic")
await proxy.updateGlobalState("apiModelId", "test-model")
// Mock data in secrets cache
await proxy.storeSecret("apiKey", "test-api-key")
const config = proxy.getApiConfiguration()
// Should contain values from global state
expect(config.apiProvider).toBe("anthropic")
expect(config.apiModelId).toBe("test-model")
// Should contain values from secrets
expect(config.apiKey).toBe("test-api-key")
})
it("should handle special case for apiProvider defaulting", async () => {
// Clear apiProvider but set apiKey
await proxy.updateGlobalState("apiProvider", undefined)
await proxy.storeSecret("apiKey", "test-api-key")
const config = proxy.getApiConfiguration()
// Should default to anthropic when apiKey exists
expect(config.apiProvider).toBe("anthropic")
// Clear both apiProvider and apiKey
await proxy.updateGlobalState("apiProvider", undefined)
await proxy.storeSecret("apiKey", undefined)
const configWithoutKey = proxy.getApiConfiguration()
// Should default to openrouter when no apiKey exists
expect(configWithoutKey.apiProvider).toBe("openrouter")
})
})
describe("updateApiConfiguration", () => {
it("should update both global state and secrets", async () => {
const apiConfig: ApiConfiguration = {
apiProvider: "anthropic",
apiModelId: "claude-latest",
apiKey: "test-api-key",
}
await proxy.updateApiConfiguration(apiConfig)
// Should update global state
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic")
expect(mockGlobalState.update).toHaveBeenCalledWith("apiModelId", "claude-latest")
// Should update secrets
expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "test-api-key")
// Check that values are in cache
expect(proxy.getGlobalState("apiProvider")).toBe("anthropic")
expect(proxy.getGlobalState("apiModelId")).toBe("claude-latest")
expect(proxy.getSecret("apiKey")).toBe("test-api-key")
})
it("should ignore keys that aren't in either GLOBAL_STATE_KEYS or SECRET_KEYS", async () => {
// Use type assertion to add an invalid key
const apiConfig = {
apiProvider: "anthropic",
invalidKey: "should be ignored",
} as ApiConfiguration & { invalidKey: string }
await proxy.updateApiConfiguration(apiConfig)
// Should update keys in GLOBAL_STATE_KEYS
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic")
// Should not call update/store for invalid keys
expect(mockGlobalState.update).not.toHaveBeenCalledWith("invalidKey", expect.anything())
expect(mockSecrets.store).not.toHaveBeenCalledWith("invalidKey", expect.anything())
})
})
})
})

154
src/core/contextProxy.ts Normal file
View file

@ -0,0 +1,154 @@
import * as vscode from "vscode"
import { logger } from "../utils/logging"
import { ApiConfiguration, API_CONFIG_KEYS } from "../shared/api"
import { GLOBAL_STATE_KEYS, SECRET_KEYS, GlobalStateKey, SecretKey } from "../shared/globalState"
export class ContextProxy {
private readonly originalContext: vscode.ExtensionContext
private stateCache: Map<string, any>
private secretCache: Map<string, string | undefined>
constructor(context: vscode.ExtensionContext) {
// Initialize properties first
this.originalContext = context
this.stateCache = new Map()
this.secretCache = new Map()
// Initialize state cache with all defined global state keys
this.initializeStateCache()
// Initialize secret cache with all defined secret keys
this.initializeSecretCache()
logger.debug("ContextProxy created")
}
// Helper method to initialize state cache
private initializeStateCache(): void {
for (const key of GLOBAL_STATE_KEYS) {
try {
const value = this.originalContext.globalState.get(key)
this.stateCache.set(key, value)
} catch (error) {
logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
// Helper method to initialize secret cache
private initializeSecretCache(): void {
for (const key of SECRET_KEYS) {
// Get actual value and update cache when promise resolves
;(this.originalContext.secrets.get(key) as Promise<string | undefined>)
.then((value) => {
this.secretCache.set(key, value)
})
.catch((error: Error) => {
logger.error(`Error loading secret ${key}: ${error.message}`)
})
}
}
get extensionUri(): vscode.Uri {
return this.originalContext.extensionUri
}
get extensionPath(): string {
return this.originalContext.extensionPath
}
get globalStorageUri(): vscode.Uri {
return this.originalContext.globalStorageUri
}
get logUri(): vscode.Uri {
return this.originalContext.logUri
}
get extension(): vscode.Extension<any> | undefined {
return this.originalContext.extension
}
get extensionMode(): vscode.ExtensionMode {
return this.originalContext.extensionMode
}
getGlobalState<T>(key: string): T | undefined
getGlobalState<T>(key: string, defaultValue: T): T
getGlobalState<T>(key: string, defaultValue?: T): T | undefined {
const value = this.stateCache.get(key) as T | undefined
return value !== undefined ? value : (defaultValue as T | undefined)
}
updateGlobalState<T>(key: string, value: T): Thenable<void> {
this.stateCache.set(key, value)
return this.originalContext.globalState.update(key, value)
}
getSecret(key: string): string | undefined {
return this.secretCache.get(key)
}
storeSecret(key: string, value?: string): Thenable<void> {
// Update cache
this.secretCache.set(key, value)
// Write directly to context
if (value === undefined) {
return this.originalContext.secrets.delete(key)
} else {
return this.originalContext.secrets.store(key, value)
}
}
/**
* Gets a complete ApiConfiguration object by fetching values
* from both global state and secrets storage
*/
getApiConfiguration(): ApiConfiguration {
// Create an empty ApiConfiguration object
const config: ApiConfiguration = {}
// Add all API-related keys from global state
for (const key of API_CONFIG_KEYS) {
const value = this.getGlobalState(key)
if (value !== undefined) {
// Use type assertion to avoid TypeScript error
;(config as any)[key] = value
}
}
// Add all secret values
for (const key of SECRET_KEYS) {
const value = this.getSecret(key)
if (value !== undefined) {
// Use type assertion to avoid TypeScript error
;(config as any)[key] = value
}
}
// Handle special case for apiProvider if needed (same logic as current implementation)
if (!config.apiProvider) {
if (config.apiKey) {
config.apiProvider = "anthropic"
} else {
config.apiProvider = "openrouter"
}
}
return config
}
/**
* Updates an ApiConfiguration by persisting each property
* to the appropriate storage (global state or secrets)
*/
async updateApiConfiguration(apiConfiguration: ApiConfiguration): Promise<void> {
const promises: Array<Thenable<void>> = []
// For each property, update the appropriate storage
Object.entries(apiConfiguration).forEach(([key, value]) => {
if (SECRET_KEYS.includes(key as SecretKey)) {
promises.push(this.storeSecret(key, value))
} else if (API_CONFIG_KEYS.includes(key as GlobalStateKey)) {
promises.push(this.updateGlobalState(key, value))
}
// Ignore keys that aren't in either list
})
await Promise.all(promises)
}
}

View file

@ -8,16 +8,16 @@ import * as path from "path"
import * as vscode from "vscode"
import simpleGit from "simple-git"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api"
import { CheckpointStorage } from "../../shared/checkpoints"
import { findLast } from "../../shared/array"
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import type { SecretKey, GlobalStateKey } from "../../shared/globalState"
import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState"
import { HistoryItem } from "../../shared/HistoryItem"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
import { downloadTask } from "../../integrations/misc/export-markdown"
@ -35,6 +35,7 @@ import { getDiffStrategy } from "../diff/DiffStrategy"
import { SYSTEM_PROMPT } from "../prompts/system"
import { ConfigManager } from "../config/ConfigManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { ContextProxy } from "../contextProxy"
import { buildApiHandler } from "../../api"
import { getOpenRouterModels } from "../../api/providers/openrouter"
import { getGlamaModels } from "../../api/providers/glama"
@ -66,6 +67,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private workspaceTracker?: WorkspaceTracker
protected mcpHub?: McpHub // Change from private to protected
private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement
private contextProxy: ContextProxy
configManager: ConfigManager
customModesManager: CustomModesManager
@ -74,6 +76,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private readonly outputChannel: vscode.OutputChannel,
) {
this.outputChannel.appendLine("ClineProvider instantiated")
this.contextProxy = new ContextProxy(context)
ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this)
this.configManager = new ConfigManager(this.context)
@ -242,11 +245,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
localResourceRoots: [this.contextProxy.extensionUri],
}
webviewView.webview.html =
this.context.extensionMode === vscode.ExtensionMode.Development
this.contextProxy.extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(webviewView.webview)
: this.getHtmlContent(webviewView.webview)
@ -399,8 +402,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
const nonce = getNonce()
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this.context.extensionUri, [
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
"webview-ui",
"build",
"assets",
"index.css",
])
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, [
"node_modules",
"@vscode",
"codicons",
@ -466,15 +474,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
"webview-ui",
"build",
"assets",
"index.css",
])
// The JS file from the React build output
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
const scriptUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "build", "assets", "index.js"])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = getUri(webview, this.context.extensionUri, [
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, [
"node_modules",
"@vscode",
"codicons",
@ -1265,7 +1278,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Try to get enhancement config first, fall back to current config
let configToUse: ApiConfiguration = apiConfiguration
if (enhancementApiConfigId) {
const config = listApiConfigMeta?.find((c) => c.id === enhancementApiConfigId)
const config = listApiConfigMeta?.find(
(c: ApiConfigMeta) => c.id === enhancementApiConfigId,
)
if (config?.name) {
const loadedConfig = await this.configManager.loadConfig(config.name)
if (loadedConfig.apiProvider) {
@ -1644,108 +1659,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
const {
apiProvider,
apiModelId,
apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiCustomModelInfo,
openAiUseAzure,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterBaseUrl,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
vsCodeLmModelSelector,
mistralApiKey,
mistralCodestralUrl,
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
lmStudioDraftModelId,
lmStudioSpeculativeDecodingEnabled,
} = apiConfiguration
await Promise.all([
this.updateGlobalState("apiProvider", apiProvider),
this.updateGlobalState("apiModelId", apiModelId),
this.storeSecret("apiKey", apiKey),
this.updateGlobalState("glamaModelId", glamaModelId),
this.updateGlobalState("glamaModelInfo", glamaModelInfo),
this.storeSecret("glamaApiKey", glamaApiKey),
this.storeSecret("openRouterApiKey", openRouterApiKey),
this.storeSecret("awsAccessKey", awsAccessKey),
this.storeSecret("awsSecretKey", awsSecretKey),
this.storeSecret("awsSessionToken", awsSessionToken),
this.updateGlobalState("awsRegion", awsRegion),
this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference),
this.updateGlobalState("awsProfile", awsProfile),
this.updateGlobalState("awsUseProfile", awsUseProfile),
this.updateGlobalState("vertexProjectId", vertexProjectId),
this.updateGlobalState("vertexRegion", vertexRegion),
this.updateGlobalState("openAiBaseUrl", openAiBaseUrl),
this.storeSecret("openAiApiKey", openAiApiKey),
this.updateGlobalState("openAiModelId", openAiModelId),
this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo),
this.updateGlobalState("openAiUseAzure", openAiUseAzure),
this.updateGlobalState("ollamaModelId", ollamaModelId),
this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl),
this.updateGlobalState("lmStudioModelId", lmStudioModelId),
this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
this.storeSecret("geminiApiKey", geminiApiKey),
this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
this.storeSecret("deepSeekApiKey", deepSeekApiKey),
this.updateGlobalState("azureApiVersion", azureApiVersion),
this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled),
this.updateGlobalState("openRouterModelId", openRouterModelId),
this.updateGlobalState("openRouterModelInfo", openRouterModelInfo),
this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl),
this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform),
this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector),
this.storeSecret("mistralApiKey", mistralApiKey),
this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl),
this.storeSecret("unboundApiKey", unboundApiKey),
this.updateGlobalState("unboundModelId", unboundModelId),
this.updateGlobalState("unboundModelInfo", unboundModelInfo),
this.storeSecret("requestyApiKey", requestyApiKey),
this.updateGlobalState("requestyModelId", requestyModelId),
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
this.updateGlobalState("modelTemperature", modelTemperature),
this.updateGlobalState("modelMaxTokens", modelMaxTokens),
this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens),
this.updateGlobalState("lmStudioDraftModelId", lmStudioDraftModelId),
this.updateGlobalState("lmStudioSpeculativeDecodingEnabled", lmStudioSpeculativeDecodingEnabled),
])
// Update all configuration values through the contextProxy
await this.contextProxy.updateApiConfiguration(apiConfiguration)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
@ -1806,13 +1722,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async ensureSettingsDirectoryExists(): Promise<string> {
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
const settingsDir = path.join(this.contextProxy.globalStorageUri.fsPath, "settings")
await fs.mkdir(settingsDir, { recursive: true })
return settingsDir
}
private async ensureCacheDirectoryExists() {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
const cacheDir = path.join(this.contextProxy.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
@ -1900,7 +1816,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = history.find((item) => item.id === id)
if (historyItem) {
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
const taskDirPath = path.join(this.contextProxy.globalStorageUri.fsPath, "tasks", id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
@ -2053,7 +1969,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.cline?.taskId
? (taskHistory || []).find((item) => item.id === this.cline?.taskId)
? (taskHistory || []).find((item: HistoryItem) => item.id === this.cline?.taskId)
: undefined,
clineMessages: this.cline?.clineMessages || [],
taskHistory: (taskHistory || [])
@ -2145,273 +2061,58 @@ export class ClineProvider implements vscode.WebviewViewProvider {
*/
async getState() {
const [
storedApiProvider,
apiModelId,
apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiCustomModelInfo,
openAiUseAzure,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
mistralApiKey,
mistralCodestralUrl,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterBaseUrl,
openRouterUseMiddleOutTransform,
lastShownAnnouncementId,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
alwaysAllowMcp,
alwaysAllowModeSwitch,
taskHistory,
allowedCommands,
soundEnabled,
diffEnabled,
enableCheckpoints,
checkpointStorage,
soundVolume,
browserViewportSize,
fuzzyMatchThreshold,
preferredLanguage,
writeDelayMs,
screenshotQuality,
terminalOutputLineLimit,
mcpEnabled,
enableMcpServerCreation,
alwaysApproveResubmit,
requestDelaySeconds,
rateLimitSeconds,
currentApiConfigName,
listApiConfigMeta,
vsCodeLmModelSelector,
mode,
modeApiConfigs,
customModePrompts,
customSupportPrompts,
enhancementApiConfigId,
autoApprovalEnabled,
customModes,
experiments,
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
maxOpenTabsContext,
browserToolEnabled,
lmStudioSpeculativeDecodingEnabled,
lmStudioDraftModelId,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("glamaApiKey") as Promise<string | undefined>,
this.getGlobalState("glamaModelId") as Promise<string | undefined>,
this.getGlobalState("glamaModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>,
this.getSecret("awsSessionToken") as Promise<string | undefined>,
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getGlobalState("awsUseCrossRegionInference") as Promise<boolean | undefined>,
this.getGlobalState("awsProfile") as Promise<string | undefined>,
this.getGlobalState("awsUseProfile") as Promise<boolean | undefined>,
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
this.getGlobalState("vertexRegion") as Promise<string | undefined>,
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>,
this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
this.getSecret("geminiApiKey") as Promise<string | undefined>,
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
this.getSecret("mistralApiKey") as Promise<string | undefined>,
this.getGlobalState("mistralCodestralUrl") as Promise<string | undefined>,
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
this.getGlobalState("openAiStreamingEnabled") as Promise<boolean | undefined>,
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("openRouterBaseUrl") as Promise<string | undefined>,
this.getGlobalState("openRouterUseMiddleOutTransform") as Promise<boolean | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>,
this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowMcp") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowModeSwitch") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("enableCheckpoints") as Promise<boolean | undefined>,
this.getGlobalState("checkpointStorage") as Promise<CheckpointStorage | undefined>,
this.getGlobalState("soundVolume") as Promise<number | undefined>,
this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
this.getGlobalState("preferredLanguage") as Promise<string | undefined>,
this.getGlobalState("writeDelayMs") as Promise<number | undefined>,
this.getGlobalState("screenshotQuality") as Promise<number | undefined>,
this.getGlobalState("terminalOutputLineLimit") as Promise<number | undefined>,
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
this.getGlobalState("enableMcpServerCreation") as Promise<boolean | undefined>,
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
this.getGlobalState("rateLimitSeconds") as Promise<number | undefined>,
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
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("customModePrompts") as Promise<CustomModePrompts | undefined>,
this.getGlobalState("customSupportPrompts") as Promise<CustomSupportPrompts | undefined>,
this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>,
this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>,
this.customModesManager.getCustomModes(),
this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
this.getSecret("unboundApiKey") as Promise<string | undefined>,
this.getGlobalState("unboundModelId") as Promise<string | undefined>,
this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("requestyApiKey") as Promise<string | undefined>,
this.getGlobalState("requestyModelId") as Promise<string | undefined>,
this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("modelTemperature") as Promise<number | undefined>,
this.getGlobalState("modelMaxTokens") as Promise<number | undefined>,
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
this.getGlobalState("browserToolEnabled") as Promise<boolean | undefined>,
this.getGlobalState("lmStudioSpeculativeDecodingEnabled") as Promise<boolean | undefined>,
this.getGlobalState("lmStudioDraftModelId") as Promise<string | undefined>,
])
// Get ApiConfiguration directly from contextProxy
const apiConfiguration = this.contextProxy.getApiConfiguration()
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
} else {
// Either new user or legacy user that doesn't have the apiProvider stored in state
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
if (apiKey) {
apiProvider = "anthropic"
} else {
// New users should default to openrouter
apiProvider = "openrouter"
}
}
// Create an object to store all fetched values (excluding API config which we already have)
const stateValues: Record<GlobalStateKey, any> = {} as Record<GlobalStateKey, any>
// Create promise arrays for global state
const statePromises = GLOBAL_STATE_KEYS
// Filter out API config keys since we already have them
.filter((key) => !API_CONFIG_KEYS.includes(key))
.map((key) => this.getGlobalState(key))
// Add promise for custom modes which is handled separately
const customModesPromise = this.customModesManager.getCustomModes()
let idx = 0
const valuePromises = await Promise.all([...statePromises, customModesPromise])
// Populate stateValues
GLOBAL_STATE_KEYS.filter((key) => !API_CONFIG_KEYS.includes(key)).forEach((key) => {
stateValues[key] = valuePromises[idx]
idx = idx + 1
})
let customModes = valuePromises[idx] as ModeConfig[] | undefined
// Return the same structure as before
return {
apiConfiguration: {
apiProvider,
apiModelId,
apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiCustomModelInfo,
openAiUseAzure,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
mistralApiKey,
mistralCodestralUrl,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterBaseUrl,
openRouterUseMiddleOutTransform,
vsCodeLmModelSelector,
unboundApiKey,
unboundModelId,
unboundModelInfo,
requestyApiKey,
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
lmStudioSpeculativeDecodingEnabled,
lmStudioDraftModelId,
},
lastShownAnnouncementId,
customInstructions,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
alwaysAllowWrite: alwaysAllowWrite ?? false,
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
alwaysAllowMcp: alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
taskHistory,
allowedCommands,
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true,
enableCheckpoints: enableCheckpoints ?? true,
checkpointStorage: checkpointStorage ?? "task",
soundVolume,
browserViewportSize: browserViewportSize ?? "900x600",
screenshotQuality: screenshotQuality ?? 75,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
writeDelayMs: writeDelayMs ?? 1000,
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
mode: mode ?? defaultModeSlug,
apiConfiguration,
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
customInstructions: stateValues.customInstructions,
alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false,
alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false,
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false,
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
taskHistory: stateValues.taskHistory,
allowedCommands: stateValues.allowedCommands,
soundEnabled: stateValues.soundEnabled ?? false,
diffEnabled: stateValues.diffEnabled ?? true,
enableCheckpoints: stateValues.enableCheckpoints ?? false,
checkpointStorage: stateValues.checkpointStorage ?? "task",
soundVolume: stateValues.soundVolume,
browserViewportSize: stateValues.browserViewportSize ?? "900x600",
screenshotQuality: stateValues.screenshotQuality ?? 75,
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
writeDelayMs: stateValues.writeDelayMs ?? 1000,
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
mode: stateValues.mode ?? defaultModeSlug,
preferredLanguage:
preferredLanguage ??
stateValues.preferredLanguage ??
(() => {
// Get VSCode's locale setting
const vscodeLang = vscode.env.language
@ -2441,23 +2142,23 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Return mapped language or default to English
return langMap[vscodeLang] ?? langMap[vscodeLang.split("-")[0]] ?? "English"
})(),
mcpEnabled: mcpEnabled ?? true,
enableMcpServerCreation: enableMcpServerCreation ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: Math.max(5, requestDelaySeconds ?? 10),
rateLimitSeconds: rateLimitSeconds ?? 0,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
modeApiConfigs: modeApiConfigs ?? ({} as Record<Mode, string>),
customModePrompts: customModePrompts ?? {},
customSupportPrompts: customSupportPrompts ?? {},
enhancementApiConfigId,
experiments: experiments ?? experimentDefault,
autoApprovalEnabled: autoApprovalEnabled ?? false,
mcpEnabled: stateValues.mcpEnabled ?? true,
enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true,
alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? false,
requestDelaySeconds: Math.max(5, stateValues.requestDelaySeconds ?? 10),
rateLimitSeconds: stateValues.rateLimitSeconds ?? 0,
currentApiConfigName: stateValues.currentApiConfigName ?? "default",
listApiConfigMeta: stateValues.listApiConfigMeta ?? [],
modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record<Mode, string>),
customModePrompts: stateValues.customModePrompts ?? {},
customSupportPrompts: stateValues.customSupportPrompts ?? {},
enhancementApiConfigId: stateValues.enhancementApiConfigId,
experiments: stateValues.experiments ?? experimentDefault,
autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false,
customModes,
maxOpenTabsContext: maxOpenTabsContext ?? 20,
openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true,
browserToolEnabled: browserToolEnabled ?? true,
maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20,
openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true,
browserToolEnabled: stateValues.browserToolEnabled ?? true,
}
}
@ -2477,25 +2178,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// global
async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
await this.contextProxy.updateGlobalState(key, value)
}
async getGlobalState(key: GlobalStateKey) {
return await this.context.globalState.get(key)
return await this.contextProxy.getGlobalState(key)
}
// secrets
public async storeSecret(key: SecretKey, value?: string) {
if (value) {
await this.context.secrets.store(key, value)
} else {
await this.context.secrets.delete(key)
}
await this.contextProxy.storeSecret(key, value)
}
private async getSecret(key: SecretKey) {
return await this.context.secrets.get(key)
return await this.contextProxy.getSecret(key)
}
// dev
@ -2512,26 +2209,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
for (const key of this.context.globalState.keys()) {
await this.context.globalState.update(key, undefined)
await this.contextProxy.updateGlobalState(key, undefined)
}
const secretKeys: SecretKey[] = [
"apiKey",
"glamaApiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
]
for (const key of secretKeys) {
for (const key of SECRET_KEYS) {
await this.storeSecret(key, undefined)
}
await this.configManager.resetAllConfigs()
await this.customModesManager.resetCustomModes()
if (this.cline) {

View file

@ -5,6 +5,7 @@ import axios from "axios"
import { ClineProvider } from "../ClineProvider"
import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
import { GlobalStateKey, SecretKey } from "../../../shared/globalState"
import { setSoundEnabled } from "../../../utils/sound"
import { defaultModeSlug } from "../../../shared/modes"
import { experimentDefault } from "../../../shared/experiments"
@ -12,6 +13,49 @@ import { experimentDefault } from "../../../shared/experiments"
// Mock setup must come before imports
jest.mock("../../prompts/sections/custom-instructions")
// Mock ContextProxy
jest.mock("../../contextProxy", () => {
return {
ContextProxy: jest.fn().mockImplementation((context) => ({
originalContext: context,
extensionUri: context.extensionUri,
extensionPath: context.extensionPath,
globalStorageUri: context.globalStorageUri,
logUri: context.logUri,
extension: context.extension,
extensionMode: context.extensionMode,
getGlobalState: jest
.fn()
.mockImplementation((key, defaultValue) => context.globalState.get(key, defaultValue)),
updateGlobalState: jest.fn().mockImplementation((key, value) => context.globalState.update(key, value)),
getSecret: jest.fn().mockImplementation((key) => context.secrets.get(key)),
storeSecret: jest
.fn()
.mockImplementation((key, value) =>
value ? context.secrets.store(key, value) : context.secrets.delete(key),
),
getApiConfiguration: jest.fn().mockImplementation(() => ({
apiProvider: "openrouter",
// Add other common properties
})),
updateApiConfiguration: jest.fn().mockImplementation(async (apiConfiguration) => {
// Mock implementation that simulates updating state and secrets
for (const [key, value] of Object.entries(apiConfiguration)) {
if (key === "apiKey" || key === "openAiApiKey") {
context.secrets.store(key, value)
} else {
context.globalState.update(key, value)
}
}
return Promise.resolve()
}),
saveChanges: jest.fn().mockResolvedValue(undefined),
dispose: jest.fn().mockResolvedValue(undefined),
hasPendingChanges: jest.fn().mockReturnValue(false),
})),
}
})
// Mock dependencies
jest.mock("vscode")
jest.mock("delay")
@ -235,6 +279,12 @@ describe("ClineProvider", () => {
let mockOutputChannel: vscode.OutputChannel
let mockWebviewView: vscode.WebviewView
let mockPostMessage: jest.Mock
let mockContextProxy: {
updateGlobalState: jest.Mock
getGlobalState: jest.Mock
storeSecret: jest.Mock
dispose: jest.Mock
}
beforeEach(() => {
// Reset mocks
@ -307,6 +357,8 @@ describe("ClineProvider", () => {
} as unknown as vscode.WebviewView
provider = new ClineProvider(mockContext, mockOutputChannel)
// @ts-ignore - Access private property for testing
mockContextProxy = provider.contextProxy
// @ts-ignore - Accessing private property for testing.
provider.customModesManager = mockCustomModesManager
@ -479,6 +531,7 @@ describe("ClineProvider", () => {
await messageHandler({ type: "writeDelayMs", value: 2000 })
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("writeDelayMs", 2000)
expect(mockContext.globalState.update).toHaveBeenCalledWith("writeDelayMs", 2000)
expect(mockPostMessage).toHaveBeenCalled()
})
@ -492,6 +545,7 @@ describe("ClineProvider", () => {
// Simulate setting sound to enabled
await messageHandler({ type: "soundEnabled", bool: true })
expect(setSoundEnabled).toHaveBeenCalledWith(true)
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("soundEnabled", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
@ -614,6 +668,7 @@ describe("ClineProvider", () => {
// Test alwaysApproveResubmit
await messageHandler({ type: "alwaysApproveResubmit", bool: true })
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("alwaysApproveResubmit", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("alwaysApproveResubmit", true)
expect(mockPostMessage).toHaveBeenCalled()
@ -1484,6 +1539,106 @@ describe("ClineProvider", () => {
expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [
{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
])
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("listApiConfigMeta", [
{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
])
})
})
})
describe("ContextProxy integration", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockContextProxy: any
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Setup basic mocks
mockContext = {
globalState: { get: jest.fn(), update: jest.fn(), keys: jest.fn().mockReturnValue([]) },
secrets: { get: jest.fn(), store: jest.fn(), delete: jest.fn() },
extensionUri: {} as vscode.Uri,
globalStorageUri: { fsPath: "/test/path" },
extension: { packageJSON: { version: "1.0.0" } },
} as unknown as vscode.ExtensionContext
mockOutputChannel = { appendLine: jest.fn() } as unknown as vscode.OutputChannel
provider = new ClineProvider(mockContext, mockOutputChannel)
// @ts-ignore - accessing private property for testing
mockContextProxy = provider.contextProxy
})
test("updateGlobalState uses contextProxy", async () => {
await provider.updateGlobalState("currentApiConfigName" as GlobalStateKey, "testValue")
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("currentApiConfigName", "testValue")
})
test("getGlobalState uses contextProxy", async () => {
mockContextProxy.getGlobalState.mockResolvedValueOnce("testValue")
const result = await provider.getGlobalState("currentApiConfigName" as GlobalStateKey)
expect(mockContextProxy.getGlobalState).toHaveBeenCalledWith("currentApiConfigName")
expect(result).toBe("testValue")
})
test("storeSecret uses contextProxy", async () => {
await provider.storeSecret("apiKey" as SecretKey, "test-secret")
expect(mockContextProxy.storeSecret).toHaveBeenCalledWith("apiKey", "test-secret")
})
test("contextProxy methods are available", () => {
// Verify the contextProxy has all the required methods
expect(mockContextProxy.getGlobalState).toBeDefined()
expect(mockContextProxy.updateGlobalState).toBeDefined()
expect(mockContextProxy.storeSecret).toBeDefined()
expect(mockContextProxy.getApiConfiguration).toBeDefined()
expect(mockContextProxy.updateApiConfiguration).toBeDefined()
})
test("getState uses contextProxy.getApiConfiguration", async () => {
// Setup mock API configuration
const mockApiConfig = {
apiProvider: "anthropic",
apiModelId: "claude-latest",
apiKey: "test-api-key",
}
mockContextProxy.getApiConfiguration.mockReturnValue(mockApiConfig)
// Get state
const state = await provider.getState()
// Verify getApiConfiguration was called
expect(mockContextProxy.getApiConfiguration).toHaveBeenCalled()
// Verify state has the API configuration from contextProxy
expect(state.apiConfiguration).toBe(mockApiConfig)
})
test("updateApiConfiguration uses contextProxy.updateApiConfiguration", async () => {
// Setup test config
const testApiConfig = {
apiProvider: "anthropic",
apiModelId: "claude-latest",
apiKey: "test-api-key",
}
// Mock methods needed for the test
provider.configManager = {
listConfig: jest.fn().mockResolvedValue([]),
setModeConfig: jest.fn(),
} as any
// Mock getState for mode
jest.spyOn(provider, "getState").mockResolvedValue({
mode: "code",
} as any)
// Call the private method - need to use any to access it
await (provider as any).updateApiConfiguration(testApiConfig)
// Verify contextProxy.updateApiConfiguration was called with the right config
expect(mockContextProxy.updateApiConfiguration).toHaveBeenCalledWith(testApiConfig)
})
})

View file

@ -58,7 +58,6 @@ export interface ApiHandlerOptions {
azureApiVersion?: string
openRouterUseMiddleOutTransform?: boolean
openAiStreamingEnabled?: boolean
setAzureApiVersion?: boolean
deepSeekBaseUrl?: string
deepSeekApiKey?: string
includeMaxTokens?: boolean
@ -78,6 +77,52 @@ export type ApiConfiguration = ApiHandlerOptions & {
id?: string // stable unique identifier
}
// Import GlobalStateKey type from globalState.ts
import { GlobalStateKey } from "./globalState"
// Define API configuration keys for dynamic object building
export const API_CONFIG_KEYS: GlobalStateKey[] = [
"apiModelId",
"anthropicBaseUrl",
"vsCodeLmModelSelector",
"glamaModelId",
"glamaModelInfo",
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"awsRegion",
"awsUseCrossRegionInference",
// "awsUsePromptCache", // NOT exist on GlobalStateKey
// "awspromptCacheId", // NOT exist on GlobalStateKey
"awsProfile",
"awsUseProfile",
"vertexProjectId",
"vertexRegion",
"openAiBaseUrl",
"openAiModelId",
"openAiCustomModelInfo",
"openAiUseAzure",
"ollamaModelId",
"ollamaBaseUrl",
"lmStudioModelId",
"lmStudioBaseUrl",
"lmStudioDraftModelId",
"lmStudioSpeculativeDecodingEnabled",
"mistralCodestralUrl",
"azureApiVersion",
"openRouterUseMiddleOutTransform",
"openAiStreamingEnabled",
// "deepSeekBaseUrl", // not exist on GlobalStateKey
// "includeMaxTokens", // not exist on GlobalStateKey
"unboundModelId",
"unboundModelInfo",
"requestyModelId",
"requestyModelInfo",
"modelTemperature",
"modelMaxTokens",
"modelMaxThinkingTokens",
]
// Models
export interface ModelInfo {

View file

@ -1,23 +1,20 @@
import { ApiConfiguration } from "../shared/api"
import { SECRET_KEYS } from "./globalState"
export function checkExistKey(config: ApiConfiguration | undefined) {
return config
? [
config.apiKey,
config.glamaApiKey,
config.openRouterApiKey,
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaModelId,
config.lmStudioModelId,
config.geminiApiKey,
config.openAiNativeApiKey,
config.deepSeekApiKey,
config.mistralApiKey,
config.vsCodeLmModelSelector,
config.requestyApiKey,
config.unboundApiKey,
].some((key) => key !== undefined)
: false
if (!config) return false
// Check all secret keys from the centralized SECRET_KEYS array
const hasSecretKey = SECRET_KEYS.some((key) => config[key as keyof ApiConfiguration] !== undefined)
// Check additional non-secret configuration properties
const hasOtherConfig = [
config.awsRegion,
config.vertexProjectId,
config.ollamaModelId,
config.lmStudioModelId,
config.vsCodeLmModelSelector,
].some((value) => value !== undefined)
return hasSecretKey || hasOtherConfig
}

View file

@ -1,90 +1,100 @@
export type SecretKey =
| "apiKey"
| "glamaApiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "mistralApiKey"
| "unboundApiKey"
| "requestyApiKey"
// Define the array first with 'as const' to create a readonly tuple type
export const SECRET_KEYS = [
"apiKey",
"glamaApiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
] as const
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "glamaModelId"
| "glamaModelInfo"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiCustomModelInfo"
| "openAiUseAzure"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "lmStudioDraftModelId"
| "lmStudioSpeculativeDecodingEnabled"
| "anthropicBaseUrl"
| "azureApiVersion"
| "openAiStreamingEnabled"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterBaseUrl"
| "openRouterUseMiddleOutTransform"
| "allowedCommands"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "enableCheckpoints"
| "checkpointStorage"
| "browserViewportSize"
| "screenshotQuality"
| "fuzzyMatchThreshold"
| "preferredLanguage" // Language setting for Cline's communication
| "writeDelayMs"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
| "vsCodeLmModelSelector"
| "mode"
| "modeApiConfigs"
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "experiments" // Map of experiment IDs to their enabled state
| "autoApprovalEnabled"
| "customModes" // Array of custom modes
| "unboundModelId"
| "requestyModelId"
| "requestyModelInfo"
| "unboundModelInfo"
| "modelTemperature"
| "modelMaxTokens"
| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
| "mistralCodestralUrl"
| "maxOpenTabsContext"
| "browserToolEnabled" // Setting to enable/disable the browser tool
// Derive the type from the array - creates a union of string literals
export type SecretKey = (typeof SECRET_KEYS)[number]
// Define the array first with 'as const' to create a readonly tuple type
export const GLOBAL_STATE_KEYS = [
"apiProvider",
"apiModelId",
"glamaModelId",
"glamaModelInfo",
"awsRegion",
"awsUseCrossRegionInference",
"awsProfile",
"awsUseProfile",
"vertexProjectId",
"vertexRegion",
"lastShownAnnouncementId",
"customInstructions",
"alwaysAllowReadOnly",
"alwaysAllowWrite",
"alwaysAllowExecute",
"alwaysAllowBrowser",
"alwaysAllowMcp",
"alwaysAllowModeSwitch",
"taskHistory",
"openAiBaseUrl",
"openAiModelId",
"openAiCustomModelInfo",
"openAiUseAzure",
"ollamaModelId",
"ollamaBaseUrl",
"lmStudioModelId",
"lmStudioBaseUrl",
"anthropicBaseUrl",
"modelMaxThinkingTokens",
"azureApiVersion",
"openAiStreamingEnabled",
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"openRouterUseMiddleOutTransform",
"allowedCommands",
"soundEnabled",
"soundVolume",
"diffEnabled",
"enableCheckpoints",
"checkpointStorage",
"browserViewportSize",
"screenshotQuality",
"fuzzyMatchThreshold",
"preferredLanguage", // Language setting for Cline's communication
"writeDelayMs",
"terminalOutputLineLimit",
"mcpEnabled",
"enableMcpServerCreation",
"alwaysApproveResubmit",
"requestDelaySeconds",
"rateLimitSeconds",
"currentApiConfigName",
"listApiConfigMeta",
"vsCodeLmModelSelector",
"mode",
"modeApiConfigs",
"customModePrompts",
"customSupportPrompts",
"enhancementApiConfigId",
"experiments", // Map of experiment IDs to their enabled state
"autoApprovalEnabled",
"customModes", // Array of custom modes
"unboundModelId",
"requestyModelId",
"requestyModelInfo",
"unboundModelInfo",
"modelTemperature",
"modelMaxTokens",
"mistralCodestralUrl",
"maxOpenTabsContext",
"browserToolEnabled",
"lmStudioSpeculativeDecodingEnabled",
"lmStudioDraftModelId",
] as const
// Derive the type from the array - creates a union of string literals
export type GlobalStateKey = (typeof GLOBAL_STATE_KEYS)[number]