Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,8 @@ suite("Roo Code Subtasks", function () {
...parentProfile,
openRouterModelId: "openai/gpt-4.1-mini",
}
const priorModeApiConfigs = api.getConfiguration().modeApiConfigs ?? {}
const priorConfiguration = api.getConfiguration()
const priorActiveProfile = api.getActiveProfile()
const parentProfileId = await api.upsertProfile("subtask-parent-profile", parentProfile, true)
const childProfileId = await api.upsertProfile("subtask-child-profile", childProfile, false)
await api.setConfiguration({
Expand Down Expand Up @@ -735,7 +736,10 @@ suite("Roo Code Subtasks", function () {
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
await api.setConfiguration({ modeApiConfigs: priorModeApiConfigs })
await api.setConfiguration(priorConfiguration)
if (priorActiveProfile) {
await api.setActiveProfile(priorActiveProfile)
}
await api.deleteProfile("subtask-child-profile").catch(() => {})
await api.deleteProfile("subtask-parent-profile").catch(() => {})
while (api.getCurrentTaskStack().length > 0) {
Expand Down
86 changes: 74 additions & 12 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,37 @@ export class ClineProvider
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
private providerProfileMutationQueue = Promise.resolve()

private runDelegationTransition<T>(parentTaskId: string, fn: () => Promise<T>): Promise<T> {
this.delegationTransitionLocks ??= new Map()
return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, fn)
}

private enqueueProviderProfileMutation<T>(fn: () => Promise<T>): Promise<T> {
const run = this.providerProfileMutationQueue.then(fn, fn)
const callerResult = this.withProviderProfileMutationTimeout(run)
this.providerProfileMutationQueue = run.then(
() => undefined,
() => undefined,
)
return callerResult
}

private withProviderProfileMutationTimeout<T>(operation: Promise<T>): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error("Provider profile mutation timed out"))
}, ClineProvider.PENDING_OPERATION_TIMEOUT_MS)
})

return Promise.race([operation, timeout]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId)
}
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
private readonly pendingEditOperations: PendingEditOperationStore

private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
Expand Down Expand Up @@ -1506,9 +1532,15 @@ export class ClineProvider
/**
* Handle switching to a new mode, including updating the associated API configuration
* @param newMode The mode to switch to
* @param targetTask The task whose in-memory mode should be updated. Defaults to the
* current task. Pass null to apply only global mode/profile effects for a pending child.
*/
public async handleModeSwitch(newMode: Mode) {
const task = this.getCurrentTask()
public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) {
return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask))
}

private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise<void> {
const task = targetTask
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (task) {
TelemetryService.instance.captureModeSwitch(task.taskId, newMode)
Expand Down Expand Up @@ -1545,7 +1577,9 @@ export class ClineProvider
// If workspace lock is on, keep the current API config — don't load mode-specific config
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
if (lockApiConfigAcrossModes) {
await this.postStateToWebview()
if (targetTask !== null) {
await this.postStateToWebview()
}
return
}

Expand All @@ -1571,7 +1605,10 @@ export class ClineProvider
const hasActualSettings = !!fullProfile.apiProvider

if (hasActualSettings) {
await this.activateProviderProfile({ name: profile.name })
await this.activateProviderProfileUnlocked(
{ name: profile.name },
targetTask === null ? { skipCurrentTaskRebuild: true } : undefined,
)
} else {
// The task will continue with the current/default configuration.
}
Expand All @@ -1591,7 +1628,9 @@ export class ClineProvider
}
}

await this.postStateToWebview()
if (targetTask !== null) {
await this.postStateToWebview()
}
}

// Provider Profile Management
Expand All @@ -1607,8 +1646,9 @@ export class ClineProvider
*/
private updateTaskApiHandlerIfNeeded(
providerSettings: ProviderSettings,
options: { forceRebuild?: boolean } = {},
options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean } = {},
): void {
if (options.skipCurrentTaskRebuild) return
const task = this.getCurrentTask()
if (!task) return

Expand Down Expand Up @@ -1724,7 +1764,11 @@ export class ClineProvider
await this.postStateToWebview()
}

private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise<void> {
private async persistStickyProviderProfileToCurrentTask(
apiConfigName: string,
options: { skipCurrentTaskRebuild?: boolean } = {},
): Promise<void> {
if (options.skipCurrentTaskRebuild) return
const task = this.getCurrentTask()
if (!task) {
return
Expand Down Expand Up @@ -1754,12 +1798,28 @@ export class ClineProvider

async activateProviderProfile(
args: { name: string } | { id: string },
options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean },
options?: {
persistModeConfig?: boolean
persistTaskHistory?: boolean
skipCurrentTaskRebuild?: boolean
},
) {
return this.enqueueProviderProfileMutation(() => this.activateProviderProfileUnlocked(args, options))
}

private async activateProviderProfileUnlocked(
args: { name: string } | { id: string },
options?: {
persistModeConfig?: boolean
persistTaskHistory?: boolean
skipCurrentTaskRebuild?: boolean
},
): Promise<void> {
const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)

const persistModeConfig = options?.persistModeConfig ?? true
const persistTaskHistory = options?.persistTaskHistory ?? true
const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false

// See `upsertProviderProfile` for a description of what this is doing.
await Promise.all([
Expand All @@ -1775,17 +1835,19 @@ export class ClineProvider
}

// Change the provider for the current task.
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild })

// Update the current task's sticky provider profile, unless this activation is
// being used purely as a non-persisting restoration (e.g., reopening a task from history).
if (persistTaskHistory) {
await this.persistStickyProviderProfileToCurrentTask(name)
await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild })
}

await this.postStateToWebview()
if (!skipCurrentTaskRebuild) {
await this.postStateToWebview()
}

if (providerSettings.apiProvider) {
if (providerSettings.apiProvider && !skipCurrentTaskRebuild) {
this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider })
}
}
Expand Down
103 changes: 102 additions & 1 deletion src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import * as vscode from "vscode"

import { TelemetryService } from "@roo-code/telemetry"
import { getModelId } from "@roo-code/types"
import { getModelId, RooCodeEventName } from "@roo-code/types"

import { ContextProxy } from "../../config/ContextProxy"
import type { Mode } from "../../../shared/modes"
import { Task, TaskOptions } from "../../task/Task"
import { ClineProvider } from "../ClineProvider"

Expand Down Expand Up @@ -110,6 +111,7 @@ vi.mock("../../task/Task", () => ({
overwriteApiConversationHistory: vi.fn(),
taskId: options?.historyItem?.id || "test-task-id",
emit: vi.fn(),
setTaskApiConfigName: vi.fn(),
updateApiConfiguration: vi.fn().mockImplementation(function (this: any, newConfig: any) {
this.apiConfiguration = newConfig
}),
Expand Down Expand Up @@ -235,6 +237,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
{ name: "test-config", id: "test-id", apiProvider: "openrouter", modelId: "openai/gpt-4" },
]),
setModeConfig: vi.fn(),
getModeConfigId: vi.fn().mockResolvedValue(undefined),
activateProfile: vi.fn().mockResolvedValue({
name: "test-config",
id: "test-id",
Expand Down Expand Up @@ -410,6 +413,104 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
})

describe("activateProviderProfile", () => {
test("serializes provider profile mutations without interleaving", async () => {
const events: string[] = []
let resolveFirst!: () => void

provider["providerSettingsManager"].activateProfile = vi
.fn()
.mockImplementationOnce(async () => {
events.push("first:start")
await new Promise<void>((resolve) => {
resolveFirst = resolve
})
events.push("first:end")
return {
name: "first-profile",
id: "first-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4",
}
})
.mockImplementationOnce(async () => {
events.push("second:start")
return {
name: "second-profile",
id: "second-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4.1-mini",
}
})

const first = provider.activateProviderProfile({ name: "first-profile" })
const second = provider.activateProviderProfile({ name: "second-profile" })

await Promise.resolve()
expect(events).toEqual(["first:start"])

resolveFirst()
await first
await second

expect(events).toEqual(["first:start", "first:end", "second:start"])
})

test("provider profile mutation rejection does not poison later queued mutations", async () => {
const firstError = new Error("first profile failed")

provider["providerSettingsManager"].activateProfile = vi
.fn()
.mockRejectedValueOnce(firstError)
.mockResolvedValueOnce({
name: "second-profile",
id: "second-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4.1-mini",
})

await expect(provider.activateProviderProfile({ name: "first-profile" })).rejects.toThrow(firstError)
await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined()
})

test("fan-out preparation leaves the focused task untouched", async () => {
const mockTask = new Task({
...defaultTaskOptions,
apiConfiguration: {
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4",
},
})
await provider.addClineToStack(mockTask)
provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id")
provider["providerSettingsManager"].listConfig = vi
.fn()
.mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: "openrouter" }])
provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({
name: "ask-profile",
id: "ask-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4.1-mini",
})
provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue({
name: "ask-profile",
id: "ask-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4.1-mini",
})
const emitSpy = vi.spyOn(provider, "emit")
const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)

await provider.handleModeSwitch("ask" as Mode, null)

expect(mockTask.updateApiConfiguration).not.toHaveBeenCalled()
expect(mockTask.setTaskApiConfigName).not.toHaveBeenCalled()
expect(emitSpy).not.toHaveBeenCalledWith(
RooCodeEventName.ProviderProfileChanged,
expect.objectContaining({ name: "ask-profile" }),
)
expect(postStateSpy).not.toHaveBeenCalled()
})

test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => {
const mockTask = new Task({
...defaultTaskOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,14 +374,15 @@ describe("ClineProvider - Lock API Config Across Modes", () => {
apiProvider: "anthropic",
})

const activateProviderProfileSpy = vi
.spyOn(provider, "activateProviderProfile")
.mockResolvedValue(undefined)
const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "architect-profile",
apiProvider: "anthropic",
})

await provider.handleModeSwitch("architect")

expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect")
expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" })
expect(activateProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" })
})
})
})
38 changes: 38 additions & 0 deletions src/extension/__tests__/api-configuration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest"
import type * as vscode from "vscode"

import { API } from "../api"
import type { ClineProvider } from "../../core/webview/ClineProvider"

vi.mock("@roo-code/ipc", () => ({
IpcServer: class {},
}))

describe("API - configuration", () => {
it("persists every supplied mode API config mapping", async () => {
const setValues = vi.fn().mockResolvedValue(undefined)
const saveConfig = vi.fn().mockResolvedValue("default-id")
const setModeConfig = vi.fn().mockResolvedValue(undefined)
const postStateToWebview = vi.fn().mockResolvedValue(undefined)
const provider = {
context: {},
on: vi.fn(),
contextProxy: { setValues },
providerSettingsManager: { saveConfig, setModeConfig },
postStateToWebview,
} as unknown as ClineProvider
const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel
const api = new API(outputChannel, provider)

await api.setConfiguration({
currentApiConfigName: "default",
modeApiConfigs: { code: "code-config", architect: "architect-config" },
})

expect(saveConfig).toHaveBeenCalledWith("default", expect.objectContaining({ currentApiConfigName: "default" }))
expect(setModeConfig).toHaveBeenCalledTimes(2)
expect(setModeConfig).toHaveBeenCalledWith("code", "code-config")
expect(setModeConfig).toHaveBeenCalledWith("architect", "architect-config")
expect(postStateToWebview).toHaveBeenCalledOnce()
})
})
Loading
Loading