diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 667afc25..c1a26b2a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -236,6 +236,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo - **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou)) - **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon)) - **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey)) +- **Edit tool fails on files with UTF-8 BOM**: Files with UTF-8 BOM marker could cause "text not found" errors since the LLM doesn't include the invisible BOM character. BOM is now stripped before matching and restored on write. ([#394](https://github.com/badlogic/pi-mono/pull/394) by [@prathamdby](https://github.com/prathamdby)) - **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri)) - **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez)) - **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus)) diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 810e011a..a29710c7 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -24,6 +24,11 @@ export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; } +/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ +export function stripBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + /** * Generate a unified diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). @@ -160,7 +165,10 @@ export async function computeEditDiff( } // Read the file - const content = await readFile(absolutePath, "utf-8"); + const rawContent = await readFile(absolutePath, "utf-8"); + + // Strip BOM before matching (LLM won't include invisible BOM in oldText) + const { text: content } = stripBom(rawContent); const normalizedContent = normalizeToLF(content); const normalizedOldText = normalizeToLF(oldText); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index dcfb843e..3360308c 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -2,7 +2,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile, writeFile } from "fs/promises"; -import { detectLineEnding, generateDiffString, normalizeToLF, restoreLineEndings } from "./edit-diff.js"; +import { detectLineEnding, generateDiffString, normalizeToLF, restoreLineEndings, stripBom } from "./edit-diff.js"; import { resolveToCwd } from "./path-utils.js"; const editSchema = Type.Object({ @@ -74,13 +74,16 @@ export function createEditTool(cwd: string): AgentTool { } // Read the file - const content = await readFile(absolutePath, "utf-8"); + const rawContent = await readFile(absolutePath, "utf-8"); // Check if aborted after reading if (aborted) { return; } + // Strip BOM before matching (LLM won't include invisible BOM in oldText) + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const normalizedOldText = normalizeToLF(oldText); @@ -140,7 +143,7 @@ export function createEditTool(cwd: string): AgentTool { return; } - const finalContent = restoreLineEndings(normalizedNewContent, originalEnding); + const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding); await writeFile(absolutePath, finalContent, "utf-8"); // Check if aborted after writing diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 8f18b9aa..d4601eb9 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -433,4 +433,18 @@ describe("edit tool CRLF handling", () => { }), ).rejects.toThrow(/Found 2 occurrences/); }); + + it("should preserve UTF-8 BOM after edit", async () => { + const testFile = join(testDir, "bom-test.txt"); + writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n"); + + await editTool.execute("test-bom", { + path: testFile, + oldText: "second\n", + newText: "REPLACED\n", + }); + + const content = readFileSync(testFile, "utf-8"); + expect(content).toBe("\uFEFFfirst\r\nREPLACED\r\nthird\r\n"); + }); });