diff --git a/.changeset/fix-cli-file-duplication.md b/.changeset/fix-cli-file-duplication.md new file mode 100644 index 00000000000..289a03ce9e4 --- /dev/null +++ b/.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 diff --git a/packages/agent-runtime/src/host/VSCode.ts b/packages/agent-runtime/src/host/VSCode.ts index db8f1cdc233..6ed9d0f9297 100644 --- a/packages/agent-runtime/src/host/VSCode.ts +++ b/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 diff --git a/packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts b/packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts new file mode 100644 index 00000000000..86c60e1ce10 --- /dev/null +++ b/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") + }) +}) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 091ac1a3b9b..bb287a24a12 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/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) { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d8cce9503c0..58a9d4446b0 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/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) + }) + }) }) })