Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-cli-file-duplication.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 16 additions & 20 deletions packages/agent-runtime/src/host/VSCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1506,36 +1506,32 @@ export class WorkspaceAPI {
// File doesn't exist, start with empty content
}

const originalLines = content.split("\n")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this hardcoded newline character work as intended on window?

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
Expand Down
53 changes: 53 additions & 0 deletions packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions src/integrations/editor/DiffViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions src/integrations/editor/__tests__/DiffViewProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
})