Feat ContextProxy to improve state management

- Add ContextProxy class as a wrapper around VSCode's ExtensionContext
- Implement batched state updates for performance optimization
- Update ClineProvider to use ContextProxy instead of direct context access
- Add comprehensive test coverage for ContextProxy
- Extract SECRET_KEYS and GLOBAL_STATE_KEYS constants for better maintainability
This commit is contained in:
sam hoang 2025-02-27 14:37:34 +07:00
parent 541b54e015
commit 381b07849a
5 changed files with 797 additions and 406 deletions

View file

@ -0,0 +1,282 @@
import * as vscode from "vscode"
import { ContextProxy } from "../contextProxy"
import { logger } from "../../utils/logging"
// Mock the logger
jest.mock("../../utils/logging", () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}))
// 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(),
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("getGlobalState", () => {
it("should return pending change when it exists", async () => {
// Set up a pending change
await proxy.updateGlobalState("test-key", "new-value")
// Should return the pending value
const result = await proxy.getGlobalState("test-key")
expect(result).toBe("new-value")
// Original context should not be called
expect(mockGlobalState.get).not.toHaveBeenCalled()
})
it("should fall back to original context when no pending change exists", async () => {
// Set up original context value
mockGlobalState.get.mockReturnValue("original-value")
// Should get from original context
const result = await proxy.getGlobalState("test-key")
expect(result).toBe("original-value")
expect(mockGlobalState.get).toHaveBeenCalledWith("test-key", undefined)
})
it("should handle default values correctly", async () => {
// No value in either pending or original
mockGlobalState.get.mockImplementation((key: string, defaultValue: any) => defaultValue)
// Should return the default value
const result = await proxy.getGlobalState("test-key", "default-value")
expect(result).toBe("default-value")
})
})
describe("updateGlobalState", () => {
it("should buffer changes without calling original context", async () => {
await proxy.updateGlobalState("test-key", "new-value")
// Should have called logger.debug
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering state update"))
// Should not have called original context
expect(mockGlobalState.update).not.toHaveBeenCalled()
// Should have stored the value in pendingStateChanges
const storedValue = await proxy.getGlobalState("test-key")
expect(storedValue).toBe("new-value")
})
it("should throw an error when context is disposed", async () => {
await proxy.dispose()
await expect(proxy.updateGlobalState("test-key", "new-value")).rejects.toThrow(
"Cannot update state on disposed context",
)
})
})
describe("getSecret", () => {
it("should return pending secret when it exists", async () => {
// Set up a pending secret
await proxy.storeSecret("api-key", "secret123")
// Should return the pending value
const result = await proxy.getSecret("api-key")
expect(result).toBe("secret123")
// Original context should not be called
expect(mockSecrets.get).not.toHaveBeenCalled()
})
it("should fall back to original context when no pending secret exists", async () => {
// Set up original context value
mockSecrets.get.mockResolvedValue("original-secret")
// Should get from original context
const result = await proxy.getSecret("api-key")
expect(result).toBe("original-secret")
expect(mockSecrets.get).toHaveBeenCalledWith("api-key")
})
})
describe("storeSecret", () => {
it("should buffer secret changes without calling original context", async () => {
await proxy.storeSecret("api-key", "new-secret")
// Should have called logger.debug
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering secret update"))
// Should not have called original context
expect(mockSecrets.store).not.toHaveBeenCalled()
// Should have stored the value in pendingSecretChanges
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 stored undefined in pendingSecretChanges
const storedValue = await proxy.getSecret("api-key")
expect(storedValue).toBeUndefined()
})
it("should throw an error when context is disposed", async () => {
await proxy.dispose()
await expect(proxy.storeSecret("api-key", "new-secret")).rejects.toThrow(
"Cannot store secret on disposed context",
)
})
})
describe("saveChanges", () => {
it("should apply state changes to original context", async () => {
// Set up pending changes
await proxy.updateGlobalState("key1", "value1")
await proxy.updateGlobalState("key2", "value2")
// Save changes
await proxy.saveChanges()
// Should have called update on original context
expect(mockGlobalState.update).toHaveBeenCalledTimes(2)
expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1")
expect(mockGlobalState.update).toHaveBeenCalledWith("key2", "value2")
// Should have cleared pending changes
expect(proxy.hasPendingChanges()).toBe(false)
})
it("should apply secret changes to original context", async () => {
// Set up pending changes
await proxy.storeSecret("secret1", "value1")
await proxy.storeSecret("secret2", undefined)
// Save changes
await proxy.saveChanges()
// Should have called store and delete on original context
expect(mockSecrets.store).toHaveBeenCalledTimes(1)
expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1")
expect(mockSecrets.delete).toHaveBeenCalledTimes(1)
expect(mockSecrets.delete).toHaveBeenCalledWith("secret2")
// Should have cleared pending changes
expect(proxy.hasPendingChanges()).toBe(false)
})
it("should do nothing when there are no pending changes", async () => {
await proxy.saveChanges()
expect(mockGlobalState.update).not.toHaveBeenCalled()
expect(mockSecrets.store).not.toHaveBeenCalled()
expect(mockSecrets.delete).not.toHaveBeenCalled()
})
it("should throw an error when context is disposed", async () => {
await proxy.dispose()
await expect(proxy.saveChanges()).rejects.toThrow("Cannot save changes on disposed context")
})
})
describe("dispose", () => {
it("should save pending changes to original context", async () => {
// Set up pending changes
await proxy.updateGlobalState("key1", "value1")
await proxy.storeSecret("secret1", "value1")
// Dispose
await proxy.dispose()
// Should have saved changes
expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1")
expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1")
// Should be marked as disposed
expect(proxy.hasPendingChanges()).toBe(false)
})
})
describe("hasPendingChanges", () => {
it("should return false when no changes are pending", () => {
expect(proxy.hasPendingChanges()).toBe(false)
})
it("should return true when state changes are pending", async () => {
await proxy.updateGlobalState("key", "value")
expect(proxy.hasPendingChanges()).toBe(true)
})
it("should return true when secret changes are pending", async () => {
await proxy.storeSecret("key", "value")
expect(proxy.hasPendingChanges()).toBe(true)
})
it("should return false after changes are saved", async () => {
await proxy.updateGlobalState("key", "value")
expect(proxy.hasPendingChanges()).toBe(true)
await proxy.saveChanges()
expect(proxy.hasPendingChanges()).toBe(false)
})
})
})

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

@ -0,0 +1,123 @@
import * as vscode from "vscode"
import { logger } from "../utils/logging"
/**
* A proxy class for vscode.ExtensionContext that buffers state changes
* and only commits them when explicitly requested or during disposal.
*/
export class ContextProxy {
private readonly originalContext: vscode.ExtensionContext
private pendingStateChanges: Map<string, any>
private pendingSecretChanges: Map<string, string | undefined>
private disposed: boolean
constructor(context: vscode.ExtensionContext) {
this.originalContext = context
this.pendingStateChanges = new Map()
this.pendingSecretChanges = new Map()
this.disposed = false
logger.debug("ContextProxy created")
}
// Read-only pass-through properties
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
}
// State management methods
async getGlobalState<T>(key: string): Promise<T | undefined>
async getGlobalState<T>(key: string, defaultValue: T): Promise<T>
async getGlobalState<T>(key: string, defaultValue?: T): Promise<T | undefined> {
// Check pending changes first
if (this.pendingStateChanges.has(key)) {
const value = this.pendingStateChanges.get(key) as T | undefined
return value !== undefined ? value : (defaultValue as T | undefined)
}
// Fall back to original context
return this.originalContext.globalState.get<T>(key, defaultValue as T)
}
async updateGlobalState<T>(key: string, value: T): Promise<void> {
if (this.disposed) {
throw new Error("Cannot update state on disposed context")
}
logger.debug(`ContextProxy: buffering state update for key "${key}"`)
this.pendingStateChanges.set(key, value)
}
// Secret storage methods
async getSecret(key: string): Promise<string | undefined> {
// Check pending changes first
if (this.pendingSecretChanges.has(key)) {
return this.pendingSecretChanges.get(key)
}
// Fall back to original context
return this.originalContext.secrets.get(key)
}
async storeSecret(key: string, value?: string): Promise<void> {
if (this.disposed) {
throw new Error("Cannot store secret on disposed context")
}
logger.debug(`ContextProxy: buffering secret update for key "${key}"`)
this.pendingSecretChanges.set(key, value)
}
// Save pending changes to actual context
async saveChanges(): Promise<void> {
if (this.disposed) {
throw new Error("Cannot save changes on disposed context")
}
// Apply state changes
if (this.pendingStateChanges.size > 0) {
logger.debug(`ContextProxy: applying ${this.pendingStateChanges.size} buffered state changes`)
for (const [key, value] of this.pendingStateChanges.entries()) {
await this.originalContext.globalState.update(key, value)
}
this.pendingStateChanges.clear()
}
// Apply secret changes
if (this.pendingSecretChanges.size > 0) {
logger.debug(`ContextProxy: applying ${this.pendingSecretChanges.size} buffered secret changes`)
for (const [key, value] of this.pendingSecretChanges.entries()) {
if (value === undefined) {
await this.originalContext.secrets.delete(key)
} else {
await this.originalContext.secrets.store(key, value)
}
}
this.pendingSecretChanges.clear()
}
}
// Called when the provider is disposing
async dispose(): Promise<void> {
if (!this.disposed) {
logger.debug("ContextProxy: disposing and saving pending changes")
await this.saveChanges()
this.disposed = true
}
}
// Method to check if there are pending changes
hasPendingChanges(): boolean {
return this.pendingStateChanges.size > 0 || this.pendingSecretChanges.size > 0
}
}

View file

@ -12,7 +12,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
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"
@ -34,6 +34,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"
@ -65,6 +66,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
@ -73,6 +75,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)
@ -115,6 +118,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.mcpHub = undefined
this.customModesManager?.dispose()
this.outputChannel.appendLine("Disposed all disposables")
// Dispose the context proxy to commit any pending changes
await this.contextProxy.dispose()
this.outputChannel.appendLine("Disposed context proxy")
ClineProvider.activeInstances.delete(this)
// Unregister from McpServerManager
@ -241,11 +247,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)
@ -389,8 +395,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",
@ -456,15 +467,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",
@ -1249,7 +1265,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) {
@ -1628,108 +1646,21 @@ 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),
])
// Create an array of promises to update state
const promises: Promise<any>[] = []
// For each property in apiConfiguration, update the appropriate state
Object.entries(apiConfiguration).forEach(([key, value]) => {
// Check if this key is a secret
if (SECRET_KEYS.includes(key as SecretKey)) {
promises.push(this.storeSecret(key as SecretKey, value))
} else {
promises.push(this.updateGlobalState(key as GlobalStateKey, value))
}
})
await Promise.all(promises)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
@ -1790,13 +1721,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
}
@ -1884,7 +1815,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)
@ -2049,7 +1980,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 || [])
@ -2140,189 +2071,41 @@ 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,
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("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>,
// Create an object to store all fetched values
const stateValues: Record<GlobalStateKey | SecretKey, any> = {} as Record<GlobalStateKey | SecretKey, any>
const secretValues: Record<SecretKey, any> = {} as Record<SecretKey, any>
// Create promise arrays for global state and secrets
const statePromises = GLOBAL_STATE_KEYS.map((key) => this.getGlobalState(key))
const secretPromises = SECRET_KEYS.map((key) => this.getSecret(key))
// Add promise for custom modes which is handled separately
const customModesPromise = this.customModesManager.getCustomModes()
// Wait for all promises to resolve
const [stateResults, secretResults, customModes] = await Promise.all([
Promise.all(statePromises),
Promise.all(secretPromises),
customModesPromise,
])
// Populate stateValues and secretValues
GLOBAL_STATE_KEYS.forEach((key, index) => {
stateValues[key] = stateResults[index]
})
SECRET_KEYS.forEach((key, index) => {
secretValues[key] = secretResults[index]
})
// Determine apiProvider with the same logic as before
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
if (stateValues.apiProvider) {
apiProvider = stateValues.apiProvider
} 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) {
if (secretValues.apiKey) {
apiProvider = "anthropic"
} else {
// New users should default to openrouter
@ -2330,80 +2113,73 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
// Build the apiConfiguration object combining state values and secrets
const apiConfiguration: ApiConfiguration = {
apiProvider,
apiModelId: stateValues.apiModelId,
glamaModelId: stateValues.glamaModelId,
glamaModelInfo: stateValues.glamaModelInfo,
awsRegion: stateValues.awsRegion,
awsUseCrossRegionInference: stateValues.awsUseCrossRegionInference,
awsProfile: stateValues.awsProfile,
awsUseProfile: stateValues.awsUseProfile,
vertexProjectId: stateValues.vertexProjectId,
vertexRegion: stateValues.vertexRegion,
openAiBaseUrl: stateValues.openAiBaseUrl,
openAiModelId: stateValues.openAiModelId,
openAiCustomModelInfo: stateValues.openAiCustomModelInfo,
openAiUseAzure: stateValues.openAiUseAzure,
ollamaModelId: stateValues.ollamaModelId,
ollamaBaseUrl: stateValues.ollamaBaseUrl,
lmStudioModelId: stateValues.lmStudioModelId,
lmStudioBaseUrl: stateValues.lmStudioBaseUrl,
anthropicBaseUrl: stateValues.anthropicBaseUrl,
modelMaxThinkingTokens: stateValues.modelMaxThinkingTokens,
mistralCodestralUrl: stateValues.mistralCodestralUrl,
azureApiVersion: stateValues.azureApiVersion,
openAiStreamingEnabled: stateValues.openAiStreamingEnabled,
openRouterModelId: stateValues.openRouterModelId,
openRouterModelInfo: stateValues.openRouterModelInfo,
openRouterBaseUrl: stateValues.openRouterBaseUrl,
openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform,
vsCodeLmModelSelector: stateValues.vsCodeLmModelSelector,
unboundModelId: stateValues.unboundModelId,
unboundModelInfo: stateValues.unboundModelInfo,
requestyModelId: stateValues.requestyModelId,
requestyModelInfo: stateValues.requestyModelInfo,
modelTemperature: stateValues.modelTemperature,
modelMaxTokens: stateValues.modelMaxTokens,
lmStudioSpeculativeDecodingEnabled: stateValues.lmStudioSpeculativeDecodingEnabled,
lmStudioDraftModelId: stateValues.lmStudioDraftModelId,
// Add all secrets
...secretValues,
}
// 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,
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,
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
@ -2433,23 +2209,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,
}
}
@ -2469,25 +2245,29 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// global
async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
this.outputChannel.appendLine(`Updating global state: ${key}`)
await this.contextProxy.updateGlobalState(key, value)
// // If we have a lot of pending changes, consider saving them periodically
// if (this.contextProxy.hasPendingChanges() && Math.random() < 0.1) { // 10% chance to save changes
// this.outputChannel.appendLine("Periodically flushing context state changes")
// await this.contextProxy.saveChanges()
// }
}
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)
}
this.outputChannel.appendLine(`Storing secret: ${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
@ -2504,24 +2284,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
for (const key of this.context.globalState.keys()) {
await this.context.globalState.update(key, undefined)
// Still using original context for listing keys
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()

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,34 @@ 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),
),
saveChanges: jest.fn().mockResolvedValue(undefined),
dispose: jest.fn().mockResolvedValue(undefined),
hasPendingChanges: jest.fn().mockReturnValue(false),
})),
}
})
// Mock dependencies
jest.mock("vscode")
jest.mock("delay")
@ -153,6 +182,16 @@ jest.mock("../../../utils/sound", () => ({
setSoundEnabled: jest.fn(),
}))
// Mock logger
jest.mock("../../../utils/logging", () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
}))
// Mock ESM modules
jest.mock("p-wait-for", () => ({
__esModule: true,
@ -235,6 +274,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 +352,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
@ -478,6 +525,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()
})
@ -491,6 +539,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()
@ -613,6 +662,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()
@ -1253,6 +1303,17 @@ describe("ClineProvider", () => {
// Verify state was posted to webview
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" }))
})
test("disposes the contextProxy when provider is disposed", async () => {
// Setup mock Cline instance
const mockCline = {
abortTask: jest.fn(),
}
// @ts-ignore - accessing private property for testing
provider.cline = mockCline
await provider.dispose()
expect(mockContextProxy.dispose).toHaveBeenCalled()
})
})
describe("updateCustomMode", () => {
@ -1474,6 +1535,7 @@ describe("ClineProvider", () => {
apiConfiguration: testApiConfig,
})
// Reset jest.mock calls tracking
// Verify config was saved
expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig)
@ -1481,6 +1543,74 @@ 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" },
])
// Reset jest.mock calls tracking for subsequent tests
jest.clearAllMocks()
})
})
})
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()
})
test("contextProxy is properly disposed", async () => {
// Setup mock Cline instance
const mockCline = {
abortTask: jest.fn(),
}
// @ts-ignore - accessing private property for testing
provider.cline = mockCline
await provider.dispose()
expect(mockContextProxy.dispose).toHaveBeenCalled()
})
})

View file

@ -13,6 +13,22 @@ export type SecretKey =
| "unboundApiKey"
| "requestyApiKey"
export const SECRET_KEYS: SecretKey[] = [
"apiKey",
"glamaApiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
]
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
@ -83,7 +99,80 @@ export type GlobalStateKey =
| "unboundModelInfo"
| "modelTemperature"
| "modelMaxTokens"
| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
| "modelMaxThinkingTokens"
| "mistralCodestralUrl"
| "maxOpenTabsContext"
| "browserToolEnabled" // Setting to enable/disable the browser tool
export const GLOBAL_STATE_KEYS: 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",
"anthropicBaseUrl",
"modelMaxThinkingTokens",
"azureApiVersion",
"openAiStreamingEnabled",
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"openRouterUseMiddleOutTransform",
"allowedCommands",
"soundEnabled",
"soundVolume",
"diffEnabled",
"enableCheckpoints",
"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",
]