| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- import { afterEach, describe, expect } from "bun:test"
- import path from "path"
- import fs from "fs/promises"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
- import { EditTool } from "../../src/tool/edit"
- import { disposeAllInstances, TestInstance } from "../fixture/fixture"
- import { LSP } from "@/lsp/lsp"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { Format } from "../../src/format"
- import { Agent } from "../../src/agent/agent"
- import { EventV2Bridge } from "../../src/event-v2-bridge"
- import { Truncate } from "@/tool/truncate"
- import { SessionID, MessageID } from "../../src/session/schema"
- import * as Tool from "../../src/tool/tool"
- import { testEffect } from "../lib/effect"
- import { Watcher } from "@kirincode-ai/core/filesystem/watcher"
- const ctx = {
- sessionID: SessionID.make("ses_test-edit-session"),
- messageID: MessageID.make("msg_test"),
- callID: "",
- agent: "build",
- abort: AbortSignal.any([]),
- messages: [],
- metadata: () => Effect.void,
- ask: () => Effect.void,
- }
- afterEach(async () => {
- await disposeAllInstances()
- })
- const layer = LayerNode.compile(
- LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]),
- )
- const it = testEffect(layer)
- const init = Effect.fn("EditToolTest.init")(function* () {
- const info = yield* EditTool
- return yield* info.init()
- })
- const run = Effect.fn("EditToolTest.run")(function* (
- args: Tool.InferParameters<typeof EditTool>,
- next: Tool.Context = ctx,
- ) {
- const tool = yield* init()
- return yield* tool.execute(args, next)
- })
- const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameters<typeof EditTool>) {
- const exit = yield* run(args).pipe(Effect.exit)
- if (Exit.isFailure(exit)) {
- const err = Cause.squash(exit.cause)
- return err instanceof Error ? err : new Error(String(err))
- }
- throw new Error("expected edit to fail")
- })
- const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) {
- const fs = yield* FSUtil.Service
- yield* fs.writeWithDirs(p, content)
- })
- const load = Effect.fn("EditToolTest.load")(function* (p: string) {
- const fs = yield* FSUtil.Service
- return yield* fs.readFileString(p)
- })
- const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) {
- return yield* Effect.promise(() => fs.readFile(p, "utf-8"))
- })
- const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) {
- const fs = yield* FSUtil.Service
- yield* fs.makeDirectory(p)
- })
- const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof Watcher.Event.Updated) {
- const events = yield* EventV2Bridge.Service
- const deferred = yield* Deferred.make<void>()
- const unsub = yield* events.listen((event) => {
- if (event.type === def.type) Deferred.doneUnsafe(deferred, Effect.void)
- return Effect.void
- })
- yield* Effect.addFinalizer(() => unsub)
- return deferred
- })
- describe("tool.edit", () => {
- describe("creating new files", () => {
- it.instance("creates new file when oldString is empty", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "newfile.txt")
- const result = yield* run({ filePath: filepath, oldString: "", newString: "new content" })
- expect(result.metadata.diff).toContain("new content")
- expect(yield* load(filepath)).toBe("new content")
- }),
- )
- it.instance("rejects empty oldString on existing files and leaves content unchanged", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "existing.cs")
- const bom = String.fromCharCode(0xfeff)
- const original = `${bom}using System;\n`
- yield* put(filepath, original)
- expect((yield* fail({ filePath: filepath, oldString: "", newString: "using Up;\n" })).message).toContain(
- "oldString cannot be empty",
- )
- const content = yield* loadRaw(filepath)
- expect(content).toBe(original)
- }),
- )
- it.instance("creates new file with nested directories", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "nested", "dir", "file.txt")
- yield* run({ filePath: filepath, oldString: "", newString: "nested file" })
- expect(yield* load(filepath)).toBe("nested file")
- }),
- )
- it.instance("emits add event for new files", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const updated = yield* onceBus(Watcher.Event.Updated)
- yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" })
- yield* Deferred.await(updated)
- }),
- )
- })
- describe("editing existing files", () => {
- it.instance("replaces text in existing file", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "existing.txt")
- yield* put(filepath, "old content here")
- const result = yield* run({ filePath: filepath, oldString: "old content", newString: "new content" })
- expect(result.output).toContain("Edit applied successfully")
- expect(yield* load(filepath)).toBe("new content here")
- }),
- )
- it.instance("replaces the first visible line in BOM files", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "existing.cs")
- const bom = String.fromCharCode(0xfeff)
- yield* put(filepath, `${bom}using System;\nclass Test {}\n`)
- const result = yield* run({ filePath: filepath, oldString: "using System;", newString: "using Up;" })
- expect(result.metadata.diff).toContain("-using System;")
- expect(result.metadata.diff).toContain("+using Up;")
- expect(result.metadata.diff).not.toContain(bom)
- const content = yield* loadRaw(filepath)
- expect(content.charCodeAt(0)).toBe(0xfeff)
- expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
- }),
- )
- it.instance("throws error when file does not exist", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- expect(
- (yield* fail({ filePath: path.join(test.directory, "nonexistent.txt"), oldString: "old", newString: "new" }))
- .message,
- ).toContain("not found")
- }),
- )
- it.instance("throws error when oldString equals newString", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "content")
- expect((yield* fail({ filePath: filepath, oldString: "same", newString: "same" })).message).toContain(
- "identical",
- )
- }),
- )
- it.instance("throws error when oldString not found in file", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "actual content")
- expect(yield* fail({ filePath: filepath, oldString: "not in file", newString: "replacement" })).toBeInstanceOf(
- Error,
- )
- }),
- )
- it.instance("rejects loose block-anchor matches and leaves content unchanged", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.ts")
- const original = [
- "function configure() {",
- " keepImportantState()",
- " removeAllUserData()",
- " archiveBackups()",
- " auditLog()",
- "}",
- ].join("\n")
- yield* put(filepath, original)
- expect(
- (yield* fail({
- filePath: filepath,
- oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
- newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
- })).message,
- ).toContain("Could not find oldString")
- expect(yield* load(filepath)).toBe(original)
- }),
- )
- it.instance("rejects block-anchor matches with unrelated middle content", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.ts")
- const original = ["function configure() {", " removeAllUserData()", "}"].join("\n")
- yield* put(filepath, original)
- expect(
- (yield* fail({
- filePath: filepath,
- oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
- newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
- })).message,
- ).toContain("Could not find oldString")
- expect(yield* load(filepath)).toBe(original)
- }),
- )
- it.instance("replaces all occurrences with replaceAll option", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "foo bar foo baz foo")
- yield* run({ filePath: filepath, oldString: "foo", newString: "qux", replaceAll: true })
- expect(yield* load(filepath)).toBe("qux bar qux baz qux")
- }),
- )
- it.instance("emits change event for existing files", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "original")
- const updated = yield* onceBus(Watcher.Event.Updated)
- yield* run({ filePath: filepath, oldString: "original", newString: "modified" })
- yield* Deferred.await(updated)
- }),
- )
- })
- describe("edge cases", () => {
- it.instance("handles multiline replacements", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "line1\nline2\nline3")
- yield* run({ filePath: filepath, oldString: "line2", newString: "new line 2\nextra line" })
- expect(yield* load(filepath)).toBe("line1\nnew line 2\nextra line\nline3")
- }),
- )
- it.instance("handles CRLF line endings", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "line1\r\nold\r\nline3")
- yield* run({ filePath: filepath, oldString: "old", newString: "new" })
- expect(yield* load(filepath)).toBe("line1\r\nnew\r\nline3")
- }),
- )
- it.instance("throws error when oldString equals newString", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "content")
- expect((yield* fail({ filePath: filepath, oldString: "", newString: "" })).message).toContain("identical")
- }),
- )
- it.instance("throws error when path is directory", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const dirpath = path.join(test.directory, "adir")
- yield* makeDirectory(dirpath)
- expect((yield* fail({ filePath: dirpath, oldString: "old", newString: "new" })).message).toContain("directory")
- }),
- )
- it.instance("tracks file diff statistics", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "line1\nline2\nline3")
- const result = yield* run({ filePath: filepath, oldString: "line2", newString: "new line a\nnew line b" })
- expect(result.metadata.filediff).toBeDefined()
- expect(result.metadata.filediff.file).toBe(filepath)
- expect(result.metadata.filediff.additions).toBeGreaterThan(0)
- }),
- )
- })
- describe("line endings", () => {
- const old = "alpha\nbeta\ngamma"
- const next = "alpha\nbeta-updated\ngamma"
- const alt = "alpha\nbeta\nomega"
- const normalize = (text: string, ending: "\n" | "\r\n") => {
- const normalized = text.replaceAll("\r\n", "\n")
- if (ending === "\n") return normalized
- return normalized.replaceAll("\n", "\r\n")
- }
- const count = (content: string) => {
- const crlf = content.match(/\r\n/g)?.length ?? 0
- const lf = content.match(/\n/g)?.length ?? 0
- return {
- crlf,
- lf: lf - crlf,
- }
- }
- const expectLf = (content: string) => {
- const counts = count(content)
- expect(counts.crlf).toBe(0)
- expect(counts.lf).toBeGreaterThan(0)
- }
- const expectCrlf = (content: string) => {
- const counts = count(content)
- expect(counts.lf).toBe(0)
- expect(counts.crlf).toBeGreaterThan(0)
- }
- type Input = {
- content: string
- oldString: string
- newString: string
- replaceAll?: boolean
- }
- const apply = Effect.fn("EditToolTest.lineEndings.apply")(function* (input: Input) {
- const test = yield* TestInstance
- const filePath = path.join(test.directory, "test.txt")
- yield* put(filePath, input.content)
- yield* run({
- filePath,
- oldString: input.oldString,
- newString: input.newString,
- replaceAll: input.replaceAll,
- })
- return yield* load(filePath)
- })
- it.instance("preserves LF with LF multi-line strings", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\n")
- const output = yield* apply({
- content,
- oldString: normalize(old, "\n"),
- newString: normalize(next, "\n"),
- })
- expect(output).toBe(normalize(next + "\n", "\n"))
- expectLf(output)
- }),
- )
- it.instance("preserves CRLF with CRLF multi-line strings", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\r\n")
- const output = yield* apply({
- content,
- oldString: normalize(old, "\r\n"),
- newString: normalize(next, "\r\n"),
- })
- expect(output).toBe(normalize(next + "\n", "\r\n"))
- expectCrlf(output)
- }),
- )
- it.instance("preserves LF when old/new use CRLF", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\n")
- const output = yield* apply({
- content,
- oldString: normalize(old, "\r\n"),
- newString: normalize(next, "\r\n"),
- })
- expect(output).toBe(normalize(next + "\n", "\n"))
- expectLf(output)
- }),
- )
- it.instance("preserves CRLF when old/new use LF", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\r\n")
- const output = yield* apply({
- content,
- oldString: normalize(old, "\n"),
- newString: normalize(next, "\n"),
- })
- expect(output).toBe(normalize(next + "\n", "\r\n"))
- expectCrlf(output)
- }),
- )
- it.instance("preserves LF when newString uses CRLF", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\n")
- const output = yield* apply({
- content,
- oldString: normalize(old, "\n"),
- newString: normalize(next, "\r\n"),
- })
- expect(output).toBe(normalize(next + "\n", "\n"))
- expectLf(output)
- }),
- )
- it.instance("preserves CRLF when newString uses LF", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\r\n")
- const output = yield* apply({
- content,
- oldString: normalize(old, "\r\n"),
- newString: normalize(next, "\n"),
- })
- expect(output).toBe(normalize(next + "\n", "\r\n"))
- expectCrlf(output)
- }),
- )
- it.instance("preserves LF with mixed old/new line endings", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\n")
- const output = yield* apply({
- content,
- oldString: "alpha\nbeta\r\ngamma",
- newString: "alpha\r\nbeta\nomega",
- })
- expect(output).toBe(normalize(alt + "\n", "\n"))
- expectLf(output)
- }),
- )
- it.instance("preserves CRLF with mixed old/new line endings", () =>
- Effect.gen(function* () {
- const content = normalize(old + "\n", "\r\n")
- const output = yield* apply({
- content,
- oldString: "alpha\r\nbeta\ngamma",
- newString: "alpha\nbeta\r\nomega",
- })
- expect(output).toBe(normalize(alt + "\n", "\r\n"))
- expectCrlf(output)
- }),
- )
- it.instance("replaceAll preserves LF for multi-line blocks", () =>
- Effect.gen(function* () {
- const blockOld = "alpha\nbeta"
- const blockNew = "alpha\nbeta-updated"
- const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
- const output = yield* apply({
- content,
- oldString: normalize(blockOld, "\n"),
- newString: normalize(blockNew, "\n"),
- replaceAll: true,
- })
- expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
- expectLf(output)
- }),
- )
- it.instance("replaceAll preserves CRLF for multi-line blocks", () =>
- Effect.gen(function* () {
- const blockOld = "alpha\nbeta"
- const blockNew = "alpha\nbeta-updated"
- const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
- const output = yield* apply({
- content,
- oldString: normalize(blockOld, "\r\n"),
- newString: normalize(blockNew, "\r\n"),
- replaceAll: true,
- })
- expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
- expectCrlf(output)
- }),
- )
- })
- describe("concurrent editing", () => {
- it.instance("preserves concurrent edits to different sections of the same file", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* put(filepath, "top = 0\nmiddle = keep\nbottom = 0\n")
- const firstAsk = yield* Deferred.make<void>()
- let asks = 0
- const delayedCtx = {
- ...ctx,
- ask: () =>
- Effect.gen(function* () {
- asks++
- if (asks !== 1) return
- yield* Deferred.succeed(firstAsk, undefined)
- yield* Effect.sleep("50 millis")
- }),
- }
- const first = yield* run(
- {
- filePath: filepath,
- oldString: "top = 0",
- newString: "top = 1",
- },
- delayedCtx,
- ).pipe(Effect.forkScoped)
- yield* Deferred.await(firstAsk)
- yield* Effect.all([
- Fiber.join(first),
- run(
- {
- filePath: filepath,
- oldString: "bottom = 0",
- newString: "bottom = 2",
- },
- delayedCtx,
- ),
- ])
- expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
- }),
- )
- })
- })
|