edit.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import { afterEach, describe, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  5. import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
  6. import { EditTool } from "../../src/tool/edit"
  7. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  8. import { LSP } from "@/lsp/lsp"
  9. import { FSUtil } from "@kirincode-ai/core/fs-util"
  10. import { Format } from "../../src/format"
  11. import { Agent } from "../../src/agent/agent"
  12. import { EventV2Bridge } from "../../src/event-v2-bridge"
  13. import { Truncate } from "@/tool/truncate"
  14. import { SessionID, MessageID } from "../../src/session/schema"
  15. import * as Tool from "../../src/tool/tool"
  16. import { testEffect } from "../lib/effect"
  17. import { Watcher } from "@kirincode-ai/core/filesystem/watcher"
  18. const ctx = {
  19. sessionID: SessionID.make("ses_test-edit-session"),
  20. messageID: MessageID.make("msg_test"),
  21. callID: "",
  22. agent: "build",
  23. abort: AbortSignal.any([]),
  24. messages: [],
  25. metadata: () => Effect.void,
  26. ask: () => Effect.void,
  27. }
  28. afterEach(async () => {
  29. await disposeAllInstances()
  30. })
  31. const layer = LayerNode.compile(
  32. LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]),
  33. )
  34. const it = testEffect(layer)
  35. const init = Effect.fn("EditToolTest.init")(function* () {
  36. const info = yield* EditTool
  37. return yield* info.init()
  38. })
  39. const run = Effect.fn("EditToolTest.run")(function* (
  40. args: Tool.InferParameters<typeof EditTool>,
  41. next: Tool.Context = ctx,
  42. ) {
  43. const tool = yield* init()
  44. return yield* tool.execute(args, next)
  45. })
  46. const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameters<typeof EditTool>) {
  47. const exit = yield* run(args).pipe(Effect.exit)
  48. if (Exit.isFailure(exit)) {
  49. const err = Cause.squash(exit.cause)
  50. return err instanceof Error ? err : new Error(String(err))
  51. }
  52. throw new Error("expected edit to fail")
  53. })
  54. const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) {
  55. const fs = yield* FSUtil.Service
  56. yield* fs.writeWithDirs(p, content)
  57. })
  58. const load = Effect.fn("EditToolTest.load")(function* (p: string) {
  59. const fs = yield* FSUtil.Service
  60. return yield* fs.readFileString(p)
  61. })
  62. const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) {
  63. return yield* Effect.promise(() => fs.readFile(p, "utf-8"))
  64. })
  65. const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) {
  66. const fs = yield* FSUtil.Service
  67. yield* fs.makeDirectory(p)
  68. })
  69. const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof Watcher.Event.Updated) {
  70. const events = yield* EventV2Bridge.Service
  71. const deferred = yield* Deferred.make<void>()
  72. const unsub = yield* events.listen((event) => {
  73. if (event.type === def.type) Deferred.doneUnsafe(deferred, Effect.void)
  74. return Effect.void
  75. })
  76. yield* Effect.addFinalizer(() => unsub)
  77. return deferred
  78. })
  79. describe("tool.edit", () => {
  80. describe("creating new files", () => {
  81. it.instance("creates new file when oldString is empty", () =>
  82. Effect.gen(function* () {
  83. const test = yield* TestInstance
  84. const filepath = path.join(test.directory, "newfile.txt")
  85. const result = yield* run({ filePath: filepath, oldString: "", newString: "new content" })
  86. expect(result.metadata.diff).toContain("new content")
  87. expect(yield* load(filepath)).toBe("new content")
  88. }),
  89. )
  90. it.instance("rejects empty oldString on existing files and leaves content unchanged", () =>
  91. Effect.gen(function* () {
  92. const test = yield* TestInstance
  93. const filepath = path.join(test.directory, "existing.cs")
  94. const bom = String.fromCharCode(0xfeff)
  95. const original = `${bom}using System;\n`
  96. yield* put(filepath, original)
  97. expect((yield* fail({ filePath: filepath, oldString: "", newString: "using Up;\n" })).message).toContain(
  98. "oldString cannot be empty",
  99. )
  100. const content = yield* loadRaw(filepath)
  101. expect(content).toBe(original)
  102. }),
  103. )
  104. it.instance("creates new file with nested directories", () =>
  105. Effect.gen(function* () {
  106. const test = yield* TestInstance
  107. const filepath = path.join(test.directory, "nested", "dir", "file.txt")
  108. yield* run({ filePath: filepath, oldString: "", newString: "nested file" })
  109. expect(yield* load(filepath)).toBe("nested file")
  110. }),
  111. )
  112. it.instance("emits add event for new files", () =>
  113. Effect.gen(function* () {
  114. const test = yield* TestInstance
  115. const updated = yield* onceBus(Watcher.Event.Updated)
  116. yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" })
  117. yield* Deferred.await(updated)
  118. }),
  119. )
  120. })
  121. describe("editing existing files", () => {
  122. it.instance("replaces text in existing file", () =>
  123. Effect.gen(function* () {
  124. const test = yield* TestInstance
  125. const filepath = path.join(test.directory, "existing.txt")
  126. yield* put(filepath, "old content here")
  127. const result = yield* run({ filePath: filepath, oldString: "old content", newString: "new content" })
  128. expect(result.output).toContain("Edit applied successfully")
  129. expect(yield* load(filepath)).toBe("new content here")
  130. }),
  131. )
  132. it.instance("replaces the first visible line in BOM files", () =>
  133. Effect.gen(function* () {
  134. const test = yield* TestInstance
  135. const filepath = path.join(test.directory, "existing.cs")
  136. const bom = String.fromCharCode(0xfeff)
  137. yield* put(filepath, `${bom}using System;\nclass Test {}\n`)
  138. const result = yield* run({ filePath: filepath, oldString: "using System;", newString: "using Up;" })
  139. expect(result.metadata.diff).toContain("-using System;")
  140. expect(result.metadata.diff).toContain("+using Up;")
  141. expect(result.metadata.diff).not.toContain(bom)
  142. const content = yield* loadRaw(filepath)
  143. expect(content.charCodeAt(0)).toBe(0xfeff)
  144. expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
  145. }),
  146. )
  147. it.instance("throws error when file does not exist", () =>
  148. Effect.gen(function* () {
  149. const test = yield* TestInstance
  150. expect(
  151. (yield* fail({ filePath: path.join(test.directory, "nonexistent.txt"), oldString: "old", newString: "new" }))
  152. .message,
  153. ).toContain("not found")
  154. }),
  155. )
  156. it.instance("throws error when oldString equals newString", () =>
  157. Effect.gen(function* () {
  158. const test = yield* TestInstance
  159. const filepath = path.join(test.directory, "file.txt")
  160. yield* put(filepath, "content")
  161. expect((yield* fail({ filePath: filepath, oldString: "same", newString: "same" })).message).toContain(
  162. "identical",
  163. )
  164. }),
  165. )
  166. it.instance("throws error when oldString not found in file", () =>
  167. Effect.gen(function* () {
  168. const test = yield* TestInstance
  169. const filepath = path.join(test.directory, "file.txt")
  170. yield* put(filepath, "actual content")
  171. expect(yield* fail({ filePath: filepath, oldString: "not in file", newString: "replacement" })).toBeInstanceOf(
  172. Error,
  173. )
  174. }),
  175. )
  176. it.instance("rejects loose block-anchor matches and leaves content unchanged", () =>
  177. Effect.gen(function* () {
  178. const test = yield* TestInstance
  179. const filepath = path.join(test.directory, "file.ts")
  180. const original = [
  181. "function configure() {",
  182. " keepImportantState()",
  183. " removeAllUserData()",
  184. " archiveBackups()",
  185. " auditLog()",
  186. "}",
  187. ].join("\n")
  188. yield* put(filepath, original)
  189. expect(
  190. (yield* fail({
  191. filePath: filepath,
  192. oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
  193. newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
  194. })).message,
  195. ).toContain("Could not find oldString")
  196. expect(yield* load(filepath)).toBe(original)
  197. }),
  198. )
  199. it.instance("rejects block-anchor matches with unrelated middle content", () =>
  200. Effect.gen(function* () {
  201. const test = yield* TestInstance
  202. const filepath = path.join(test.directory, "file.ts")
  203. const original = ["function configure() {", " removeAllUserData()", "}"].join("\n")
  204. yield* put(filepath, original)
  205. expect(
  206. (yield* fail({
  207. filePath: filepath,
  208. oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
  209. newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
  210. })).message,
  211. ).toContain("Could not find oldString")
  212. expect(yield* load(filepath)).toBe(original)
  213. }),
  214. )
  215. it.instance("replaces all occurrences with replaceAll option", () =>
  216. Effect.gen(function* () {
  217. const test = yield* TestInstance
  218. const filepath = path.join(test.directory, "file.txt")
  219. yield* put(filepath, "foo bar foo baz foo")
  220. yield* run({ filePath: filepath, oldString: "foo", newString: "qux", replaceAll: true })
  221. expect(yield* load(filepath)).toBe("qux bar qux baz qux")
  222. }),
  223. )
  224. it.instance("emits change event for existing files", () =>
  225. Effect.gen(function* () {
  226. const test = yield* TestInstance
  227. const filepath = path.join(test.directory, "file.txt")
  228. yield* put(filepath, "original")
  229. const updated = yield* onceBus(Watcher.Event.Updated)
  230. yield* run({ filePath: filepath, oldString: "original", newString: "modified" })
  231. yield* Deferred.await(updated)
  232. }),
  233. )
  234. })
  235. describe("edge cases", () => {
  236. it.instance("handles multiline replacements", () =>
  237. Effect.gen(function* () {
  238. const test = yield* TestInstance
  239. const filepath = path.join(test.directory, "file.txt")
  240. yield* put(filepath, "line1\nline2\nline3")
  241. yield* run({ filePath: filepath, oldString: "line2", newString: "new line 2\nextra line" })
  242. expect(yield* load(filepath)).toBe("line1\nnew line 2\nextra line\nline3")
  243. }),
  244. )
  245. it.instance("handles CRLF line endings", () =>
  246. Effect.gen(function* () {
  247. const test = yield* TestInstance
  248. const filepath = path.join(test.directory, "file.txt")
  249. yield* put(filepath, "line1\r\nold\r\nline3")
  250. yield* run({ filePath: filepath, oldString: "old", newString: "new" })
  251. expect(yield* load(filepath)).toBe("line1\r\nnew\r\nline3")
  252. }),
  253. )
  254. it.instance("throws error when oldString equals newString", () =>
  255. Effect.gen(function* () {
  256. const test = yield* TestInstance
  257. const filepath = path.join(test.directory, "file.txt")
  258. yield* put(filepath, "content")
  259. expect((yield* fail({ filePath: filepath, oldString: "", newString: "" })).message).toContain("identical")
  260. }),
  261. )
  262. it.instance("throws error when path is directory", () =>
  263. Effect.gen(function* () {
  264. const test = yield* TestInstance
  265. const dirpath = path.join(test.directory, "adir")
  266. yield* makeDirectory(dirpath)
  267. expect((yield* fail({ filePath: dirpath, oldString: "old", newString: "new" })).message).toContain("directory")
  268. }),
  269. )
  270. it.instance("tracks file diff statistics", () =>
  271. Effect.gen(function* () {
  272. const test = yield* TestInstance
  273. const filepath = path.join(test.directory, "file.txt")
  274. yield* put(filepath, "line1\nline2\nline3")
  275. const result = yield* run({ filePath: filepath, oldString: "line2", newString: "new line a\nnew line b" })
  276. expect(result.metadata.filediff).toBeDefined()
  277. expect(result.metadata.filediff.file).toBe(filepath)
  278. expect(result.metadata.filediff.additions).toBeGreaterThan(0)
  279. }),
  280. )
  281. })
  282. describe("line endings", () => {
  283. const old = "alpha\nbeta\ngamma"
  284. const next = "alpha\nbeta-updated\ngamma"
  285. const alt = "alpha\nbeta\nomega"
  286. const normalize = (text: string, ending: "\n" | "\r\n") => {
  287. const normalized = text.replaceAll("\r\n", "\n")
  288. if (ending === "\n") return normalized
  289. return normalized.replaceAll("\n", "\r\n")
  290. }
  291. const count = (content: string) => {
  292. const crlf = content.match(/\r\n/g)?.length ?? 0
  293. const lf = content.match(/\n/g)?.length ?? 0
  294. return {
  295. crlf,
  296. lf: lf - crlf,
  297. }
  298. }
  299. const expectLf = (content: string) => {
  300. const counts = count(content)
  301. expect(counts.crlf).toBe(0)
  302. expect(counts.lf).toBeGreaterThan(0)
  303. }
  304. const expectCrlf = (content: string) => {
  305. const counts = count(content)
  306. expect(counts.lf).toBe(0)
  307. expect(counts.crlf).toBeGreaterThan(0)
  308. }
  309. type Input = {
  310. content: string
  311. oldString: string
  312. newString: string
  313. replaceAll?: boolean
  314. }
  315. const apply = Effect.fn("EditToolTest.lineEndings.apply")(function* (input: Input) {
  316. const test = yield* TestInstance
  317. const filePath = path.join(test.directory, "test.txt")
  318. yield* put(filePath, input.content)
  319. yield* run({
  320. filePath,
  321. oldString: input.oldString,
  322. newString: input.newString,
  323. replaceAll: input.replaceAll,
  324. })
  325. return yield* load(filePath)
  326. })
  327. it.instance("preserves LF with LF multi-line strings", () =>
  328. Effect.gen(function* () {
  329. const content = normalize(old + "\n", "\n")
  330. const output = yield* apply({
  331. content,
  332. oldString: normalize(old, "\n"),
  333. newString: normalize(next, "\n"),
  334. })
  335. expect(output).toBe(normalize(next + "\n", "\n"))
  336. expectLf(output)
  337. }),
  338. )
  339. it.instance("preserves CRLF with CRLF multi-line strings", () =>
  340. Effect.gen(function* () {
  341. const content = normalize(old + "\n", "\r\n")
  342. const output = yield* apply({
  343. content,
  344. oldString: normalize(old, "\r\n"),
  345. newString: normalize(next, "\r\n"),
  346. })
  347. expect(output).toBe(normalize(next + "\n", "\r\n"))
  348. expectCrlf(output)
  349. }),
  350. )
  351. it.instance("preserves LF when old/new use CRLF", () =>
  352. Effect.gen(function* () {
  353. const content = normalize(old + "\n", "\n")
  354. const output = yield* apply({
  355. content,
  356. oldString: normalize(old, "\r\n"),
  357. newString: normalize(next, "\r\n"),
  358. })
  359. expect(output).toBe(normalize(next + "\n", "\n"))
  360. expectLf(output)
  361. }),
  362. )
  363. it.instance("preserves CRLF when old/new use LF", () =>
  364. Effect.gen(function* () {
  365. const content = normalize(old + "\n", "\r\n")
  366. const output = yield* apply({
  367. content,
  368. oldString: normalize(old, "\n"),
  369. newString: normalize(next, "\n"),
  370. })
  371. expect(output).toBe(normalize(next + "\n", "\r\n"))
  372. expectCrlf(output)
  373. }),
  374. )
  375. it.instance("preserves LF when newString uses CRLF", () =>
  376. Effect.gen(function* () {
  377. const content = normalize(old + "\n", "\n")
  378. const output = yield* apply({
  379. content,
  380. oldString: normalize(old, "\n"),
  381. newString: normalize(next, "\r\n"),
  382. })
  383. expect(output).toBe(normalize(next + "\n", "\n"))
  384. expectLf(output)
  385. }),
  386. )
  387. it.instance("preserves CRLF when newString uses LF", () =>
  388. Effect.gen(function* () {
  389. const content = normalize(old + "\n", "\r\n")
  390. const output = yield* apply({
  391. content,
  392. oldString: normalize(old, "\r\n"),
  393. newString: normalize(next, "\n"),
  394. })
  395. expect(output).toBe(normalize(next + "\n", "\r\n"))
  396. expectCrlf(output)
  397. }),
  398. )
  399. it.instance("preserves LF with mixed old/new line endings", () =>
  400. Effect.gen(function* () {
  401. const content = normalize(old + "\n", "\n")
  402. const output = yield* apply({
  403. content,
  404. oldString: "alpha\nbeta\r\ngamma",
  405. newString: "alpha\r\nbeta\nomega",
  406. })
  407. expect(output).toBe(normalize(alt + "\n", "\n"))
  408. expectLf(output)
  409. }),
  410. )
  411. it.instance("preserves CRLF with mixed old/new line endings", () =>
  412. Effect.gen(function* () {
  413. const content = normalize(old + "\n", "\r\n")
  414. const output = yield* apply({
  415. content,
  416. oldString: "alpha\r\nbeta\ngamma",
  417. newString: "alpha\nbeta\r\nomega",
  418. })
  419. expect(output).toBe(normalize(alt + "\n", "\r\n"))
  420. expectCrlf(output)
  421. }),
  422. )
  423. it.instance("replaceAll preserves LF for multi-line blocks", () =>
  424. Effect.gen(function* () {
  425. const blockOld = "alpha\nbeta"
  426. const blockNew = "alpha\nbeta-updated"
  427. const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
  428. const output = yield* apply({
  429. content,
  430. oldString: normalize(blockOld, "\n"),
  431. newString: normalize(blockNew, "\n"),
  432. replaceAll: true,
  433. })
  434. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
  435. expectLf(output)
  436. }),
  437. )
  438. it.instance("replaceAll preserves CRLF for multi-line blocks", () =>
  439. Effect.gen(function* () {
  440. const blockOld = "alpha\nbeta"
  441. const blockNew = "alpha\nbeta-updated"
  442. const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
  443. const output = yield* apply({
  444. content,
  445. oldString: normalize(blockOld, "\r\n"),
  446. newString: normalize(blockNew, "\r\n"),
  447. replaceAll: true,
  448. })
  449. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
  450. expectCrlf(output)
  451. }),
  452. )
  453. })
  454. describe("concurrent editing", () => {
  455. it.instance("preserves concurrent edits to different sections of the same file", () =>
  456. Effect.gen(function* () {
  457. const test = yield* TestInstance
  458. const filepath = path.join(test.directory, "file.txt")
  459. yield* put(filepath, "top = 0\nmiddle = keep\nbottom = 0\n")
  460. const firstAsk = yield* Deferred.make<void>()
  461. let asks = 0
  462. const delayedCtx = {
  463. ...ctx,
  464. ask: () =>
  465. Effect.gen(function* () {
  466. asks++
  467. if (asks !== 1) return
  468. yield* Deferred.succeed(firstAsk, undefined)
  469. yield* Effect.sleep("50 millis")
  470. }),
  471. }
  472. const first = yield* run(
  473. {
  474. filePath: filepath,
  475. oldString: "top = 0",
  476. newString: "top = 1",
  477. },
  478. delayedCtx,
  479. ).pipe(Effect.forkScoped)
  480. yield* Deferred.await(firstAsk)
  481. yield* Effect.all([
  482. Fiber.join(first),
  483. run(
  484. {
  485. filePath: filepath,
  486. oldString: "bottom = 0",
  487. newString: "bottom = 2",
  488. },
  489. delayedCtx,
  490. ),
  491. ])
  492. expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
  493. }),
  494. )
  495. })
  496. })