fix(vscode): clear task-scoped settings overlay when task view is cleared or switched (#13310)

* fix(vscode): clear task-scoped settings overlay when task view is cleared or switched

Toggling an auto-approve setting while a task is open writes
autoApprovalSettings into the StateManager's task-settings overlay
(updateAutoApprovalSettings -> setTaskSettings). The SDK controller never
cleared that overlay on clearTask/showTaskWithId (the legacy controller
did), so after New Task the stale overlay kept shadowing global settings
in getGlobalSettingsKey(): toggle RPCs were accepted into global state,
but every posted state still carried the overlay's old version, which the
webview rejects as not newer - the auto-approve checkboxes froze forever.

Restore legacy parity in SdkTaskControlCoordinator: drop the overlay
(persisting pending writes first) in clearTask() and before installing a
different task's proxy in showTaskWithId().

Fixes #13260

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* changeset

* test(vscode): add end-to-end regression test for auto-approve freeze after New Task

Wires the real StateManager, the real updateAutoApprovalSettings handler,
and the real SdkTaskControlCoordinator.clearTask() together with the
webview's version gate modeled on ExtensionStateContext, pinning the
end-to-end invariant behind #13260: checkbox toggles must keep reaching
the webview after a mid-task toggle followed by New Task. Verified the
test fails when the clearTaskSettings() call is removed from clearTask().

* fix implicit any in regression test

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Mikołaj Kondratek
2026-08-19 01:54:59 +02:00
committed by GitHub
parent 508a5322af
commit a5ac26f279
5 changed files with 229 additions and 0 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
+1
View File
@@ -600,6 +600,7 @@ export class Controller {
},
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
postStateToWebview: () => this.postStateToWebview(),
clearTaskSettings: () => this.stateManager.clearTaskSettings(),
})
this.taskStart = new SdkTaskStartCoordinator({
stateManager: this.stateManager,
@@ -0,0 +1,170 @@
// Regression test for #13260: auto-approve checkboxes froze after "New Task".
//
// Toggling an auto-approve setting while a task view is open writes
// autoApprovalSettings into the StateManager's task-settings overlay
// (updateAutoApprovalSettings -> setTaskSettings), and the overlay shadows
// global settings in getGlobalSettingsKey(). If clearTask() leaves the overlay
// behind, every later toggle RPC is accepted into global state but every
// posted state still carries the overlay's old version — which the webview
// rejects as not newer, so the checkboxes never move again.
//
// Unlike the SdkTaskControlCoordinator unit tests (which assert clearTask
// calls clearTaskSettings), this test wires the REAL StateManager, the REAL
// updateAutoApprovalSettings handler, and the REAL coordinator clearTask()
// together, with the webview's version gate modeled on
// ExtensionStateContext.tsx, so the end-to-end invariant is what's pinned:
// after New Task, checkbox clicks must reach the webview.
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
import type { Controller } from "@/core/controller"
import { updateAutoApprovalSettings } from "@/core/controller/state/updateAutoApprovalSettings"
import { StateManager } from "@/core/storage/StateManager"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
import { AutoApprovalSettingsRequest } from "@/shared/proto/cline/state"
import { createStorageContext } from "@/shared/storage/storage-context"
import { SdkTaskControlCoordinator, type SdkTaskControlCoordinatorOptions } from "./sdk-task-control-coordinator"
import type { TaskProxy } from "./task-proxy"
vi.mock("@/services/logging/distinctId", () => ({
initializeDistinctId: vi.fn(async () => undefined),
}))
describe("auto-approve settings after New Task (#13260)", () => {
let clineDir: string
let stateManager: StateManager
let task: TaskProxy | undefined
// What the webview last received via subscribeToState. It only accepts
// autoApprovalSettings whose version is strictly greater than what it holds
// (ExtensionStateContext.tsx).
let webviewSettings: typeof DEFAULT_AUTO_APPROVAL_SETTINGS
// Mirrors getStateToPostToWebview: autoApprovalSettings resolves through
// getGlobalSettingsKey, where the task-settings overlay shadows global state.
const resolveSettings = () => stateManager.getGlobalSettingsKey("autoApprovalSettings")
const postStateToWebview = async () => {
const incoming = resolveSettings()
if ((incoming.version ?? 1) > (webviewSettings.version ?? 1)) {
webviewSettings = incoming
}
}
const makeTaskProxy = (taskId: string): TaskProxy =>
({ taskId, messageStateHandler: { clear: () => {} } }) as unknown as TaskProxy
const makeCoordinator = () =>
new SdkTaskControlCoordinator({
sessions: { endActiveSession: async () => {} },
interactions: { clearPending: () => {} },
messages: { cancelPendingSave: () => {} },
taskHistory: {},
getTask: () => task,
setTask: (next: TaskProxy | undefined) => {
task = next
},
onAskResponse: async () => {},
resetMessageTranslator: () => {},
postStateToWebview,
clearTaskSettings: () => stateManager.clearTaskSettings(),
setTurnPhase: () => {},
} as unknown as SdkTaskControlCoordinatorOptions)
const makeController = () =>
({
task,
getStateToPostToWebview: async () => ({ autoApprovalSettings: resolveSettings() }),
postStateToWebview,
stateManager,
}) as unknown as Controller
// What the webview's useAutoApproveActions.updateAction sends on a checkbox click.
const clickCheckbox = async (action: string, value: boolean) => {
await updateAutoApprovalSettings(
makeController(),
AutoApprovalSettingsRequest.create({
version: (webviewSettings.version ?? 1) + 1,
actions: { ...webviewSettings.actions, [action]: value },
}),
)
}
beforeAll(async () => {
clineDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-13260-regression-"))
await StateManager.initialize(createStorageContext({ clineDir, workspacePath: clineDir }))
stateManager = StateManager.get()
})
beforeEach(async () => {
await stateManager.clearTaskSettings()
stateManager.setGlobalState("autoApprovalSettings", { ...DEFAULT_AUTO_APPROVAL_SETTINGS })
task = undefined
webviewSettings = resolveSettings()
})
afterAll(async () => {
await StateManager.get().flushPendingState()
await StateManager.get().reInitialize()
await fs.rm(clineDir, { recursive: true, force: true })
})
it("keeps checkboxes working after a mid-task toggle followed by New Task", async () => {
// A task view is open (running or completed — the proxy stays installed
// either way) and the user unchecks "Edit files". The handler writes the
// setting to BOTH global state and the task overlay.
task = makeTaskProxy("task-1")
await clickCheckbox("editFiles", false)
expect(webviewSettings.actions.editFiles).toBe(false)
// User clicks "New Task" — the real coordinator path, which must drop the
// overlay along with the task proxy.
await makeCoordinator().clearTask()
await postStateToWebview()
expect(task).toBeUndefined()
// The next checkbox click must reach the webview: accepted into global
// state AND visible in the next posted state.
const versionBefore = webviewSettings.version
await clickCheckbox("readFiles", false)
expect(webviewSettings.actions.readFiles).toBe(false)
expect(webviewSettings.version).toBe(versionBefore + 1)
// The mid-task toggle survives New Task.
expect(webviewSettings.actions.editFiles).toBe(false)
})
it("freezes forever if the overlay outlives the task view (the failure mode the fix prevents)", async () => {
// Same start: mid-task toggle populates the overlay.
task = makeTaskProxy("task-1")
await clickCheckbox("editFiles", false)
const versionAfterToggle = webviewSettings.version
// Buggy New Task: task proxy dropped, overlay left behind.
task = undefined
await postStateToWebview()
// Every subsequent click is accepted into global state but never surfaces:
// the stale overlay version shadows global in every posted state, and the
// webview rejects non-newer versions. No amount of state posts recovers.
await clickCheckbox("readFiles", false)
await postStateToWebview()
await clickCheckbox("useBrowser", false)
await postStateToWebview()
expect(webviewSettings.actions.readFiles).toBe(true)
expect(webviewSettings.actions.useBrowser).toBe(true)
expect(webviewSettings.version).toBe(versionAfterToggle)
})
it("never freezes when toggles happen with no task view open", async () => {
// Control: with no task, only global state is written — no overlay exists,
// so New Task has nothing to leak.
await clickCheckbox("editFiles", false)
await makeCoordinator().clearTask()
await postStateToWebview()
const versionBefore = webviewSettings.version
await clickCheckbox("readFiles", false)
expect(webviewSettings.actions.readFiles).toBe(false)
expect(webviewSettings.version).toBe(versionBefore + 1)
})
})
@@ -87,6 +87,40 @@ describe("SdkTaskControlCoordinator", () => {
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
})
it("drops the task-scoped settings overlay when the task is cleared (#13260)", async () => {
// autoApprovalSettings written via setTaskSettings while a task is open
// shadow global settings in getGlobalSettingsKey(). If the overlay
// survives "New Task", later global updates are accepted but never
// reach the webview (the stale overlay version wins), freezing the
// auto-approve checkboxes.
const { coordinator, options } = makeCoordinator({
activeSession: makeActiveSession(),
task: makeTask("task-1"),
})
await coordinator.clearTask()
expect(options.clearTaskSettings).toHaveBeenCalledOnce()
})
it("drops the outgoing task's settings overlay when switching to another task", async () => {
const { coordinator, options } = makeCoordinator({
activeSession: makeActiveSession(),
task: makeTask("old-task"),
hasHistoryItem: true,
clineMessages: [{ ts: 1, type: "say", say: "task", text: "hello" }],
sessionStatus: "completed",
})
await coordinator.showTaskWithId("task-1")
expect(options.clearTaskSettings).toHaveBeenCalledOnce()
// The overlay must be gone before the new proxy is installed.
expect(options.clearTaskSettings.mock.invocationCallOrder[0]).toBeLessThan(
options.setTask.mock.invocationCallOrder[0],
)
})
it("shows a task by creating a proxy, loading messages, and appending a fresh resume ask", async () => {
const existingTask = makeTask("old-task")
const activeSession = makeActiveSession()
@@ -461,6 +495,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
raiseCancelFence: vi.fn(),
setTurnPhase: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
clearTaskSettings: vi.fn().mockResolvedValue(undefined),
} as unknown as SdkTaskControlCoordinatorOptions & {
sessions: SdkTaskControlCoordinatorOptions["sessions"] & {
getActiveSession: ReturnType<typeof vi.fn>
@@ -484,6 +519,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
resetMessageTranslator: ReturnType<typeof vi.fn>
setTurnPhase: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
clearTaskSettings: ReturnType<typeof vi.fn>
}
return {
@@ -17,6 +17,17 @@ export interface SdkTaskControlCoordinatorOptions {
onAskResponse: (text?: string, images?: string[], files?: string[]) => Promise<void>
resetMessageTranslator: () => void
postStateToWebview: () => Promise<void>
/**
* Drops the StateManager's task-scoped settings overlay (persisting pending
* writes first). Task settings — e.g. autoApprovalSettings written by
* toggling auto-approve while a task is open — shadow global settings in
* getGlobalSettingsKey(). If the overlay outlives the task view, later
* global updates are accepted but never surface in posted state (the stale
* overlay version wins), which froze the auto-approve checkboxes after
* "New Task" (#13260). Must run whenever the task view is cleared or
* switched to another task.
*/
clearTaskSettings: () => Promise<void>
/**
* Sets the authoritative turn phase. showTaskWithId must derive the phase
* from the reopened conversation (resumable/completed) — leaving the
@@ -113,6 +124,8 @@ export class SdkTaskControlCoordinator {
this.options.setTask(undefined)
}
await this.options.clearTaskSettings()
this.options.resetMessageTranslator()
}
@@ -186,6 +199,10 @@ export class SdkTaskControlCoordinator {
currentTask.messageStateHandler.clear()
}
// The outgoing task's settings overlay must not apply to the newly
// opened task (see clearTaskSettings option doc).
await this.options.clearTaskSettings()
this.options.resetMessageTranslator()
// Load messages before installing the new task proxy so any concurrent