Compare commits

...
Author SHA1 Message Date
Max Paulus 🥪 abbeea0bfa fix(vscode): remove plan-to-act switch tool 2026-06-30 15:06:43 -07:00
8 changed files with 73 additions and 119 deletions
-4
View File
@@ -258,10 +258,6 @@ export class Controller {
this.sessionConfigBuilder = new SdkSessionConfigBuilder({
stateManager: this.stateManager,
emitHookMessage: (msg) => this.messages.emitHookMessage(msg),
onSwitchToActMode: () => {
this.mode.queueSwitchToActMode()
},
shouldStopAfterModeSwitch: () => this.mode.hasPendingModeChange(),
onConsecutiveMistakeLimitReached: (context) => this.interactions.handleConsecutiveMistakeLimitReached(context),
})
this.interactions = new SdkInteractionCoordinator({
+1 -1
View File
@@ -60,7 +60,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`
Once the user has reviewed your plan and wants implementation to begin, ask them to toggle to Act mode using the Plan/Act toggle. You cannot switch modes yourself. Do not implement until the user has manually switched to Act mode.`
// ---------------------------------------------------------------------------
// Types
@@ -15,60 +15,21 @@ vi.mock("./hooks-adapter", () => ({
}))
describe("SdkSessionConfigBuilder", () => {
it("adds the CLI plan-mode switch_to_act_mode tool only in plan mode", async () => {
const stateManager = {
getGlobalSettingsKey: vi.fn(() => "plan"),
}
const onSwitchToActMode = vi.fn()
const builder = new SdkSessionConfigBuilder({
stateManager: stateManager as never,
emitHookMessage: vi.fn(),
onSwitchToActMode,
})
mocks.buildSessionConfig.mockResolvedValueOnce({
extraTools: [],
hooks: {},
})
const planConfig = await builder.build({ cwd: "/workspace", mode: "plan" })
const switchTool = planConfig.extraTools?.find((tool) => tool.name === "switch_to_act_mode")
expect(switchTool).toBeDefined()
// Ends the run cleanly after the tool result so the loop never starts an
// iteration that the stop hook would abort (which surfaced in the webview
// as "API Request Cancelled").
expect(switchTool?.lifecycle?.completesRun).toBe(true)
expect(await switchTool?.execute({}, {} as never)).toBe(
"You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)",
)
expect(onSwitchToActMode).toHaveBeenCalledOnce()
mocks.buildSessionConfig.mockResolvedValueOnce({
extraTools: [switchTool],
hooks: {},
})
const actConfig = await builder.build({ cwd: "/workspace", mode: "act" })
expect(actConfig.extraTools?.some((tool) => tool.name === "switch_to_act_mode")).toBe(false)
})
it("stops before the next model call after switch_to_act_mode queues a mode change", async () => {
const baseBeforeModel = vi.fn(async () => ({ metadata: "base" }))
mocks.buildAgentHooks.mockReturnValueOnce({ beforeModel: baseBeforeModel })
mocks.buildSessionConfig.mockResolvedValueOnce({ hooks: {} })
it("does not expose the SDK switch_to_act_mode tool in VS Code plan mode", async () => {
const builder = new SdkSessionConfigBuilder({
stateManager: {} as never,
emitHookMessage: vi.fn(),
onSwitchToActMode: vi.fn(),
shouldStopAfterModeSwitch: () => true,
})
const config = await builder.build({ cwd: "/workspace", mode: "act" })
await expect(config.hooks?.beforeModel?.({} as never)).resolves.toEqual({
metadata: "base",
stop: true,
mocks.buildSessionConfig.mockResolvedValueOnce({
extraTools: [{ name: "switch_to_act_mode" }, { name: "attempt_completion" }],
hooks: {},
})
expect(baseBeforeModel).toHaveBeenCalledOnce()
const planConfig = await builder.build({ cwd: "/workspace", mode: "plan" })
expect(planConfig.extraTools?.some((tool) => tool.name === "switch_to_act_mode")).toBe(false)
expect(planConfig.extraTools?.some((tool) => tool.name === "attempt_completion")).toBe(true)
})
it("passes the mistake-limit callback into the SDK config without overriding SDK execution defaults", async () => {
@@ -78,7 +39,6 @@ describe("SdkSessionConfigBuilder", () => {
const builder = new SdkSessionConfigBuilder({
stateManager: { getGlobalSettingsKey: vi.fn(() => 3) } as never,
emitHookMessage: vi.fn(),
onSwitchToActMode: vi.fn(),
onConsecutiveMistakeLimitReached,
})
@@ -1,5 +1,4 @@
import type { CoreSessionConfig } from "@cline/core"
import { type AgentTool, createTool } from "@cline/shared"
import type { StateManager } from "@/core/storage/StateManager"
import { buildSessionConfig, type SessionConfigInput } from "./cline-session-factory"
import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter"
@@ -7,8 +6,6 @@ import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter"
export interface SdkSessionConfigBuilderOptions {
stateManager: StateManager
emitHookMessage: HookMessageEmitter
onSwitchToActMode: () => void
shouldStopAfterModeSwitch?: () => boolean
onConsecutiveMistakeLimitReached?: CoreSessionConfig["onConsecutiveMistakeLimitReached"]
}
@@ -21,61 +18,9 @@ export class SdkSessionConfigBuilder {
config.onConsecutiveMistakeLimitReached = this.options.onConsecutiveMistakeLimitReached
}
const baseHooks = buildAgentHooks(this.options.stateManager, this.options.emitHookMessage)
config.hooks = {
...baseHooks,
beforeModel: async (ctx) => {
const baseControl = await baseHooks.beforeModel?.(ctx)
if (this.options.shouldStopAfterModeSwitch?.()) {
return {
...baseControl,
stop: true,
}
}
return baseControl
},
}
if (input.mode === "plan") {
// Match the CLI interactive runtime: plan-mode sessions expose a
// switch_to_act_mode tool in addition to the read-only planning tools.
config.extraTools = [...(config.extraTools ?? []), this.createSwitchToActModeTool()]
} else {
// The switch tool is plan-only in the CLI and should disappear after
// rebuilding the session in act mode.
config.extraTools = config.extraTools?.filter((tool) => tool.name !== "switch_to_act_mode")
}
config.hooks = buildAgentHooks(this.options.stateManager, this.options.emitHookMessage)
config.extraTools = config.extraTools?.filter((tool) => tool.name !== "switch_to_act_mode")
return config
}
private createSwitchToActModeTool(): AgentTool {
return createTool({
name: "switch_to_act_mode",
description:
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
inputSchema: {
type: "object",
properties: {},
},
timeoutMs: 5000,
retryable: false,
maxRetries: 0,
// End the run cleanly right after the tool result instead of letting the
// loop start another iteration that the beforeModel stop hook would abort.
// An aborted run leaves a dangling api_req_started spinner behind, which the
// webview renders as "API Request Cancelled".
lifecycle: {
completesRun: true,
},
execute: async () => {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
if (currentMode === "act") {
return "Already in act mode."
}
this.options.onSwitchToActMode()
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)"
},
})
}
}
@@ -41,6 +41,24 @@ describe("SdkSessionLifecycle", () => {
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
})
it("disables file mutation tools for plan-mode sessions even without auto-approval settings", async () => {
const sdkHost = makeSdkHost()
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
const lifecycle = makeLifecycle()
await lifecycle.startNewSession({ config: { mode: "plan" } } as StartInput)
expect(sdkHost.start).toHaveBeenCalledWith(
expect.objectContaining({
toolPolicies: expect.objectContaining({
editor: { enabled: false, autoApprove: false },
write_to_file: { enabled: false, autoApprove: false },
run_commands: { autoApprove: false },
}),
}),
)
})
it("reuses the shared session host across sessions", async () => {
const sdkHost = makeSdkHost({
start: vi.fn().mockResolvedValueOnce({ sessionId: "session-1" }).mockResolvedValueOnce({ sessionId: "session-2" }),
@@ -475,7 +493,7 @@ describe("SdkSessionLifecycle", () => {
function makeLifecycle(overrides: Partial<ConstructorParameters<typeof SdkSessionLifecycle>[0]> = {}) {
return new SdkSessionLifecycle({
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
mcpHub: {} as any,
mcpHub: { getServers: () => [] } as any,
requestToolApproval: vi.fn(),
askQuestion: vi.fn(),
onSessionEvent: vi.fn(),
+5 -1
View File
@@ -116,7 +116,11 @@ export class SdkSessionLifecycle {
}
const autoApprovalSettings = StateManager.get().getGlobalSettingsKey("autoApprovalSettings")
const toolPolicies = autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined
const mode = startInput.config?.mode === "plan" ? "plan" : "act"
const toolPolicies =
autoApprovalSettings || mode === "plan"
? buildToolPolicies(autoApprovalSettings, this.options.mcpHub, mode)
: undefined
const sdkHost = await this.getOrCreateSharedHost()
+28 -1
View File
@@ -1,6 +1,33 @@
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { describe, expect, it } from "vitest"
import { isToolAutoApproved } from "./sdk-tool-policies"
import { buildToolPolicies, isToolAutoApproved } from "./sdk-tool-policies"
describe("buildToolPolicies", () => {
it("keeps command tools enabled in plan mode", () => {
const policies = buildToolPolicies(DEFAULT_AUTO_APPROVAL_SETTINGS, undefined, "plan")
expect(policies.run_commands).toEqual({ autoApprove: false })
expect(policies.execute_command).toEqual({ autoApprove: false })
})
it("disables file mutation tools in plan mode", () => {
const policies = buildToolPolicies(DEFAULT_AUTO_APPROVAL_SETTINGS, undefined, "plan")
expect(policies.editor).toEqual({ enabled: false, autoApprove: false })
expect(policies.write_to_file).toEqual({ enabled: false, autoApprove: false })
expect(policies.replace_in_file).toEqual({ enabled: false, autoApprove: false })
expect(policies.apply_patch).toEqual({ enabled: false, autoApprove: false })
expect(policies.delete_file).toEqual({ enabled: false, autoApprove: false })
expect(policies.new_rule).toEqual({ enabled: false, autoApprove: false })
})
it("keeps file mutation tools approval-gated in act mode", () => {
const policies = buildToolPolicies(DEFAULT_AUTO_APPROVAL_SETTINGS, undefined, "act")
expect(policies.editor).toEqual({ autoApprove: false })
expect(policies.write_to_file).toEqual({ autoApprove: false })
})
})
describe("isToolAutoApproved", () => {
it("does not auto-approve command tools by default", () => {
+9 -5
View File
@@ -1,6 +1,9 @@
import type { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import type { Mode } from "@shared/storage/types"
import type { McpHub } from "@/services/mcp/McpHub"
const FILE_MUTATION_TOOLS = ["editor", "replace_in_file", "write_to_file", "apply_patch", "delete_file", "new_rule"]
/**
* Build SDK `toolPolicies` for tools governed by Cline's auto-approval UI.
*
@@ -11,19 +14,20 @@ import type { McpHub } from "@/services/mcp/McpHub"
* active sessions in sync when the user toggles auto-approval mid-task.
*/
export function buildToolPolicies(
_settings: AutoApprovalSettings,
_settings: AutoApprovalSettings | undefined,
mcpHub?: McpHub,
mode: Mode = "act",
): Record<string, { enabled?: boolean; autoApprove?: boolean }> {
const policies: Record<string, { enabled?: boolean; autoApprove?: boolean }> = {}
const set = (tools: string[]) => {
const set = (tools: string[], policy: { enabled?: boolean; autoApprove?: boolean } = { autoApprove: false }) => {
for (const tool of tools) {
policies[tool] = { autoApprove: false }
policies[tool] = { ...policy }
}
}
set(["read_files", "read_file", "list_files", "list_code_definition_names", "search_codebase", "search_files"])
set(["editor", "replace_in_file", "write_to_file", "apply_patch", "delete_file"])
set(FILE_MUTATION_TOOLS, mode === "plan" ? { enabled: false, autoApprove: false } : { autoApprove: false })
set(["run_commands", "execute_command"])
set(["fetch_web_content", "web_fetch", "web_search"])
@@ -79,7 +83,7 @@ function isReadTool(toolName: string): boolean {
}
function isEditTool(toolName: string): boolean {
return ["editor", "replace_in_file", "write_to_file", "apply_patch", "delete_file"].includes(toolName)
return FILE_MUTATION_TOOLS.includes(toolName)
}
function isCommandTool(toolName: string): boolean {