// Integration tests verifying that the agent file tools (read, write, edit, // apply_patch) detect and preserve the original encoding of files on disk. // Tests exercise the real tool pipeline rather than the Encoding helper // directly so we validate end-to-end behaviour. import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" import iconv from "iconv-lite" import { Agent } from "../../src/agent/agent" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Bus } from "../../src/bus" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { EditTool } from "../../src/tool/edit" import { Format } from "../../src/format" import { Instance } from "../../src/kilocode/instance" import { Instruction } from "../../src/session/instruction" import { LSP } from "../../src/lsp/lsp" import { MessageID, SessionID } from "../../src/session/schema" import { ReadTool } from "../../src/tool/read" import * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" import { WriteTool } from "../../src/tool/write" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-encoding"), messageID: MessageID.make("msg_test-encoding"), callID: "", agent: "build", abort: AbortSignal.any([]), messages: [], metadata: () => Effect.void, ask: () => Effect.void, } afterEach(async () => { await disposeAllInstances() }) const it = testEffect( Layer.mergeAll( Agent.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, Bus.layer, Format.defaultLayer, Truncate.defaultLayer, ), ) const runRead = (args: Tool.InferParameters) => Effect.gen(function* () { const info = yield* ReadTool const tool = yield* info.init() return yield* tool.execute(args, ctx) }) const runWrite = (args: Tool.InferParameters) => Effect.gen(function* () { const info = yield* WriteTool const tool = yield* info.init() return yield* tool.execute(args, ctx) }) const runEdit = (args: Tool.InferParameters) => Effect.gen(function* () { const info = yield* EditTool const tool = yield* info.init() return yield* tool.execute(args, ctx) }) const runPatch = (args: Tool.InferParameters) => Effect.gen(function* () { const info = yield* ApplyPatchTool const tool = yield* info.init() return yield* tool.execute(args, ctx) }) // FileTime was removed upstream; edit/write no longer require a prior read. const markRead = (_filepath: string) => Effect.void // iconv-lite's UTF codecs don't emit BOMs, but this codebase supports // "UTF-X with BOM" as a distinct variant. Prepend one here for fixture files // that are meant to have one. const UTF8_BOM = "utf-8-bom" const encodeBytes = (text: string, encoding: string): Buffer => { if (encoding === UTF8_BOM) return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), iconv.encode(text, "utf-8")]) const lower = encoding.toLowerCase() if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)]) if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)]) if (lower === "utf-32le") return Buffer.concat([Buffer.from([0xff, 0xfe, 0x00, 0x00]), iconv.encode(text, encoding)]) if (lower === "utf-32be") return Buffer.concat([Buffer.from([0x00, 0x00, 0xfe, 0xff]), iconv.encode(text, encoding)]) return iconv.encode(text, encoding) } // Create a file with the given encoding by writing raw bytes. const putEncoded = (filepath: string, text: string, encoding: string) => Effect.promise(async () => { await fs.mkdir(path.dirname(filepath), { recursive: true }) await fs.writeFile(filepath, encodeBytes(text, encoding)) }) const loadDecoded = (filepath: string, encoding: string) => Effect.promise(async () => { const bytes = await fs.readFile(filepath) if (encoding === UTF8_BOM) { const stripped = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf return iconv.decode(stripped ? bytes.subarray(3) : bytes, "utf-8") } return iconv.decode(bytes, encoding) }) const loadBytes = (filepath: string) => Effect.promise(() => fs.readFile(filepath)) // Sample phrases chosen to exercise each encoding's characteristic byte patterns. const samples = { utf8: "Hello, world! — £100", shiftJis: "こんにちは、世界!日本語のテストです。", eucJp: "日本語のEUC-JPテスト文字列です。", gb2312: "你好,世界!这是简体中文测试。", big5: "你好,世界!這是繁體中文測試。", eucKr: "안녕하세요, 세계! 한국어 테스트입니다.", windows1251: "Привет, мир! Это тест кириллицы.", koi8r: "Привет, мир! КОИ-8 Р тест.", } describe("tool encoding preservation", () => { describe("ReadTool decodes files with non-UTF-8 encodings", () => { const cases: Array<[string, string, string]> = [ ["UTF-8", "utf-8", samples.utf8], ["UTF-8 with BOM", UTF8_BOM, samples.utf8], ["UTF-16 LE with BOM", "utf-16le", samples.utf8], ["UTF-16 BE with BOM", "utf-16be", samples.utf8], ["UTF-32 LE with BOM", "utf-32le", samples.utf8], ["UTF-32 BE with BOM", "utf-32be", samples.utf8], ["Shift_JIS", "Shift_JIS", samples.shiftJis], ["EUC-JP", "euc-jp", samples.eucJp], ["GB2312", "gb2312", samples.gb2312], ["Big5", "big5", samples.big5], ["EUC-KR", "euc-kr", samples.eucKr], ["Windows-1251", "windows-1251", samples.windows1251], ["KOI8-R", "koi8-r", samples.koi8r], ] for (const [label, encoding, text] of cases) { it.live(`decodes ${label} content for the model`, () => provideEncoded(encoding, text, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(text) }), ), ) } }) describe("ReadTool does not flag non-Latin text files as binary", () => { it.live("accepts Shift_JIS", () => provideEncoded("Shift_JIS", samples.shiftJis, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(samples.shiftJis) }), ), ) it.live("accepts UTF-16 LE with BOM (contains NUL bytes)", () => provideEncoded("utf-16le", samples.utf8, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(samples.utf8) }), ), ) it.live("accepts UTF-32 LE with BOM (3 of every 4 bytes are NUL)", () => provideEncoded("utf-32le", samples.utf8, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(samples.utf8) }), ), ) }) describe("WriteTool preserves existing file encoding when overwriting", () => { const cases: Array<[string, string, string]> = [ ["UTF-8 with BOM", UTF8_BOM, samples.utf8], ["Shift_JIS", "Shift_JIS", samples.shiftJis], ["GB2312", "gb2312", samples.gb2312], ["Windows-1251", "windows-1251", samples.windows1251], ["UTF-16 LE", "utf-16le", samples.utf8], ["UTF-32 LE", "utf-32le", samples.utf8], ["UTF-32 BE", "utf-32be", samples.utf8], ] for (const [label, encoding, original] of cases) { it.live(`preserves ${label} encoding on overwrite`, () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "file.txt") yield* putEncoded(filepath, original, encoding) yield* markRead(filepath) const replacement = original + " updated" yield* runWrite({ filePath: filepath, content: replacement }) const decoded = yield* loadDecoded(filepath, encoding) expect(decoded).toBe(replacement) // Bytes should still match the original encoding (and differ from UTF-8). const bytes = yield* loadBytes(filepath) expect(bytes.equals(encodeBytes(replacement, encoding))).toBe(true) }), ), ) } it.live("defaults new files to UTF-8", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "new.txt") yield* runWrite({ filePath: filepath, content: samples.utf8 }) const bytes = yield* loadBytes(filepath) expect(bytes.equals(Buffer.from(samples.utf8, "utf-8"))).toBe(true) }), ), ) // Guard against double-BOM regressions: if the model ever hands back content // that already starts with U+FEFF (e.g. by round-tripping literal bytes), // writing it to a BOM-encoded file must still produce exactly one BOM. const bomCases: Array<[string, string, Buffer]> = [ ["UTF-8 with BOM", UTF8_BOM, Buffer.from([0xef, 0xbb, 0xbf])], ["UTF-16 LE", "utf-16le", Buffer.from([0xff, 0xfe])], ["UTF-16 BE", "utf-16be", Buffer.from([0xfe, 0xff])], ["UTF-32 LE", "utf-32le", Buffer.from([0xff, 0xfe, 0x00, 0x00])], ["UTF-32 BE", "utf-32be", Buffer.from([0x00, 0x00, 0xfe, 0xff])], ] for (const [label, encoding, bom] of bomCases) { it.live(`does not emit a double BOM for ${label} when content starts with U+FEFF`, () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "file.txt") yield* putEncoded(filepath, "hello", encoding) yield* markRead(filepath) yield* runWrite({ filePath: filepath, content: "\uFEFFgoodbye" }) const bytes = yield* loadBytes(filepath) // Exactly one BOM prefix, immediately followed by encoded payload. expect(bytes.subarray(0, bom.length).equals(bom)).toBe(true) expect(bytes.subarray(bom.length, bom.length * 2).equals(bom)).toBe(false) const decoded = yield* loadDecoded(filepath, encoding) expect(decoded).toBe("goodbye") }), ), ) } }) describe("EditTool preserves existing file encoding across edits", () => { const cases: Array<[string, string, string, string, string]> = [ ["UTF-8 with BOM", UTF8_BOM, samples.utf8 + "\n second line", "world", "earth"], ["Shift_JIS", "Shift_JIS", samples.shiftJis, "日本語", "ニホンゴ"], ["GB2312", "gb2312", samples.gb2312, "简体中文", "中文简体"], ["Windows-1251", "windows-1251", samples.windows1251, "мир", "планета"], ["UTF-16 LE", "utf-16le", samples.utf8 + "\n second line", "world", "earth"], ["UTF-32 LE", "utf-32le", samples.utf8 + "\n second line", "world", "earth"], ] for (const [label, encoding, original, oldString, newString] of cases) { it.live(`preserves ${label} through edit`, () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "doc.txt") yield* putEncoded(filepath, original, encoding) yield* markRead(filepath) yield* runEdit({ filePath: filepath, oldString, newString }) const decoded = yield* loadDecoded(filepath, encoding) const expected = original.replace(oldString, newString) expect(decoded).toBe(expected) const bytes = yield* loadBytes(filepath) expect(bytes.equals(encodeBytes(expected, encoding))).toBe(true) }), ), ) } }) describe("ApplyPatchTool preserves encoding", () => { it.live("preserves Shift_JIS through an update hunk", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "doc.txt") const replacement = "日本語" const original = "line1\n" + samples.shiftJis + "\nline3\n" const expected = original.replace(samples.shiftJis, replacement) yield* putEncoded(filepath, original, "Shift_JIS") const patch = [ "*** Begin Patch", "*** Update File: doc.txt", "@@", " line1", "-" + samples.shiftJis, "+" + replacement, " line3", "*** End Patch", ].join("\n") yield* runPatch({ patchText: patch }) const decoded = yield* loadDecoded(filepath, "Shift_JIS") expect(decoded).toBe(expected) // Bytes must still be Shift_JIS, not silently promoted to UTF-8. const bytes = yield* loadBytes(filepath) expect(bytes.equals(encodeBytes(expected, "Shift_JIS"))).toBe(true) }), ), ) // Regression guard: the diff and additions/deletions counts surfaced to the // user (and to the permission prompt) are derived from the pre-patch read // of the file. A previous version reused a hard-coded UTF-8 decoder for // that read, producing mojibake for any non-UTF-8 file. The bytes ended up // correct because the patch helper does its own encoding-aware read, so // tests that only checked final file bytes (above) missed the bug. it.live("returns a non-mojibake diff for a Shift_JIS update", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "doc.txt") const replacement = "日本語" const original = "line1\n" + samples.shiftJis + "\nline3\n" yield* putEncoded(filepath, original, "Shift_JIS") const patch = [ "*** Begin Patch", "*** Update File: doc.txt", "@@", " line1", "-" + samples.shiftJis, "+" + replacement, " line3", "*** End Patch", ].join("\n") const result = (yield* runPatch({ patchText: patch })) as { metadata: { diff: string files: Array<{ additions: number; deletions: number }> } } // The diff must contain the real decoded old/new lines, not a UTF-8 // misread of the Shift_JIS bytes (which would surface as U+FFFD). expect(result.metadata.diff).toContain(samples.shiftJis) expect(result.metadata.diff).toContain(replacement) expect(result.metadata.diff).not.toContain("\uFFFD") // Per-file stats are derived from the same diff, so a mojibake read // would inflate both additions and deletions. expect(result.metadata.files).toHaveLength(1) expect(result.metadata.files[0].additions).toBe(1) expect(result.metadata.files[0].deletions).toBe(1) }), ), ) it.live("new files added via apply_patch are UTF-8", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const patch = ["*** Begin Patch", "*** Add File: new.txt", "+hello world", "*** End Patch"].join("\n") yield* runPatch({ patchText: patch }) const bytes = yield* loadBytes(path.join(dir, "new.txt")) expect(bytes.equals(Buffer.from("hello world\n", "utf-8"))).toBe(true) }), ), ) // Deletes exercise a code path in patch/index.ts that doesn't write bytes // back — verify it still works when the target file is non-UTF-8, because // the deletion code has to decode the old contents to confirm match. it.live("deletes a Windows-1251 file without UTF-8 corruption errors", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "legacy.txt") yield* putEncoded(filepath, samples.windows1251, "windows-1251") const patch = ["*** Begin Patch", "*** Delete File: legacy.txt", "*** End Patch"].join("\n") yield* runPatch({ patchText: patch }) const exists = yield* Effect.promise(() => fs .access(filepath) .then(() => true) .catch(() => false), ) expect(exists).toBe(false) }), ), ) }) // EditTool's replaceAll path rewrites the entire buffer and re-encodes it // in one shot — regression guard that re-encoding a multi-occurrence edit in // a legacy encoding yields byte-exact output. describe("EditTool replaceAll preserves non-UTF-8 encoding", () => { it.live("replaces every occurrence in Shift_JIS", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "doc.txt") // Pad with additional Shift_JIS text so chardet has enough bytes // to confidently identify the encoding. const pad = samples.shiftJis + "\n" const original = pad + "日本語\n日本語\n日本語\n" + pad yield* putEncoded(filepath, original, "Shift_JIS") yield* markRead(filepath) yield* runEdit({ filePath: filepath, oldString: "日本語", newString: "ニホンゴ", replaceAll: true }) const expected = pad.replaceAll("日本語", "ニホンゴ") + "ニホンゴ\nニホンゴ\nニホンゴ\n" + pad.replaceAll("日本語", "ニホンゴ") const decoded = yield* loadDecoded(filepath, "Shift_JIS") expect(decoded).toBe(expected) const bytes = yield* loadBytes(filepath) expect(bytes.equals(encodeBytes(expected, "Shift_JIS"))).toBe(true) }), ), ) }) }) // Shared helper to set up a temp instance with an encoded file at `file.txt`. function provideEncoded(encoding: string, text: string, body: (filepath: string) => Effect.Effect) { return provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "file.txt") yield* putEncoded(filepath, text, encoding) yield* markRead(filepath) return yield* body(filepath) }), ) }