apply_patch.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import * as fs from "fs/promises"
  4. import { ApplyPatchTool } from "../../src/tool/apply_patch"
  5. import { Instance } from "../../src/project/instance"
  6. import { tmpdir } from "../fixture/fixture"
  7. const baseCtx = {
  8. sessionID: "test",
  9. messageID: "",
  10. callID: "",
  11. agent: "build",
  12. abort: AbortSignal.any([]),
  13. metadata: () => {},
  14. }
  15. type AskInput = {
  16. permission: string
  17. patterns: string[]
  18. always: string[]
  19. metadata: { diff: string }
  20. }
  21. type ToolCtx = typeof baseCtx & {
  22. ask: (input: AskInput) => Promise<void>
  23. }
  24. const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
  25. const tool = await ApplyPatchTool.init()
  26. return tool.execute(params, ctx)
  27. }
  28. const makeCtx = () => {
  29. const calls: AskInput[] = []
  30. const ctx: ToolCtx = {
  31. ...baseCtx,
  32. ask: async (input) => {
  33. calls.push(input)
  34. },
  35. }
  36. return { ctx, calls }
  37. }
  38. describe("tool.apply_patch freeform", () => {
  39. test("requires patchText", async () => {
  40. const { ctx } = makeCtx()
  41. await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
  42. })
  43. test("rejects invalid patch format", async () => {
  44. const { ctx } = makeCtx()
  45. await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
  46. })
  47. test("rejects empty patch", async () => {
  48. const { ctx } = makeCtx()
  49. const emptyPatch = "*** Begin Patch\n*** End Patch"
  50. await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
  51. })
  52. test("applies add/update/delete in one patch", async () => {
  53. await using fixture = await tmpdir()
  54. const { ctx, calls } = makeCtx()
  55. await Instance.provide({
  56. directory: fixture.path,
  57. fn: async () => {
  58. const modifyPath = path.join(fixture.path, "modify.txt")
  59. const deletePath = path.join(fixture.path, "delete.txt")
  60. await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
  61. await fs.writeFile(deletePath, "obsolete\n", "utf-8")
  62. const patchText =
  63. "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
  64. const result = await execute({ patchText }, ctx)
  65. expect(result.title).toContain("Success. Updated the following files")
  66. expect(result.output).toContain("Success. Updated the following files")
  67. expect(result.metadata.diff).toContain("Index:")
  68. expect(calls.length).toBe(1)
  69. const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
  70. expect(added).toBe("created\n")
  71. expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
  72. await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
  73. },
  74. })
  75. })
  76. test("applies multiple hunks to one file", async () => {
  77. await using fixture = await tmpdir()
  78. const { ctx } = makeCtx()
  79. await Instance.provide({
  80. directory: fixture.path,
  81. fn: async () => {
  82. const target = path.join(fixture.path, "multi.txt")
  83. await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
  84. const patchText =
  85. "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
  86. await execute({ patchText }, ctx)
  87. expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
  88. },
  89. })
  90. })
  91. test("inserts lines with insert-only hunk", async () => {
  92. await using fixture = await tmpdir()
  93. const { ctx } = makeCtx()
  94. await Instance.provide({
  95. directory: fixture.path,
  96. fn: async () => {
  97. const target = path.join(fixture.path, "insert_only.txt")
  98. await fs.writeFile(target, "alpha\nomega\n", "utf-8")
  99. const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
  100. await execute({ patchText }, ctx)
  101. expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
  102. },
  103. })
  104. })
  105. test("appends trailing newline on update", async () => {
  106. await using fixture = await tmpdir()
  107. const { ctx } = makeCtx()
  108. await Instance.provide({
  109. directory: fixture.path,
  110. fn: async () => {
  111. const target = path.join(fixture.path, "no_newline.txt")
  112. await fs.writeFile(target, "no newline at end", "utf-8")
  113. const patchText =
  114. "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
  115. await execute({ patchText }, ctx)
  116. const contents = await fs.readFile(target, "utf-8")
  117. expect(contents.endsWith("\n")).toBe(true)
  118. expect(contents).toBe("first line\nsecond line\n")
  119. },
  120. })
  121. })
  122. test("moves file to a new directory", async () => {
  123. await using fixture = await tmpdir()
  124. const { ctx } = makeCtx()
  125. await Instance.provide({
  126. directory: fixture.path,
  127. fn: async () => {
  128. const original = path.join(fixture.path, "old", "name.txt")
  129. await fs.mkdir(path.dirname(original), { recursive: true })
  130. await fs.writeFile(original, "old content\n", "utf-8")
  131. const patchText =
  132. "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
  133. await execute({ patchText }, ctx)
  134. const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
  135. await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
  136. expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
  137. },
  138. })
  139. })
  140. test("moves file overwriting existing destination", async () => {
  141. await using fixture = await tmpdir()
  142. const { ctx } = makeCtx()
  143. await Instance.provide({
  144. directory: fixture.path,
  145. fn: async () => {
  146. const original = path.join(fixture.path, "old", "name.txt")
  147. const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
  148. await fs.mkdir(path.dirname(original), { recursive: true })
  149. await fs.mkdir(path.dirname(destination), { recursive: true })
  150. await fs.writeFile(original, "from\n", "utf-8")
  151. await fs.writeFile(destination, "existing\n", "utf-8")
  152. const patchText =
  153. "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
  154. await execute({ patchText }, ctx)
  155. await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
  156. expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
  157. },
  158. })
  159. })
  160. test("adds file overwriting existing file", async () => {
  161. await using fixture = await tmpdir()
  162. const { ctx } = makeCtx()
  163. await Instance.provide({
  164. directory: fixture.path,
  165. fn: async () => {
  166. const target = path.join(fixture.path, "duplicate.txt")
  167. await fs.writeFile(target, "old content\n", "utf-8")
  168. const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
  169. await execute({ patchText }, ctx)
  170. expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
  171. },
  172. })
  173. })
  174. test("rejects update when target file is missing", async () => {
  175. await using fixture = await tmpdir()
  176. const { ctx } = makeCtx()
  177. await Instance.provide({
  178. directory: fixture.path,
  179. fn: async () => {
  180. const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
  181. await expect(execute({ patchText }, ctx)).rejects.toThrow(
  182. "apply_patch verification failed: Failed to read file to update",
  183. )
  184. },
  185. })
  186. })
  187. test("rejects delete when file is missing", async () => {
  188. await using fixture = await tmpdir()
  189. const { ctx } = makeCtx()
  190. await Instance.provide({
  191. directory: fixture.path,
  192. fn: async () => {
  193. const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
  194. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  195. },
  196. })
  197. })
  198. test("rejects delete when target is a directory", async () => {
  199. await using fixture = await tmpdir()
  200. const { ctx } = makeCtx()
  201. await Instance.provide({
  202. directory: fixture.path,
  203. fn: async () => {
  204. const dirPath = path.join(fixture.path, "dir")
  205. await fs.mkdir(dirPath)
  206. const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
  207. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  208. },
  209. })
  210. })
  211. test("rejects invalid hunk header", async () => {
  212. await using fixture = await tmpdir()
  213. const { ctx } = makeCtx()
  214. await Instance.provide({
  215. directory: fixture.path,
  216. fn: async () => {
  217. const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
  218. await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
  219. },
  220. })
  221. })
  222. test("rejects update with missing context", async () => {
  223. await using fixture = await tmpdir()
  224. const { ctx } = makeCtx()
  225. await Instance.provide({
  226. directory: fixture.path,
  227. fn: async () => {
  228. const target = path.join(fixture.path, "modify.txt")
  229. await fs.writeFile(target, "line1\nline2\n", "utf-8")
  230. const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
  231. await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
  232. expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
  233. },
  234. })
  235. })
  236. test("verification failure leaves no side effects", async () => {
  237. await using fixture = await tmpdir()
  238. const { ctx } = makeCtx()
  239. await Instance.provide({
  240. directory: fixture.path,
  241. fn: async () => {
  242. const patchText =
  243. "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
  244. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  245. const createdPath = path.join(fixture.path, "created.txt")
  246. await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
  247. },
  248. })
  249. })
  250. test("supports end of file anchor", async () => {
  251. await using fixture = await tmpdir()
  252. const { ctx } = makeCtx()
  253. await Instance.provide({
  254. directory: fixture.path,
  255. fn: async () => {
  256. const target = path.join(fixture.path, "tail.txt")
  257. await fs.writeFile(target, "alpha\nlast\n", "utf-8")
  258. const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
  259. await execute({ patchText }, ctx)
  260. expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
  261. },
  262. })
  263. })
  264. test("rejects missing second chunk context", async () => {
  265. await using fixture = await tmpdir()
  266. const { ctx } = makeCtx()
  267. await Instance.provide({
  268. directory: fixture.path,
  269. fn: async () => {
  270. const target = path.join(fixture.path, "two_chunks.txt")
  271. await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
  272. const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
  273. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  274. expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
  275. },
  276. })
  277. })
  278. test("disambiguates change context with @@ header", async () => {
  279. await using fixture = await tmpdir()
  280. const { ctx } = makeCtx()
  281. await Instance.provide({
  282. directory: fixture.path,
  283. fn: async () => {
  284. const target = path.join(fixture.path, "multi_ctx.txt")
  285. await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
  286. const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
  287. await execute({ patchText }, ctx)
  288. expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
  289. },
  290. })
  291. })
  292. test("EOF anchor matches from end of file first", async () => {
  293. await using fixture = await tmpdir()
  294. const { ctx } = makeCtx()
  295. await Instance.provide({
  296. directory: fixture.path,
  297. fn: async () => {
  298. const target = path.join(fixture.path, "eof_anchor.txt")
  299. // File has duplicate "marker" lines - one in middle, one at end
  300. await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
  301. // With EOF anchor, should match the LAST "marker" line, not the first
  302. const patchText =
  303. "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
  304. await execute({ patchText }, ctx)
  305. // First marker unchanged, second marker changed
  306. expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
  307. },
  308. })
  309. })
  310. test("parses heredoc-wrapped patch", async () => {
  311. await using fixture = await tmpdir()
  312. const { ctx } = makeCtx()
  313. await Instance.provide({
  314. directory: fixture.path,
  315. fn: async () => {
  316. const patchText = `cat <<'EOF'
  317. *** Begin Patch
  318. *** Add File: heredoc_test.txt
  319. +heredoc content
  320. *** End Patch
  321. EOF`
  322. await execute({ patchText }, ctx)
  323. const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
  324. expect(content).toBe("heredoc content\n")
  325. },
  326. })
  327. })
  328. test("parses heredoc-wrapped patch without cat", async () => {
  329. await using fixture = await tmpdir()
  330. const { ctx } = makeCtx()
  331. await Instance.provide({
  332. directory: fixture.path,
  333. fn: async () => {
  334. const patchText = `<<EOF
  335. *** Begin Patch
  336. *** Add File: heredoc_no_cat.txt
  337. +no cat prefix
  338. *** End Patch
  339. EOF`
  340. await execute({ patchText }, ctx)
  341. const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
  342. expect(content).toBe("no cat prefix\n")
  343. },
  344. })
  345. })
  346. test("matches with trailing whitespace differences", async () => {
  347. await using fixture = await tmpdir()
  348. const { ctx } = makeCtx()
  349. await Instance.provide({
  350. directory: fixture.path,
  351. fn: async () => {
  352. const target = path.join(fixture.path, "trailing_ws.txt")
  353. // File has trailing spaces on some lines
  354. await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
  355. // Patch doesn't have trailing spaces - should still match via rstrip pass
  356. const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
  357. await execute({ patchText }, ctx)
  358. expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
  359. },
  360. })
  361. })
  362. test("matches with leading whitespace differences", async () => {
  363. await using fixture = await tmpdir()
  364. const { ctx } = makeCtx()
  365. await Instance.provide({
  366. directory: fixture.path,
  367. fn: async () => {
  368. const target = path.join(fixture.path, "leading_ws.txt")
  369. // File has leading spaces
  370. await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
  371. // Patch without leading spaces - should match via trim pass
  372. const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
  373. await execute({ patchText }, ctx)
  374. expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
  375. },
  376. })
  377. })
  378. test("matches with Unicode punctuation differences", async () => {
  379. await using fixture = await tmpdir()
  380. const { ctx } = makeCtx()
  381. await Instance.provide({
  382. directory: fixture.path,
  383. fn: async () => {
  384. const target = path.join(fixture.path, "unicode.txt")
  385. // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
  386. const leftQuote = "\u201C"
  387. const rightQuote = "\u201D"
  388. const emDash = "\u2014"
  389. await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
  390. // Patch uses ASCII equivalents - should match via normalized pass
  391. // The replacement uses ASCII quotes from the patch (not preserving Unicode)
  392. const patchText =
  393. '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
  394. await execute({ patchText }, ctx)
  395. // Result has ASCII quotes because that's what the patch specifies
  396. expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
  397. },
  398. })
  399. })
  400. })