diff --git a/.changeset/clear-task-settings-overlay.md b/.changeset/clear-task-settings-overlay.md new file mode 100644 index 0000000000..c4128d7026 --- /dev/null +++ b/.changeset/clear-task-settings-overlay.md @@ -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 diff --git a/apps/vscode/src/sdk/SdkController.ts b/apps/vscode/src/sdk/SdkController.ts index 52f334cb1d..f5f91fb2f5 100644 --- a/apps/vscode/src/sdk/SdkController.ts +++ b/apps/vscode/src/sdk/SdkController.ts @@ -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, diff --git a/apps/vscode/src/sdk/auto-approve-overlay-regression.test.ts b/apps/vscode/src/sdk/auto-approve-overlay-regression.test.ts new file mode 100644 index 0000000000..1bf3ef522c --- /dev/null +++ b/apps/vscode/src/sdk/auto-approve-overlay-regression.test.ts @@ -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) + }) +}) diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts index 51b7de4606..83804d3e85 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts @@ -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 = {}) { 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 @@ -484,6 +519,7 @@ function makeCoordinator(input: Partial = {}) { resetMessageTranslator: ReturnType setTurnPhase: ReturnType postStateToWebview: ReturnType + clearTaskSettings: ReturnType } return { diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.ts index 5ede3864a0..447bb708f7 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.ts @@ -17,6 +17,17 @@ export interface SdkTaskControlCoordinatorOptions { onAskResponse: (text?: string, images?: string[], files?: string[]) => Promise resetMessageTranslator: () => void postStateToWebview: () => Promise + /** + * 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 /** * 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