Skip to content

Commit 4f88e1f

Browse files
committed
fix: prevent file path corruption in WriteToFileTool and GenerateImageTool
- Remove incorrect removeClosingTag calls from execute methods - removeClosingTag should only be used in handlePartial for streaming - Fixes issue where paths ending with "str" were corrupted - Add comprehensive tests for path handling Fixes #9298
1 parent 7a06902 commit 4f88e1f

File tree

3 files changed

+232
-4
lines changed

3 files changed

+232
-4
lines changed

src/core/tools/GenerateImageTool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,12 @@ export class GenerateImageTool extends BaseTool<"generate_image"> {
139139

140140
const selectedModel = state?.openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0]
141141

142-
const fullPath = path.resolve(task.cwd, removeClosingTag("path", relPath))
142+
const fullPath = path.resolve(task.cwd, relPath)
143143
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
144144

145145
const sharedMessageProps = {
146146
tool: "generateImage" as const,
147-
path: getReadablePath(task.cwd, removeClosingTag("path", relPath)),
147+
path: getReadablePath(task.cwd, relPath),
148148
content: prompt,
149149
isOutsideWorkspace,
150150
isProtected: isWriteProtected,

src/core/tools/WriteToFileTool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
9090
newContent = unescapeHtmlEntities(newContent)
9191
}
9292

93-
const fullPath = relPath ? path.resolve(task.cwd, removeClosingTag("path", relPath)) : ""
93+
const fullPath = relPath ? path.resolve(task.cwd, relPath) : ""
9494
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
9595

9696
const sharedMessageProps: ClineSayTool = {
9797
tool: fileExists ? "editedExistingFile" : "newFileCreated",
98-
path: getReadablePath(task.cwd, removeClosingTag("path", relPath)),
98+
path: getReadablePath(task.cwd, relPath),
9999
content: newContent,
100100
isOutsideWorkspace,
101101
isProtected: isWriteProtected,
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { vi, describe, it, expect, beforeEach } from "vitest"
2+
import { WriteToFileTool } from "../WriteToFileTool"
3+
import { GenerateImageTool } from "../GenerateImageTool"
4+
import { Task } from "../../task/Task"
5+
import type { ToolCallbacks } from "../BaseTool"
6+
import { EXPERIMENT_IDS } from "../../../shared/experiments"
7+
8+
describe("File path corruption fix", () => {
9+
let mockTask: Task
10+
let mockCallbacks: ToolCallbacks
11+
let writeToFileTool: WriteToFileTool
12+
let generateImageTool: GenerateImageTool
13+
14+
beforeEach(() => {
15+
// Create mock task with minimal required properties
16+
mockTask = {
17+
cwd: "/root/oxyde_strat",
18+
consecutiveMistakeCount: 0,
19+
recordToolError: vi.fn(),
20+
sayAndCreateMissingParamError: vi.fn(),
21+
say: vi.fn(),
22+
rooIgnoreController: {
23+
validateAccess: vi.fn().mockReturnValue(true),
24+
},
25+
rooProtectedController: {
26+
isWriteProtected: vi.fn().mockReturnValue(false),
27+
},
28+
diffViewProvider: {
29+
editType: undefined,
30+
originalContent: "",
31+
open: vi.fn(),
32+
update: vi.fn(),
33+
scrollToFirstDiff: vi.fn(),
34+
saveChanges: vi.fn(),
35+
saveDirectly: vi.fn(),
36+
pushToolWriteResult: vi.fn().mockResolvedValue("File saved successfully"),
37+
reset: vi.fn(),
38+
revertChanges: vi.fn(),
39+
},
40+
api: {
41+
getModel: () => ({
42+
id: "test-model",
43+
info: {},
44+
}),
45+
},
46+
fileContextTracker: {
47+
trackFileContext: vi.fn(),
48+
},
49+
providerRef: {
50+
deref: vi.fn().mockReturnValue({
51+
getState: vi.fn().mockResolvedValue({
52+
diagnosticsEnabled: true,
53+
writeDelayMs: 0,
54+
experiments: {
55+
[EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION]: true,
56+
[EXPERIMENT_IDS.IMAGE_GENERATION]: true,
57+
},
58+
openRouterImageApiKey: "test-key",
59+
openRouterImageGenerationSelectedModel: "test-model",
60+
}),
61+
}),
62+
},
63+
} as any
64+
65+
// Create mock callbacks
66+
const mockAskApproval = vi.fn().mockResolvedValue(true)
67+
mockCallbacks = {
68+
askApproval: mockAskApproval as any,
69+
handleError: vi.fn(),
70+
pushToolResult: vi.fn(),
71+
removeClosingTag: ((tag: string, text?: string) => {
72+
// The fix: removeClosingTag should NOT be called in execute methods
73+
// It should only be used in handlePartial for cleaning streaming tags
74+
// So in execute, we just return the text as-is
75+
return text || ""
76+
}) as any,
77+
}
78+
79+
writeToFileTool = new WriteToFileTool()
80+
generateImageTool = new GenerateImageTool()
81+
})
82+
83+
describe("WriteToFileTool", () => {
84+
it("should handle paths ending with 'str' correctly", async () => {
85+
const testPath = "stratoxyde-v2/src/services/storage/saveStates.ts"
86+
const params = {
87+
path: testPath,
88+
content: "test content",
89+
line_count: 1,
90+
}
91+
92+
await writeToFileTool.execute(params, mockTask, mockCallbacks)
93+
94+
// Verify the path was not corrupted
95+
expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith(
96+
testPath, // Path should remain unchanged
97+
"test content",
98+
false,
99+
true,
100+
0,
101+
)
102+
})
103+
104+
it("should handle paths containing 'str' in the middle correctly", async () => {
105+
const testPath = "infrastructure/services/strategy.ts"
106+
const params = {
107+
path: testPath,
108+
content: "test content",
109+
line_count: 1,
110+
}
111+
112+
await writeToFileTool.execute(params, mockTask, mockCallbacks)
113+
114+
// Verify the path was not corrupted
115+
expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith(
116+
testPath, // Path should remain unchanged
117+
"test content",
118+
false,
119+
true,
120+
0,
121+
)
122+
})
123+
124+
it("should handle complex paths with 'str' correctly", async () => {
125+
const testPath = "/root/oxyde_strat/stratoxyde-v2/src/services/storage/saveStates.ts"
126+
const params = {
127+
path: testPath,
128+
content: "test content",
129+
line_count: 1,
130+
}
131+
132+
await writeToFileTool.execute(params, mockTask, mockCallbacks)
133+
134+
// Verify the path was not corrupted
135+
expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith(
136+
testPath, // Path should remain unchanged
137+
"test content",
138+
false,
139+
true,
140+
0,
141+
)
142+
})
143+
})
144+
145+
describe("GenerateImageTool", () => {
146+
beforeEach(() => {
147+
// Mock OpenRouterHandler
148+
vi.mock("../../../api/providers/openrouter", () => ({
149+
OpenRouterHandler: vi.fn().mockImplementation(() => ({
150+
generateImage: vi.fn().mockResolvedValue({
151+
success: true,
152+
imageData:
153+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
154+
}),
155+
})),
156+
}))
157+
})
158+
159+
it("should handle paths ending with 'str' correctly", async () => {
160+
const testPath = "images/illustr.png"
161+
const params = {
162+
prompt: "test prompt",
163+
path: testPath,
164+
image: undefined,
165+
}
166+
167+
await generateImageTool.execute(params, mockTask, mockCallbacks)
168+
169+
// Verify the path was used correctly in the approval message
170+
const mockAskApproval = mockCallbacks.askApproval as any
171+
const approvalCall = mockAskApproval.mock.calls[0]
172+
const approvalMessage = JSON.parse(approvalCall[1])
173+
expect(approvalMessage.path).toBe(testPath)
174+
})
175+
176+
it("should handle paths containing 'str' correctly", async () => {
177+
const testPath = "assets/structures/diagram.png"
178+
const params = {
179+
prompt: "test prompt",
180+
path: testPath,
181+
image: undefined,
182+
}
183+
184+
await generateImageTool.execute(params, mockTask, mockCallbacks)
185+
186+
// Verify the path was used correctly
187+
const mockAskApproval = mockCallbacks.askApproval as any
188+
const approvalCall = mockAskApproval.mock.calls[0]
189+
const approvalMessage = JSON.parse(approvalCall[1])
190+
expect(approvalMessage.path).toBe(testPath)
191+
})
192+
})
193+
194+
describe("removeClosingTag behavior", () => {
195+
it("should only be used in handlePartial, not execute", () => {
196+
// Create a proper removeClosingTag implementation for partial messages
197+
const properRemoveClosingTag = (tag: string, text: string | undefined, isPartial: boolean): string => {
198+
if (!isPartial) {
199+
return text || ""
200+
}
201+
if (!text) {
202+
return ""
203+
}
204+
// This regex should only apply to partial XML tags at the end
205+
const tagRegex = new RegExp(
206+
`\\s?<\/?${tag
207+
.split("")
208+
.map((char: string) => `(?:${char})?`)
209+
.join("")}$`,
210+
"g",
211+
)
212+
return text.replace(tagRegex, "")
213+
}
214+
215+
// When isPartial is false, should return text as-is
216+
expect(properRemoveClosingTag("path", "test/path/str", false)).toBe("test/path/str")
217+
expect(properRemoveClosingTag("path", "infrastructure", false)).toBe("infrastructure")
218+
219+
// When isPartial is true and text ends with partial XML tag
220+
expect(properRemoveClosingTag("path", "test/file</pa", true)).toBe("test/file")
221+
expect(properRemoveClosingTag("path", "test/file</", true)).toBe("test/file")
222+
expect(properRemoveClosingTag("path", "test/file<", true)).toBe("test/file")
223+
224+
// When isPartial is true but text doesn't end with XML tag
225+
expect(properRemoveClosingTag("path", "test/path/str", true)).toBe("test/path/str")
226+
})
227+
})
228+
})

0 commit comments

Comments
 (0)