apply_patch.test.ts 17 KB

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