diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dc3ea072fd..d117d7bd84 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -99,6 +99,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -107,6 +116,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..50d9a978f6 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -632,6 +632,7 @@ export interface WebviewMessage { | "deleteRule" | "openRuleFile" | "openRulesDirectory" + viewStateId?: string text?: string taskId?: string editedMessageContent?: string diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 206d6ca611..d3f9f2d977 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -52,6 +52,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -112,6 +113,7 @@ import { saveTaskMessages, TaskHistoryStore, assertValidTransition, + ApiMessage, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -120,6 +122,8 @@ import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +type PersistedViewState = NonNullable[string] + /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -171,6 +175,9 @@ 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 static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel @@ -270,6 +277,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 = "aug-2026-v3.76.0-dcg-providers-terminal" // v3.76.0 destructive command guard, provider improvements, and terminal execution fix @@ -284,14 +310,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 void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -322,6 +351,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) => { @@ -472,6 +504,138 @@ export class ClineProvider } } + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return {} + } + + return { ...viewStates } + } + + private async savePersistedViewState(values: Partial): Promise { + const viewStateId = this.viewStateId + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const current = states[viewStateId] ?? {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + private prunePersistedViewStates(states: Record): Record { + return Object.fromEntries( + Object.entries(states) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + + 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 non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + */ + private async loadViewState(): Promise { + try { + const persisted = this.getPersistedViewStates()[this.viewStateId] + const loadedState: Partial = {} + + if (persisted?.mode) { + loadedState.mode = persisted.mode + } + + if (persisted?.currentApiConfigName) { + loadedState.currentApiConfigName = persisted.currentApiConfigName + + try { + const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ + name: persisted.currentApiConfigName, + }) + loadedState.apiConfiguration = apiConfiguration as ProviderSettings + } catch (error) { + this.log( + `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + this.viewLocalState = loadedState + 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. Only non-secret selections are persisted durably. + */ + private async saveViewState(key: keyof ExtensionState, value: unknown): Promise { + try { + await this._saveViewLocalStateFromMutation({ [key]: value } as Partial & + Partial) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[saveViewState] Error saving state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -479,7 +643,8 @@ export class ClineProvider event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this { - return super.on(event, listener as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (super.on as any)(event, listener) } /** @@ -489,7 +654,8 @@ export class ClineProvider event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this { - return super.off(event, listener as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (super.off as any)(event, listener) } /** @@ -852,7 +1018,7 @@ export class ClineProvider public static async handleCodeAction( command: CodeActionId, promptType: CodeActionName, - params: Record, + params: Record, ): Promise { // Capture telemetry for code action usage TelemetryService.instance.captureCodeActionUsed(promptType) @@ -884,7 +1050,7 @@ export class ClineProvider public static async handleTerminalAction( command: TerminalActionId, promptType: TerminalActionPromptType, - params: Record, + params: Record, ): Promise { TelemetryService.instance.captureCodeActionUsed(promptType) @@ -1146,6 +1312,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, @@ -1594,7 +1761,7 @@ export class ClineProvider } // Only update the task's mode after successful persistence. - ;(task as any)._taskMode = newMode + ;(task as unknown as Record)._taskMode = newMode } catch (error) { // If persistence fails, log the error but don't update the in-memory state. this.log( @@ -1607,7 +1774,7 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + await this.saveViewState("mode", newMode) this.emit(RooCodeEventName.ModeChanged, newMode) @@ -1712,7 +1879,7 @@ export class ClineProvider task.updateApiConfiguration(providerSettings) } else { // No rebuild needed, just sync apiConfiguration - ;(task as any).apiConfiguration = providerSettings + ;(task as Task).apiConfiguration = providerSettings } } @@ -1757,13 +1924,23 @@ export class ClineProvider // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), this.updateGlobalState("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), + this.saveViewState("currentApiConfigName", name), + this.saveViewState("apiConfiguration", providerSettings), ]) + await this._saveViewLocalStateFromMutation({ + listApiConfigMeta, + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1807,6 +1984,11 @@ export class ClineProvider listApiConfigMeta: entries, }) + this._updateViewLocalStateFromMutation({ + currentApiConfigName: profileToActivate, + listApiConfigMeta: entries, + }) + await this.postStateToWebview() } @@ -1874,11 +2056,21 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), this.contextProxy.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), + this.saveViewState("currentApiConfigName", name), + this.saveViewState("apiConfiguration", providerSettings), ]) + + await this._saveViewLocalStateFromMutation({ + listApiConfigMeta, + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) } const { mode } = await this.getState() @@ -2731,12 +2923,18 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // Merge viewLocalState on top of global state so a provider can serve + // state values scoped to its own view while preserving ContextProxy defaults. + 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. @@ -2798,117 +2996,120 @@ export class ClineProvider // Return the same structure as before. 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, + apiConfiguration: { + ...providerSettings, + ...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, destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, - 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, + 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: (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: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + 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, } } @@ -3011,6 +3212,7 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) + await this._saveViewLocalStateFromMutation({ [key]: value }) } public getValue(key: K) { @@ -3018,11 +3220,94 @@ export class ClineProvider } public getValues() { - return this.contextProxy.getValues() + return { ...this.contextProxy.getValues(), ...this.viewLocalState } } public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) + await this._saveViewLocalStateFromMutation(values) + } + + private async _saveViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + await this._persistViewLocalStateFromMutation(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 & Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val + } + } + + if ("apiConfiguration" in values) { + const val = values.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 = + "apiProvider" in providerSettingsUpdate + ? providerSettingsUpdate + : { + ...(this.viewLocalState.apiConfiguration ?? {}), + ...providerSettingsUpdate, + } + } + } + + private async _persistViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + const persistedValues: Partial = {} + + if ("mode" in values) { + persistedValues.mode = values.mode as PersistedViewState["mode"] + } + + if ("currentApiConfigName" in values) { + persistedValues.currentApiConfigName = values.currentApiConfigName + } + + if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { + await this.savePersistedViewState(persistedValues) + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} } // dev @@ -3051,6 +3336,10 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() @@ -3714,7 +4003,7 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode as any) + await this.handleModeSwitch(mode as Mode) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ @@ -3734,7 +4023,7 @@ export class ClineProvider // Without this, the child's fire-and-forget startTask() races with step 5, // and the last writer to globalState overwrites the other's changes— // causing the parent's delegation fields to be lost. - const child = await this.createTask(message, undefined, parent as any, { + const child = await this.createTask(message, undefined, parent as Task | undefined, { initialTodos, initialStatus: "active", startTask: false, @@ -3891,12 +4180,12 @@ export class ClineProvider parentClineMessages = [] } - let parentApiMessages: any[] = [] + let parentApiMessages: ApiMessage[] = [] try { - parentApiMessages = (await readApiMessages({ + parentApiMessages = await readApiMessages({ taskId: parentTaskId, globalStoragePath, - })) as any[] + }) } catch { parentApiMessages = [] } @@ -4007,7 +4296,7 @@ export class ClineProvider } } - await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) + await saveApiMessages({ messages: parentApiMessages, taskId: parentTaskId, globalStoragePath }) // 4) Close child instance if still open (single-open-task invariant). // This MUST happen BEFORE marking the child "completed" because @@ -4082,7 +4371,7 @@ export class ClineProvider // non-fatal } try { - await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + await parentInstance.overwriteApiConversationHistory(parentApiMessages) } catch { // non-fatal } 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..3cac0026ac --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,1605 @@ +// 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" + +// Type helper for accessing private members of ClineProvider in tests +interface ClineProviderPrivateAccess { + viewLocalState: Partial + saveViewState(key: keyof ExtensionState, value: unknown): Promise + setViewStateId(viewStateId: string | undefined): Promise + loadViewState(): Promise + prunePersistedViewStates(states: Record): Record + _clearViewLocalState(): void + log(message: string): void + resolveWebviewView(webviewView: vscode.WebviewView): Promise +} + +function asProviderAccess(provider: ClineProvider): ClineProviderPrivateAccess { + return provider as unknown as ClineProviderPrivateAccess +} + +// 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: { affectsConfiguration: (key: string) => boolean }) => void) => { + 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({ dispose: vi.fn() }), + 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: { + globalState?: { + get: (key: string) => unknown + update: (key: string, value: unknown) => Promise + keys?: () => string[] + } + globalStorageUri?: { fsPath: string } + extensionUri?: { fsPath: string } + }, + ) { + 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: unknown) => { + 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)) + resetAllState = vi.fn().mockImplementation(() => { + const keys = this.context?.globalState?.keys?.() ?? [] + return Promise.all(keys.map((key: string) => this.setValue(key, undefined))).then(() => undefined) + }) + } + return { ContextProxy: MockContextProxy } +}) + +// Mock Task +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(function (options: { historyItem?: { id: string } }) { + 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), + resetAllConfigs: 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([]), + resetCustomModes: vi.fn().mockResolvedValue(undefined), + 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: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => { + return Object.keys(globalState) + }), + } as vscode.Memento, + 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() + }), + onDidChange: vi.fn(), + } as unknown as vscode.SecretStorage, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + } as vscode.Memento, + 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 + }) + + const createMockWebviewView = (postMessage = vi.fn()) => + ({ + webview: { + postMessage, + 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() })), + }) as unknown as vscode.WebviewView + + 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)) + + await asProviderAccess(provider2).saveViewState("mode", "debugger") + await asProviderAccess(provider1).saveViewState("mode", "architect") + + 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 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 = asProviderAccess(provider1).saveViewState.bind(asProviderAccess(provider1)) + const saveViewState2 = asProviderAccess(provider2).saveViewState.bind(asProviderAccess(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 = asProviderAccess(provider1).saveViewState.bind(asProviderAccess(provider1)) + const saveViewState2 = asProviderAccess(provider2).saveViewState.bind(asProviderAccess(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 and persist mode through registered viewStates", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await asProviderAccess(provider).setViewStateId("stable-sidebar-view") + + await asProviderAccess(provider).saveViewState("mode", "architect") + + expect(asProviderAccess(provider).viewLocalState.mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("stable-sidebar-view") + await asProviderAccess(provider).saveViewState("currentApiConfigName", "my-profile") + + expect(asProviderAccess(provider).viewLocalState.currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const testApiConfig = { + apiProvider: "openrouter" as const, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await asProviderAccess(provider).setViewStateId("stable-sidebar-view") + await asProviderAccess(provider).saveViewState("apiConfiguration", testApiConfig) + + expect(asProviderAccess(provider).viewLocalState.apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).saveViewState("mode", "architect") + expect(asProviderAccess(provider).viewLocalState.mode).toBe("architect") + + await asProviderAccess(provider).saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(asProviderAccess(provider).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 asProviderAccess(provider).saveViewState("currentApiConfigName", "my-profile") + expect(asProviderAccess(provider).viewLocalState.currentApiConfigName).toBe("my-profile") + + await asProviderAccess(provider).saveViewState("currentApiConfigName", null) + + expect( + Object.prototype.hasOwnProperty.call(asProviderAccess(provider).viewLocalState, "currentApiConfigName"), + ).toBe(false) + + await provider.dispose() + }) + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("stable-sidebar-view") + + // saveViewState catches errors internally and doesn't re-throw them + // so the test verifies that viewLocalState is not updated when persistence fails + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockImplementationOnce(async () => { + throw new Error("persist failed") + }) + + await asProviderAccess(provider).saveViewState("mode", "architect") + + expect(asProviderAccess(provider).viewLocalState).not.toHaveProperty("mode") + expect(asProviderAccess(provider).viewLocalState).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await asProviderAccess(provider1).setViewStateId("stable-sidebar-view") + await asProviderAccess(provider2).setViewStateId("stable-editor-view") + + await Promise.all([ + asProviderAccess(provider1).saveViewState("mode", "architect"), + asProviderAccess(provider2).saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("loadViewState", () => { + it("should keep viewLocalState empty when no stable per-view values exist", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await vi.waitFor(() => { + expect(asProviderAccess(provider).viewLocalState).toEqual({}) + }) + + const state = await provider.getState() + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should restore mode and currentApiConfigName from hydrated viewStates after extension reload", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const stableViewId = "stable-sidebar-view" + + await provider.contextProxy.setValue("viewStates", { + [stableViewId]: { mode: "architect", currentApiConfigName: "new-profile", updatedAt: 123 }, + }) + + await asProviderAccess(provider).setViewStateId(stableViewId) + + const state = await provider.getState() + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("new-profile") + + await provider.dispose() + }) + + it("should resolve API configuration from the persisted profile selection", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const stableViewId = "stable-editor-tab-a" + const getProfileSpy = vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "profile-a", + id: "profile-a-id", + apiProvider: "openrouter", + openRouterModelId: "openrouter/anthropic/claude-sonnet-4", + } as { name: string; id?: string } & Partial) + + await provider.contextProxy.setValue("viewStates", { + [stableViewId]: { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 123 }, + }) + await provider.contextProxy.setValue("mode", "debugger") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + await (provider.contextProxy.setValue as (key: string, value: unknown) => Promise)( + "apiConfiguration", + { apiProvider: "anthropic" }, + ) + + await asProviderAccess(provider).setViewStateId(stableViewId) + const state = await provider.getState() + + expect(getProfileSpy).toHaveBeenCalledWith({ name: "profile-a" }) + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("profile-a") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: "openrouter", + openRouterModelId: "openrouter/anthropic/claude-sonnet-4", + }) + + await provider.dispose() + }) + + it("should not throw when a persisted profile selection cannot be resolved", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const stableViewId = "stable-editor-tab-a" + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue(new Error("missing profile")) + + await provider.contextProxy.setValue("viewStates", { + [stableViewId]: { mode: "architect", currentApiConfigName: "deleted-profile", updatedAt: 123 }, + }) + + await expect(asProviderAccess(provider).setViewStateId(stableViewId)).resolves.toBeUndefined() + const state = await provider.getState() + + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("deleted-profile") + expect(state.apiConfiguration.apiProvider).toBe("anthropic") + + 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(asProviderAccess(provider), "log") + + asProviderAccess(provider).viewLocalState = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValue").mockImplementation(() => { + throw new Error("load failed") + }) + + await asProviderAccess(provider).loadViewState() + + expect(asProviderAccess(provider).viewLocalState.mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = asProviderAccess(provider).prunePersistedViewStates(states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-5"]).toBeDefined() + expect(pruned["view-4"]).toBeUndefined() + + 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 asProviderAccess(provider).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 asProviderAccess(provider).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 asProviderAccess(provider).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 merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + } + const contextProxyAccess = provider.contextProxy as unknown as { + setValues: (values: Partial) => Promise + } + await contextProxyAccess.setValues({ + mode: "debugger", + currentApiConfigName: "shared-profile", + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "shared-key", + }, + customModePrompts: { code: { roleDefinition: "shared" } }, + }) + + await providerAccess.saveViewState("mode", "architect") + await providerAccess.saveViewState("currentApiConfigName", "view-profile") + await providerAccess.saveViewState("apiConfiguration", { + apiProvider: "openrouter", + openRouterApiKey: "view-key", + }) + + const values = provider.getValues() + + expect(values.mode).toBe("architect") + expect(values.currentApiConfigName).toBe("view-profile") + expect(values.apiConfiguration).toEqual({ + apiProvider: "openrouter", + openRouterApiKey: "view-key", + }) + expect(values.customModePrompts).toEqual({ code: { roleDefinition: "shared" } }) + + 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 asProviderAccess(provider).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(asProviderAccess(provider).viewLocalState.apiConfiguration?.apiProvider).toBe("bedrock") + expect(asProviderAccess(provider).viewLocalState.apiConfiguration).not.toHaveProperty("openRouterModelId") + + await provider.dispose() + }) + + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("stable-sidebar-view") + await (provider.setValue as (key: string, value: unknown) => Promise)("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" } as Partial) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("tab panel/with.dots and spaces") + await (provider.setValue as (key: string, value: unknown) => Promise)("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("view-a") + const firstSave = asProviderAccess(provider).saveViewState("mode", "architect") + await firstWriteStarted + await asProviderAccess(provider).setViewStateId("view-b") + releaseFirstWrite() + await firstSave + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("view-b") + + await provider.dispose() + }) + + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await asProviderAccess(provider).setViewStateId("tab-to-preserve") + await asProviderAccess(provider).saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + }) + }) + + describe("profile mutations", () => { + it("should synchronize viewLocalState when activateProviderProfile mutates ContextProxy", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValueOnce({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "openrouter", + openRouterModelId: "openrouter/new-model", + } as { name: string; id?: string; apiProvider: import("@roo-code/types").ProviderName } & Partial< + import("@roo-code/types").ProviderSettings + >) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([ + { id: "new-profile-id", name: "new-profile", apiProvider: "openrouter" }, + ] as Array<{ id: string; name: string; apiProvider: import("@roo-code/types").ProviderName }>) + const saveViewStateSpy = vi.spyOn(asProviderAccess(provider), "saveViewState") + asProviderAccess(provider).viewLocalState = { + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: "anthropic" }, + } + + await provider.activateProviderProfile({ name: "new-profile" }) + const state = await provider.getState() + + // activateProviderProfile calls saveViewState as part of its flow to persist the activated profile + expect(saveViewStateSpy).toHaveBeenCalledWith("currentApiConfigName", "new-profile") + expect(state.currentApiConfigName).toBe("new-profile") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: "openrouter", + openRouterModelId: "openrouter/new-model", + }) + + await provider.dispose() + }) + + it("should synchronize viewLocalState when upsertProviderProfile activates a saved profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "test-id", name: "saved-profile", apiProvider: "bedrock" }, + ] as Array<{ id: string; name: string; apiProvider: import("@roo-code/types").ProviderName }>) + const saveViewStateSpy = vi.spyOn(asProviderAccess(provider), "saveViewState") + asProviderAccess(provider).viewLocalState = { + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: "anthropic" }, + } + + await provider.upsertProviderProfile("saved-profile", { + apiProvider: "bedrock", + awsRegion: "us-east-1", + } as Partial) + const state = await provider.getState() + + // upsertProviderProfile calls saveViewState as part of its flow to persist the activated profile + expect(saveViewStateSpy).toHaveBeenCalledWith("currentApiConfigName", "saved-profile") + expect(state.currentApiConfigName).toBe("saved-profile") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: "bedrock", + awsRegion: "us-east-1", + }) + + await provider.dispose() + }) + it("should synchronize viewLocalState when deleteProviderProfile selects a replacement profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider.contextProxy.setValue("currentApiConfigName", "deleted-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "deleted-id", name: "deleted-profile", apiProvider: "anthropic" }, + { id: "replacement-id", name: "replacement-profile", apiProvider: "openrouter" }, + ]) + asProviderAccess(provider).viewLocalState = { + currentApiConfigName: "deleted-profile", + apiConfiguration: { apiProvider: "anthropic" }, + } + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: "anthropic", + } as { id: string; name: string; apiProvider?: import("@roo-code/types").ProviderName }) + const state = await provider.getState() + + expect(state.currentApiConfigName).toBe("replacement-profile") + expect(state.listApiConfigMeta).toEqual([ + { id: "replacement-id", name: "replacement-profile", apiProvider: "openrouter" }, + ]) + + await provider.dispose() + }) + + it("should clear viewLocalState when resetState resets ContextProxy", async () => { + vi.mocked(vscode.window.showInformationMessage).mockImplementationOnce( + async (_message: string, _options: unknown, ...items: unknown[]) => + items[0] as import("vscode").MessageItem | undefined, + ) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + asProviderAccess(provider).viewLocalState = { + mode: "architect", + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: "openrouter" }, + } + + await provider.resetState() + + expect(asProviderAccess(provider).viewLocalState).toEqual({}) + + await provider.dispose() + }) + }) + + describe("provider profile activation", () => { + it("should sync view-local apiConfiguration when activating an upserted profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await asProviderAccess(provider).saveViewState("apiConfiguration", { + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4.1", + }) + + const providerSettings = { + apiProvider: "zai" as const, + zaiApiKey: "mock-key", + zaiApiLine: "international_api" as const, + apiModelId: "glm-5.1", + } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("zai-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "default", id: "zai-profile-id", apiProvider: "zai" }, + ]) + + await provider.upsertProviderProfile("default", providerSettings, true) + + const state = await provider.getState() + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration).toMatchObject(providerSettings) + expect(state.apiConfiguration.apiProvider).toBe("zai") + expect(state.apiConfiguration).not.toHaveProperty("openRouterModelId") + expect(asProviderAccess(provider).viewLocalState.apiConfiguration).toMatchObject(providerSettings) + + await provider.dispose() + }) + }) + + describe("handleModeSwitch integration", () => { + it("should update viewLocalState.mode when handleModeSwitch is called", async () => { + const postMessage = vi.fn() + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).resolveWebviewView(createMockWebviewView(postMessage)) + + const saveViewStateSpy = vi.spyOn(asProviderAccess(provider), "saveViewState") + + await provider.handleModeSwitch("architect" as string) + + expect(asProviderAccess(provider).viewLocalState.mode).toBe("architect") + 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 postMessage = 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.providerSettingsManager, "getModeConfigId") + + await asProviderAccess(provider).resolveWebviewView(createMockWebviewView(postMessage)) + postMessage.mockClear() + + await provider.handleModeSwitch("architect" as string) + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(postMessage).toHaveBeenCalled() + + await provider.dispose() + }) + + it("should activate configured mode profile when switching modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([ + { id: "profile-id", name: "mode-profile", apiProvider: "openrouter" }, + ] as Array<{ id: string; name: string; apiProvider: import("@roo-code/types").ProviderName }>) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce({ + name: "mode-profile", + apiProvider: "openrouter", + } as { name: string } & Partial) + const activateProviderProfileUnlockedSpy = vi.spyOn( + provider as unknown as { activateProviderProfileUnlocked: (...args: unknown[]) => Promise }, + "activateProviderProfileUnlocked", + ) // Double assertion required because `activateProviderProfileUnlocked` is a private method. + + await provider.handleModeSwitch("architect" as string) + + expect(activateProviderProfileUnlockedSpy).toHaveBeenCalledWith( + { name: "mode-profile" }, + undefined, + expect.anything(), + ) + + await provider.dispose() + }) + + it("should leave current configuration unchanged for empty mode profiles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("empty-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([ + { id: "empty-profile-id", name: "empty-profile" }, + ] as Array<{ id: string; name: string }>) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce({ + name: "empty-profile", + } as { name: string } & Partial) + const activateProviderProfileSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.handleModeSwitch("architect" as string) + + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should emit ModeChanged event after handleModeSwitch", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const modeChangedSpy = vi.fn() + + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + + await provider.handleModeSwitch("architect" as string) + + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + 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, "editor", new ContextProxy(mockContext)) + + await asProviderAccess(provider1).saveViewState("mode", "code") + await asProviderAccess(provider1).saveViewState("currentApiConfigName", "profile-1") + await asProviderAccess(provider2).saveViewState("mode", "architect") + await asProviderAccess(provider2).saveViewState("currentApiConfigName", "profile-2") + await asProviderAccess(provider3).saveViewState("mode", "debugger") + await asProviderAccess(provider3).saveViewState("currentApiConfigName", "profile-3") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + const state3 = await provider3.getState() + + expect(state1.mode).toBe("code") + expect(state1.currentApiConfigName).toBe("profile-1") + expect(state2.mode).toBe("architect") + expect(state2.currentApiConfigName).toBe("profile-2") + expect(state3.mode).toBe("debugger") + expect(state3.currentApiConfigName).toBe("profile-3") + + await provider1.dispose() + await provider2.dispose() + await provider3.dispose() + }) + + it("should handle mode switch in one instance without affecting others", async () => { + const postMessage1 = vi.fn() + const postMessage2 = vi.fn() + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await asProviderAccess(provider1).resolveWebviewView(createMockWebviewView(postMessage1)) + await asProviderAccess(provider2).resolveWebviewView(createMockWebviewView(postMessage2)) + await asProviderAccess(provider1).saveViewState("mode", "code") + await asProviderAccess(provider2).saveViewState("mode", "debugger") + + await provider1.handleModeSwitch("architect" as string) + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + expect(asProviderAccess(provider2).viewLocalState.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("_clearViewLocalState", () => { + it("should clear all view-local state values", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await asProviderAccess(provider).saveViewState("mode", "architect") + await asProviderAccess(provider).saveViewState("currentApiConfigName", "my-profile") + await asProviderAccess(provider).saveViewState("apiConfiguration", { apiProvider: "openrouter" }) + + expect(asProviderAccess(provider).viewLocalState.mode).toBe("architect") + expect(asProviderAccess(provider).viewLocalState.currentApiConfigName).toBe("my-profile") + expect(asProviderAccess(provider).viewLocalState.apiConfiguration).toEqual({ + apiProvider: "openrouter", + }) + + // Call _clearViewLocalState + asProviderAccess(provider)._clearViewLocalState() + + // All values should be cleared + expect(asProviderAccess(provider).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 asProviderAccess(provider).saveViewState("mode", "architect") + + let state = await provider.getState() + expect(state.mode).toBe("architect") + + // Clear viewLocalState + asProviderAccess(provider)._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(asProviderAccess(provider)._clearViewLocalState()).toBeUndefined() + expect(asProviderAccess(provider).viewLocalState).toEqual({}) + + await provider.dispose() + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 00f848bec4..d6c375877e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -578,7 +578,7 @@ describe("ClineProvider", () => { }) test("does not reload full model details when the LM Studio model is already loaded", async () => { - vi.mocked(hasLoadedFullDetails).mockReturnValue(true) + vi.mocked(hasLoadedFullDetails).mockReturnValueOnce(true) await provider.performPreparationTasks({ apiConfiguration: { @@ -1839,8 +1839,13 @@ describe("ClineProvider", () => { // Switch to architect mode await provider.handleModeSwitch("architect") - // Verify mode was updated - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + [provider.viewId]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify saved config was loaded expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") @@ -1871,8 +1876,13 @@ describe("ClineProvider", () => { // Switch to architect mode await provider.handleModeSwitch("architect") - // Verify mode was updated - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + [provider.viewId]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify current config was saved as default for new mode expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index e6d8c9325f..8a4a33b87b 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -350,8 +350,13 @@ describe("ClineProvider - Sticky Mode", () => { // Switch mode await provider.handleModeSwitch("architect") - // Verify mode was updated in global state - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + [provider.viewId]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify task history was updated with new mode expect(updateTaskHistorySpy).toHaveBeenCalledWith( @@ -682,8 +687,13 @@ describe("ClineProvider - Sticky Mode", () => { // Switch mode - should not throw await expect(provider.handleModeSwitch("architect")).resolves.not.toThrow() - // Verify mode was still updated in global state - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was still updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + [provider.viewId]: expect.objectContaining({ mode: "architect" }), + }), + ) }) it("should handle null/undefined mode gracefully", async () => { @@ -859,12 +869,16 @@ describe("ClineProvider - Sticky Mode", () => { await Promise.all(switches) - // Find the last mode update call - const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - const lastModeCall = modeCalls[modeCalls.length - 1] + // Find the last durable view state update call + const viewStateCalls = vi + .mocked(mockContext.globalState.update) + .mock.calls.filter((call) => call[0] === "viewStates") + const lastViewStateCall = viewStateCalls[viewStateCalls.length - 1] // Verify the last mode switch wins - expect(lastModeCall).toEqual(["mode", "code"]) + expect(lastViewStateCall?.[1]).toMatchObject({ + [provider.viewId]: { mode: "code" }, + }) // Verify task history was updated with final mode const lastCall = updateTaskHistorySpy.mock.calls[updateTaskHistorySpy.mock.calls.length - 1] @@ -955,7 +969,12 @@ describe("ClineProvider - Sticky Mode", () => { await provider.handleModeSwitch("invalid-mode" as any) // The mode WILL be updated to invalid-mode (this is the actual behavior) - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "invalid-mode") + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + [provider.viewId]: expect.objectContaining({ mode: "invalid-mode" }), + }), + ) }) it("should handle errors during mode switch gracefully", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 3ceeb2f895..d8fef4f8e4 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { webviewMessageHandler } from "../webviewMessageHandler" +import type { WebviewMessage } from "@roo-code/types" + import type { ClineProvider } from "../ClineProvider" // Mock vscode (minimal) @@ -37,10 +39,16 @@ vi.mock("vscode", () => ({ // Mock modelCache getModels/flushModels used by the handler const getModelsMock = vi.fn() const flushModelsMock = vi.fn() +const kimiCodeGetAccessTokenMock = vi.fn() vi.mock("../../../api/providers/fetchers/modelCache", () => ({ getModels: (...args: any[]) => getModelsMock(...args), flushModels: (...args: any[]) => flushModelsMock(...args), })) +vi.mock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + getAccessToken: (...args: unknown[]) => kimiCodeGetAccessTokenMock(...args), + }, +})) describe("webviewMessageHandler - requestRouterModels provider filter", () => { let mockProvider: ClineProvider & { @@ -296,6 +304,31 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("continues posting routerModels when Kimi Code OAuth lookup fails", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + kimiCodeAuthMethod: "oauth", + }, + }) + kimiCodeGetAccessTokenMock.mockRejectedValueOnce(new Error("refresh failed")) + + await webviewMessageHandler(mockProvider, { + type: "requestRouterModels", + values: { provider: "kimi-code" }, + } satisfies WebviewMessage) + + expect(kimiCodeGetAccessTokenMock).toHaveBeenCalledOnce() + expect(mockProvider.log).toHaveBeenCalledWith( + "[requestRouterModels] kimi-code credential lookup failed: refresh failed", + ) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "kimi-code" })) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "routerModels", + routerModels: {}, + values: { provider: "kimi-code" }, + }) + }) + it("fetches Moonshot models when stored Moonshot credentials exist", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index a3b76aa8b2..9d84fc7323 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(), @@ -87,6 +98,7 @@ const mockClineProvider = { postMessageToWebview: vi.fn(), customModesManager: { getCustomModes: vi.fn(), + updateCustomMode: vi.fn(), deleteCustomMode: vi.fn(), }, context: { @@ -107,6 +119,7 @@ const mockClineProvider = { getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -136,7 +149,7 @@ vi.mock("vscode", () => { }) vi.mock("../../../i18n", () => ({ - t: vi.fn((key: string, args?: Record) => { + t: vi.fn((key: string, args?: Record) => { // For the delete confirmation with rules, we need to return the interpolated string if (key === "common:confirmation.delete_custom_mode_with_rules" && args) { return `Are you sure you want to delete this ${args.scope} mode?\n\nThis will also delete the associated rules folder at:\n${args.rulesFolderPath}` @@ -182,6 +195,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") @@ -198,6 +212,53 @@ import { resolveImageMentions } from "../../mentions/resolveImageMentions" import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +describe("webviewMessageHandler - webviewDidLaunch", () => { + type ProviderWithPrivateMethods = typeof mockClineProvider & { + setViewStateId: ReturnType + workspaceTracker: { initializeFilePaths: ReturnType } + providerSettingsManager: { + listConfig: ReturnType + hasConfig: ReturnType + } + activateProviderProfile: ReturnType + getMcpHub: ReturnType + getStateToPostToWebview: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic" }, + currentApiConfigName: "view-local-profile", + } as unknown as import("@roo-code/types").ExtensionState) + const providerAccess = mockClineProvider as ProviderWithPrivateMethods + providerAccess.setViewStateId = vi.fn().mockResolvedValue(undefined) + providerAccess.workspaceTracker = { + initializeFilePaths: vi.fn().mockResolvedValue(undefined), + } as unknown as typeof providerAccess.workspaceTracker + providerAccess.providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "shared-profile", apiProvider: "anthropic" }]), + hasConfig: vi.fn().mockResolvedValue(false), + } as unknown as typeof providerAccess.providerSettingsManager + providerAccess.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + providerAccess.getMcpHub = vi.fn().mockReturnValue(undefined) + providerAccess.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)) + + const providerAccess = mockClineProvider as ProviderWithPrivateMethods + expect(providerAccess.setViewStateId).toHaveBeenCalledWith("view-1") + expect(providerAccess.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(providerAccess.providerSettingsManager.hasConfig).not.toHaveBeenCalledWith("shared-profile") + }) +}) + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -280,7 +341,7 @@ describe("webviewMessageHandler - image mentions", () => { cwd: "/mock/workspace", rooIgnoreController: undefined, handleWebviewAskResponse: mockHandleWebviewAskResponse, - } as any) + } as unknown as ReturnType) await webviewMessageHandler(mockClineProvider, { type: "askResponse", @@ -532,9 +593,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Must be fetched despite no configured key, forwarding apiKey: undefined. expect(mockGetModels).toHaveBeenCalledWith({ provider: "opencode-go", apiKey: undefined }) - const routerModelsCall = (mockClineProvider.postMessageToWebview as any).mock.calls.find( - ([msg]: [{ type: string }]) => msg.type === "routerModels", - ) + const postMessageMock = mockClineProvider.postMessageToWebview as ReturnType + const routerModelsCall = postMessageMock.mock.calls.find((call) => call[0].type === "routerModels") expect(routerModelsCall?.[0].routerModels["opencode-go"]).toEqual(mockModels) }) @@ -852,7 +912,7 @@ describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { }) it("posts error when not authenticated", async () => { - await webviewMessageHandler(mockClineProvider, { type: "requestOpenAiCodexRateLimits" } as any) + await webviewMessageHandler(mockClineProvider, { type: "requestOpenAiCodexRateLimits" }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "openAiCodexRateLimits", @@ -868,7 +928,7 @@ describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { fetchedAt: 1700000000000, }) - await webviewMessageHandler(mockClineProvider, { type: "requestOpenAiCodexRateLimits" } as any) + await webviewMessageHandler(mockClineProvider, { type: "requestOpenAiCodexRateLimits" }) expect(mockFetchOpenAiCodexRateLimitInfo).toHaveBeenCalledWith("token", { accountId: "acct_123" }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ @@ -1002,7 +1062,7 @@ describe("webviewMessageHandler - message dialog preferences", () => { taskId: "test-task-id", apiConversationHistory: [], clineMessages: [], - } as any) + } as unknown as ReturnType) // Reset getValue mock vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(false) }) @@ -1012,7 +1072,7 @@ describe("webviewMessageHandler - message dialog preferences", () => { vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ clineMessages: [], apiConversationHistory: [], - } as any) // Mock current cline with proper structure + } as unknown as ReturnType) await webviewMessageHandler(mockClineProvider, { type: "deleteMessage", @@ -1032,7 +1092,7 @@ describe("webviewMessageHandler - message dialog preferences", () => { vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ clineMessages: [], apiConversationHistory: [], - } as any) // Mock current cline with proper structure + } as unknown as ReturnType) await webviewMessageHandler(mockClineProvider, { type: "submitEditedMessage", @@ -1052,7 +1112,8 @@ describe("webviewMessageHandler - message dialog preferences", () => { }) describe("webviewMessageHandler - mcpEnabled", () => { - let mockMcpHub: any + let mockMcpHub: { handleMcpEnabledChange: ReturnType } + let getMcpHubMock: ReturnType beforeEach(() => { vi.clearAllMocks() @@ -1062,8 +1123,14 @@ describe("webviewMessageHandler - mcpEnabled", () => { handleMcpEnabledChange: vi.fn().mockResolvedValue(undefined), } + getMcpHubMock = vi.fn().mockReturnValue(mockMcpHub) + // Ensure provider exposes getMcpHub and returns our mock - ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(mockMcpHub) + Object.defineProperty(mockClineProvider, "getMcpHub", { + value: getMcpHubMock, + writable: true, + configurable: true, + }) }) it("delegates enable=true to McpHub and posts updated state", async () => { @@ -1072,7 +1139,7 @@ describe("webviewMessageHandler - mcpEnabled", () => { updatedSettings: { mcpEnabled: true }, }) - expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1) + expect(getMcpHubMock).toHaveBeenCalledTimes(1) expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledTimes(1) expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledWith(true) expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) @@ -1084,21 +1151,25 @@ describe("webviewMessageHandler - mcpEnabled", () => { updatedSettings: { mcpEnabled: false }, }) - expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1) + expect(getMcpHubMock).toHaveBeenCalledTimes(1) expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledTimes(1) expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledWith(false) expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) it("handles missing McpHub instance gracefully and still posts state", async () => { - ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined) + Object.defineProperty(mockClineProvider, "getMcpHub", { + value: vi.fn().mockReturnValue(undefined), + writable: true, + configurable: true, + }) await webviewMessageHandler(mockClineProvider, { type: "updateSettings", updatedSettings: { mcpEnabled: true }, }) - expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1) + expect(mockClineProvider.getMcpHub).toHaveBeenCalledTimes(1) expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) }) @@ -1242,7 +1313,7 @@ describe("webviewMessageHandler - terminalProfile", () => { await webviewMessageHandler(mockClineProvider, { type: "updateSettings", - updatedSettings: { terminalProfile: 42 as any }, + updatedSettings: { terminalProfile: 42 as unknown as string }, }) expect(Terminal.getTerminalProfile()).toBeUndefined() @@ -1476,7 +1547,11 @@ describe("webviewMessageHandler - rules", () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue(undefined) - ;(mockClineProvider as any).cwd = "/mock/workspace" + Object.defineProperty(mockClineProvider, "cwd", { + value: "/mock/workspace", + writable: true, + configurable: true, + }) }) it("routes rules management messages with the current workspace", async () => { @@ -1489,7 +1564,7 @@ describe("webviewMessageHandler - rules", () => { ] as const for (const message of messages) { - await webviewMessageHandler(mockClineProvider, message as any) + await webviewMessageHandler(mockClineProvider, message as Parameters[1]) } expect(handleRequestRules).toHaveBeenCalledWith(mockClineProvider, "/mock/workspace") @@ -1505,7 +1580,7 @@ describe("webviewMessageHandler - rules", () => { } as unknown as ReturnType) const message = { type: "requestRules" } as const - await webviewMessageHandler(mockClineProvider, message as any) + await webviewMessageHandler(mockClineProvider, message as Parameters[1]) expect(handleRequestRules).toHaveBeenCalledWith(mockClineProvider, "/mock/task-workspace") }) @@ -1516,12 +1591,17 @@ describe("webviewMessageHandler - downloadErrorDiagnostics", () => { vi.clearAllMocks() // Ensure contextProxy has a globalStorageUri for the handler - ;(mockClineProvider as any).contextProxy.globalStorageUri = { fsPath: "/mock/global/storage" } + Object.defineProperty(mockClineProvider.contextProxy, "globalStorageUri", { + value: { fsPath: "/mock/global/storage" }, + writable: true, + configurable: true, + }) // Provide a current task with a stable ID vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ taskId: "test-task-id", - } as any) + handleWebviewAskResponse: vi.fn(), + } as unknown as ReturnType) }) it("calls generateErrorDiagnostics with correct parameters", async () => { @@ -1534,7 +1614,7 @@ describe("webviewMessageHandler - downloadErrorDiagnostics", () => { model: "test-model", details: "Sample error details", }, - } as any) + } as Parameters[1]) // Verify generateErrorDiagnostics was called with the correct parameters expect(generateErrorDiagnostics).toHaveBeenCalledTimes(1) @@ -1553,15 +1633,25 @@ describe("webviewMessageHandler - downloadErrorDiagnostics", () => { }) it("shows error when no active task", async () => { - vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue(null as any) + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue(undefined) await webviewMessageHandler(mockClineProvider, { type: "downloadErrorDiagnostics", values: {}, - } as any) + } as Parameters[1]) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No active task to generate diagnostics for") + }) + + it("shows error when no active task", async () => { + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "downloadErrorDiagnostics", + values: {}, + }) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No active task to generate diagnostics for") - expect(generateErrorDiagnostics).not.toHaveBeenCalled() }) }) @@ -1572,43 +1662,55 @@ describe("zooCodeSignOut", () => { it("disconnects Zoo Code and clears tokens from all Zoo Gateway profiles", async () => { const { disconnectZooCode } = await import("../../../services/zoo-code-auth") - const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) - const saveConfig = vi.fn().mockResolvedValue(undefined) - - ;(mockClineProvider as any).contextProxy = { - ...mockClineProvider.contextProxy, - getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), - getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), - } - ;(mockClineProvider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([ - { name: "Zoo Gateway", apiProvider: "zoo-gateway" }, - { name: "Backup Zoo", apiProvider: "zoo-gateway" }, - ]), - getProfile: vi - .fn() - .mockResolvedValueOnce({ - apiProvider: "zoo-gateway", - zooSessionToken: "token-active", - zooGatewayModelId: "anthropic/claude-sonnet-4", - }) - .mockResolvedValueOnce({ - apiProvider: "zoo-gateway", - zooSessionToken: "token-backup", - }), - saveConfig, - } - ;(mockClineProvider as any).upsertProviderProfile = upsertProviderProfile + const mockUpsertProviderProfile = vi.fn().mockResolvedValue(undefined) + const mockSaveConfig = vi.fn().mockResolvedValue(undefined) + + Object.defineProperty(mockClineProvider, "contextProxy", { + value: { + ...mockClineProvider.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), + }, + writable: true, + configurable: true, + }) + Object.defineProperty(mockClineProvider, "providerSettingsManager", { + value: { + listConfig: vi.fn().mockResolvedValue([ + { name: "Zoo Gateway", apiProvider: "zoo-gateway" }, + { name: "Backup Zoo", apiProvider: "zoo-gateway" }, + ]), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + apiProvider: "zoo-gateway", + zooSessionToken: "token-active", + zooGatewayModelId: "anthropic/claude-sonnet-4", + }) + .mockResolvedValueOnce({ + apiProvider: "zoo-gateway", + zooSessionToken: "token-backup", + }), + saveConfig: mockSaveConfig, + }, + writable: true, + configurable: true, + }) + Object.defineProperty(mockClineProvider, "upsertProviderProfile", { + value: mockUpsertProviderProfile, + writable: true, + configurable: true, + }) await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) expect(disconnectZooCode).toHaveBeenCalled() - expect(upsertProviderProfile).toHaveBeenCalledWith( + expect(mockUpsertProviderProfile).toHaveBeenCalledWith( "Zoo Gateway", expect.not.objectContaining({ zooSessionToken: expect.anything() }), true, ) - expect(saveConfig).toHaveBeenCalledWith( + expect(mockSaveConfig).toHaveBeenCalledWith( "Backup Zoo", expect.not.objectContaining({ zooSessionToken: expect.anything() }), ) @@ -1616,26 +1718,38 @@ describe("zooCodeSignOut", () => { }) it("still clears the in-memory handler when the active profile token is already empty on disk", async () => { - const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) + const mockUpsertProviderProfile = vi.fn().mockResolvedValue(undefined) - ;(mockClineProvider as any).contextProxy = { - ...mockClineProvider.contextProxy, - getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), - getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), - } - ;(mockClineProvider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), - getProfile: vi.fn().mockResolvedValue({ - apiProvider: "zoo-gateway", - zooGatewayModelId: "anthropic/claude-sonnet-4", - }), - saveConfig: vi.fn(), - } - ;(mockClineProvider as any).upsertProviderProfile = upsertProviderProfile + Object.defineProperty(mockClineProvider, "contextProxy", { + value: { + ...mockClineProvider.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), + }, + writable: true, + configurable: true, + }) + Object.defineProperty(mockClineProvider, "providerSettingsManager", { + value: { + listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + getProfile: vi.fn().mockResolvedValue({ + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + }), + saveConfig: vi.fn(), + }, + writable: true, + configurable: true, + }) + Object.defineProperty(mockClineProvider, "upsertProviderProfile", { + value: mockUpsertProviderProfile, + writable: true, + configurable: true, + }) await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) - expect(upsertProviderProfile).toHaveBeenCalledWith( + expect(mockUpsertProviderProfile).toHaveBeenCalledWith( "Zoo Gateway", expect.not.objectContaining({ zooSessionToken: expect.anything() }), true, @@ -1670,8 +1784,16 @@ describe("webviewMessageHandler - kimiCodeSignIn", () => { })) const mockOpenExternal = vi.fn().mockResolvedValue(true) - ;(vscode as any).env = { openExternal: mockOpenExternal } - ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + Object.defineProperty(vscode, "env", { + value: { openExternal: mockOpenExternal }, + writable: true, + configurable: true, + }) + Object.defineProperty(vscode, "Uri", { + value: { parse: vi.fn((url: string) => url) }, + writable: true, + configurable: true, + }) await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) @@ -1701,8 +1823,16 @@ describe("webviewMessageHandler - kimiCodeSignIn", () => { })) const mockOpenExternal = vi.fn().mockResolvedValue(true) - ;(vscode as any).env = { openExternal: mockOpenExternal } - ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + Object.defineProperty(vscode, "env", { + value: { openExternal: mockOpenExternal }, + writable: true, + configurable: true, + }) + Object.defineProperty(vscode, "Uri", { + value: { parse: vi.fn((url: string) => url) }, + writable: true, + configurable: true, + }) await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) await new Promise((resolve) => setTimeout(resolve, 10)) @@ -1726,8 +1856,16 @@ describe("webviewMessageHandler - kimiCodeSignIn", () => { })) const mockOpenExternal = vi.fn().mockResolvedValue(true) - ;(vscode as any).env = { openExternal: mockOpenExternal } - ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + Object.defineProperty(vscode, "env", { + value: { openExternal: mockOpenExternal }, + writable: true, + configurable: true, + }) + Object.defineProperty(vscode, "Uri", { + value: { parse: vi.fn((url: string) => url) }, + writable: true, + configurable: true, + }) await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) await new Promise((resolve) => setTimeout(resolve, 10)) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a28ce12d0..e998388bc9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -16,6 +16,7 @@ import { type Command as SlashCommand, type WebviewMessage, type EditQueuedMessagePayload, + type UpdateTodoListPayload, TelemetryEventName, RooCodeSettings, ExperimentId, @@ -217,7 +218,10 @@ export const webviewMessageHandler = async ( * this function prefers non-summary messages to ensure user operations * target the intended message rather than the summary. */ - const findMessageIndices = (messageTs: number, currentCline: any) => { + const findMessageIndices = ( + messageTs: number, + currentCline: { clineMessages: ClineMessage[]; apiConversationHistory: ApiMessage[] }, + ) => { // Find the exact message by timestamp, not the first one after a cutoff const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts === messageTs) @@ -237,7 +241,7 @@ export const webviewMessageHandler = async ( * Fallback: find first API history index at or after a timestamp. * Used when the exact user message isn't present in apiConversationHistory (e.g., after condense). */ - const findFirstApiIndexAtOrAfter = (ts: number, currentCline: any) => { + const findFirstApiIndexAtOrAfter = (ts: number, currentCline: { apiConversationHistory: ApiMessage[] }) => { if (typeof ts !== "number") return -1 return currentCline.apiConversationHistory.findIndex( (msg: ApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts, @@ -327,7 +331,7 @@ export const webviewMessageHandler = async ( } else { // For non-checkpoint deletes, preserve checkpoint associations for remaining messages // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() + const preservedCheckpoints = new Map>() for (let i = 0; i < messageIndex; i++) { const msg = currentCline.clineMessages[i] if (msg?.checkpoint && msg.ts) { @@ -494,7 +498,7 @@ export const webviewMessageHandler = async ( } // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() + const preservedCheckpoints = new Map>() for (let i = 0; i < deleteFromMessageIndex; i++) { const msg = currentCline.clineMessages[i] if (msg?.checkpoint && msg.ts) { @@ -558,7 +562,9 @@ export const webviewMessageHandler = async ( } switch (message.type) { - case "webviewDidLaunch": + case "webviewDidLaunch": { + await provider.setViewStateId(message.viewStateId) + // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) @@ -607,7 +613,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))) { @@ -642,6 +649,7 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + } case "newTask": // Initializing new instance of Cline will make sure that any // agentically running promises in old instance don't affect our new @@ -1220,18 +1228,24 @@ export const webviewMessageHandler = async ( }) if (!providerFilter || providerFilter === "kimi-code") { - const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") - const kimiCodeAuthMethod = - message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" - const kimiCodeApiKey = - kimiCodeAuthMethod === "api-key" - ? (message?.values?.kimiCodeApiKey ?? apiConfiguration.kimiCodeApiKey) - : await kimiCodeOAuthManager.getAccessToken() - if (kimiCodeApiKey) { - candidates.push({ - key: "kimi-code", - options: { provider: "kimi-code", apiKey: kimiCodeApiKey }, - }) + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + const kimiCodeAuthMethod = + message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" + const kimiCodeApiKey = + kimiCodeAuthMethod === "api-key" + ? (message?.values?.kimiCodeApiKey ?? apiConfiguration.kimiCodeApiKey) + : await kimiCodeOAuthManager.getAccessToken() + if (kimiCodeApiKey) { + candidates.push({ + key: "kimi-code", + options: { provider: "kimi-code", apiKey: kimiCodeApiKey }, + }) + } + } catch (error) { + provider.log( + `[requestRouterModels] kimi-code credential lookup failed: ${error instanceof Error ? error.message : String(error)}`, + ) } } @@ -2085,7 +2099,7 @@ export const webviewMessageHandler = async ( break } case "updateTodoList": { - const payload = message.payload as { todos?: any[] } + const payload = message.payload as UpdateTodoListPayload const todos = payload?.todos if (Array.isArray(todos)) { await setPendingTodoList(todos) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 53d5ba4441..cf865cd4b6 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1049,11 +1049,6 @@ "count": 2 } }, - "core/webview/ClineProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 34 @@ -1144,21 +1139,11 @@ "count": 3 } }, - "core/webview/__tests__/webviewMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, "core/webview/messageEnhancer.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 } }, - "core/webview/webviewMessageHandler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "extension.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index a672abbdc1..61c2f6a0d8 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -511,7 +511,10 @@ export const ExtensionStateContextProvider: React.FC<{ }, [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/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..70cc10c0e6 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,89 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +const createMockStorage = (initialState: Record = {}) => { + const state = { ...initialState } + return { + getItem: vi.fn((key: string) => state[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + state[key] = value + }), + removeItem: vi.fn((key: string) => { + delete state[key] + }), + clear: vi.fn(() => { + for (const key of Object.keys(state)) { + delete state[key] + } + }), + } as unknown as Storage +} + +describe("VSCodeAPIWrapper", () => { + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }) + }) + + it("reuses the persisted webview viewStateId when browser storage is available", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "persisted-view" }) }), + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("persisted-view") + }) + + it("creates and persists a new viewStateId when storage has been cleared", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("falls back to in-memory state when browser storage access is restricted", () => { + const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const storage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + } as unknown as Storage + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(randomUUID).toHaveBeenCalledTimes(1) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.setItem).toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..fe7940d142 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -11,8 +11,9 @@ import { WebviewMessage } from "@roo/WebviewMessage" * dev server by using native web browser features that mock the functionality * enabled by acquireVsCodeApi. */ -class VSCodeAPIWrapper { +export class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private fallbackState: unknown | undefined constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -22,6 +23,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,10 +75,18 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { - const state = localStorage.getItem("vscodeState") - return state ? JSON.parse(state) : undefined } + + try { + if (typeof localStorage?.getItem === "function") { + const state = localStorage.getItem("vscodeState") + return state ? JSON.parse(state) : this.fallbackState + } + } catch { + return this.fallbackState + } + + return this.fallbackState } /** @@ -69,10 +103,20 @@ class VSCodeAPIWrapper { public setState(newState: T): T { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) - return newState } + + this.fallbackState = newState + + try { + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + } + + return newState } }