Skip to content

Commit cf6b384

Browse files
committed
fix: prevent file save before user approval when PREVENT_FOCUS_DISRUPTION is enabled
- Move saveDirectly call to occur AFTER user approval in writeToFileTool - Reset diffViewProvider state when user rejects approval - Add tests to verify files are not saved before approval - Fixes issue where files were being written to disk before user clicked Save Fixes #7683
1 parent 7935c94 commit cf6b384

File tree

2 files changed

+127
-10
lines changed

2 files changed

+127
-10
lines changed

src/core/tools/__tests__/writeToFileTool.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as path from "path"
2+
import * as fs from "fs/promises"
23

34
import type { MockedFunction } from "vitest"
45

@@ -10,6 +11,7 @@ import { unescapeHtmlEntities } from "../../../utils/text-normalization"
1011
import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
1112
import { ToolUse, ToolResponse } from "../../../shared/tools"
1213
import { writeToFileTool } from "../writeToFileTool"
14+
import { experiments, EXPERIMENT_IDS } from "../../../shared/experiments"
1315

1416
vi.mock("path", async () => {
1517
const originalPath = await vi.importActual("path")
@@ -27,6 +29,19 @@ vi.mock("delay", () => ({
2729
default: vi.fn(),
2830
}))
2931

32+
vi.mock("fs/promises", () => ({
33+
readFile: vi.fn().mockResolvedValue("original content"),
34+
}))
35+
36+
vi.mock("../../../shared/experiments", () => ({
37+
experiments: {
38+
isEnabled: vi.fn().mockReturnValue(false), // Default to disabled
39+
},
40+
EXPERIMENT_IDS: {
41+
PREVENT_FOCUS_DISRUPTION: "preventFocusDisruption",
42+
},
43+
}))
44+
3045
vi.mock("../../../utils/fs", () => ({
3146
fileExistsAtPath: vi.fn().mockResolvedValue(false),
3247
}))
@@ -108,6 +123,8 @@ describe("writeToFileTool", () => {
108123
const mockedEveryLineHasLineNumbers = everyLineHasLineNumbers as MockedFunction<typeof everyLineHasLineNumbers>
109124
const mockedStripLineNumbers = stripLineNumbers as MockedFunction<typeof stripLineNumbers>
110125
const mockedPathResolve = path.resolve as MockedFunction<typeof path.resolve>
126+
const mockedExperimentsIsEnabled = experiments.isEnabled as MockedFunction<typeof experiments.isEnabled>
127+
const mockedFsReadFile = fs.readFile as MockedFunction<typeof fs.readFile>
111128

112129
const mockCline: any = {}
113130
let mockAskApproval: ReturnType<typeof vi.fn>
@@ -127,6 +144,8 @@ describe("writeToFileTool", () => {
127144
mockedUnescapeHtmlEntities.mockImplementation((content) => content)
128145
mockedEveryLineHasLineNumbers.mockReturnValue(false)
129146
mockedStripLineNumbers.mockImplementation((content) => content)
147+
mockedExperimentsIsEnabled.mockReturnValue(false) // Default to disabled
148+
mockedFsReadFile.mockResolvedValue("original content")
130149

131150
mockCline.cwd = "/"
132151
mockCline.consecutiveMistakeCount = 0
@@ -416,4 +435,98 @@ describe("writeToFileTool", () => {
416435
expect(mockCline.diffViewProvider.reset).toHaveBeenCalled()
417436
})
418437
})
438+
439+
describe("PREVENT_FOCUS_DISRUPTION experiment", () => {
440+
beforeEach(() => {
441+
// Reset the experiments mock for these tests
442+
mockedExperimentsIsEnabled.mockReset()
443+
})
444+
445+
it("should NOT save file before user approval when experiment is enabled", async () => {
446+
// Enable the PREVENT_FOCUS_DISRUPTION experiment
447+
mockedExperimentsIsEnabled.mockReturnValue(true)
448+
449+
mockCline.providerRef.deref().getState.mockResolvedValue({
450+
diagnosticsEnabled: true,
451+
writeDelayMs: 1000,
452+
experiments: {
453+
preventFocusDisruption: true,
454+
},
455+
})
456+
457+
// Mock saveDirectly to track when it's called
458+
const saveDirectlySpy = vi.fn().mockResolvedValue({
459+
newProblemsMessage: "",
460+
userEdits: undefined,
461+
finalContent: testContent,
462+
})
463+
mockCline.diffViewProvider.saveDirectly = saveDirectlySpy
464+
465+
// User rejects the approval
466+
mockAskApproval.mockResolvedValue(false)
467+
468+
await executeWriteFileTool({}, { fileExists: false })
469+
470+
// Verify that askApproval was called
471+
expect(mockAskApproval).toHaveBeenCalled()
472+
473+
// Verify that saveDirectly was NOT called since user rejected
474+
expect(saveDirectlySpy).not.toHaveBeenCalled()
475+
476+
// Verify that the diffViewProvider state was reset
477+
expect(mockCline.diffViewProvider.editType).toBe(undefined)
478+
expect(mockCline.diffViewProvider.originalContent).toBe(undefined)
479+
})
480+
481+
it("should save file AFTER user approval when experiment is enabled", async () => {
482+
// Enable the PREVENT_FOCUS_DISRUPTION experiment
483+
mockedExperimentsIsEnabled.mockReturnValue(true)
484+
485+
mockCline.providerRef.deref().getState.mockResolvedValue({
486+
diagnosticsEnabled: true,
487+
writeDelayMs: 1000,
488+
experiments: {
489+
preventFocusDisruption: true,
490+
},
491+
})
492+
493+
// Mock saveDirectly to track when it's called
494+
const saveDirectlySpy = vi.fn().mockResolvedValue({
495+
newProblemsMessage: "",
496+
userEdits: undefined,
497+
finalContent: testContent,
498+
})
499+
mockCline.diffViewProvider.saveDirectly = saveDirectlySpy
500+
501+
// Mock pushToolWriteResult
502+
mockCline.diffViewProvider.pushToolWriteResult = vi.fn().mockResolvedValue("Tool result message")
503+
504+
// User approves
505+
mockAskApproval.mockResolvedValue(true)
506+
507+
// Track the order of calls
508+
const callOrder: string[] = []
509+
mockAskApproval.mockImplementation(async () => {
510+
callOrder.push("askApproval")
511+
return true
512+
})
513+
saveDirectlySpy.mockImplementation(async () => {
514+
callOrder.push("saveDirectly")
515+
return {
516+
newProblemsMessage: "",
517+
userEdits: undefined,
518+
finalContent: testContent,
519+
}
520+
})
521+
522+
await executeWriteFileTool({}, { fileExists: false })
523+
524+
// Verify that askApproval was called BEFORE saveDirectly
525+
expect(callOrder).toEqual(["askApproval", "saveDirectly"])
526+
527+
// Verify both were called
528+
expect(mockAskApproval).toHaveBeenCalled()
529+
expect(saveDirectlySpy).toHaveBeenCalledWith(testFilePath, testContent, false, true, 1000)
530+
})
531+
})
419532
})

src/core/tools/writeToFileTool.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,16 @@ export async function writeToFileTool(
202202
}
203203
}
204204

205+
// Set up diffViewProvider properties needed for saveDirectly BEFORE asking for approval
206+
// This ensures we have the original content for comparison but don't write anything yet
207+
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
208+
if (fileExists) {
209+
const absolutePath = path.resolve(cline.cwd, relPath)
210+
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
211+
} else {
212+
cline.diffViewProvider.originalContent = ""
213+
}
214+
205215
const completeMessage = JSON.stringify({
206216
...sharedMessageProps,
207217
content: newContent,
@@ -210,19 +220,13 @@ export async function writeToFileTool(
210220
const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
211221

212222
if (!didApprove) {
223+
// Reset the diffViewProvider state since we're not proceeding
224+
cline.diffViewProvider.editType = undefined
225+
cline.diffViewProvider.originalContent = undefined
213226
return
214227
}
215228

216-
// Set up diffViewProvider properties needed for saveDirectly
217-
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
218-
if (fileExists) {
219-
const absolutePath = path.resolve(cline.cwd, relPath)
220-
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
221-
} else {
222-
cline.diffViewProvider.originalContent = ""
223-
}
224-
225-
// Save directly without showing diff view or opening the file
229+
// Only save directly AFTER user approval
226230
await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
227231
} else {
228232
// Original behavior with diff view

0 commit comments

Comments
 (0)