Skip to content

Commit 3249fc6

Browse files
committed
Fix #3650: write_to_file to allow empty content-files & small fixes
Early check for partial-block Small typo Missing path normalization
1 parent 9d9880a commit 3249fc6

File tree

1 file changed

+191
-149
lines changed

1 file changed

+191
-149
lines changed

src/core/tools/writeToFileTool.ts

Lines changed: 191 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,95 @@ export async function writeToFileTool(
2626
let newContent: string | undefined = block.params.content
2727
let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0")
2828

29-
if (!relPath || !newContent) {
30-
// checking for newContent ensure relPath is complete
31-
// wait so we can determine if it's a new file or editing an existing file
29+
// Handle partial blocks first - minimal validation, just streaming
30+
if (block.partial) {
31+
if (!relPath || newContent === undefined) {
32+
// checking for newContent ensure relPath is complete
33+
// wait so we can determine if it's a new file or editing an existing file
34+
return
35+
}
36+
37+
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
38+
if (!accessAllowed) {
39+
await cline.say("rooignore_error", relPath)
40+
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
41+
return
42+
}
43+
44+
// Check if file exists using cached map or fs.access
45+
let fileExists: boolean
46+
if (cline.diffViewProvider.editType !== undefined) {
47+
fileExists = cline.diffViewProvider.editType === "modify"
48+
} else {
49+
const absolutePath = path.resolve(cline.cwd, relPath)
50+
fileExists = await fileExistsAtPath(absolutePath)
51+
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
52+
}
53+
54+
// pre-processing newContent for partial streaming
55+
if (newContent.startsWith("```")) {
56+
newContent = newContent.split("\n").slice(1).join("\n").trim()
57+
}
58+
if (newContent.endsWith("```")) {
59+
newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
60+
}
61+
if (!cline.api.getModel().id.includes("claude")) {
62+
newContent = unescapeHtmlEntities(newContent)
63+
}
64+
65+
const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)).toPosix() : ""
66+
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
67+
68+
const sharedMessageProps: ClineSayTool = {
69+
tool: fileExists ? "editedExistingFile" : "newFileCreated",
70+
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
71+
content: newContent,
72+
isOutsideWorkspace,
73+
}
74+
75+
try {
76+
// update gui message
77+
const partialMessage = JSON.stringify(sharedMessageProps)
78+
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
79+
80+
// update editor
81+
if (!cline.diffViewProvider.isEditing) {
82+
// open the editor and prepare to stream content in
83+
await cline.diffViewProvider.open(relPath)
84+
}
85+
86+
// editor is open, stream content in
87+
await cline.diffViewProvider.update(
88+
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
89+
false,
90+
)
91+
92+
return
93+
} catch (error) {
94+
await handleError("writing file", error)
95+
await cline.diffViewProvider.reset()
96+
return
97+
}
98+
}
99+
100+
// Handle non-partial blocks - full validation and processing
101+
if (!relPath) {
102+
cline.consecutiveMistakeCount++
103+
cline.recordToolError("write_to_file")
104+
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
105+
await cline.diffViewProvider.reset()
32106
return
33107
}
34108

35-
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
109+
if (newContent === undefined) {
110+
cline.consecutiveMistakeCount++
111+
cline.recordToolError("write_to_file")
112+
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
113+
await cline.diffViewProvider.reset()
114+
return
115+
}
36116

117+
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
37118
if (!accessAllowed) {
38119
await cline.say("rooignore_error", relPath)
39120
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
@@ -42,7 +123,6 @@ export async function writeToFileTool(
42123

43124
// Check if file exists using cached map or fs.access
44125
let fileExists: boolean
45-
46126
if (cline.diffViewProvider.editType !== undefined) {
47127
fileExists = cline.diffViewProvider.editType === "modify"
48128
} else {
@@ -66,7 +146,7 @@ export async function writeToFileTool(
66146
}
67147

68148
// Determine if the path is outside the workspace
69-
const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)) : ""
149+
const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)).toPosix() : ""
70150
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
71151

72152
const sharedMessageProps: ClineSayTool = {
@@ -77,176 +157,138 @@ export async function writeToFileTool(
77157
}
78158

79159
try {
80-
if (block.partial) {
81-
// update gui message
82-
const partialMessage = JSON.stringify(sharedMessageProps)
83-
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
160+
if (predictedLineCount === undefined) {
161+
cline.consecutiveMistakeCount++
162+
cline.recordToolError("write_to_file")
84163

85-
// update editor
86-
if (!cline.diffViewProvider.isEditing) {
87-
// open the editor and prepare to stream content in
88-
await cline.diffViewProvider.open(relPath)
89-
}
164+
// Calculate the actual number of lines in the content
165+
const actualLineCount = newContent.split("\n").length
90166

91-
// editor is open, stream content in
92-
await cline.diffViewProvider.update(
93-
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
94-
false,
167+
// Check if this is a new file or existing file
168+
const isNewFile = !fileExists
169+
170+
// Check if diffStrategy is enabled
171+
const diffStrategyEnabled = !!cline.diffStrategy
172+
173+
// Use more specific error message for line_count that provides guidance based on the situation
174+
await cline.say(
175+
"error",
176+
`Roo tried to use write_to_file${
177+
relPath ? ` for '${relPath.toPosix()}'` : ""
178+
} but the required parameter 'line_count' was missing or truncated after ${actualLineCount} lines of content were written. Retrying...`,
95179
)
96180

181+
pushToolResult(
182+
formatResponse.toolError(
183+
formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled),
184+
),
185+
)
186+
await cline.diffViewProvider.revertChanges()
97187
return
98-
} else {
99-
if (!relPath) {
100-
cline.consecutiveMistakeCount++
101-
cline.recordToolError("write_to_file")
102-
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
103-
await cline.diffViewProvider.reset()
104-
return
105-
}
106-
107-
if (!newContent) {
108-
cline.consecutiveMistakeCount++
109-
cline.recordToolError("write_to_file")
110-
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
111-
await cline.diffViewProvider.reset()
112-
return
113-
}
188+
}
114189

115-
if (!predictedLineCount) {
116-
cline.consecutiveMistakeCount++
117-
cline.recordToolError("write_to_file")
190+
cline.consecutiveMistakeCount = 0
118191

119-
// Calculate the actual number of lines in the content
120-
const actualLineCount = newContent.split("\n").length
192+
// if isEditingFile false, that means we have the full contents of the file already.
193+
// it's important to note how cline function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So cline part of the logic will always be called.
194+
// in other words, you must always repeat the block.partial logic here
195+
if (!cline.diffViewProvider.isEditing) {
196+
// show gui message before showing edit animation
197+
const partialMessage = JSON.stringify(sharedMessageProps)
198+
await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor
199+
await cline.diffViewProvider.open(relPath)
200+
}
121201

122-
// Check if this is a new file or existing file
123-
const isNewFile = !fileExists
202+
await cline.diffViewProvider.update(
203+
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
204+
true,
205+
)
124206

125-
// Check if diffStrategy is enabled
126-
const diffStrategyEnabled = !!cline.diffStrategy
207+
await delay(300) // wait for diff view to update
208+
cline.diffViewProvider.scrollToFirstDiff()
127209

128-
// Use more specific error message for line_count that provides guidance based on the situation
129-
await cline.say(
130-
"error",
131-
`Roo tried to use write_to_file${
132-
relPath ? ` for '${relPath.toPosix()}'` : ""
133-
} but the required parameter 'line_count' was missing or truncated after ${actualLineCount} lines of content were written. Retrying...`,
134-
)
210+
// Check for code omissions before proceeding
211+
if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
212+
if (cline.diffStrategy) {
213+
await cline.diffViewProvider.revertChanges()
135214

136215
pushToolResult(
137216
formatResponse.toolError(
138-
formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled),
217+
`Content appears to be truncated (file has ${
218+
newContent.split("\n").length
219+
} lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
139220
),
140221
)
141-
await cline.diffViewProvider.revertChanges()
142222
return
143-
}
144-
145-
cline.consecutiveMistakeCount = 0
146-
147-
// if isEditingFile false, that means we have the full contents of the file already.
148-
// it's important to note how cline function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So cline part of the logic will always be called.
149-
// in other words, you must always repeat the block.partial logic here
150-
if (!cline.diffViewProvider.isEditing) {
151-
// show gui message before showing edit animation
152-
const partialMessage = JSON.stringify(sharedMessageProps)
153-
await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor
154-
await cline.diffViewProvider.open(relPath)
155-
}
156-
157-
await cline.diffViewProvider.update(
158-
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
159-
true,
160-
)
161-
162-
await delay(300) // wait for diff view to update
163-
cline.diffViewProvider.scrollToFirstDiff()
164-
165-
// Check for code omissions before proceeding
166-
if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
167-
if (cline.diffStrategy) {
168-
await cline.diffViewProvider.revertChanges()
169-
170-
pushToolResult(
171-
formatResponse.toolError(
172-
`Content appears to be truncated (file has ${
173-
newContent.split("\n").length
174-
} lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
175-
),
223+
} else {
224+
vscode.window
225+
.showWarningMessage(
226+
"Potential code truncation detected. This happens when the AI reaches its max output limit.",
227+
"Follow cline guide to fix the issue",
176228
)
177-
return
178-
} else {
179-
vscode.window
180-
.showWarningMessage(
181-
"Potential code truncation detected. cline happens when the AI reaches its max output limit.",
182-
"Follow cline guide to fix the issue",
183-
)
184-
.then((selection) => {
185-
if (selection === "Follow cline guide to fix the issue") {
186-
vscode.env.openExternal(
187-
vscode.Uri.parse(
188-
"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
189-
),
190-
)
191-
}
192-
})
193-
}
229+
.then((selection) => {
230+
if (selection === "Follow cline guide to fix the issue") {
231+
vscode.env.openExternal(
232+
vscode.Uri.parse(
233+
"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
234+
),
235+
)
236+
}
237+
})
194238
}
239+
}
195240

196-
const completeMessage = JSON.stringify({
197-
...sharedMessageProps,
198-
content: fileExists ? undefined : newContent,
199-
diff: fileExists
200-
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
201-
: undefined,
202-
} satisfies ClineSayTool)
203-
204-
const didApprove = await askApproval("tool", completeMessage)
205-
206-
if (!didApprove) {
207-
await cline.diffViewProvider.revertChanges()
208-
return
209-
}
241+
const completeMessage = JSON.stringify({
242+
...sharedMessageProps,
243+
content: fileExists ? undefined : newContent,
244+
diff: fileExists
245+
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
246+
: undefined,
247+
} satisfies ClineSayTool)
210248

211-
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
249+
const didApprove = await askApproval("tool", completeMessage)
212250

213-
// Track file edit operation
214-
if (relPath) {
215-
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
216-
}
251+
if (!didApprove) {
252+
await cline.diffViewProvider.revertChanges()
253+
return
254+
}
217255

218-
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
256+
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
219257

220-
if (userEdits) {
221-
await cline.say(
222-
"user_feedback_diff",
223-
JSON.stringify({
224-
tool: fileExists ? "editedExistingFile" : "newFileCreated",
225-
path: getReadablePath(cline.cwd, relPath),
226-
diff: userEdits,
227-
} satisfies ClineSayTool),
228-
)
258+
// Track file edit operation
259+
if (relPath) {
260+
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
261+
}
229262

230-
pushToolResult(
231-
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
232-
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
233-
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
234-
finalContent || "",
235-
)}\n</final_file_content>\n\n` +
236-
`Please note:\n` +
237-
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
238-
`2. Proceed with the task using this updated file content as the new baseline.\n` +
239-
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
240-
`${newProblemsMessage}`,
241-
)
242-
} else {
243-
pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`)
244-
}
263+
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
245264

246-
await cline.diffViewProvider.reset()
265+
if (userEdits) {
266+
await cline.say(
267+
"user_feedback_diff",
268+
JSON.stringify({
269+
tool: fileExists ? "editedExistingFile" : "newFileCreated",
270+
path: getReadablePath(cline.cwd, relPath),
271+
diff: userEdits,
272+
} satisfies ClineSayTool),
273+
)
247274

248-
return
275+
pushToolResult(
276+
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
277+
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
278+
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
279+
finalContent || "",
280+
)}\n</final_file_content>\n\n` +
281+
`Please note:\n` +
282+
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
283+
`2. Proceed with the task using this updated file content as the new baseline.\n` +
284+
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
285+
`${newProblemsMessage}`,
286+
)
287+
} else {
288+
pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`)
249289
}
290+
291+
await cline.diffViewProvider.reset()
250292
} catch (error) {
251293
await handleError("writing file", error)
252294
await cline.diffViewProvider.reset()

0 commit comments

Comments
 (0)