tool-apply-patch.test.ts 17 KB

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