Skip to content

Commit 71ba421

Browse files
committed
fix(provider): preserve queued profile mutation ordering
1 parent 6a03068 commit 71ba421

3 files changed

Lines changed: 71 additions & 17 deletions

File tree

src/core/webview/ClineProvider.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export class ClineProvider
195195
private taskHistoryStoreInitialized = false
196196
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
197197
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
198-
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
198+
public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
199199
private providerProfileMutationQueue = Promise.resolve()
200200

201201
private runDelegationTransition<T>(parentTaskId: string, fn: () => Promise<T>): Promise<T> {
@@ -204,11 +204,12 @@ export class ClineProvider
204204
}
205205

206206
private enqueueProviderProfileMutation<T>(fn: () => Promise<T>): Promise<T> {
207+
// Run after either outcome so a rejected mutation never poisons the queue.
207208
const run = this.providerProfileMutationQueue.then(fn, fn)
208209
let timedOut = false
209210
const callerResult = this.withProviderProfileMutationTimeout(run, () => {
210211
timedOut = true
211-
this.log("Provider profile mutation timed out; releasing the mutation queue")
212+
this.log("Provider profile mutation timed out; waiting for the in-flight mutation to settle")
212213
})
213214

214215
void run.then(
@@ -228,9 +229,9 @@ export class ClineProvider
228229
},
229230
)
230231

231-
// Advance from the timeout-bounded caller result, rather than the raw operation. A
232-
// provider call that never settles must not block all subsequent profile changes.
233-
this.providerProfileMutationQueue = callerResult.then(
232+
// Keep the raw operation as the queue boundary. Releasing the queue on timeout
233+
// would allow its later state writes to overwrite a subsequent mutation.
234+
this.providerProfileMutationQueue = run.then(
234235
() => undefined,
235236
() => undefined,
236237
)
@@ -252,6 +253,7 @@ export class ClineProvider
252253
}
253254
})
254255
}
256+
255257
private readonly pendingEditOperations: PendingEditOperationStore
256258

257259
private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
@@ -1560,10 +1562,8 @@ export class ClineProvider
15601562
* @param targetTask The task whose in-memory mode should be updated. Defaults to the
15611563
* current task. Pass null to apply only global mode/profile effects for a pending child.
15621564
*/
1563-
public async handleModeSwitch(newMode: Mode, targetTask?: Task | null) {
1564-
return this.enqueueProviderProfileMutation(() =>
1565-
this.handleModeSwitchUnlocked(newMode, targetTask === undefined ? this.getCurrentTask() : targetTask),
1566-
)
1565+
public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) {
1566+
return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask))
15671567
}
15681568

15691569
private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise<void> {

src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
457457

458458
test("provider profile mutation rejection does not poison later queued mutations", async () => {
459459
const firstError = new Error("first profile failed")
460+
const setValueSpy = vi.spyOn(provider.contextProxy, "setValue")
460461

461462
provider["providerSettingsManager"].activateProfile = vi
462463
.fn()
@@ -470,14 +471,27 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
470471

471472
await expect(provider.activateProviderProfile({ name: "first-profile" })).rejects.toThrow(firstError)
472473
await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined()
474+
expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile")
473475
})
474476

475-
test("provider profile mutation timeout releases later queued mutations", async () => {
477+
test("timed-out provider profile mutations retain the queue boundary until they settle", async () => {
476478
vi.useFakeTimers()
477479
const logSpy = vi.spyOn(provider, "log")
480+
let resolveFirst!: () => void
481+
const firstActivation = new Promise<void>((resolve) => {
482+
resolveFirst = resolve
483+
})
478484
provider["providerSettingsManager"].activateProfile = vi
479485
.fn()
480-
.mockImplementationOnce(() => new Promise<never>(() => {}))
486+
.mockImplementationOnce(async () => {
487+
await firstActivation
488+
return {
489+
name: "first-profile",
490+
id: "first-id",
491+
apiProvider: "openrouter",
492+
openRouterModelId: "openai/gpt-4",
493+
}
494+
})
481495
.mockResolvedValueOnce({
482496
name: "second-profile",
483497
id: "second-id",
@@ -488,17 +502,24 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
488502
try {
489503
const first = provider.activateProviderProfile({ name: "first-profile" })
490504
const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out")
491-
await vi.advanceTimersByTimeAsync(30_000)
505+
await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS)
492506
await firstResult
493507

494-
await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined()
495-
expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; releasing the mutation queue")
508+
const second = provider.activateProviderProfile({ name: "second-profile" })
509+
expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1)
510+
511+
resolveFirst()
512+
await expect(second).resolves.toBeUndefined()
513+
expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(2)
514+
expect(logSpy).toHaveBeenCalledWith(
515+
"Provider profile mutation timed out; waiting for the in-flight mutation to settle",
516+
)
496517
} finally {
497518
vi.useRealTimers()
498519
}
499520
})
500521

501-
test("mode switch resolves its default task when its queued mutation starts", async () => {
522+
test("mode switch preserves its default task when queued behind a profile mutation", async () => {
502523
let releaseProfileActivation!: () => void
503524
const profileActivation = new Promise<void>((resolve) => {
504525
releaseProfileActivation = resolve
@@ -515,6 +536,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
515536

516537
const firstTask = new Task(defaultTaskOptions)
517538
const secondTask = new Task(defaultTaskOptions)
539+
Object.defineProperty(firstTask, "taskId", { value: "first-task-id" })
540+
Object.defineProperty(secondTask, "taskId", { value: "second-task-id" })
541+
firstTask["_taskMode"] = "code" as Mode
542+
secondTask["_taskMode"] = "code" as Mode
518543
await provider.addClineToStack(firstTask)
519544

520545
const profileSwitch = provider.activateProviderProfile({ name: "first-profile" })
@@ -525,8 +550,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
525550
await profileSwitch
526551
await modeSwitch
527552

528-
expect(firstTask["_taskMode"]).not.toBe("ask")
529-
expect(secondTask["_taskMode"]).toBe("ask")
553+
expect(firstTask["_taskMode"]).toBe("ask")
554+
expect(secondTask["_taskMode"]).toBe("code")
530555
})
531556

532557
test("fan-out preparation leaves the focused task untouched", async () => {
@@ -570,6 +595,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
570595
expect(postStateSpy).not.toHaveBeenCalled()
571596
expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "ask-profile")
572597
expect(setProviderSettingsSpy).not.toHaveBeenCalled()
598+
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "ask")
599+
expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" })
573600
})
574601

575602
test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => {

src/extension/__tests__/api-configuration.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,36 @@ describe("API - configuration", () => {
3030
})
3131

3232
expect(saveConfig).toHaveBeenCalledWith("default", expect.objectContaining({ currentApiConfigName: "default" }))
33+
expect(setValues).toHaveBeenCalledWith(
34+
expect.objectContaining({
35+
currentApiConfigName: "default",
36+
modeApiConfigs: expect.anything(),
37+
}),
38+
)
3339
expect(setModeConfig).toHaveBeenCalledTimes(2)
3440
expect(setModeConfig).toHaveBeenCalledWith("code", "code-config")
3541
expect(setModeConfig).toHaveBeenCalledWith("architect", "architect-config")
3642
expect(postStateToWebview).toHaveBeenCalledOnce()
3743
})
44+
45+
it("does not persist mode mappings when none are supplied", async () => {
46+
const setValues = vi.fn().mockResolvedValue(undefined)
47+
const saveConfig = vi.fn().mockResolvedValue("default-id")
48+
const setModeConfig = vi.fn().mockResolvedValue(undefined)
49+
const postStateToWebview = vi.fn().mockResolvedValue(undefined)
50+
const provider = {
51+
context: {},
52+
on: vi.fn(),
53+
contextProxy: { setValues },
54+
providerSettingsManager: { saveConfig, setModeConfig },
55+
postStateToWebview,
56+
} as unknown as ClineProvider
57+
const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel
58+
const api = new API(outputChannel, provider)
59+
60+
await api.setConfiguration({ currentApiConfigName: "default" })
61+
62+
expect(setModeConfig).not.toHaveBeenCalled()
63+
expect(postStateToWebview).toHaveBeenCalledOnce()
64+
})
3865
})

0 commit comments

Comments
 (0)