tool-patch.test.ts 17 KB

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