apply_patch.test.ts 19 KB

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