tool-apply-patch.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
  5. import { FileMutation } from "@opencode-ai/core/file-mutation"
  6. import { FSUtil } from "@opencode-ai/core/fs-util"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  9. import { PermissionV2 } from "@opencode-ai/core/permission"
  10. import { AbsolutePath } from "@opencode-ai/core/schema"
  11. import { SessionV2 } from "@opencode-ai/core/session"
  12. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  13. import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
  14. import { location } from "./fixture/location"
  15. import { tmpdir } from "./fixture/tmpdir"
  16. import { testEffect } from "./lib/effect"
  17. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  18. const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
  19. const assertions: PermissionV2.AssertInput[] = []
  20. let denyAction: string | undefined
  21. let failRemoveTarget: string | undefined
  22. let readsBeforeEditApproval = 0
  23. let editApproved = false
  24. let blockRemoveTarget: string | undefined
  25. let removeStarted: Deferred.Deferred<void> | undefined
  26. let releaseRemove: Deferred.Deferred<void> | undefined
  27. let afterEditApproval = (): Effect.Effect<void> => Effect.void
  28. const permission = Layer.succeed(
  29. PermissionV2.Service,
  30. PermissionV2.Service.of({
  31. assert: (input) =>
  32. Effect.sync(() => {
  33. assertions.push(input)
  34. if (input.action === "edit") editApproved = true
  35. }).pipe(
  36. Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
  37. Effect.andThen(
  38. input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
  39. ),
  40. ),
  41. ask: () => Effect.die("unused"),
  42. reply: () => Effect.die("unused"),
  43. get: () => Effect.die("unused"),
  44. forSession: () => Effect.die("unused"),
  45. list: () => Effect.die("unused"),
  46. }),
  47. )
  48. const reset = () => {
  49. assertions.length = 0
  50. denyAction = undefined
  51. failRemoveTarget = undefined
  52. readsBeforeEditApproval = 0
  53. editApproved = false
  54. blockRemoveTarget = undefined
  55. removeStarted = undefined
  56. releaseRemove = undefined
  57. afterEditApproval = () => Effect.void
  58. }
  59. const filesystem = Layer.effect(
  60. FSUtil.Service,
  61. Effect.gen(function* () {
  62. const fs = yield* FSUtil.Service
  63. return FSUtil.Service.of({
  64. ...fs,
  65. readFile: (target) =>
  66. Effect.sync(() => {
  67. if (!editApproved) readsBeforeEditApproval++
  68. }).pipe(Effect.andThen(fs.readFile(target))),
  69. remove: (target, options) => {
  70. if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
  71. if (blockRemoveTarget && path.basename(target) === blockRemoveTarget && removeStarted && releaseRemove)
  72. return Deferred.succeed(removeStarted, undefined).pipe(
  73. Effect.andThen(Deferred.await(releaseRemove)),
  74. Effect.andThen(fs.remove(target, options)),
  75. )
  76. return fs.remove(target, options)
  77. },
  78. })
  79. }),
  80. ).pipe(Layer.provide(FSUtil.defaultLayer))
  81. const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
  82. const activeLocation = Layer.succeed(
  83. Location.Service,
  84. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  85. )
  86. const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
  87. const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
  88. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  89. const patch = ApplyPatchTool.layer.pipe(
  90. Layer.provide(registry),
  91. Layer.provide(permission),
  92. Layer.provide(resolution),
  93. Layer.provide(mutation),
  94. Layer.provide(filesystem),
  95. )
  96. return Effect.gen(function* () {
  97. return yield* body(yield* ToolRegistry.Service)
  98. }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch)))
  99. }
  100. const call = (patchText: string, id = "call-apply-patch") => ({
  101. sessionID,
  102. ...toolIdentity,
  103. call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
  104. })
  105. // apply_patch is only materialized for OpenAI/GPT models.
  106. const model = { id: "gpt-5", provider: "openai" }
  107. const exists = (target: string) =>
  108. Effect.promise(() =>
  109. fs.stat(target).then(
  110. () => true,
  111. () => false,
  112. ),
  113. )
  114. const it = testEffect(Layer.empty)
  115. describe("ApplyPatchTool", () => {
  116. it.live("registers and sequentially applies add, update, and delete hunks", () =>
  117. Effect.acquireUseRelease(
  118. Effect.promise(() => tmpdir()),
  119. (tmp) => {
  120. reset()
  121. const update = path.join(tmp.path, "update.txt")
  122. const remove = path.join(tmp.path, "remove.txt")
  123. return Effect.promise(() =>
  124. Promise.all([fs.writeFile(update, "before\n"), fs.writeFile(remove, "remove\n")]),
  125. ).pipe(
  126. Effect.andThen(
  127. withTool(tmp.path, (registry) =>
  128. Effect.gen(function* () {
  129. expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
  130. "apply_patch",
  131. ])
  132. const settled = yield* settleTool(
  133. registry,
  134. call(
  135. "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
  136. ),
  137. model,
  138. )
  139. expect(settled.result).toEqual({
  140. type: "text",
  141. value: "Applied patch sequentially:\nA nested/new.txt\nM update.txt\nD remove.txt",
  142. })
  143. expect(settled.output?.structured).toMatchObject({
  144. applied: [
  145. { type: "add", resource: "nested/new.txt" },
  146. { type: "update", resource: "update.txt" },
  147. { type: "delete", resource: "remove.txt" },
  148. ],
  149. files: [
  150. {
  151. file: "nested/new.txt",
  152. status: "added",
  153. additions: 1,
  154. deletions: 0,
  155. patch: expect.stringContaining("+created"),
  156. },
  157. {
  158. file: "update.txt",
  159. status: "modified",
  160. additions: 1,
  161. deletions: 1,
  162. patch: expect.stringContaining("-before\n+after"),
  163. },
  164. {
  165. file: "remove.txt",
  166. status: "deleted",
  167. additions: 0,
  168. deletions: 1,
  169. patch: expect.stringContaining("-remove"),
  170. },
  171. ],
  172. })
  173. expect(assertions).toMatchObject([
  174. { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
  175. ])
  176. expect(readsBeforeEditApproval).toBe(0)
  177. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
  178. "created\n",
  179. )
  180. expect(yield* Effect.promise(() => fs.readFile(update, "utf8"))).toBe("after\n")
  181. expect(yield* exists(remove)).toBe(false)
  182. }),
  183. ),
  184. ),
  185. )
  186. },
  187. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  188. ),
  189. )
  190. it.live("rejects moves before applying any hunk", () =>
  191. Effect.acquireUseRelease(
  192. Effect.promise(() => tmpdir()),
  193. (tmp) => {
  194. reset()
  195. const source = path.join(tmp.path, "old.txt")
  196. return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
  197. Effect.andThen(
  198. withTool(tmp.path, (registry) =>
  199. Effect.gen(function* () {
  200. expect(
  201. yield* executeTool(
  202. registry,
  203. call(
  204. "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
  205. ),
  206. model,
  207. ),
  208. ).toEqual({ type: "error", value: "apply_patch moves are not supported yet" })
  209. expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
  210. expect(assertions).toEqual([])
  211. }),
  212. ),
  213. ),
  214. )
  215. },
  216. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  217. ),
  218. )
  219. it.live("approves an external directory and the batch before reading external update content", () =>
  220. Effect.acquireUseRelease(
  221. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  222. ([active, outside]) => {
  223. reset()
  224. const target = path.join(outside.path, "external.txt")
  225. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  226. Effect.andThen(
  227. withTool(active.path, (registry) =>
  228. Effect.gen(function* () {
  229. expect(
  230. yield* executeTool(
  231. registry,
  232. call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
  233. model,
  234. ),
  235. ).toMatchObject({ type: "text" })
  236. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  237. expect(readsBeforeEditApproval).toBe(0)
  238. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  239. }),
  240. ),
  241. ),
  242. )
  243. },
  244. ([active, outside]) =>
  245. Effect.promise(() =>
  246. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  247. ),
  248. ),
  249. )
  250. it.live("approves one external directory scope for multiple files under the same parent", () =>
  251. Effect.acquireUseRelease(
  252. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  253. ([active, outside]) => {
  254. reset()
  255. const first = path.join(outside.path, "first.txt")
  256. const second = path.join(outside.path, "second.txt")
  257. return Effect.promise(() =>
  258. Promise.all([fs.writeFile(first, "before\n"), fs.writeFile(second, "before\n")]),
  259. ).pipe(
  260. Effect.andThen(
  261. withTool(active.path, (registry) =>
  262. Effect.gen(function* () {
  263. expect(
  264. yield* executeTool(
  265. registry,
  266. call(
  267. `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
  268. ),
  269. model,
  270. ),
  271. ).toMatchObject({ type: "text" })
  272. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  273. expect(assertions[0]?.resources).toEqual([
  274. path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
  275. ])
  276. }),
  277. ),
  278. ),
  279. )
  280. },
  281. ([active, outside]) =>
  282. Effect.promise(() =>
  283. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  284. ),
  285. ),
  286. )
  287. it.live("rejects invalid later update before applying an earlier add", () =>
  288. Effect.acquireUseRelease(
  289. Effect.promise(() => tmpdir()),
  290. (tmp) => {
  291. reset()
  292. return withTool(tmp.path, (registry) =>
  293. Effect.gen(function* () {
  294. expect(
  295. yield* executeTool(
  296. registry,
  297. call(
  298. "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
  299. ),
  300. model,
  301. ),
  302. ).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
  303. expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
  304. }),
  305. )
  306. },
  307. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  308. ),
  309. )
  310. it.live("rejects add hunks targeting an existing file without replacing it", () =>
  311. Effect.acquireUseRelease(
  312. Effect.promise(() => tmpdir()),
  313. (tmp) => {
  314. reset()
  315. const target = path.join(tmp.path, "existing.txt")
  316. return Effect.promise(() => fs.writeFile(target, "sentinel\n")).pipe(
  317. Effect.andThen(
  318. withTool(tmp.path, (registry) =>
  319. Effect.gen(function* () {
  320. expect(
  321. yield* executeTool(
  322. registry,
  323. call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
  324. model,
  325. ),
  326. ).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
  327. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
  328. }),
  329. ),
  330. ),
  331. )
  332. },
  333. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  334. ),
  335. )
  336. it.live("rejects an add target that appears during permission approval", () =>
  337. Effect.acquireUseRelease(
  338. Effect.promise(() => tmpdir()),
  339. (tmp) => {
  340. reset()
  341. const target = path.join(tmp.path, "appeared.txt")
  342. afterEditApproval = () => Effect.promise(() => fs.writeFile(target, "winner\n")).pipe(Effect.orDie)
  343. return withTool(tmp.path, (registry) =>
  344. Effect.gen(function* () {
  345. expect(
  346. yield* executeTool(
  347. registry,
  348. call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
  349. model,
  350. ),
  351. ).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
  352. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
  353. }),
  354. )
  355. },
  356. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  357. ),
  358. )
  359. it.live("preserves a later commit defect after earlier sequential applications", () =>
  360. Effect.acquireUseRelease(
  361. Effect.promise(() => tmpdir()),
  362. (tmp) => {
  363. reset()
  364. const first = path.join(tmp.path, "first.txt")
  365. const second = path.join(tmp.path, "second.txt")
  366. failRemoveTarget = path.basename(second)
  367. return Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])).pipe(
  368. Effect.andThen(
  369. withTool(tmp.path, (registry) =>
  370. Effect.gen(function* () {
  371. expect(
  372. Exit.isFailure(
  373. yield* executeTool(
  374. registry,
  375. call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
  376. model,
  377. ).pipe(Effect.exit),
  378. ),
  379. ).toBe(true)
  380. expect(yield* exists(first)).toBe(false)
  381. expect(yield* exists(second)).toBe(true)
  382. }),
  383. ),
  384. ),
  385. )
  386. },
  387. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  388. ),
  389. )
  390. it.live("finishes the sequential commit phase when interrupted after the first mutation", () =>
  391. Effect.acquireUseRelease(
  392. Effect.promise(() => tmpdir()),
  393. (tmp) => {
  394. reset()
  395. const first = path.join(tmp.path, "first.txt")
  396. const second = path.join(tmp.path, "second.txt")
  397. blockRemoveTarget = path.basename(second)
  398. return Effect.gen(function* () {
  399. removeStarted = yield* Deferred.make<void>()
  400. releaseRemove = yield* Deferred.make<void>()
  401. yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
  402. yield* withTool(tmp.path, (registry) =>
  403. Effect.gen(function* () {
  404. const run = yield* executeTool(
  405. registry,
  406. call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
  407. model,
  408. ).pipe(Effect.forkChild)
  409. yield* Deferred.await(removeStarted!)
  410. const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
  411. yield* Deferred.succeed(releaseRemove!, undefined)
  412. yield* Fiber.join(interrupt)
  413. expect(yield* exists(first)).toBe(false)
  414. expect(yield* exists(second)).toBe(false)
  415. }),
  416. )
  417. })
  418. },
  419. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  420. ),
  421. )
  422. })