diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index 765854275..41f15842b 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -28,6 +28,7 @@ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; import { isInternalUrlPath } from "../../tools/path-utils"; import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard"; import { canonicalSnapshotKey } from "../file-snapshot-store"; +import { isNotebookPath } from "../notebook"; import { readEditFileText, serializeEditFileText } from "../read-file"; import type { LspBatchRequest } from "../renderer"; @@ -123,8 +124,9 @@ export class HashlineFilesystem extends Filesystem { return content; } - async readBinary(relativePath: string): Promise { + async readBinary(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); + if (isNotebookPath(absolutePath)) return undefined; try { return await fs.readFile(absolutePath); } catch (error) { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 1a09e9a50..6627351ff 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -128,6 +128,39 @@ describe("hashline executor", () => { }); }); + it("edits BOM-prefixed notebooks through the virtual cell text", async () => { + await withTempDir(async tempDir => { + const filePath = path.join(tempDir, "notebook.ipynb"); + const notebook = { + cells: [ + { + cell_type: "markdown", + metadata: { keep: true }, + source: ["# Title\n"], + }, + ], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }; + await Bun.write( + filePath, + new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(JSON.stringify(notebook))]), + ); + const session = makeHashlineSession(tempDir); + const editableText = "# %% [markdown] cell:0\n# Title\n"; + const sourceTag = recordFullSnapshot(getFileReadCache(session), filePath, editableText); + const input = `${header("notebook.ipynb", sourceTag)}\n${sameLineRange(tag(2, "# Title"))}\n${repl("# Updated")}\n`; + + await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)); + + const updated = await Bun.file(filePath).json(); + expect(updated.cells).toHaveLength(1); + expect(updated.cells[0].source).toEqual(["# Updated\n"]); + expect(updated.cells[0].metadata).toEqual({ keep: true }); + }); + }); + it("emits an actionable no-op diagnostic when the payload matches the file byte-for-byte", async () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); diff --git a/packages/hashline/src/fs.ts b/packages/hashline/src/fs.ts index 425e78576..bca93496a 100644 --- a/packages/hashline/src/fs.ts +++ b/packages/hashline/src/fs.ts @@ -65,8 +65,8 @@ export abstract class Filesystem { /** Read the file's full text content. Throw on missing file. */ abstract readText(path: string): Promise; - /** Read the file's raw bytes when text decoding may hide leading bytes such as a UTF-8 BOM. */ - readBinary?(path: string): Promise; + /** Read raw bytes for backends whose text is a direct decode of persisted bytes. */ + readBinary?(path: string): Promise; /** Validate that `path` is writable before a prepared batch starts committing. */ async preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise {}