apply_patch.test.ts 19 KB

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