edit.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  1. import { afterAll, afterEach, describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { Effect, Layer, ManagedRuntime } from "effect"
  5. import { EditTool } from "../../src/tool/edit"
  6. import { Instance } from "../../src/project/instance"
  7. import { tmpdir } from "../fixture/fixture"
  8. import { FileTime } from "../../src/file/time"
  9. import { LSP } from "../../src/lsp"
  10. import { AppFileSystem } from "../../src/filesystem"
  11. import { Format } from "../../src/format"
  12. import { Agent } from "../../src/agent/agent"
  13. import { Bus } from "../../src/bus"
  14. import { BusEvent } from "../../src/bus/bus-event"
  15. import { Truncate } from "../../src/tool/truncate"
  16. import { SessionID, MessageID } from "../../src/session/schema"
  17. const ctx = {
  18. sessionID: SessionID.make("ses_test-edit-session"),
  19. messageID: MessageID.make(""),
  20. callID: "",
  21. agent: "build",
  22. abort: AbortSignal.any([]),
  23. messages: [],
  24. metadata: () => Effect.void,
  25. ask: () => Effect.void,
  26. }
  27. afterEach(async () => {
  28. await Instance.disposeAll()
  29. })
  30. async function touch(file: string, time: number) {
  31. const date = new Date(time)
  32. await fs.utimes(file, date, date)
  33. }
  34. const runtime = ManagedRuntime.make(
  35. Layer.mergeAll(
  36. LSP.defaultLayer,
  37. FileTime.defaultLayer,
  38. AppFileSystem.defaultLayer,
  39. Format.defaultLayer,
  40. Bus.layer,
  41. Truncate.defaultLayer,
  42. Agent.defaultLayer,
  43. ),
  44. )
  45. afterAll(async () => {
  46. await runtime.dispose()
  47. })
  48. const resolve = () =>
  49. runtime.runPromise(
  50. Effect.gen(function* () {
  51. const info = yield* EditTool
  52. return yield* info.init()
  53. }),
  54. )
  55. const readFileTime = (sessionID: SessionID, filepath: string) =>
  56. runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
  57. const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
  58. runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
  59. async function onceBus<D extends BusEvent.Definition>(def: D) {
  60. const result = Promise.withResolvers<void>()
  61. const unsub = await subscribeBus(def, () => {
  62. unsub()
  63. result.resolve()
  64. })
  65. return {
  66. wait: result.promise,
  67. unsub,
  68. }
  69. }
  70. describe("tool.edit", () => {
  71. describe("creating new files", () => {
  72. test("creates new file when oldString is empty", async () => {
  73. await using tmp = await tmpdir()
  74. const filepath = path.join(tmp.path, "newfile.txt")
  75. await Instance.provide({
  76. directory: tmp.path,
  77. fn: async () => {
  78. const edit = await resolve()
  79. const result = await Effect.runPromise(
  80. edit.execute(
  81. {
  82. filePath: filepath,
  83. oldString: "",
  84. newString: "new content",
  85. },
  86. ctx,
  87. ),
  88. )
  89. expect(result.metadata.diff).toContain("new content")
  90. const content = await fs.readFile(filepath, "utf-8")
  91. expect(content).toBe("new content")
  92. },
  93. })
  94. })
  95. test("creates new file with nested directories", async () => {
  96. await using tmp = await tmpdir()
  97. const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
  98. await Instance.provide({
  99. directory: tmp.path,
  100. fn: async () => {
  101. const edit = await resolve()
  102. await Effect.runPromise(
  103. edit.execute(
  104. {
  105. filePath: filepath,
  106. oldString: "",
  107. newString: "nested file",
  108. },
  109. ctx,
  110. ),
  111. )
  112. const content = await fs.readFile(filepath, "utf-8")
  113. expect(content).toBe("nested file")
  114. },
  115. })
  116. })
  117. test("emits add event for new files", async () => {
  118. await using tmp = await tmpdir()
  119. const filepath = path.join(tmp.path, "new.txt")
  120. await Instance.provide({
  121. directory: tmp.path,
  122. fn: async () => {
  123. const { FileWatcher } = await import("../../src/file/watcher")
  124. const updated = await onceBus(FileWatcher.Event.Updated)
  125. try {
  126. const edit = await resolve()
  127. await Effect.runPromise(
  128. edit.execute(
  129. {
  130. filePath: filepath,
  131. oldString: "",
  132. newString: "content",
  133. },
  134. ctx,
  135. ),
  136. )
  137. await updated.wait
  138. } finally {
  139. updated.unsub()
  140. }
  141. },
  142. })
  143. })
  144. })
  145. describe("editing existing files", () => {
  146. test("replaces text in existing file", async () => {
  147. await using tmp = await tmpdir()
  148. const filepath = path.join(tmp.path, "existing.txt")
  149. await fs.writeFile(filepath, "old content here", "utf-8")
  150. await Instance.provide({
  151. directory: tmp.path,
  152. fn: async () => {
  153. await readFileTime(ctx.sessionID, filepath)
  154. const edit = await resolve()
  155. const result = await Effect.runPromise(
  156. edit.execute(
  157. {
  158. filePath: filepath,
  159. oldString: "old content",
  160. newString: "new content",
  161. },
  162. ctx,
  163. ),
  164. )
  165. expect(result.output).toContain("Edit applied successfully")
  166. const content = await fs.readFile(filepath, "utf-8")
  167. expect(content).toBe("new content here")
  168. },
  169. })
  170. })
  171. test("throws error when file does not exist", async () => {
  172. await using tmp = await tmpdir()
  173. const filepath = path.join(tmp.path, "nonexistent.txt")
  174. await Instance.provide({
  175. directory: tmp.path,
  176. fn: async () => {
  177. await readFileTime(ctx.sessionID, filepath)
  178. const edit = await resolve()
  179. await expect(
  180. Effect.runPromise(
  181. edit.execute(
  182. {
  183. filePath: filepath,
  184. oldString: "old",
  185. newString: "new",
  186. },
  187. ctx,
  188. ),
  189. ),
  190. ).rejects.toThrow("not found")
  191. },
  192. })
  193. })
  194. test("throws error when oldString equals newString", async () => {
  195. await using tmp = await tmpdir()
  196. const filepath = path.join(tmp.path, "file.txt")
  197. await fs.writeFile(filepath, "content", "utf-8")
  198. await Instance.provide({
  199. directory: tmp.path,
  200. fn: async () => {
  201. const edit = await resolve()
  202. await expect(
  203. Effect.runPromise(
  204. edit.execute(
  205. {
  206. filePath: filepath,
  207. oldString: "same",
  208. newString: "same",
  209. },
  210. ctx,
  211. ),
  212. ),
  213. ).rejects.toThrow("identical")
  214. },
  215. })
  216. })
  217. test("throws error when oldString not found in file", async () => {
  218. await using tmp = await tmpdir()
  219. const filepath = path.join(tmp.path, "file.txt")
  220. await fs.writeFile(filepath, "actual content", "utf-8")
  221. await Instance.provide({
  222. directory: tmp.path,
  223. fn: async () => {
  224. await readFileTime(ctx.sessionID, filepath)
  225. const edit = await resolve()
  226. await expect(
  227. Effect.runPromise(
  228. edit.execute(
  229. {
  230. filePath: filepath,
  231. oldString: "not in file",
  232. newString: "replacement",
  233. },
  234. ctx,
  235. ),
  236. ),
  237. ).rejects.toThrow()
  238. },
  239. })
  240. })
  241. test("throws error when file was not read first (FileTime)", async () => {
  242. await using tmp = await tmpdir()
  243. const filepath = path.join(tmp.path, "file.txt")
  244. await fs.writeFile(filepath, "content", "utf-8")
  245. await Instance.provide({
  246. directory: tmp.path,
  247. fn: async () => {
  248. const edit = await resolve()
  249. await expect(
  250. Effect.runPromise(
  251. edit.execute(
  252. {
  253. filePath: filepath,
  254. oldString: "content",
  255. newString: "modified",
  256. },
  257. ctx,
  258. ),
  259. ),
  260. ).rejects.toThrow("You must read file")
  261. },
  262. })
  263. })
  264. test("throws error when file has been modified since read", async () => {
  265. await using tmp = await tmpdir()
  266. const filepath = path.join(tmp.path, "file.txt")
  267. await fs.writeFile(filepath, "original content", "utf-8")
  268. await touch(filepath, 1_000)
  269. await Instance.provide({
  270. directory: tmp.path,
  271. fn: async () => {
  272. // Read first
  273. await readFileTime(ctx.sessionID, filepath)
  274. // Simulate external modification
  275. await fs.writeFile(filepath, "modified externally", "utf-8")
  276. await touch(filepath, 2_000)
  277. // Try to edit with the new content
  278. const edit = await resolve()
  279. await expect(
  280. Effect.runPromise(
  281. edit.execute(
  282. {
  283. filePath: filepath,
  284. oldString: "modified externally",
  285. newString: "edited",
  286. },
  287. ctx,
  288. ),
  289. ),
  290. ).rejects.toThrow("modified since it was last read")
  291. },
  292. })
  293. })
  294. test("replaces all occurrences with replaceAll option", async () => {
  295. await using tmp = await tmpdir()
  296. const filepath = path.join(tmp.path, "file.txt")
  297. await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
  298. await Instance.provide({
  299. directory: tmp.path,
  300. fn: async () => {
  301. await readFileTime(ctx.sessionID, filepath)
  302. const edit = await resolve()
  303. await Effect.runPromise(
  304. edit.execute(
  305. {
  306. filePath: filepath,
  307. oldString: "foo",
  308. newString: "qux",
  309. replaceAll: true,
  310. },
  311. ctx,
  312. ),
  313. )
  314. const content = await fs.readFile(filepath, "utf-8")
  315. expect(content).toBe("qux bar qux baz qux")
  316. },
  317. })
  318. })
  319. test("emits change event for existing files", async () => {
  320. await using tmp = await tmpdir()
  321. const filepath = path.join(tmp.path, "file.txt")
  322. await fs.writeFile(filepath, "original", "utf-8")
  323. await Instance.provide({
  324. directory: tmp.path,
  325. fn: async () => {
  326. await readFileTime(ctx.sessionID, filepath)
  327. const { FileWatcher } = await import("../../src/file/watcher")
  328. const updated = await onceBus(FileWatcher.Event.Updated)
  329. try {
  330. const edit = await resolve()
  331. await Effect.runPromise(
  332. edit.execute(
  333. {
  334. filePath: filepath,
  335. oldString: "original",
  336. newString: "modified",
  337. },
  338. ctx,
  339. ),
  340. )
  341. await updated.wait
  342. } finally {
  343. updated.unsub()
  344. }
  345. },
  346. })
  347. })
  348. })
  349. describe("edge cases", () => {
  350. test("handles multiline replacements", async () => {
  351. await using tmp = await tmpdir()
  352. const filepath = path.join(tmp.path, "file.txt")
  353. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  354. await Instance.provide({
  355. directory: tmp.path,
  356. fn: async () => {
  357. await readFileTime(ctx.sessionID, filepath)
  358. const edit = await resolve()
  359. await Effect.runPromise(
  360. edit.execute(
  361. {
  362. filePath: filepath,
  363. oldString: "line2",
  364. newString: "new line 2\nextra line",
  365. },
  366. ctx,
  367. ),
  368. )
  369. const content = await fs.readFile(filepath, "utf-8")
  370. expect(content).toBe("line1\nnew line 2\nextra line\nline3")
  371. },
  372. })
  373. })
  374. test("handles CRLF line endings", async () => {
  375. await using tmp = await tmpdir()
  376. const filepath = path.join(tmp.path, "file.txt")
  377. await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
  378. await Instance.provide({
  379. directory: tmp.path,
  380. fn: async () => {
  381. await readFileTime(ctx.sessionID, filepath)
  382. const edit = await resolve()
  383. await Effect.runPromise(
  384. edit.execute(
  385. {
  386. filePath: filepath,
  387. oldString: "old",
  388. newString: "new",
  389. },
  390. ctx,
  391. ),
  392. )
  393. const content = await fs.readFile(filepath, "utf-8")
  394. expect(content).toBe("line1\r\nnew\r\nline3")
  395. },
  396. })
  397. })
  398. test("throws error when oldString equals newString", async () => {
  399. await using tmp = await tmpdir()
  400. const filepath = path.join(tmp.path, "file.txt")
  401. await fs.writeFile(filepath, "content", "utf-8")
  402. await Instance.provide({
  403. directory: tmp.path,
  404. fn: async () => {
  405. const edit = await resolve()
  406. await expect(
  407. Effect.runPromise(
  408. edit.execute(
  409. {
  410. filePath: filepath,
  411. oldString: "",
  412. newString: "",
  413. },
  414. ctx,
  415. ),
  416. ),
  417. ).rejects.toThrow("identical")
  418. },
  419. })
  420. })
  421. test("throws error when path is directory", async () => {
  422. await using tmp = await tmpdir()
  423. const dirpath = path.join(tmp.path, "adir")
  424. await fs.mkdir(dirpath)
  425. await Instance.provide({
  426. directory: tmp.path,
  427. fn: async () => {
  428. await readFileTime(ctx.sessionID, dirpath)
  429. const edit = await resolve()
  430. await expect(
  431. Effect.runPromise(
  432. edit.execute(
  433. {
  434. filePath: dirpath,
  435. oldString: "old",
  436. newString: "new",
  437. },
  438. ctx,
  439. ),
  440. ),
  441. ).rejects.toThrow("directory")
  442. },
  443. })
  444. })
  445. test("tracks file diff statistics", async () => {
  446. await using tmp = await tmpdir()
  447. const filepath = path.join(tmp.path, "file.txt")
  448. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  449. await Instance.provide({
  450. directory: tmp.path,
  451. fn: async () => {
  452. await readFileTime(ctx.sessionID, filepath)
  453. const edit = await resolve()
  454. const result = await Effect.runPromise(
  455. edit.execute(
  456. {
  457. filePath: filepath,
  458. oldString: "line2",
  459. newString: "new line a\nnew line b",
  460. },
  461. ctx,
  462. ),
  463. )
  464. expect(result.metadata.filediff).toBeDefined()
  465. expect(result.metadata.filediff.file).toBe(filepath)
  466. expect(result.metadata.filediff.additions).toBeGreaterThan(0)
  467. },
  468. })
  469. })
  470. })
  471. describe("line endings", () => {
  472. const old = "alpha\nbeta\ngamma"
  473. const next = "alpha\nbeta-updated\ngamma"
  474. const alt = "alpha\nbeta\nomega"
  475. const normalize = (text: string, ending: "\n" | "\r\n") => {
  476. const normalized = text.replaceAll("\r\n", "\n")
  477. if (ending === "\n") return normalized
  478. return normalized.replaceAll("\n", "\r\n")
  479. }
  480. const count = (content: string) => {
  481. const crlf = content.match(/\r\n/g)?.length ?? 0
  482. const lf = content.match(/\n/g)?.length ?? 0
  483. return {
  484. crlf,
  485. lf: lf - crlf,
  486. }
  487. }
  488. const expectLf = (content: string) => {
  489. const counts = count(content)
  490. expect(counts.crlf).toBe(0)
  491. expect(counts.lf).toBeGreaterThan(0)
  492. }
  493. const expectCrlf = (content: string) => {
  494. const counts = count(content)
  495. expect(counts.lf).toBe(0)
  496. expect(counts.crlf).toBeGreaterThan(0)
  497. }
  498. type Input = {
  499. content: string
  500. oldString: string
  501. newString: string
  502. replaceAll?: boolean
  503. }
  504. const apply = async (input: Input) => {
  505. await using tmp = await tmpdir({
  506. init: async (dir) => {
  507. await Bun.write(path.join(dir, "test.txt"), input.content)
  508. },
  509. })
  510. return await Instance.provide({
  511. directory: tmp.path,
  512. fn: async () => {
  513. const edit = await resolve()
  514. const filePath = path.join(tmp.path, "test.txt")
  515. await readFileTime(ctx.sessionID, filePath)
  516. await Effect.runPromise(
  517. edit.execute(
  518. {
  519. filePath,
  520. oldString: input.oldString,
  521. newString: input.newString,
  522. replaceAll: input.replaceAll,
  523. },
  524. ctx,
  525. ),
  526. )
  527. return await Bun.file(filePath).text()
  528. },
  529. })
  530. }
  531. test("preserves LF with LF multi-line strings", async () => {
  532. const content = normalize(old + "\n", "\n")
  533. const output = await apply({
  534. content,
  535. oldString: normalize(old, "\n"),
  536. newString: normalize(next, "\n"),
  537. })
  538. expect(output).toBe(normalize(next + "\n", "\n"))
  539. expectLf(output)
  540. })
  541. test("preserves CRLF with CRLF multi-line strings", async () => {
  542. const content = normalize(old + "\n", "\r\n")
  543. const output = await apply({
  544. content,
  545. oldString: normalize(old, "\r\n"),
  546. newString: normalize(next, "\r\n"),
  547. })
  548. expect(output).toBe(normalize(next + "\n", "\r\n"))
  549. expectCrlf(output)
  550. })
  551. test("preserves LF when old/new use CRLF", async () => {
  552. const content = normalize(old + "\n", "\n")
  553. const output = await apply({
  554. content,
  555. oldString: normalize(old, "\r\n"),
  556. newString: normalize(next, "\r\n"),
  557. })
  558. expect(output).toBe(normalize(next + "\n", "\n"))
  559. expectLf(output)
  560. })
  561. test("preserves CRLF when old/new use LF", async () => {
  562. const content = normalize(old + "\n", "\r\n")
  563. const output = await apply({
  564. content,
  565. oldString: normalize(old, "\n"),
  566. newString: normalize(next, "\n"),
  567. })
  568. expect(output).toBe(normalize(next + "\n", "\r\n"))
  569. expectCrlf(output)
  570. })
  571. test("preserves LF when newString uses CRLF", async () => {
  572. const content = normalize(old + "\n", "\n")
  573. const output = await apply({
  574. content,
  575. oldString: normalize(old, "\n"),
  576. newString: normalize(next, "\r\n"),
  577. })
  578. expect(output).toBe(normalize(next + "\n", "\n"))
  579. expectLf(output)
  580. })
  581. test("preserves CRLF when newString uses LF", async () => {
  582. const content = normalize(old + "\n", "\r\n")
  583. const output = await apply({
  584. content,
  585. oldString: normalize(old, "\r\n"),
  586. newString: normalize(next, "\n"),
  587. })
  588. expect(output).toBe(normalize(next + "\n", "\r\n"))
  589. expectCrlf(output)
  590. })
  591. test("preserves LF with mixed old/new line endings", async () => {
  592. const content = normalize(old + "\n", "\n")
  593. const output = await apply({
  594. content,
  595. oldString: "alpha\nbeta\r\ngamma",
  596. newString: "alpha\r\nbeta\nomega",
  597. })
  598. expect(output).toBe(normalize(alt + "\n", "\n"))
  599. expectLf(output)
  600. })
  601. test("preserves CRLF with mixed old/new line endings", async () => {
  602. const content = normalize(old + "\n", "\r\n")
  603. const output = await apply({
  604. content,
  605. oldString: "alpha\r\nbeta\ngamma",
  606. newString: "alpha\nbeta\r\nomega",
  607. })
  608. expect(output).toBe(normalize(alt + "\n", "\r\n"))
  609. expectCrlf(output)
  610. })
  611. test("replaceAll preserves LF for multi-line blocks", async () => {
  612. const blockOld = "alpha\nbeta"
  613. const blockNew = "alpha\nbeta-updated"
  614. const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
  615. const output = await apply({
  616. content,
  617. oldString: normalize(blockOld, "\n"),
  618. newString: normalize(blockNew, "\n"),
  619. replaceAll: true,
  620. })
  621. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
  622. expectLf(output)
  623. })
  624. test("replaceAll preserves CRLF for multi-line blocks", async () => {
  625. const blockOld = "alpha\nbeta"
  626. const blockNew = "alpha\nbeta-updated"
  627. const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
  628. const output = await apply({
  629. content,
  630. oldString: normalize(blockOld, "\r\n"),
  631. newString: normalize(blockNew, "\r\n"),
  632. replaceAll: true,
  633. })
  634. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
  635. expectCrlf(output)
  636. })
  637. })
  638. describe("concurrent editing", () => {
  639. test("serializes concurrent edits to same file", async () => {
  640. await using tmp = await tmpdir()
  641. const filepath = path.join(tmp.path, "file.txt")
  642. await fs.writeFile(filepath, "0", "utf-8")
  643. await Instance.provide({
  644. directory: tmp.path,
  645. fn: async () => {
  646. await readFileTime(ctx.sessionID, filepath)
  647. const edit = await resolve()
  648. // Two concurrent edits
  649. const promise1 = Effect.runPromise(
  650. edit.execute(
  651. {
  652. filePath: filepath,
  653. oldString: "0",
  654. newString: "1",
  655. },
  656. ctx,
  657. ),
  658. )
  659. // Need to read again since FileTime tracks per-session
  660. await readFileTime(ctx.sessionID, filepath)
  661. const promise2 = Effect.runPromise(
  662. edit.execute(
  663. {
  664. filePath: filepath,
  665. oldString: "0",
  666. newString: "2",
  667. },
  668. ctx,
  669. ),
  670. )
  671. // Both should complete without error (though one might fail due to content mismatch)
  672. const results = await Promise.allSettled([promise1, promise2])
  673. expect(results.some((r) => r.status === "fulfilled")).toBe(true)
  674. },
  675. })
  676. })
  677. })
  678. })