diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..b4a35485b0 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -621,6 +621,7 @@ export interface WebviewMessage { | "openRuleFile" | "openRulesDirectory" text?: string + viewStateId?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 9cbb364c92..80a26944e5 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -836,6 +836,104 @@ describe("importExport", () => { expect(mockProvider.settingsImportedAt).toBeUndefined() }) + it("should call broadcastResetToAllInstances after successful import when available", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + ]) + + const broadcastResetSpy = vi.fn().mockResolvedValue(undefined) + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + broadcastResetToAllInstances: broadcastResetSpy, + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + expect(broadcastResetSpy).toHaveBeenCalledTimes(1) + + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + }) + + it("should skip broadcastResetToAllInstances when provider does not have it", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + ]) + + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + // No broadcastResetToAllInstances property + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + }) + it("should handle multiple profiles with mixed valid and invalid providers", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index b5fd5fdf98..79f48b84c5 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -36,6 +36,7 @@ type ImportWithProviderOptions = ImportOptions & { provider: { settingsImportedAt?: number postStateToWebview: () => Promise + broadcastResetToAllInstances?(): Promise } } @@ -385,6 +386,18 @@ export const importSettingsWithFeedback = async ( if (result.success) { provider.settingsImportedAt = Date.now() await provider.postStateToWebview() + + // Broadcast invalidation to all other live ClineProvider instances so parallel + // tabs don't keep stale view-local state after a settings import. + try { + if (provider.broadcastResetToAllInstances) { + await provider.broadcastResetToAllInstances() + } + } catch (error) { + // Log but do not fail the import if broadcast fails — the import itself succeeded. + console.warn(`Failed to broadcast reset after settings import: ${error}`) + } + provider.settingsImportedAt = undefined const warnings = "warnings" in result ? result.warnings : undefined diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6e5545a1f2..d2f3d1cc9a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -50,6 +50,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" @@ -161,6 +162,7 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel @@ -211,6 +213,25 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "jul-2026-v3.68.0-friendli-ollama-anthropic-apimodelid" // v3.68.0 Friendli GLM-5.2 support, native Ollama thinking/reasoning, Anthropic custom apiModelId fix @@ -225,14 +246,17 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -263,6 +287,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -281,6 +308,9 @@ export class ClineProvider this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) + // Set up global state listener for cross-view synchronization + this.setupGlobalStateListener() + // Forward task events to the provider. // We do something fairly similar for the IPC-based API. this.taskCreationCallback = (instance: Task) => { @@ -398,6 +428,146 @@ export class ClineProvider } } + /** + * Derive a view-specific ContextProxy key for persisting view-local state. + * Uses a stable per-view id so each restored tab reads and writes its own values + * independent of provider construction order. + * + * PERSISTENCE NOTE (Finding #2): These keys (`__view_state_{id}_mode`, etc.) are NOT + * included in `GLOBAL_STATE_KEYS` from @roo-code/types, so they survive within a single + * extension session but do NOT persist across extension host restarts. If the extension is + * reloaded, view-local state falls back to the shared global keys (e.g. `mode`, + * `currentApiConfigName`). For cross-session persistence of view-specific values, either: + * - Add these keys to GLOBAL_STATE_KEYS, or + * - Use vscode.workspaceState instead of contextProxy.globalState. + */ + private viewStateKeyFor(key: "mode" | "currentApiConfigName" | "apiConfiguration"): string { + return `__view_state_${this.viewStateId}_${key}` + } + + public async setViewStateId(viewStateId: string | undefined): Promise { + const normalizedViewStateId = viewStateId?.trim() + + if (!normalizedViewStateId || normalizedViewStateId === this.viewStateId) { + return + } + + this.viewStateId = normalizedViewStateId.replace(/[^A-Za-z0-9_-]/g, "_") + await this.loadViewState() + } + + /** + * Loads initial state from global state into the view-local state buffer. + * For mode, currentApiConfigName, and apiConfiguration, reads from the view-specific key first; + * falls back to the shared key for backward compatibility with existing persisted state. + */ + private async loadViewState(): Promise { + try { + const stateValues = this.contextProxy.getValues() + const providerSettings = this.contextProxy.getProviderSettings() + + // Try view-specific keys first, then fall back to shared keys for backward compatibility. + const getViewSpecificValue = (sharedKey: "mode" | "currentApiConfigName" | "apiConfiguration") => { + const viewKey = this.viewStateKeyFor(sharedKey) + return ( + (this.contextProxy.getValue(viewKey as any) as any) ?? + (sharedKey === "apiConfiguration" ? providerSettings : stateValues[sharedKey]) + ) + } + + this.viewLocalState = { + mode: getViewSpecificValue("mode"), + currentApiConfigName: getViewSpecificValue("currentApiConfigName"), + apiConfiguration: getViewSpecificValue("apiConfiguration") ?? providerSettings, + // customModePrompts / modeApiConfigs are intentionally excluded from viewLocalState. + // They are project-level shared data — always read directly from contextProxy via + // getState() so all parallel tabs see the same values without being frozen into a + // stale local snapshot. + } + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Save a single view-local state value and sync to global state using a view-specific key. + * This allows each Provider instance to have its own mode/apiConfig for parallel mode support. + */ + private async saveViewState(key: keyof ExtensionState, value: any): Promise { + // Update local cache first. Undefined/null clears should not leave a local override behind. + if (value === undefined || value === null) { + delete this.viewLocalState[key] + } else { + this.viewLocalState[key] = value + } + + // Persist to view-specific ContextProxy key for mode/currentApiConfigName/apiConfiguration, + // so recreated views restore their own values instead of the last writer's shared state. + if (key === "mode" || key === "currentApiConfigName" || key === "apiConfiguration") { + const viewKey = this.viewStateKeyFor(key as "mode" | "currentApiConfigName" | "apiConfiguration") + await this.contextProxy.setValue(viewKey as any, value) + } + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + + /** + * Sync all view-local state to global state. + * Called when user explicitly saves settings or switches tabs. + */ + private async syncViewStateToGlobal(): Promise { + try { + const keysToSync: Array = ["mode", "currentApiConfigName", "apiConfiguration"] + + for (const key of keysToSync) { + if (this.viewLocalState[key] !== undefined) { + await this.contextProxy.setValue(key as any, this.viewLocalState[key]) + } + } + + this.log(`[syncViewStateToGlobal] Synced all state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[syncViewStateToGlobal] Failed to sync state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Sets up listeners for global state changes that may affect viewLocalState. + * When other views or the extension host modify mode or API configuration, + * this listener ensures the current view can receive notifications and update + * its local cache accordingly. + */ + private setupGlobalStateListener(): void { + // Listen for VSCode configuration changes that may affect mode or apiConfig + if (!vscode.workspace.onDidChangeConfiguration) { + return + } + + const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { + if ( + e.affectsConfiguration(`${Package.name}.mode`) || + e.affectsConfiguration(`${Package.name}.currentApiConfigName`) + ) { + this.log( + `[setupGlobalStateListener] Configuration changed for mode or currentApiConfigName, reloading state for viewId ${this.viewId}`, + ) + + // Reload viewLocalState from global state + await this.loadViewState() + + // Notify webview of the change + await this.postStateToWebview() + } + }) + + this.disposables.push(configDisposable) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -1039,6 +1209,7 @@ export class ClineProvider } await this.updateGlobalState("mode", historyItem.mode) + this.viewLocalState.mode = historyItem.mode // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, @@ -1480,6 +1651,9 @@ export class ClineProvider } } + // Save to view-local state first for parallel mode support. + await this.saveViewState("mode", newMode) + await this.updateGlobalState("mode", newMode) this.emit(RooCodeEventName.ModeChanged, newMode) @@ -1522,7 +1696,9 @@ export class ClineProvider } } else { // If no saved config for this mode, save current config as default. - const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") + // Use view-local state (via getState()) so another tab's global write doesn't supply + // this view's configuration when running in parallel mode. + const { currentApiConfigName: currentApiConfigNameAfter } = await this.getState() if (currentApiConfigNameAfter) { const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) @@ -1621,6 +1797,10 @@ export class ClineProvider this.contextProxy.setProviderSettings(providerSettings), ]) + // Update view-local state for parallel mode support. + this._updateViewLocalStateFromMutation({ currentApiConfigName: name } as Partial) + this.viewLocalState.apiConfiguration = providerSettings + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1644,8 +1824,11 @@ export class ClineProvider } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { - const globalSettings = this.contextProxy.getValues() - let profileToActivate: string | undefined = globalSettings.currentApiConfigName + // Use merged state (view-local + global) so we read THIS TAB's current profile choice, + // NOT the shared global state which may belong to another parallel tab. + const { currentApiConfigName: globalCurrentProfile } = await this.getState() + + let profileToActivate: string | undefined = globalCurrentProfile if (profileToDelete.name === profileToActivate) { profileToActivate = this.getProviderProfileEntries().find(({ name }) => name !== profileToDelete.name)?.name @@ -1657,12 +1840,17 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + const globalSettings = await this.getState() + await this.contextProxy.setValues({ ...globalSettings, currentApiConfigName: profileToActivate, listApiConfigMeta: entries, }) + // Update view-local state for parallel mode support. + this._updateViewLocalStateFromMutation({ currentApiConfigName: profileToActivate }) + await this.postStateToWebview() } @@ -1710,6 +1898,10 @@ export class ClineProvider this.contextProxy.setProviderSettings(providerSettings), ]) + // Update view-local state for parallel mode support. + this._updateViewLocalStateFromMutation({ currentApiConfigName: name } as Partial) + this.viewLocalState.apiConfiguration = providerSettings + const { mode } = await this.getState() if (id && persistModeConfig) { @@ -2546,12 +2738,24 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // NEW: Merge viewLocalState on top of global state + // This allows each Provider instance to have its own mode/apiConfig for parallel mode support. + // + // CREDENTIAL MERGING NOTE (Finding #1): The spread `{ ...stateValues, ...this.viewLocalState }` + // means that if Tab A has a local `apiConfiguration` with an API key but Tab B only has the shared + // state, Tab B's `getState()` will still see Tab A's key (because both share the same ContextProxy). + // This is expected behavior — parallel tabs within the same extension host share memory by design. + // If isolation is needed in the future, consider per-tab secret storage via vscode.SecretStorage. + const mergedStateValues = { ...stateValues, ...this.viewLocalState } + const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. + // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = - stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) - ? stateValues.apiProvider + mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) + ? (mergedStateValues.apiProvider as ProviderName) : "anthropic" // Build the apiConfiguration object combining state values and secrets. @@ -2612,116 +2816,128 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // Return the same structure as before. + // Fields that should prioritize viewLocalState (mode, currentApiConfigName, + // apiConfiguration, customModePrompts, modeApiConfigs) are already merged via + // mergedStateValues above, so they will use local cache values when available. return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + apiConfiguration: { + ...providerSettings, + // Prioritize viewLocalState.apiConfiguration for parallel mode support + ...mergedStateValues.apiConfiguration, + }, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: this.taskHistoryStore.getAll(), - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + // mode: Prioritize viewLocalState for parallel mode support + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + // currentApiConfigName: Prioritize viewLocalState for parallel mode support + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + // modeApiConfigs: Prioritize viewLocalState for parallel mode support + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + // customModePrompts: Prioritize viewLocalState for parallel mode support + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } @@ -2824,6 +3040,7 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) + this._updateViewLocalStateFromMutation({ [key]: value }) } public getValue(key: K) { @@ -2836,6 +3053,76 @@ export class ClineProvider public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val as any + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val as any + } + } + + if ("apiConfiguration" in values) { + const val = (values as any).apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } else if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + const providerSettingsUpdate = PROVIDER_SETTINGS_KEYS.reduce((acc, key) => { + if (key in values) { + return { ...acc, [key]: values[key as keyof RooCodeSettings] } + } + + return acc + }, {} as ProviderSettings) + + this.viewLocalState.apiConfiguration = { + ...(this.viewLocalState.apiConfiguration ?? {}), + ...providerSettingsUpdate, + } + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} + } + + /** + * Broadcast a reset event to all other live ClineProvider instances, clearing their + * view-local state caches and posting updated state so parallel tabs stay in sync. + * Also exposed for use by importSettingsWithFeedback (via broadcastResetToAllInstances callback). + */ + async broadcastResetToAllInstances(): Promise { + const allInstances = ClineProvider.getAllInstances() + for (const instance of allInstances) { + if (instance !== this) { + instance._clearViewLocalState() + await instance.postStateToWebview() + } + } } // dev @@ -2864,9 +3151,15 @@ export class ClineProvider } await this.contextProxy.resetAllState() + this._clearViewLocalState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() + + // Broadcast reset to all other live instances so parallel tabs don't keep stale state. + await this.broadcastResetToAllInstances() + await this.postStateToWebview() await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts new file mode 100644 index 0000000000..85fdb418c4 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,1900 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.parallelMode.spec.ts + +import * as vscode from "vscode" + +import { type ExtensionMessage, type ExtensionState, RooCodeEventName } from "@roo-code/types" + +import { defaultModeSlug } from "../../../shared/modes" +import { ContextProxy } from "../../config/ContextProxy" +import { ClineProvider } from "../ClineProvider" +import { TelemetryService } from "@roo-code/telemetry" + +// Mock p-wait-for +vi.mock("p-wait-for", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock fs/promises +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + const mocked = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + } + + return { + ...actual, + ...mocked, + default: { + ...actual, + ...mocked, + }, + } +}) + +// Mock axios +vi.mock("axios", () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), + }, + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), +})) + +// Mock safeWriteJson +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + +// Mock path utils +vi.mock("../../../utils/path", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getWorkspacePath: vi.fn().mockReturnValue(""), + } +}) + +// Mock storage utils +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +// Mock MCP types +vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ + CallToolResultSchema: {}, + ListResourcesResultSchema: {}, + ListResourceTemplatesResultSchema: {}, + ListToolsResultSchema: {}, + ReadResourceResultSchema: {}, + ErrorCode: { + InvalidRequest: "InvalidRequest", + MethodNotFound: "MethodNotFound", + InternalError: "InternalError", + }, + McpError: class McpError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.name = "McpError" + this.code = code + } + }, +})) + +// Mock delay +vi.mock("delay", () => { + const delayFn = (_ms: number) => Promise.resolve() + delayFn.createDelay = () => delayFn + delayFn.reject = () => Promise.reject(new Error("Delay rejected")) + delayFn.range = () => Promise.resolve() + return { default: delayFn } +}) + +// Mock MCP client +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + __esModule: true, + Client: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + callTool: vi.fn().mockResolvedValue({ content: [] }), + } + }), +})) + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ + __esModule: true, + StdioClientTransport: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +const { onDidChangeConfigurationMock } = vi.hoisted(() => { + const onDidChangeConfigurationMock = vi.fn((handler: (e: any) => any) => { + const disposable = { + dispose: vi.fn(), + } + const checkedKeys: string[] = [] + void handler({ + affectsConfiguration: (key: string) => { + checkedKeys.push(key) + return false + }, + }) + + if (checkedKeys.includes("workbench.colorTheme")) { + onDidChangeConfigurationMock.mock.calls.pop() + } + + return disposable + }) + + return { onDidChangeConfigurationMock } +}) + +// Mock vscode +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + EventEmitter: vi.fn().mockImplementation(function () { + return { + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + } + }), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + Range: class Range { + constructor( + readonly startLine: number, + readonly startCharacter: number, + readonly endLine: number, + readonly endCharacter: number, + ) {} + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + getWorkspaceFolder: vi.fn(), + createFileSystemWatcher: vi.fn().mockReturnValue({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + onDidChangeConfiguration: onDidChangeConfigurationMock, + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + activeTextEditor: undefined, + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn().mockReturnValue({}), + tabGroups: { + onDidChangeTabs: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +// Mock TTS utils +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +// Mock API +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +// Mock system prompt +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +// Mock WorkspaceTracker - simple mock that works (same pattern as sticky-mode.spec.ts) +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(function () { + return { + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + } + }), +})) +// Mock ContextProxy for viewLocalState tests +vi.mock("../../config/ContextProxy", () => { + const defaultState = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + class MockContextProxy { + public globalStorageUri: { fsPath: string } + public extensionUri: { fsPath: string } + public extensionMode = 1 + + constructor(public context: any) { + this.globalStorageUri = context?.globalStorageUri ?? { fsPath: "/test/storage/path" } + this.extensionUri = context?.extensionUri ?? { fsPath: "/test/path" } + } + + getValues = vi.fn().mockImplementation(() => ({ + ...defaultState, + mode: this.context?.globalState?.get("mode") ?? defaultState.mode, + currentApiConfigName: + this.context?.globalState?.get("currentApiConfigName") ?? defaultState.currentApiConfigName, + apiConfiguration: this.context?.globalState?.get("apiConfiguration") ?? defaultState.apiConfiguration, + customModePrompts: this.context?.globalState?.get("customModePrompts") ?? defaultState.customModePrompts, + modeApiConfigs: this.context?.globalState?.get("modeApiConfigs") ?? defaultState.modeApiConfigs, + listApiConfigMeta: this.context?.globalState?.get("listApiConfigMeta") ?? defaultState.listApiConfigMeta, + pinnedApiConfigs: this.context?.globalState?.get("pinnedApiConfigs") ?? defaultState.pinnedApiConfigs, + })) + getValue = vi.fn().mockImplementation((key: string) => this.context?.globalState?.get(key)) + getProviderSettings = vi.fn().mockReturnValue({ apiProvider: "anthropic" }) + setValue = vi.fn().mockImplementation((key: string, value: any) => { + return this.context?.globalState?.update?.(key, value) ?? Promise.resolve() + }) + setValues = vi.fn().mockImplementation((values: Record) => { + return Promise.all(Object.entries(values).map(([key, value]) => this.setValue(key, value))).then( + () => undefined, + ) + }) + setProviderSettings = vi.fn().mockImplementation((settings: Record) => this.setValues(settings)) + } + return { ContextProxy: MockContextProxy } +}) + +// Mock Task +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(function (options: any) { + return { + api: undefined, + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + taskId: options?.historyItem?.id || "test-task-id", + emit: vi.fn(), + } + }), +})) + +// Mock extract-text +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockImplementation(async (_filePath: string) => { + const content = "const x = 1;\nconst y = 2;\nconst z = 3;" + const lines = content.split("\n") + return lines.map((line, index) => `${index + 1} | ${line}`).join("\n") + }), +})) + +// Mock model cache +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), + getModelsFromCache: vi.fn().mockReturnValue(undefined), +})) + +// Mock cloud service +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockResolvedValue([]), + getUserInfo: vi.fn().mockReturnValue(null), + getOrganizationSettings: vi.fn().mockReturnValue(null), + off: vi.fn(), + } + }, + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +// Mock modes +vi.mock("../../../shared/modes", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + modes: [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "debugger", + name: "Debugger Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are a helpful assistant", + groups: ["read"], + }, + ], + getModeBySlug: vi.fn().mockImplementation((slug: string) => { + return actual.modes?.find((m) => m.slug === slug) ?? null + }), + defaultModeSlug: "code", + } +}) + +// Mock custom instructions +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), +})) + +// Mock zoo-code-auth +vi.mock("../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), + getCachedZooCodeToken: vi.fn(), + handleAuthCallback: vi.fn(), + setZooCodeUserInfo: vi.fn(), + disconnectZooCode: vi.fn(), +})) + +// Mock diff strategy +vi.mock("../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(function () { + return { + getToolDescription: () => "test", + getName: () => "test-strategy", + applyDiff: vi.fn(), + } + }), +})) + +// Mock Terminal +vi.mock("../../../integrations/terminal/Terminal", () => ({ + Terminal: { + defaultShellIntegrationTimeout: 10000, + setShellIntegrationTimeout: vi.fn(), + setShellIntegrationDisabled: vi.fn(), + setCommandDelay: vi.fn(), + setTerminalZshClearEolMark: vi.fn(), + setTerminalZshOhMy: vi.fn(), + setTerminalZshP10k: vi.fn(), + setPowershellCounter: vi.fn(), + setTerminalZdotdir: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +// Mock McpHub and McpServerManager +vi.mock("../../services/mcp/McpHub", () => ({ + McpHub: vi.fn().mockImplementation(function () { + return { + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + } + }), +})) + +vi.mock("../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue({ + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + }), + unregisterProvider: vi.fn(), + }, +})) + +// Mock SkillsManager +vi.mock("../../services/skills/SkillsManager", () => ({ + SkillsManager: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), +})) + +// Mock MarketplaceManager +vi.mock("../../services/marketplace", () => ({ + MarketplaceManager: vi.fn().mockImplementation(function () { + return { + cleanup: vi.fn(), + } + }), +})) + +// Mock ProviderSettingsManager +vi.mock("../../config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: vi.fn().mockImplementation(function () { + return { + saveConfig: vi.fn().mockResolvedValue("test-id"), + listConfig: vi.fn().mockResolvedValue([]), + getProfile: vi.fn().mockResolvedValue({}), + activateProfile: vi.fn().mockImplementation(async (args: { name?: string; id?: string }) => ({ + name: args.name ?? "default", + id: args.id ?? "test-id", + apiProvider: "anthropic", + })), + setModeConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +// Mock CustomModesManager +vi.mock("../../config/CustomModesManager", () => ({ + CustomModesManager: vi.fn().mockImplementation(function () { + return { + updateCustomMode: vi.fn().mockResolvedValue(undefined), + getCustomModes: vi.fn().mockResolvedValue([]), + dispose: vi.fn(), + } + }), +})) + +// Mock task persistence +vi.mock("../../task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) + +vi.mock("../../task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getAll: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + migrateFromGlobalState: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), + assertValidTransition: vi.fn(), +})) + +// Mock RateLimitClock +vi.mock("../../task/RateLimitClock", () => ({ + createRateLimitClock: vi.fn().mockReturnValue({ + isRateLimited: vi.fn().mockReturnValue(false), + resetTimer: vi.fn(), + }), +})) + +beforeAll(() => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + +/** + * ClineProvider - Parallel Mode Support Tests + * + * These tests verify that the view-local state isolation feature works correctly, + * allowing multiple ClineProvider instances (e.g., in parallel tabs) to maintain + * independent mode, API configuration, and other view-specific settings. + */ +describe("ClineProvider - Parallel Mode Support", () => { + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => { + return globalState[key] + }), + update: vi.fn().mockImplementation((key: string, value: any) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => { + return Object.keys(globalState) + }), + } as any, + secrets: { + get: vi.fn().mockImplementation((key: string) => { + return secrets[key] + }), + store: vi.fn().mockImplementation((key: string, value: string) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + } as any, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + } as any, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + } as vscode.Uri, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + }) + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Access viewLocalState via private property for testing + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + // Both should start with the same default mode from global state + expect(state1.mode).toBe("code") + expect(state2.mode).toBe("code") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should allow different modes in separate instances after saveViewState", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Access private method for testing + const saveViewState1 = (provider1 as any).saveViewState.bind(provider1) + const saveViewState2 = (provider2 as any).saveViewState.bind(provider2) + + // Save different modes to each provider + await saveViewState1("mode", "architect") + await saveViewState2("mode", "debugger") + + // Verify isolation - each provider should have its own mode + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + const saveViewState1 = (provider1 as any).saveViewState.bind(provider1) + const saveViewState2 = (provider2 as any).saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState when saveViewState is called", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await (provider as any).setViewStateId("stable-sidebar-view") + + await (provider as any).saveViewState("mode", "architect") + + // Verify viewLocalState was updated + expect((provider as any).viewLocalState.mode).toBe("architect") + + // saveViewState uses the stable per-view id, not the construction-order viewId suffix. + expect(contextProxySpy).toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", "architect") + expect(contextProxySpy).not.toHaveBeenCalledWith(`__view_state_${provider.viewId}_mode`, expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState for currentApiConfigName", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("currentApiConfigName", "my-profile") + + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const testApiConfig = { + apiProvider: "openrouter" as const, + openRouterModelId: "claude-3.5-sonnet", + } + + await (provider as any).saveViewState("apiConfiguration", testApiConfig) + + expect((provider as any).viewLocalState.apiConfiguration).toEqual(testApiConfig) + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + expect((provider as any).viewLocalState.mode).toBe("architect") + + await (provider as any).saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call((provider as any).viewLocalState, "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives null", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("currentApiConfigName", "my-profile") + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + + await (provider as any).saveViewState("currentApiConfigName", null) + + expect(Object.prototype.hasOwnProperty.call((provider as any).viewLocalState, "currentApiConfigName")).toBe( + false, + ) + + await provider.dispose() + }) + }) + + describe("loadViewState", () => { + it("should load state from global state into viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Wait for the initial loadViewState to complete (it's called in constructor) + await vi.waitFor( + () => { + expect((provider as any).viewLocalState.mode).toBe("code") + return true // Success + }, + { timeout: 2000 }, + ) + + expect((provider as any).viewLocalState.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should update viewLocalState when loadViewState is called manually", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Wait for initial load to complete + await vi.waitFor( + () => { + expect((provider as any).viewLocalState.mode).toBe("code") + return true + }, + { timeout: 2000 }, + ) + + // Update global state to simulate a change from another source + const originalGet = mockContext.globalState.get.bind(mockContext.globalState) + mockContext.globalState.get = vi.fn().mockImplementation((key: string) => { + if (key === "mode") return "architect" + if (key === "currentApiConfigName") return "new-profile" + if (key === "apiConfiguration") return {} + if (key === "customModePrompts") return {} + if (key === "modeApiConfigs") return {} + return originalGet(key) + }) + + // Manually call loadViewState to simulate a state reload + await (provider as any).loadViewState() + + const state = await provider.getState() + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("new-profile") + + await provider.dispose() + }) + + it("should restore mode, current API config name, and API configuration from stable per-view state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const stableViewId = "stable-editor-tab-a" + const persistedApiConfiguration = { + apiProvider: "openrouter" as const, + openRouterModelId: "openrouter/anthropic/claude-sonnet-4", + } + + await provider.contextProxy.setValue(`__view_state_${stableViewId}_mode` as any, "architect") + await provider.contextProxy.setValue( + `__view_state_${stableViewId}_currentApiConfigName` as any, + "profile-a", + ) + await provider.contextProxy.setValue( + `__view_state_${stableViewId}_apiConfiguration` as any, + persistedApiConfiguration, + ) + await provider.contextProxy.setValue("mode" as any, "debugger") + await provider.contextProxy.setValue("currentApiConfigName" as any, "profile-b") + await provider.contextProxy.setValue("apiConfiguration" as any, { apiProvider: "anthropic" }) + + await (provider as any).setViewStateId(stableViewId) + const state = await provider.getState() + + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("profile-a") + expect(state.apiConfiguration).toMatchObject(persistedApiConfiguration) + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider as any, "log") + + ;(provider as any).viewLocalState = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValues").mockImplementation(() => { + throw new Error("load failed") + }) + + await (provider as any).loadViewState() + + expect((provider as any).viewLocalState.mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("syncViewStateToGlobal", () => { + it("should sync defined view-local values to ContextProxy", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + + ;(provider as any).viewLocalState = { + mode: "architect", + currentApiConfigName: undefined, + apiConfiguration: { apiProvider: "openrouter" }, + } + + await (provider as any).syncViewStateToGlobal() + + expect(contextProxySpy).toHaveBeenCalledWith("mode", "architect") + expect(contextProxySpy).toHaveBeenCalledWith("apiConfiguration", { apiProvider: "openrouter" }) + expect(contextProxySpy).not.toHaveBeenCalledWith("currentApiConfigName", expect.anything()) + + await provider.dispose() + }) + + it("should log sync errors without throwing", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider as any, "log") + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("sync failed")) + ;(provider as any).viewLocalState = { mode: "architect" } + + await expect((provider as any).syncViewStateToGlobal()).resolves.toBeUndefined() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to sync state")) + + await provider.dispose() + }) + }) + + describe("GlobalState listener", () => { + it("should invoke loadViewState and postStateToWebview when configuration changes for mode", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const loadViewStateSpy = vi.spyOn(provider as any, "loadViewState") + + await (provider as any).resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + + // Capture the registered config change handler + const configChangeHandler = (vscode.workspace.onDidChangeConfiguration as any).mock.calls.at(-1)?.[0] + + // Simulate a configuration change event for mode + const configChangeEvent = { + affectsConfiguration: vi.fn().mockImplementation((key: string) => { + return key === "zoo-code.mode" || key === "zoo-code.currentApiConfigName" + }), + } + + // Invoke the handler and wait for async operations to complete + await configChangeHandler(configChangeEvent) + + // Verify loadViewState was called + expect(loadViewStateSpy).toHaveBeenCalled() + + // Wait for postStateToWebview to be called after the configuration change. + await vi.waitFor( + () => { + expect(mockPostMessage).toHaveBeenCalled() + return true + }, + { timeout: 2000 }, + ) + + await provider.dispose() + }) + + it("should return without registering a listener when configuration events are unavailable", async () => { + const originalOnDidChangeConfiguration = vscode.workspace.onDidChangeConfiguration + ;(vscode.workspace as any).onDidChangeConfiguration = undefined + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const disposableCount = (provider as any).disposables.length + + ;(provider as any).setupGlobalStateListener() + + expect((provider as any).disposables).toHaveLength(disposableCount) + + await provider.dispose() + ;(vscode.workspace as any).onDidChangeConfiguration = originalOnDidChangeConfiguration + }) + + it("should ignore unrelated configuration changes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const loadViewStateSpy = vi.spyOn(provider as any, "loadViewState") + const postStateToWebviewSpy = vi.spyOn(provider as any, "postStateToWebview") + const configChangeHandler = (vscode.workspace.onDidChangeConfiguration as any).mock.calls.at(-1)?.[0] + + await configChangeHandler({ + affectsConfiguration: vi.fn().mockReturnValue(false), + }) + + expect(loadViewStateSpy).not.toHaveBeenCalled() + expect(postStateToWebviewSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should call postStateToWebview after loadViewState on config change", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const configChangeHandler = (vscode.workspace.onDidChangeConfiguration as any).mock.calls.at(-1)?.[0] + + await (provider as any).resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + + await configChangeHandler({ + affectsConfiguration: vi.fn().mockReturnValue(true), + }) + + // Wait for postStateToWebview to be called after the configuration change. + await vi.waitFor( + () => { + expect(mockPostMessage).toHaveBeenCalled() + return true + }, + { timeout: 2000 }, + ) + + await provider.dispose() + }) + }) + + describe("handleModeSwitch integration", () => { + it("should update viewLocalState.mode when handleModeSwitch is called", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + // Spy on saveViewState + const saveViewStateSpy = vi.spyOn(provider as any, "saveViewState") + + // Call handleModeSwitch + await provider.handleModeSwitch("architect" as any) + + // Verify viewLocalState was updated + expect((provider as any).viewLocalState.mode).toBe("architect") + + // Verify saveViewState was called with the new mode + expect(saveViewStateSpy).toHaveBeenCalledWith("mode", "architect") + + await provider.dispose() + }) + + it("should post state and skip mode config lookup when API config locking is enabled", async () => { + const mockPostMessage = vi.fn() + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } + mockContext.workspaceState.get = vi.fn().mockImplementation((key: string, fallback?: unknown) => { + return key === "lockApiConfigAcrossModes" ? true : fallback + }) + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const getModeConfigIdSpy = vi.spyOn((provider as any).providerSettingsManager, "getModeConfigId") + + await (provider as any).resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + + await provider.handleModeSwitch("architect" as any) + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(mockPostMessage).toHaveBeenCalled() + + await provider.dispose() + }) + + it("should activate configured mode profile when switching modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerSettingsManager = (provider as any).providerSettingsManager + providerSettingsManager.getModeConfigId.mockResolvedValueOnce("profile-id") + providerSettingsManager.listConfig.mockResolvedValueOnce([ + { id: "profile-id", name: "mode-profile", apiProvider: "openrouter" }, + ]) + providerSettingsManager.getProfile.mockResolvedValueOnce({ apiProvider: "openrouter" }) + const activateProviderProfileSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.handleModeSwitch("architect" as any) + + expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "mode-profile" }) + + await provider.dispose() + }) + + it("should leave current configuration unchanged for empty mode profiles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerSettingsManager = (provider as any).providerSettingsManager + providerSettingsManager.getModeConfigId.mockResolvedValueOnce("empty-profile-id") + providerSettingsManager.listConfig.mockResolvedValueOnce([ + { id: "empty-profile-id", name: "empty-profile" }, + ]) + providerSettingsManager.getProfile.mockResolvedValueOnce({}) + const activateProviderProfileSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.handleModeSwitch("architect" as any) + + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should emit ModeChanged event after handleModeSwitch", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + // Listen for ModeChanged event + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + + // Call handleModeSwitch + await provider.handleModeSwitch("architect" as any) + + // Verify event was emitted + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + await provider.dispose() + }) + }) + + describe("activateProviderProfile integration", () => { + it("should update viewLocalState.currentApiConfigName when activateProviderProfile is called", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + // Call activateProviderProfile + await provider.activateProviderProfile({ name: "my-profile" }) + + // Verify viewLocalState was updated (activateProviderProfile now uses _updateViewLocalStateFromMutation) + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + + await provider.dispose() + }) + + it("should skip mode and task persistence when activation options disable them", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerSettingsManager = (provider as any).providerSettingsManager + const setModeConfigSpy = vi.spyOn(providerSettingsManager, "setModeConfig") + const persistStickyProviderProfileSpy = vi.spyOn( + provider as any, + "persistStickyProviderProfileToCurrentTask", + ) + + await provider.activateProviderProfile( + { name: "my-profile" }, + { persistModeConfig: false, persistTaskHistory: false }, + ) + + expect(setModeConfigSpy).not.toHaveBeenCalled() + expect(persistStickyProviderProfileSpy).not.toHaveBeenCalled() + expect((provider as any).viewLocalState.apiConfiguration).toEqual({ apiProvider: "anthropic" }) + + await provider.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await (provider as any).saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + expect(state.language).toBeDefined() + expect(state.customModes).toBeDefined() + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("apiConfiguration", { + apiProvider: "openrouter", + openRouterApiKey: "local-key", + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("openrouter") + expect(state.apiConfiguration.openRouterApiKey).toBe("local-key") + + await provider.dispose() + }) + + it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("apiConfiguration", { + apiProvider: "openrouter", + openRouterModelId: "openrouter/old-model", + }) + + await provider.setValues({ + apiProvider: "bedrock", + awsUseApiKey: true, + awsApiKey: "mock-key", + awsRegion: "us-east-1", + apiModelId: "anthropic.claude-opus-4-8-20261215-v1:0", + awsBedrockEndpoint: "http://127.0.0.1:4567", + awsBedrockEndpointEnabled: true, + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + expect((provider as any).viewLocalState.apiConfiguration.apiProvider).toBe("bedrock") + + await provider.dispose() + }) + }) + + describe("multi-instance isolation", () => { + it("should maintain independent state across three instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider3 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + + // Each should have a unique viewId + expect(provider1.viewId).not.toBe(provider2.viewId) + expect(provider2.viewId).not.toBe(provider3.viewId) + expect(provider1.viewId).not.toBe(provider3.viewId) + + // Set different modes for each + await (provider1 as any).saveViewState("mode", "code") + await (provider2 as any).saveViewState("mode", "architect") + await (provider3 as any).saveViewState("mode", "debugger") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + const state3 = await provider3.getState() + + expect(state1.mode).toBe("code") + expect(state2.mode).toBe("architect") + expect(state3.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + await provider3.dispose() + }) + + it("should handle mode switch in one instance without affecting others", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const mockWebviewView1: any = { + webview: { + postMessage: mockPostMessage1, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const mockWebviewView2: any = { + webview: { + postMessage: mockPostMessage2, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(mockWebviewView1) + await (provider2 as any).resolveWebviewView(mockWebviewView2) + + // Set initial modes via public handleModeSwitch + await provider1.handleModeSwitch("code" as any) + await provider2.handleModeSwitch("code" as any) + + let state1 = await provider1.getState() + let state2 = await provider2.getState() + expect(state1.mode).toBe("code") + expect(state2.mode).toBe("code") + + // Switch mode in provider1 only using public handleModeSwitch + await provider1.handleModeSwitch("architect" as any) + + state1 = await provider1.getState() + state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("code") // Should remain unchanged + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("broadcastResetToAllInstances", () => { + it("should clear viewLocalState in other instances and post updated state", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const mockWebviewView1: any = { + webview: { + postMessage: mockPostMessage1, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const mockWebviewView2: any = { + webview: { + postMessage: mockPostMessage2, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(mockWebviewView1) + await (provider2 as any).resolveWebviewView(mockWebviewView2) + + // Set different modes for each provider + await (provider1 as any).saveViewState("mode", "architect") + await (provider2 as any).saveViewState("mode", "debugger") + + expect((provider1 as any).viewLocalState.mode).toBe("architect") + expect((provider2 as any).viewLocalState.mode).toBe("debugger") + + // Call broadcastResetToAllInstances on provider1 + await provider1.broadcastResetToAllInstances() + + // provider1 should NOT clear its own state + expect((provider1 as any).viewLocalState.mode).toBe("architect") + + // provider2 should have cleared its viewLocalState + expect(Object.prototype.hasOwnProperty.call((provider2 as any).viewLocalState, "mode")).toBe(false) + + // provider2 should have posted updated state to webview + expect(mockPostMessage2).toHaveBeenCalled() + }) + + it("should not broadcast to itself but clear others", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const mockWebviewView1: any = { + webview: { + postMessage: mockPostMessage1, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const mockWebviewView2: any = { + webview: { + postMessage: mockPostMessage2, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(mockWebviewView1) + await (provider2 as any).resolveWebviewView(mockWebviewView2) + + await (provider1 as any).saveViewState("mode", "architect") + await (provider2 as any).saveViewState("mode", "debugger") + + // Call broadcastResetToAllInstances on provider2 + await provider2.broadcastResetToAllInstances() + + // provider2 should NOT have posted to its own webview (it's the broadcaster) + expect(mockPostMessage2).not.toHaveBeenCalled() + + // provider1 should have been cleared and posted state + expect((provider1 as any).viewLocalState.mode).toBeUndefined() + expect(mockPostMessage1).toHaveBeenCalled() + }) + + it("should handle single instance gracefully", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + await (provider as any).saveViewState("mode", "architect") + + // Single instance broadcast should not throw + await provider.broadcastResetToAllInstances() + + // Single instance should keep its own state + expect((provider as any).viewLocalState.mode).toBe("architect") + + // Should not have posted to webview (no other instances) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("should broadcast to all instances in getAllInstances", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + const mockPostMessage3 = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider3 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + + await (provider1 as any).resolveWebviewView(createMockWebviewView(mockPostMessage1)) + await (provider2 as any).resolveWebviewView(createMockWebviewView(mockPostMessage2)) + await (provider3 as any).resolveWebviewView(createMockWebviewView(mockPostMessage3)) + + await (provider1 as any).saveViewState("mode", "code") + await (provider2 as any).saveViewState("mode", "architect") + await (provider3 as any).saveViewState("mode", "debugger") + + // Broadcast from provider1 + await provider1.broadcastResetToAllInstances() + + // provider1 keeps its state + expect((provider1 as any).viewLocalState.mode).toBe("code") + + // provider2 and provider3 should be cleared + expect(Object.prototype.hasOwnProperty.call((provider2 as any).viewLocalState, "mode")).toBe(false) + expect(Object.prototype.hasOwnProperty.call((provider3 as any).viewLocalState, "mode")).toBe(false) + + // provider2 and provider3 should have posted state + expect(mockPostMessage2).toHaveBeenCalled() + expect(mockPostMessage3).toHaveBeenCalled() + }) + }) + + describe("_clearViewLocalState", () => { + it("should clear all view-local state values", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + await (provider as any).saveViewState("currentApiConfigName", "my-profile") + await (provider as any).saveViewState("apiConfiguration", { apiProvider: "openrouter" }) + + expect((provider as any).viewLocalState.mode).toBe("architect") + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + expect((provider as any).viewLocalState.apiConfiguration).toEqual({ apiProvider: "openrouter" }) + + // Call _clearViewLocalState + ;(provider as any)._clearViewLocalState() + + // All values should be cleared + expect((provider as any).viewLocalState).toEqual({}) + + await provider.dispose() + }) + + it("should cause getState to fall back to contextProxy values after clear", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + + let state = await provider.getState() + expect(state.mode).toBe("architect") + + // Clear viewLocalState + ;(provider as any)._clearViewLocalState() + + // getState should now fall back to contextProxy (global) state + state = await provider.getState() + expect(state.mode).toBe("code") // Default from mock context + + await provider.dispose() + }) + + it("should be safe to call on empty viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Should not throw even if viewLocalState is already empty + expect((provider as any)._clearViewLocalState()).toBeUndefined() + expect((provider as any).viewLocalState).toEqual({}) + + await provider.dispose() + }) + }) + + describe("resetState broadcasts", () => { + it("should clear viewLocalState and call broadcastResetToAllInstances in resetState flow", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(createMockWebviewView(mockPostMessage1)) + await (provider2 as any).resolveWebviewView(createMockWebviewView(mockPostMessage2)) + + // Set different modes + await (provider1 as any).saveViewState("mode", "architect") + await (provider2 as any).saveViewState("mode", "debugger") + + // Verify initial state + expect((provider1 as any).viewLocalState.mode).toBe("architect") + expect((provider2 as any).viewLocalState.mode).toBe("debugger") + + // Manually call the same sequence that resetState does after confirmation: + // 1. _clearViewLocalState on the calling instance + ;(provider1 as any)._clearViewLocalState() + expect((provider1 as any).viewLocalState).toStrictEqual({}) + + // 2. broadcastResetToAllInstances to clear other instances + await provider1.broadcastResetToAllInstances() + + // provider2 should have been cleared + expect(Object.hasOwn((provider2 as any).viewLocalState, "mode")).toBe(false) + expect(mockPostMessage2).toHaveBeenCalled() + }) + }) + + describe("deleteProviderProfile", () => { + it("should use merged state (view-local) when determining profileToActivate in parallel mode", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(createMockWebviewView(mockPostMessage1)) + await (provider2 as any).resolveWebviewView(createMockWebviewView(mockPostMessage2)) + + // Set different profiles for each provider via saveViewState + await (provider1 as any).saveViewState("currentApiConfigName", "profile-a") + await (provider2 as any).saveViewState("currentApiConfigName", "profile-b") + + // Verify view-local state is isolated + const state1 = await provider1.getState() + let state2 = await provider2.getState() + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + // Set up global state listApiConfigMeta with 3 profiles (including profile-to-delete) + const profileToDelete = { id: "del-id", name: "profile-to-delete", apiProvider: "anthropic" as const } + await provider1.contextProxy.setValues({ + listApiConfigMeta: [ + { id: "a-id", name: "profile-a", apiProvider: "anthropic" }, + { id: "b-id", name: "profile-b", apiProvider: "anthropic" }, + profileToDelete, + ], + }) + + // provider2's viewLocalState has currentApiConfigName = "profile-b" + // When provider2 deletes "profile-to-delete", it should NOT activate "profile-a" (provider1's profile) + // It should keep "profile-b" because that's what THIS TAB is using + + // Spy on _updateViewLocalStateFromMutation to capture what profile gets activated + const updateViewLocalSpy = vi.spyOn(provider2 as any, "_updateViewLocalStateFromMutation") + + await provider2.deleteProviderProfile(profileToDelete) + + // The key assertion: profileToActivate should be "profile-b" (provider2's view-local), + // NOT "profile-a" (provider1's view-local from shared global state). + // _updateViewLocalStateFromMutation IS called with the merged state's currentApiConfigName. + expect(updateViewLocalSpy).toHaveBeenCalledWith({ currentApiConfigName: "profile-b" }) + + // Verify provider2's state is still "profile-b" after deletion + state2 = await provider2.getState() + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should activate the correct fallback profile when deleting the current profile in parallel mode", async () => { + const mockPostMessage = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(createMockWebviewView(mockPostMessage)) + + // Set up view-local state to use "profile-a" + await (provider as any).saveViewState("currentApiConfigName", "profile-a") + + // Set up global state with multiple profiles + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { id: "a-id", name: "profile-a", apiProvider: "anthropic" }, + { id: "b-id", name: "profile-b", apiProvider: "openrouter" }, + { id: "c-id", name: "profile-c", apiProvider: "anthropic" }, + ], + }) + + // Delete the currently active profile (profile-a) + const profileToDelete = { id: "a-id", name: "profile-a", apiProvider: "anthropic" as const } + await provider.deleteProviderProfile(profileToDelete) + + // After deletion, should activate to the first remaining profile (profile-b) + const state = await provider.getState() + expect(state.currentApiConfigName).toBe("profile-b") + + await provider.dispose() + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b1e5df93f9..09989746b4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1739,13 +1739,7 @@ describe("ClineProvider", () => { setModeConfig: vi.fn(), } as any - // Mock the ContextProxy's getValue method to return the current config name - const contextProxy = (provider as any).contextProxy - const getValueSpy = vi.spyOn(contextProxy, "getValue") - getValueSpy.mockImplementation((key: any) => { - if (key === "currentApiConfigName") return "current-config" - return undefined - }) + provider.setValue("currentApiConfigName", "current-config") // Switch to architect mode await provider.handleModeSwitch("architect") diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 12832da249..61657ad3a6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -11,6 +11,17 @@ vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ getLMStudioModels: vi.fn(), })) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + updateTelemetryState: vi.fn(), + captureCustomModeCreated: vi.fn(), + captureModeSettingChanged: vi.fn(), + }, + hasInstance: vi.fn(() => false), + }, +})) + vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), @@ -82,6 +93,7 @@ const mockClineProvider = { postMessageToWebview: vi.fn(), customModesManager: { getCustomModes: vi.fn(), + updateCustomMode: vi.fn(), deleteCustomMode: vi.fn(), }, context: { @@ -102,6 +114,7 @@ const mockClineProvider = { getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -177,6 +190,7 @@ import { getWorkspacePath } from "../../../utils/path" import { ensureSettingsDirectoryExists } from "../../../utils/globalContext" import { generateErrorDiagnostics } from "../diagnosticsHandler" import type { ModeConfig } from "@roo-code/types" +import { defaultModeSlug } from "../../../shared/modes" vi.mock("../../../utils/fs") vi.mock("../../../utils/path") @@ -193,6 +207,38 @@ import { resolveImageMentions } from "../../mentions/resolveImageMentions" import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +describe("webviewMessageHandler - webviewDidLaunch", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic" }, + currentApiConfigName: "view-local-profile", + } as any) + ;(mockClineProvider as any).setViewStateId = vi.fn().mockResolvedValue(undefined) + ;(mockClineProvider as any).workspaceTracker = { initializeFilePaths: vi.fn() } + ;(mockClineProvider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "shared-profile", apiProvider: "anthropic" }]), + hasConfig: vi.fn().mockResolvedValue(false), + } + ;(mockClineProvider as any).activateProviderProfile = vi.fn().mockResolvedValue(undefined) + ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined) + ;(mockClineProvider as any).getStateToPostToWebview = vi + .fn() + .mockResolvedValue({ telemetrySetting: "disabled" }) + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue("shared-profile") + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + it("validates the view-local currentApiConfigName on launch", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect((mockClineProvider as any).providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect((mockClineProvider as any).providerSettingsManager.hasConfig).not.toHaveBeenCalledWith("shared-profile") + }) +}) + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -776,6 +822,71 @@ describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { }) }) +describe("webviewMessageHandler - updateCustomMode", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "New Mode", + slug: "new-mode", + roleDefinition: "Test Role", + groups: [], + } as ModeConfig, + ]) + vi.mocked(mockClineProvider.customModesManager.updateCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + vi.mocked((mockClineProvider as any).handleModeSwitch).mockResolvedValue(undefined) + }) + + it("switches the active view to the saved custom mode", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateCustomMode", + modeConfig: { + name: "New Mode", + slug: "new-mode", + roleDefinition: "Test Role", + groups: [], + }, + }) + + expect(mockClineProvider.customModesManager.updateCustomMode).toHaveBeenCalledWith( + "new-mode", + expect.objectContaining({ slug: "new-mode" }), + ) + expect((mockClineProvider as any).handleModeSwitch).toHaveBeenCalledWith("new-mode") + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("mode", "new-mode") + }) +}) + +describe("webviewMessageHandler - deleteCustomMode view-local mode", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([ + { + name: "Test Global Mode", + slug: "test-global-mode", + roleDefinition: "Test Role", + groups: [], + source: "global", + } as ModeConfig, + ]) + vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(false) + vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + vi.mocked((mockClineProvider as any).handleModeSwitch).mockResolvedValue(undefined) + }) + + it("switches the active view back to the default mode", async () => { + await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug: "test-global-mode" }) + + expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith("test-global-mode") + expect((mockClineProvider as any).handleModeSwitch).toHaveBeenCalledWith(defaultModeSlug) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("mode", defaultModeSlug) + }) +}) + describe("webviewMessageHandler - deleteCustomMode", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 01abc5db98..b7b25fb406 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -558,6 +558,8 @@ export const webviewMessageHandler = async ( switch (message.type) { case "webviewDidLaunch": + await provider.setViewStateId(message.viewStateId) + // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) @@ -602,7 +604,8 @@ export const webviewMessageHandler = async ( } } - const currentConfigName = getGlobalState("currentApiConfigName") + const currentState = await provider.getState() + const currentConfigName = currentState.currentApiConfigName if (currentConfigName) { if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) { @@ -911,7 +914,11 @@ export const webviewMessageHandler = async ( providerSettingsManager: provider.providerSettingsManager, contextProxy: provider.contextProxy, customModesManager: provider.customModesManager, - provider: provider, + provider: { + settingsImportedAt: provider.settingsImportedAt, + postStateToWebview: () => provider.postStateToWebview(), + broadcastResetToAllInstances: () => provider.broadcastResetToAllInstances(), + }, }) break @@ -2195,7 +2202,7 @@ export const webviewMessageHandler = async ( // Update state after saving the mode const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) - await updateGlobalState("mode", message.modeConfig.slug) + await provider.handleModeSwitch(message.modeConfig.slug as Mode) await provider.postStateToWebview() // Track telemetry for custom mode creation or update @@ -2291,7 +2298,7 @@ export const webviewMessageHandler = async ( } // Switch back to default mode after deletion - await updateGlobalState("mode", defaultModeSlug) + await provider.handleModeSwitch(defaultModeSlug) await provider.postStateToWebview() } break diff --git a/src/extension/__tests__/api-set-configuration.spec.ts b/src/extension/__tests__/api-set-configuration.spec.ts new file mode 100644 index 0000000000..5995b57ee9 --- /dev/null +++ b/src/extension/__tests__/api-set-configuration.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { API } from "../api" +import { type RooCodeSettings } from "@roo-code/types" +import { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API - setConfiguration", () => { + let api: API + let mockOutputChannel: vscode.OutputChannel + let mockProvider: ClineProvider + let contextValues: RooCodeSettings + let viewLocalState: { apiConfiguration?: RooCodeSettings } + let mockContextProxySetValues: ReturnType Promise>> + let mockProviderSetValues: ReturnType Promise>> + let mockSaveConfig: ReturnType Promise>> + let mockPostStateToWebview: ReturnType Promise>> + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel + + contextValues = { + currentApiConfigName: "default", + apiProvider: "deepseek", + deepSeekBaseUrl: "http://localhost:3000/deepseek", + apiModelId: "deepseek-v4-pro", + } + + viewLocalState = { + apiConfiguration: { + apiProvider: "openrouter", + openRouterBaseUrl: "http://localhost:3000/openrouter", + openRouterModelId: "openrouter/old-model", + }, + } + + mockContextProxySetValues = vi.fn().mockImplementation(async (values: RooCodeSettings) => { + contextValues = { + ...contextValues, + ...values, + } + }) + + mockProviderSetValues = vi + .fn<(values: RooCodeSettings) => Promise>() + .mockImplementation(async (values: RooCodeSettings) => { + await mockContextProxySetValues(values) + viewLocalState.apiConfiguration = values + }) + + mockSaveConfig = vi + .fn<(name: string, values: RooCodeSettings) => Promise>() + .mockResolvedValue("test-id") + mockPostStateToWebview = vi.fn<() => Promise>().mockResolvedValue(undefined) + + mockProvider = { + context: {} as vscode.ExtensionContext, + contextProxy: { + setValues: mockContextProxySetValues, + }, + providerSettingsManager: { + saveConfig: mockSaveConfig, + }, + setValues: mockProviderSetValues, + postStateToWebview: mockPostStateToWebview, + getState: vi.fn().mockImplementation(async () => ({ + apiConfiguration: { + ...contextValues, + ...viewLocalState.apiConfiguration, + }, + })), + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + viewLaunched: true, + } as unknown as ClineProvider + + api = new API(mockOutputChannel, mockProvider) + }) + + it("syncs sidebar provider view-local API configuration so getState reflects the new provider", async () => { + const newConfiguration: RooCodeSettings = { + currentApiConfigName: "deepseek-v4-pro", + apiProvider: "deepseek", + deepSeekBaseUrl: "http://localhost:3000/deepseek", + apiModelId: "deepseek-v4-pro", + } + + await api.setConfiguration(newConfiguration) + + const state = await mockProvider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("deepseek") + expect(state.apiConfiguration.deepSeekBaseUrl).toBe("http://localhost:3000/deepseek") + expect(mockProviderSetValues).toHaveBeenCalledWith(newConfiguration) + expect(mockSaveConfig).toHaveBeenCalledWith("deepseek-v4-pro", newConfiguration) + expect(mockPostStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index c140ec8ec0..7df8054bd6 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -499,7 +499,7 @@ export class API extends EventEmitter implements RooCodeAPI { } public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) + await this.sidebarProvider.setValues(values) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) await this.sidebarProvider.postStateToWebview() } diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 0521499dbb..8069e2d7fa 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -190,9 +190,6 @@ const App = () => { } }, [telemetrySetting, telemetryKey, machineId, didHydrateState]) - // Tell the extension that we are ready to receive messages. - useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) - // Initialize source map support for better error reporting useEffect(() => { // Initialize source maps for better error reporting in production diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 137bed5d70..2ddb3eb15c 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -8,6 +8,7 @@ import AppWithProviders from "../App" vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), + getViewStateId: vi.fn(() => "test-view-state-id"), }, })) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 29b958319c..fba177b034 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -489,7 +489,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..720647a33d 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -22,6 +22,31 @@ class VSCodeAPIWrapper { } } + private createViewStateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + if (typeof existingViewStateId === "string" && existingViewStateId.length > 0) { + return existingViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,9 +74,11 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { + } else if (typeof localStorage?.getItem === "function") { const state = localStorage.getItem("vscodeState") return state ? JSON.parse(state) : undefined + } else { + return undefined } } @@ -70,7 +97,9 @@ class VSCodeAPIWrapper { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + } return newState } }