apply_patch.test.ts 19 KB

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