Skip to content

Commit d24d3b1

Browse files
committed
fix: preserve newlines in content parameters and update error handling in writeToFileTool tests
1 parent f7f9c24 commit d24d3b1

File tree

5 files changed

+64
-89
lines changed

5 files changed

+64
-89
lines changed

src/core/assistant-message/parseAssistantMessage.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
2424
const paramClosingTag = `</${currentParamName}>`
2525
if (currentParamValue.endsWith(paramClosingTag)) {
2626
// End of param value.
27-
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
27+
// Don't trim content parameters to preserve newlines
28+
const paramValue = currentParamValue.slice(0, -paramClosingTag.length)
29+
currentToolUse.params[currentParamName] =
30+
currentParamName === "content" ? paramValue : paramValue.trim()
2831
currentParamName = undefined
2932
continue
3033
} else {
@@ -72,9 +75,8 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
7275
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
7376

7477
if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
75-
currentToolUse.params[contentParamName] = toolContent
76-
.slice(contentStartIndex, contentEndIndex)
77-
.trim()
78+
// Don't trim content to preserve newlines
79+
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex)
7880
}
7981
}
8082

@@ -138,7 +140,9 @@ export function parseAssistantMessage(assistantMessage: string): AssistantMessag
138140
// Stream did not complete tool call, add it as partial.
139141
if (currentParamName) {
140142
// Tool call has a parameter that was not completed.
141-
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
143+
// Don't trim content parameters to preserve newlines
144+
const paramValue = accumulator.slice(currentParamValueStartIndex)
145+
currentToolUse.params[currentParamName] = currentParamName === "content" ? paramValue : paramValue.trim()
142146
}
143147

144148
contentBlocks.push(currentToolUse)

src/core/assistant-message/parseAssistantMessageV2.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,12 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
7676
)
7777
) {
7878
// Found the closing tag for the parameter.
79-
const value = assistantMessage
80-
.slice(
81-
currentParamValueStart, // Start after the opening tag.
82-
currentCharIndex - closeTag.length + 1, // End before the closing tag.
83-
)
84-
.trim()
85-
currentToolUse.params[currentParamName] = value
79+
const value = assistantMessage.slice(
80+
currentParamValueStart, // Start after the opening tag.
81+
currentCharIndex - closeTag.length + 1, // End before the closing tag.
82+
)
83+
// Don't trim content parameters to preserve newlines
84+
currentToolUse.params[currentParamName] = currentParamName === "content" ? value : value.trim()
8685
currentParamName = undefined // Go back to parsing tool content.
8786
// We don't continue loop here, need to check for tool close or other params at index i.
8887
} else {
@@ -146,10 +145,8 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
146145
const contentEnd = toolContentSlice.lastIndexOf(contentEndTag)
147146

148147
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) {
149-
const contentValue = toolContentSlice
150-
.slice(contentStart + contentStartTag.length, contentEnd)
151-
.trim()
152-
148+
// Don't trim content to preserve newlines
149+
const contentValue = toolContentSlice.slice(contentStart + contentStartTag.length, contentEnd)
153150
currentToolUse.params[contentParamName] = contentValue
154151
}
155152
}
@@ -251,9 +248,9 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
251248

252249
// Finalize any open parameter within an open tool use.
253250
if (currentToolUse && currentParamName) {
254-
currentToolUse.params[currentParamName] = assistantMessage
255-
.slice(currentParamValueStart) // From param start to end of string.
256-
.trim()
251+
const value = assistantMessage.slice(currentParamValueStart) // From param start to end of string.
252+
// Don't trim content parameters to preserve newlines
253+
currentToolUse.params[currentParamName] = currentParamName === "content" ? value : value.trim()
257254
// Tool use remains partial.
258255
}
259256

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

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -401,37 +401,31 @@ describe("writeToFileTool", () => {
401401
})
402402

403403
describe("parameter validation", () => {
404-
it("returns early without error on missing path parameter", async () => {
404+
it("errors and resets on missing path parameter", async () => {
405405
await executeWriteFileTool({ path: undefined })
406406

407-
// With the new behavior, it should return early without errors
408-
expect(mockCline.consecutiveMistakeCount).toBe(0)
409-
expect(mockCline.recordToolError).not.toHaveBeenCalled()
410-
expect(mockCline.sayAndCreateMissingParamError).not.toHaveBeenCalled()
411-
expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled()
412-
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
407+
expect(mockCline.consecutiveMistakeCount).toBe(1)
408+
expect(mockCline.recordToolError).toHaveBeenCalledWith("write_to_file")
409+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "path")
410+
expect(mockCline.diffViewProvider.reset).toHaveBeenCalled()
413411
})
414412

415-
it("returns early without error on empty path parameter", async () => {
413+
it("errors and resets on empty path parameter", async () => {
416414
await executeWriteFileTool({ path: "" })
417415

418-
// Empty string is falsy in the context of !relPath check, so it returns early
419-
expect(mockCline.consecutiveMistakeCount).toBe(0)
420-
expect(mockCline.recordToolError).not.toHaveBeenCalled()
421-
expect(mockCline.sayAndCreateMissingParamError).not.toHaveBeenCalled()
422-
expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled()
423-
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
416+
expect(mockCline.consecutiveMistakeCount).toBe(1)
417+
expect(mockCline.recordToolError).toHaveBeenCalledWith("write_to_file")
418+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "path")
419+
expect(mockCline.diffViewProvider.reset).toHaveBeenCalled()
424420
})
425421

426-
it("returns early without error on missing content parameter", async () => {
422+
it("errors and resets on missing content parameter", async () => {
427423
await executeWriteFileTool({ content: undefined })
428424

429-
// With the new behavior, it should return early without errors
430-
expect(mockCline.consecutiveMistakeCount).toBe(0)
431-
expect(mockCline.recordToolError).not.toHaveBeenCalled()
432-
expect(mockCline.sayAndCreateMissingParamError).not.toHaveBeenCalled()
433-
expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled()
434-
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
425+
expect(mockCline.consecutiveMistakeCount).toBe(1)
426+
expect(mockCline.recordToolError).toHaveBeenCalledWith("write_to_file")
427+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "content")
428+
expect(mockCline.diffViewProvider.reset).toHaveBeenCalled()
435429
})
436430
})
437431
})

src/core/tools/writeToFileTool.ts

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { formatResponse } from "../prompts/responses"
88
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
99
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
1010
import { fileExistsAtPath } from "../../utils/fs"
11-
import { addLineNumbers, stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text"
11+
import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text"
1212
import { getReadablePath } from "../../utils/path"
1313
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
1414
import { detectCodeOmission } from "../../integrations/editor/detect-omission"
@@ -26,12 +26,28 @@ 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 === undefined) {
29+
if (block.partial && (!relPath || newContent === undefined)) {
3030
// checking for newContent ensure relPath is complete
3131
// wait so we can determine if it's a new file or editing an existing file
3232
return
3333
}
3434

35+
if (!relPath) {
36+
cline.consecutiveMistakeCount++
37+
cline.recordToolError("write_to_file")
38+
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
39+
await cline.diffViewProvider.reset()
40+
return
41+
}
42+
43+
if (newContent === undefined) {
44+
cline.consecutiveMistakeCount++
45+
cline.recordToolError("write_to_file")
46+
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
47+
await cline.diffViewProvider.reset()
48+
return
49+
}
50+
3551
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
3652

3753
if (!accessAllowed) {
@@ -100,23 +116,7 @@ export async function writeToFileTool(
100116

101117
return
102118
} else {
103-
if (!relPath) {
104-
cline.consecutiveMistakeCount++
105-
cline.recordToolError("write_to_file")
106-
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
107-
await cline.diffViewProvider.reset()
108-
return
109-
}
110-
111-
if (newContent === undefined) {
112-
cline.consecutiveMistakeCount++
113-
cline.recordToolError("write_to_file")
114-
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
115-
await cline.diffViewProvider.reset()
116-
return
117-
}
118-
119-
if (!predictedLineCount) {
119+
if (predictedLineCount === undefined) {
120120
cline.consecutiveMistakeCount++
121121
cline.recordToolError("write_to_file")
122122

@@ -212,7 +212,8 @@ export async function writeToFileTool(
212212
return
213213
}
214214

215-
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
215+
// Call saveChanges to update the DiffViewProvider properties
216+
await cline.diffViewProvider.saveChanges()
216217

217218
// Track file edit operation
218219
if (relPath) {
@@ -221,31 +222,10 @@ export async function writeToFileTool(
221222

222223
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
223224

224-
if (userEdits) {
225-
await cline.say(
226-
"user_feedback_diff",
227-
JSON.stringify({
228-
tool: fileExists ? "editedExistingFile" : "newFileCreated",
229-
path: getReadablePath(cline.cwd, relPath),
230-
diff: userEdits,
231-
} satisfies ClineSayTool),
232-
)
225+
// Get the formatted response message
226+
const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists)
233227

234-
pushToolResult(
235-
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
236-
`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` +
237-
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
238-
finalContent || "",
239-
)}\n</final_file_content>\n\n` +
240-
`Please note:\n` +
241-
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
242-
`2. Proceed with the task using this updated file content as the new baseline.\n` +
243-
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
244-
`${newProblemsMessage}`,
245-
)
246-
} else {
247-
pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`)
248-
}
228+
pushToolResult(message)
249229

250230
await cline.diffViewProvider.reset()
251231

src/integrations/editor/DiffViewProvider.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ export class DiffViewProvider {
129129
// Replace all content up to the current line with accumulated lines.
130130
const edit = new vscode.WorkspaceEdit()
131131
const rangeToReplace = new vscode.Range(0, 0, endLine, 0)
132-
const contentToReplace = accumulatedLines.slice(0, endLine + 1).join("\n") + "\n"
132+
const contentToReplace =
133+
accumulatedLines.slice(0, endLine).join("\n") + (accumulatedLines.length > 0 ? "\n" : "")
133134
edit.replace(document.uri, rangeToReplace, this.stripAllBOMs(contentToReplace))
134135
await vscode.workspace.applyEdit(edit)
135136
// Update decorations.
@@ -229,12 +230,11 @@ export class DiffViewProvider {
229230
// show a diff with all the EOL differences.
230231
const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
231232

232-
// `trimEnd` to fix issue where editor adds in extra new line
233-
// automatically.
234-
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
233+
// Normalize EOL characters without trimming content
234+
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL)
235235

236236
// Just in case the new content has a mix of varying EOL characters.
237-
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
237+
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL)
238238

239239
if (normalizedEditedContent !== normalizedNewContent) {
240240
// User made changes before approving edit.

0 commit comments

Comments
 (0)