From 1c57b635f0c3c4772685a32a6e2bc7fba8a06444 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Thu, 27 Feb 2025 14:37:34 +0700 Subject: [PATCH 1/2] 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 --- src/core/__tests__/contextProxy.test.ts | 282 +++++++++ src/core/contextProxy.ts | 123 ++++ src/core/webview/ClineProvider.ts | 561 ++++++------------ .../webview/__tests__/ClineProvider.test.ts | 130 ++++ src/shared/globalState.ts | 91 ++- 5 files changed, 794 insertions(+), 393 deletions(-) create mode 100644 src/core/__tests__/contextProxy.test.ts create mode 100644 src/core/contextProxy.ts diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts new file mode 100644 index 0000000000..794cd91497 --- /dev/null +++ b/src/core/__tests__/contextProxy.test.ts @@ -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) + }) + }) +}) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts new file mode 100644 index 0000000000..e4672ae225 --- /dev/null +++ b/src/core/contextProxy.ts @@ -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 + private pendingSecretChanges: Map + 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 | undefined { + return this.originalContext.extension + } + get extensionMode(): vscode.ExtensionMode { + return this.originalContext.extensionMode + } + + // State management methods + async getGlobalState(key: string): Promise + async getGlobalState(key: string, defaultValue: T): Promise + async getGlobalState(key: string, defaultValue?: T): Promise { + // 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(key, defaultValue as T) + } + + async updateGlobalState(key: string, value: T): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e23e8762a7..c29762307d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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", @@ -1245,7 +1261,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) { @@ -1624,104 +1642,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, - } = 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), - ]) + // Create an array of promises to update state + const promises: Promise[] = [] + + // 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) } @@ -1782,13 +1717,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async ensureSettingsDirectoryExists(): Promise { - 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 } @@ -1876,7 +1811,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) @@ -2040,7 +1975,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 || []) @@ -2130,183 +2065,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, - ] = await Promise.all([ - this.getGlobalState("apiProvider") as Promise, - this.getGlobalState("apiModelId") as Promise, - this.getSecret("apiKey") as Promise, - this.getSecret("glamaApiKey") as Promise, - this.getGlobalState("glamaModelId") as Promise, - this.getGlobalState("glamaModelInfo") as Promise, - this.getSecret("openRouterApiKey") as Promise, - this.getSecret("awsAccessKey") as Promise, - this.getSecret("awsSecretKey") as Promise, - this.getSecret("awsSessionToken") as Promise, - this.getGlobalState("awsRegion") as Promise, - this.getGlobalState("awsUseCrossRegionInference") as Promise, - this.getGlobalState("awsProfile") as Promise, - this.getGlobalState("awsUseProfile") as Promise, - this.getGlobalState("vertexProjectId") as Promise, - this.getGlobalState("vertexRegion") as Promise, - this.getGlobalState("openAiBaseUrl") as Promise, - this.getSecret("openAiApiKey") as Promise, - this.getGlobalState("openAiModelId") as Promise, - this.getGlobalState("openAiCustomModelInfo") as Promise, - this.getGlobalState("openAiUseAzure") as Promise, - this.getGlobalState("ollamaModelId") as Promise, - this.getGlobalState("ollamaBaseUrl") as Promise, - this.getGlobalState("lmStudioModelId") as Promise, - this.getGlobalState("lmStudioBaseUrl") as Promise, - this.getGlobalState("anthropicBaseUrl") as Promise, - this.getSecret("geminiApiKey") as Promise, - this.getSecret("openAiNativeApiKey") as Promise, - this.getSecret("deepSeekApiKey") as Promise, - this.getSecret("mistralApiKey") as Promise, - this.getGlobalState("mistralCodestralUrl") as Promise, - this.getGlobalState("azureApiVersion") as Promise, - this.getGlobalState("openAiStreamingEnabled") as Promise, - this.getGlobalState("openRouterModelId") as Promise, - this.getGlobalState("openRouterModelInfo") as Promise, - this.getGlobalState("openRouterBaseUrl") as Promise, - this.getGlobalState("openRouterUseMiddleOutTransform") as Promise, - this.getGlobalState("lastShownAnnouncementId") as Promise, - this.getGlobalState("customInstructions") as Promise, - this.getGlobalState("alwaysAllowReadOnly") as Promise, - this.getGlobalState("alwaysAllowWrite") as Promise, - this.getGlobalState("alwaysAllowExecute") as Promise, - this.getGlobalState("alwaysAllowBrowser") as Promise, - this.getGlobalState("alwaysAllowMcp") as Promise, - this.getGlobalState("alwaysAllowModeSwitch") as Promise, - this.getGlobalState("taskHistory") as Promise, - this.getGlobalState("allowedCommands") as Promise, - this.getGlobalState("soundEnabled") as Promise, - this.getGlobalState("diffEnabled") as Promise, - this.getGlobalState("enableCheckpoints") as Promise, - this.getGlobalState("soundVolume") as Promise, - this.getGlobalState("browserViewportSize") as Promise, - this.getGlobalState("fuzzyMatchThreshold") as Promise, - this.getGlobalState("preferredLanguage") as Promise, - this.getGlobalState("writeDelayMs") as Promise, - this.getGlobalState("screenshotQuality") as Promise, - this.getGlobalState("terminalOutputLineLimit") as Promise, - this.getGlobalState("mcpEnabled") as Promise, - this.getGlobalState("enableMcpServerCreation") as Promise, - this.getGlobalState("alwaysApproveResubmit") as Promise, - this.getGlobalState("requestDelaySeconds") as Promise, - this.getGlobalState("rateLimitSeconds") as Promise, - this.getGlobalState("currentApiConfigName") as Promise, - this.getGlobalState("listApiConfigMeta") as Promise, - this.getGlobalState("vsCodeLmModelSelector") as Promise, - this.getGlobalState("mode") as Promise, - this.getGlobalState("modeApiConfigs") as Promise | undefined>, - this.getGlobalState("customModePrompts") as Promise, - this.getGlobalState("customSupportPrompts") as Promise, - this.getGlobalState("enhancementApiConfigId") as Promise, - this.getGlobalState("autoApprovalEnabled") as Promise, - this.customModesManager.getCustomModes(), - this.getGlobalState("experiments") as Promise | undefined>, - this.getSecret("unboundApiKey") as Promise, - this.getGlobalState("unboundModelId") as Promise, - this.getGlobalState("unboundModelInfo") as Promise, - this.getSecret("requestyApiKey") as Promise, - this.getGlobalState("requestyModelId") as Promise, - this.getGlobalState("requestyModelInfo") as Promise, - this.getGlobalState("modelTemperature") as Promise, - this.getGlobalState("modelMaxTokens") as Promise, - this.getGlobalState("anthropicThinking") as Promise, - this.getGlobalState("maxOpenTabsContext") as Promise, + // Create an object to store all fetched values + const stateValues: Record = {} as Record + const secretValues: Record = {} as Record + + // 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 @@ -2314,78 +2107,71 @@ 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, + // 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, - }, - 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 @@ -2415,22 +2201,22 @@ 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), - 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), + customModePrompts: stateValues.customModePrompts ?? {}, + customSupportPrompts: stateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: stateValues.enhancementApiConfigId, + experiments: stateValues.experiments ?? experimentDefault, + autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: maxOpenTabsContext ?? 20, - openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true, + maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, + openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true, } } @@ -2450,25 +2236,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 @@ -2485,24 +2275,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() diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index c8742cd3f4..40aa502231 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -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 @@ -477,6 +524,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() }) @@ -490,6 +538,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() @@ -597,6 +646,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() @@ -1237,6 +1287,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", () => { @@ -1458,6 +1519,7 @@ describe("ClineProvider", () => { apiConfiguration: testApiConfig, }) + // Reset jest.mock calls tracking // Verify config was saved expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) @@ -1465,6 +1527,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() + }) +}) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index aabc77cc01..3425fe7b47 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -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" @@ -81,6 +97,79 @@ export type GlobalStateKey = | "unboundModelInfo" | "modelTemperature" | "modelMaxTokens" - | "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`. + | "modelMaxThinkingTokens" | "mistralCodestralUrl" | "maxOpenTabsContext" + +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", +] From fbb5a19bb95e587a0fb0ccf9723cf4e437cd318e Mon Sep 17 00:00:00 2001 From: sam hoang Date: Thu, 27 Feb 2025 15:47:24 +0700 Subject: [PATCH 2/2] Refactor checkExistKey to use centralized SECRET_KEYS array --- src/shared/checkExistApiConfig.ts | 35 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 0570f6118a..c141a153d2 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -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 }