tool-apply-patch.test.ts 15 KB

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