apply_patch.test.ts 19 KB

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