edit.test.ts 20 KB

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