apply_patch.test.ts 19 KB

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