edit.test.ts 22 KB

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