mirror of
https://github.com/cline/cline.git
synced 2026-08-28 19:48:08 +08:00
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:
committed by
GitHub
parent
508a5322af
commit
a5ac26f279
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user