Skip to content

Commit c820dd0

Browse files
committed
fix(webview): gate deleted-profile re-pin task writes on the task's own profile
Port of the sibling-unit fix: a view pinned to a deleted profile can hold a task that follows a different sticky profile: the deletion re-pin must re-point the view buffer but must not force the replacement's settings onto that conversation's handler, nor rewrite its persisted sticky profile name. Both task-side writes are now gated on a taskApiConfigName match with the deleted profile, mirroring the profile-save rebuild guards. Regression test included.
1 parent 08c6fcb commit c820dd0

3 files changed

Lines changed: 94 additions & 4 deletions

File tree

‎src/core/webview/ClineProvider.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3017,11 +3017,17 @@ export class ClineProvider
30173017
// the handler with the replacement's settings and re-point the sticky name,
30183018
// or the running conversation keeps sending the deleted profile's
30193019
// configuration even though the view's buffer was re-pointed (the posted
3020-
// state prefers the task's own apiConfiguration).
3021-
if (replacementSettings) {
3022-
instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true })
3020+
// state prefers the task's own apiConfiguration). Only re-point when the
3021+
// task actually runs under the deleted profile: a view pinned to it can
3022+
// hold a task that follows a different sticky profile, and forcing the
3023+
// replacement would silently switch that conversation and rewrite its
3024+
// persisted profile name without the user asking.
3025+
if (instance.getCurrentTask()?.taskApiConfigName === deletedProfileName) {
3026+
if (replacementSettings) {
3027+
instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true })
3028+
}
3029+
await instance.persistStickyProviderProfileToCurrentTask(replacementName)
30233030
}
3024-
await instance.persistStickyProviderProfileToCurrentTask(replacementName)
30253031
await instance.postStateToWebview()
30263032
}),
30273033
)

‎src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,7 @@ describe("ClineProvider - Parallel Mode Support", () => {
12201220
// the task's handler and re-point its sticky profile name too.
12211221
const siblingTask = {
12221222
apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "doomed-key" },
1223+
taskApiConfigName: "doomed-profile",
12231224
updateApiConfiguration: vi.fn(),
12241225
setTaskApiConfigName: vi.fn(),
12251226
}

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3276,6 +3276,89 @@ describe("ClineProvider", () => {
32763276
await provider.dispose()
32773277
})
32783278

3279+
it("does not re-point a pinned sibling's task that follows another profile when its profile is deleted", async () => {
3280+
// Defensive: the doubles in this suite can leave console writes pending
3281+
// at worker teardown.
3282+
vi.spyOn(console, "log").mockImplementation(() => {})
3283+
vi.spyOn(console, "warn").mockImplementation(() => {})
3284+
vi.spyOn(console, "error").mockImplementation(() => {})
3285+
const provider = new ClineProvider(
3286+
mockContext,
3287+
mockOutputChannel,
3288+
"sidebar",
3289+
new ContextProxy(mockContext),
3290+
new WebviewFocusTracker(),
3291+
)
3292+
const sibling = new ClineProvider(
3293+
mockContext,
3294+
mockOutputChannel,
3295+
"editor",
3296+
new ContextProxy(mockContext),
3297+
new WebviewFocusTracker(),
3298+
)
3299+
try {
3300+
const oldProfile: ProviderSettingsEntry = {
3301+
name: "old-profile",
3302+
id: "old-id",
3303+
apiProvider: providerIdentifiers.openrouter,
3304+
}
3305+
const keeperProfile: ProviderSettingsEntry = {
3306+
name: "keeper-profile",
3307+
id: "keeper-id",
3308+
apiProvider: providerIdentifiers.anthropic,
3309+
}
3310+
await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile])
3311+
await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile")
3312+
// The sibling pins the profile being deleted, but its active task
3313+
// follows a different sticky profile: the deletion must re-point the
3314+
// sibling's buffer to the surviving profile without switching that
3315+
// conversation's handler or rewriting its persisted profile name.
3316+
sibling["viewLocalState"].currentApiConfigName = "old-profile"
3317+
sibling["viewLocalState"].apiConfiguration = {
3318+
apiProvider: providerIdentifiers.openrouter,
3319+
openRouterApiKey: "deleted-profile-secret",
3320+
}
3321+
vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined)
3322+
const siblingTask = {
3323+
apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" },
3324+
taskApiConfigName: "other-profile",
3325+
taskId: "sibling-task",
3326+
setTaskApiConfigName: vi.fn(),
3327+
updateApiConfiguration: vi.fn(),
3328+
}
3329+
vi.spyOn(sibling, "getCurrentTask").mockReturnValue(
3330+
siblingTask as unknown as ReturnType<typeof sibling.getCurrentTask>,
3331+
)
3332+
vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
3333+
vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({
3334+
name: "keeper-profile",
3335+
id: "keeper-id",
3336+
apiProvider: providerIdentifiers.anthropic,
3337+
})
3338+
vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined)
3339+
3340+
await provider.deleteProviderProfile(oldProfile)
3341+
3342+
// The sibling's view buffer is still re-pointed to the surviving
3343+
// profile...
3344+
expect(sibling["viewLocalState"].currentApiConfigName).toBe("keeper-profile")
3345+
expect(sibling["viewLocalState"].apiConfiguration).toEqual({
3346+
id: "keeper-id",
3347+
apiProvider: providerIdentifiers.anthropic,
3348+
})
3349+
// ...but the task follows another profile: neither the handler rebuild
3350+
// nor the sticky-name rewrite may touch it.
3351+
expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled()
3352+
expect(siblingTask.setTaskApiConfigName).not.toHaveBeenCalled()
3353+
// Let queued view-local persistence settle while the console spies are
3354+
// still installed, so teardown has no pending writes.
3355+
await new Promise((resolve) => setTimeout(resolve, 50))
3356+
} finally {
3357+
await sibling.dispose()
3358+
await provider.dispose()
3359+
}
3360+
})
3361+
32793362
it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => {
32803363
const provider = new ClineProvider(
32813364
mockContext,

0 commit comments

Comments
 (0)