Просмотр исходного кода

fix: CLI file duplication bug when writing files (#5318)

marius-kilocode 3 недель назад
Родитель
Сommit
7b1dfcff73

+ 6 - 0
.changeset/fix-cli-file-duplication.md

@@ -0,0 +1,6 @@
+---
+"@kilocode/agent-runtime": patch
+"kilo-code": patch
+---
+
+Fixed CLI file duplication bug where content was written twice when creating or editing files

+ 16 - 20
packages/agent-runtime/src/host/VSCode.ts

@@ -1506,36 +1506,32 @@ export class WorkspaceAPI {
 					// File doesn't exist, start with empty content
 				}
 
+				const originalLines = content.split("\n")
+				const getOffset = (line: number, character: number): number => {
+					let offset = 0
+					for (let i = 0; i < line && i < originalLines.length; i++) {
+						offset += (originalLines[i]?.length || 0) + 1
+					}
+					return offset + character
+				}
+
 				// Apply edits in reverse order to maintain correct positions
-				const sortedEdits = edits.sort((a, b) => {
+				const sortedEdits = [...edits].sort((a, b) => {
 					const lineDiff = b.range.start.line - a.range.start.line
 					if (lineDiff !== 0) return lineDiff
 					return b.range.start.character - a.range.start.character
 				})
 
-				const lines = content.split("\n")
+				let updatedContent = content
 				for (const textEdit of sortedEdits) {
-					const startLine = textEdit.range.start.line
-					const startChar = textEdit.range.start.character
-					const endLine = textEdit.range.end.line
-					const endChar = textEdit.range.end.character
-
-					if (startLine === endLine) {
-						// Single line edit
-						const line = lines[startLine] || ""
-						lines[startLine] = line.substring(0, startChar) + textEdit.newText + line.substring(endChar)
-					} else {
-						// Multi-line edit
-						const firstLine = lines[startLine] || ""
-						const lastLine = lines[endLine] || ""
-						const newContent =
-							firstLine.substring(0, startChar) + textEdit.newText + lastLine.substring(endChar)
-						lines.splice(startLine, endLine - startLine + 1, newContent)
-					}
+					const startOffset = getOffset(textEdit.range.start.line, textEdit.range.start.character)
+					const endOffset = getOffset(textEdit.range.end.line, textEdit.range.end.character)
+					updatedContent =
+						updatedContent.slice(0, startOffset) + textEdit.newText + updatedContent.slice(endOffset)
 				}
 
 				// Write back to file
-				const newContent = lines.join("\n")
+				const newContent = updatedContent
 				fs.writeFileSync(filePath, newContent, "utf-8")
 
 				// Update the in-memory document object to reflect the new content

+ 53 - 0
packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts

@@ -0,0 +1,53 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import fs from "fs"
+import path from "path"
+import { createVSCodeAPIMock, Uri, WorkspaceEdit, Position, Range } from "../VSCode.js"
+
+describe("WorkspaceAPI.applyEdit", () => {
+	const tempDir = path.join(process.cwd(), "packages/agent-runtime/src/host/__tests__/__tmp__")
+	const filePath = path.join(tempDir, "apply-edit.txt")
+
+	beforeEach(() => {
+		fs.mkdirSync(tempDir, { recursive: true })
+		fs.writeFileSync(filePath, "alpha\n", "utf-8")
+	})
+
+	afterEach(() => {
+		try {
+			fs.rmSync(tempDir, { recursive: true, force: true })
+		} catch (error) {
+			// Ignore cleanup errors in tests
+		}
+	})
+
+	it("applies sequential edits without mutating the edit list", async () => {
+		const vscode = createVSCodeAPIMock(tempDir, tempDir)
+		const edit = new WorkspaceEdit()
+		const uri = Uri.file(filePath)
+
+		edit.replace(uri, new Range(new Position(0, 0), new Position(0, 0)), "X")
+		edit.replace(uri, new Range(new Position(0, 0), new Position(0, 0)), "Y")
+
+		const applyEditSpy = vi.spyOn(vscode.workspace, "applyEdit")
+		await vscode.workspace.applyEdit(edit)
+		await vscode.workspace.applyEdit(edit)
+
+		expect(applyEditSpy).toHaveBeenCalledTimes(2)
+		const finalContent = fs.readFileSync(filePath, "utf-8")
+		expect(finalContent).toBe("YXYXalpha\n")
+	})
+
+	it("handles multi-line replacements without duplicating tail lines", async () => {
+		const vscode = createVSCodeAPIMock(tempDir, tempDir)
+		const edit = new WorkspaceEdit()
+		const uri = Uri.file(filePath)
+
+		fs.writeFileSync(filePath, "alpha\nbeta\n", "utf-8")
+		edit.replace(uri, new Range(new Position(0, 0), new Position(1, 0)), "one\ntwo\n")
+
+		await vscode.workspace.applyEdit(edit)
+
+		const finalContent = fs.readFileSync(filePath, "utf-8")
+		expect(finalContent).toBe("one\ntwo\nbeta\n")
+	})
+})

+ 24 - 0
src/integrations/editor/DiffViewProvider.ts

@@ -159,6 +159,30 @@ export class DiffViewProvider {
 		this.streamedLines = accumulatedLines
 
 		if (isFinal) {
+			// In CLI mode, avoid multiple applyEdit calls that can duplicate content in the mock workspace
+			// (VS Code applies WorkspaceEdit in-memory; the CLI mock writes to disk, so multiple passes risk duplication)
+			if (process.env.KILO_CLI_MODE === "true") {
+				// Preserve empty last line if original content had one.
+				const hasEmptyLastLine = this.originalContent?.endsWith("\n")
+
+				if (hasEmptyLastLine && !accumulatedContent.endsWith("\n")) {
+					accumulatedContent += "\n"
+				}
+
+				const finalEdit = new vscode.WorkspaceEdit()
+				finalEdit.replace(
+					document.uri,
+					new vscode.Range(0, 0, document.lineCount, 0),
+					this.stripAllBOMs(accumulatedContent),
+				)
+				await vscode.workspace.applyEdit(finalEdit)
+
+				// Clear all decorations at the end (after applying final edit).
+				this.fadedOverlayController.clear()
+				this.activeLineController.clear()
+				return
+			}
+
 			// Handle any remaining lines if the new content is shorter than the
 			// original.
 			if (this.streamedLines.length < document.lineCount) {

+ 15 - 0
src/integrations/editor/__tests__/DiffViewProvider.spec.ts

@@ -584,5 +584,20 @@ describe("DiffViewProvider", () => {
 				expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled()
 			})
 		})
+
+		describe("update in CLI mode", () => {
+			it("should avoid delete edit when finalizing in CLI mode", async () => {
+				process.env.KILO_CLI_MODE = "true"
+				mockWorkspaceEdit.delete.mockClear()
+				vi.mocked(vscode.workspace.applyEdit).mockClear()
+
+				;(diffViewProvider as any).originalContent = "old\ncontent\n"
+				await diffViewProvider.update("new\ncontent\n", true)
+
+				// In CLI mode, finalization should skip the delete edit path
+				expect(mockWorkspaceEdit.delete).not.toHaveBeenCalled()
+				expect(vscode.workspace.applyEdit).toHaveBeenCalledTimes(2)
+			})
+		})
 	})
 })