tool-patch.test.ts 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Exit, Layer, Schema } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Environment } from "@opencode-ai/core/environment/index"
  8. import { Formatter } from "@opencode-ai/core/formatter"
  9. import { FileMutation } from "@opencode-ai/core/file-mutation"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  12. import { Permission } from "@opencode-ai/core/permission"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { Session } from "@opencode-ai/core/session"
  15. import { Tool } from "@opencode-ai/core/tool"
  16. import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
  17. import { transformEnvironmentFiles } from "./fixture/environment"
  18. import { location } from "./fixture/location"
  19. import { tmpdir } from "./fixture/tmpdir"
  20. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  21. import { testEffect } from "./lib/effect"
  22. import { permissionLayer } from "./lib/permission"
  23. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  24. const patchToolNode = makeLocationNode({
  25. name: "test/patch-tool-plugin",
  26. layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
  27. deps: [
  28. Tool.node,
  29. LocationMutation.node,
  30. FileMutation.node,
  31. Environment.node,
  32. Formatter.node,
  33. Location.node,
  34. Permission.node,
  35. ],
  36. })
  37. const sessionID = Session.ID.make("ses_patch_tool_test")
  38. const assertions: Permission.AssertInput[] = []
  39. let denyAction: string | undefined
  40. let failRemoveTarget: string | undefined
  41. let failRemoveErrorTarget: string | undefined
  42. let failWriteTarget: string | undefined
  43. let readsBeforeEditApproval = 0
  44. let editApproved = false
  45. let afterEditApproval = (): Effect.Effect<void> => Effect.void
  46. let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
  47. const permission = permissionLayer({
  48. assert: (input) =>
  49. Effect.sync(() => {
  50. assertions.push(input)
  51. if (input.action === "edit") editApproved = true
  52. }).pipe(
  53. Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
  54. Effect.andThen(
  55. input.action === denyAction
  56. ? Effect.fail(
  57. new Permission.BlockedError({
  58. rules: [],
  59. permission: input.action,
  60. resources: input.resources,
  61. }),
  62. )
  63. : Effect.void,
  64. ),
  65. ),
  66. })
  67. const formatter = Layer.mock(Formatter.Service, {
  68. file: (target) => formatFile(target),
  69. })
  70. const reset = () => {
  71. assertions.length = 0
  72. denyAction = undefined
  73. failRemoveTarget = undefined
  74. failRemoveErrorTarget = undefined
  75. failWriteTarget = undefined
  76. readsBeforeEditApproval = 0
  77. editApproved = false
  78. afterEditApproval = () => Effect.void
  79. formatFile = () => Effect.succeed(false)
  80. }
  81. const withTool = <A, E, R>(
  82. directory: string,
  83. body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
  84. projectDirectory = directory,
  85. ) => {
  86. const activeLocation = Layer.succeed(
  87. Location.Service,
  88. Location.Service.of(
  89. location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }),
  90. ),
  91. )
  92. return Effect.gen(function* () {
  93. return yield* body(yield* Tool.Service)
  94. }).pipe(
  95. Effect.provide(
  96. AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
  97. [
  98. Environment.node,
  99. transformEnvironmentFiles(activeLocation, (files) => ({
  100. read: (target, range) =>
  101. Effect.sync(() => {
  102. if (!editApproved) readsBeforeEditApproval++
  103. }).pipe(Effect.andThen(files.read(target, range))),
  104. remove: (target) => {
  105. if (failRemoveTarget && path.basename(target) === failRemoveTarget)
  106. return Effect.die("forced remove failure")
  107. if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
  108. return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
  109. return files.remove(target)
  110. },
  111. write: (target, content) => {
  112. if (failWriteTarget && path.basename(target) === failWriteTarget)
  113. return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
  114. return files.write(target, content)
  115. },
  116. })),
  117. ],
  118. [Location.node, activeLocation],
  119. [Formatter.node, formatter],
  120. [Permission.node, permission],
  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. const exists = (target: string) =>
  131. Effect.promise(() =>
  132. fs.stat(target).then(
  133. () => true,
  134. () => false,
  135. ),
  136. )
  137. const it = testEffect(Layer.empty)
  138. const withTempTool = <A, E, R>(body: (directory: string, registry: Tool.Interface) => Effect.Effect<A, E, R>) =>
  139. Effect.acquireUseRelease(
  140. Effect.promise(() => tmpdir()),
  141. (tmp) => {
  142. reset()
  143. return withTool(tmp.path, (registry) => body(tmp.path, registry))
  144. },
  145. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  146. )
  147. describe("PatchTool", () => {
  148. it.live("registers and sequentially applies add, update, and delete hunks", () =>
  149. Effect.acquireUseRelease(
  150. Effect.promise(() => tmpdir()),
  151. (tmp) => {
  152. reset()
  153. const update = path.join(tmp.path, "update.txt")
  154. const remove = path.join(tmp.path, "remove.txt")
  155. return Effect.promise(() =>
  156. Promise.all([fs.writeFile(update, "before\n"), fs.writeFile(remove, "remove\n")]),
  157. ).pipe(
  158. Effect.andThen(
  159. withTool(tmp.path, (registry) =>
  160. Effect.gen(function* () {
  161. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch", "execute"])
  162. const settled = yield* executeTool(
  163. registry,
  164. call(
  165. "*** 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",
  166. ),
  167. )
  168. expect(settled.status).toBe("completed")
  169. if (settled.status !== "completed") return
  170. expect(settled.content).toEqual([
  171. {
  172. type: "text",
  173. text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
  174. },
  175. ])
  176. const modelText = settled.content?.[0]?.type === "text" ? settled.content[0].text : ""
  177. if (process.platform === "win32") expect(modelText).not.toContain("\\")
  178. expect(settled.output).toMatchObject({
  179. applied: [
  180. { type: "add", resource: "nested/new.txt" },
  181. { type: "update", resource: "update.txt" },
  182. { type: "delete", resource: "remove.txt" },
  183. ],
  184. files: [
  185. {
  186. file: "nested/new.txt",
  187. status: "added",
  188. additions: 1,
  189. deletions: 0,
  190. patch: expect.stringContaining("+created"),
  191. },
  192. {
  193. file: "update.txt",
  194. status: "modified",
  195. additions: 1,
  196. deletions: 1,
  197. patch: expect.stringContaining("-before\n+after"),
  198. },
  199. {
  200. file: "remove.txt",
  201. status: "deleted",
  202. additions: 0,
  203. deletions: 1,
  204. patch: expect.stringContaining("-remove"),
  205. },
  206. ],
  207. })
  208. expect(assertions).toMatchObject([
  209. {
  210. sessionID,
  211. action: "edit",
  212. resources: ["nested/new.txt", "update.txt", "remove.txt"],
  213. save: ["*"],
  214. metadata: {
  215. filepath: "nested/new.txt, update.txt, remove.txt",
  216. diff: expect.stringContaining("Index:"),
  217. files: expect.any(Array),
  218. },
  219. },
  220. ])
  221. expect(readsBeforeEditApproval).toBe(2)
  222. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
  223. "created\n",
  224. )
  225. expect(yield* Effect.promise(() => fs.readFile(update, "utf8"))).toBe("after\n")
  226. expect(yield* exists(remove)).toBe(false)
  227. }),
  228. ),
  229. ),
  230. )
  231. },
  232. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  233. ),
  234. )
  235. it.live("counts deleted lines with and without a trailing newline", () =>
  236. withTempTool((directory, registry) =>
  237. Effect.gen(function* () {
  238. yield* Effect.promise(() =>
  239. Promise.all([
  240. fs.writeFile(path.join(directory, "trailing.txt"), "remove\n"),
  241. fs.writeFile(path.join(directory, "unterminated.txt"), "remove"),
  242. ]),
  243. )
  244. const settled = yield* executeTool(
  245. registry,
  246. call("*** Begin Patch\n*** Delete File: trailing.txt\n*** Delete File: unterminated.txt\n*** End Patch"),
  247. )
  248. expect(settled.status).toBe("completed")
  249. if (settled.status !== "completed") return
  250. expect(settled.output.files).toMatchObject([
  251. { file: "trailing.txt", additions: 0, deletions: 1 },
  252. { file: "unterminated.txt", additions: 0, deletions: 1 },
  253. ])
  254. }),
  255. ),
  256. )
  257. it.live("serializes concurrent patch transactions", () =>
  258. withTempTool((directory, registry) => {
  259. const target = path.join(directory, "concurrent.txt")
  260. afterEditApproval = () =>
  261. assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
  262. return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
  263. Effect.andThen(
  264. Effect.all(
  265. [
  266. executeTool(
  267. registry,
  268. call(
  269. "*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
  270. "call-patch-one",
  271. ),
  272. ),
  273. executeTool(
  274. registry,
  275. call(
  276. "*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
  277. "call-patch-two",
  278. ),
  279. ),
  280. ],
  281. { concurrency: "unbounded" },
  282. ),
  283. ),
  284. Effect.andThen((results) =>
  285. Effect.gen(function* () {
  286. expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
  287. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
  288. }),
  289. ),
  290. )
  291. }),
  292. )
  293. it.live("returns file diffs for final formatted content", () =>
  294. withTempTool((directory, registry) => {
  295. const target = path.join(directory, "formatted.txt")
  296. formatFile = (file) =>
  297. Effect.promise(async () => {
  298. await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
  299. return true
  300. })
  301. return Effect.gen(function* () {
  302. const settled = yield* executeTool(
  303. registry,
  304. call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
  305. )
  306. expect(settled.status).toBe("completed")
  307. if (settled.status !== "completed") return
  308. expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
  309. expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
  310. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
  311. })
  312. }),
  313. )
  314. it.live("moves and updates a file", () =>
  315. Effect.acquireUseRelease(
  316. Effect.promise(() => tmpdir()),
  317. (tmp) => {
  318. reset()
  319. const source = path.join(tmp.path, "old.txt")
  320. return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
  321. Effect.andThen(
  322. withTool(tmp.path, (registry) =>
  323. Effect.gen(function* () {
  324. expect(
  325. yield* executeTool(
  326. registry,
  327. call(
  328. "*** 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",
  329. ),
  330. ),
  331. ).toMatchObject({
  332. status: "completed",
  333. content: [
  334. { type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" },
  335. ],
  336. })
  337. expect(yield* exists(source)).toBe(false)
  338. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
  339. "after\n",
  340. )
  341. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "created.txt"), "utf8"))).toBe(
  342. "created\n",
  343. )
  344. }),
  345. ),
  346. ),
  347. )
  348. },
  349. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  350. ),
  351. )
  352. it.live("moves a file over an existing destination", () =>
  353. Effect.acquireUseRelease(
  354. Effect.promise(() => tmpdir()),
  355. (tmp) => {
  356. reset()
  357. const source = path.join(tmp.path, "old.txt")
  358. const destination = path.join(tmp.path, "nested", "moved.txt")
  359. return Effect.promise(() =>
  360. Promise.all([
  361. fs.writeFile(source, "before\n"),
  362. fs
  363. .mkdir(path.dirname(destination), { recursive: true })
  364. .then(() => fs.writeFile(destination, "existing\n")),
  365. ]),
  366. ).pipe(
  367. Effect.andThen(
  368. withTool(tmp.path, (registry) =>
  369. Effect.gen(function* () {
  370. expect(
  371. yield* executeTool(
  372. registry,
  373. call(
  374. "*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
  375. ),
  376. ),
  377. ).toMatchObject({ status: "completed" })
  378. expect(yield* exists(source)).toBe(false)
  379. expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
  380. }),
  381. ),
  382. ),
  383. )
  384. },
  385. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  386. ),
  387. )
  388. it.live("moves a file without changing its contents", () =>
  389. withTempTool((directory, registry) =>
  390. Effect.gen(function* () {
  391. const source = path.join(directory, "old.txt")
  392. const destination = path.join(directory, "moved.txt")
  393. yield* Effect.promise(() => fs.writeFile(source, "same\n"))
  394. expect(
  395. yield* executeTool(
  396. registry,
  397. call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n same\n*** End Patch"),
  398. ),
  399. ).toMatchObject({
  400. status: "completed",
  401. content: [{ type: "text", text: "Success. Updated the following files:\nM moved.txt" }],
  402. })
  403. expect(yield* exists(source)).toBe(false)
  404. expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("same\n")
  405. }),
  406. ),
  407. )
  408. it.live("moves a symlink without deleting its target", () =>
  409. withTempTool((directory, registry) =>
  410. Effect.gen(function* () {
  411. if (process.platform === "win32") return
  412. const target = path.join(directory, "target.txt")
  413. const source = path.join(directory, "link.txt")
  414. const moved = path.join(directory, "moved.txt")
  415. yield* Effect.promise(() => fs.writeFile(target, "before\n"))
  416. yield* Effect.promise(() => fs.symlink(target, source))
  417. yield* executeTool(
  418. registry,
  419. call(
  420. "*** Begin Patch\n*** Update File: link.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
  421. ),
  422. )
  423. expect(yield* exists(source)).toBe(false)
  424. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
  425. expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n")
  426. }),
  427. ),
  428. )
  429. it.live("includes move file info in output and metadata", () =>
  430. withTempTool((directory, registry) =>
  431. Effect.gen(function* () {
  432. const source = path.join(directory, "old", "name.txt")
  433. yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
  434. yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
  435. const settled = yield* executeTool(
  436. registry,
  437. call(
  438. "*** 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",
  439. ),
  440. )
  441. expect(settled.status).toBe("completed")
  442. if (settled.status !== "completed") return
  443. expect(settled.output).toMatchObject({
  444. applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
  445. files: [
  446. {
  447. file: "renamed/dir/name.txt",
  448. status: "modified",
  449. patch: expect.stringContaining(`Index: ${source}`),
  450. },
  451. ],
  452. })
  453. }),
  454. ),
  455. )
  456. it.live("includes the move destination in edit permission resources", () =>
  457. withTempTool((directory, registry) =>
  458. Effect.gen(function* () {
  459. const source = path.join(directory, "old", "name.txt")
  460. yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
  461. yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
  462. yield* executeTool(
  463. registry,
  464. call(
  465. "*** 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",
  466. ),
  467. )
  468. expect(assertions).toMatchObject([
  469. {
  470. action: "edit",
  471. resources: ["old/name.txt", "renamed/dir/name.txt"],
  472. },
  473. ])
  474. }),
  475. ),
  476. )
  477. it.live("uses Location-relative resources for move targets in a nested Location", () =>
  478. Effect.acquireUseRelease(
  479. Effect.promise(() => tmpdir()),
  480. (tmp) => {
  481. reset()
  482. const active = path.join(tmp.path, "nested", "location")
  483. const source = path.join(active, "old.txt")
  484. return Effect.promise(() =>
  485. fs.mkdir(active, { recursive: true }).then(() => fs.writeFile(source, "before\n")),
  486. ).pipe(
  487. Effect.andThen(
  488. withTool(
  489. active,
  490. (registry) =>
  491. Effect.gen(function* () {
  492. const settled = yield* executeTool(
  493. registry,
  494. call(
  495. "*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
  496. ),
  497. )
  498. expect(settled).toMatchObject({
  499. status: "completed",
  500. output: { applied: [{ resource: "moved.txt" }] },
  501. })
  502. expect(assertions).toMatchObject([{ action: "edit", resources: ["old.txt", "moved.txt"] }])
  503. }),
  504. tmp.path,
  505. ),
  506. ),
  507. )
  508. },
  509. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  510. ),
  511. )
  512. it.live("inserts lines with an insert-only hunk", () =>
  513. withTempTool((directory, registry) =>
  514. Effect.gen(function* () {
  515. const target = path.join(directory, "insert-only.txt")
  516. yield* Effect.promise(() => fs.writeFile(target, "alpha\nomega\n"))
  517. yield* executeTool(
  518. registry,
  519. call("*** Begin Patch\n*** Update File: insert-only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"),
  520. )
  521. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nbeta\nomega\n")
  522. }),
  523. ),
  524. )
  525. it.live("rejects deleting a directory", () =>
  526. withTempTool((directory, registry) =>
  527. Effect.gen(function* () {
  528. yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
  529. expect(
  530. yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
  531. ).toMatchObject({ status: "error" })
  532. expect(yield* exists(path.join(directory, "dir"))).toBe(true)
  533. }),
  534. ),
  535. )
  536. it.live("rejects a missing second chunk context", () =>
  537. withTempTool((directory, registry) =>
  538. Effect.gen(function* () {
  539. const target = path.join(directory, "two-chunks.txt")
  540. yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n"))
  541. expect(
  542. yield* executeTool(
  543. registry,
  544. call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"),
  545. ),
  546. ).toMatchObject({ status: "error" })
  547. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
  548. }),
  549. ),
  550. )
  551. it.live("requires patchText", () =>
  552. withTempTool((_directory, registry) =>
  553. Effect.gen(function* () {
  554. expect(yield* executeTool(registry, call(""))).toEqual({
  555. status: "error",
  556. error: { type: "tool.execution", message: "patchText is required" },
  557. })
  558. }),
  559. ),
  560. )
  561. it.live("rejects invalid patch format", () =>
  562. withTempTool((_directory, registry) =>
  563. Effect.gen(function* () {
  564. expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
  565. status: "error",
  566. error: {
  567. type: "tool.execution",
  568. message: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
  569. },
  570. })
  571. expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
  572. status: "error",
  573. error: {
  574. type: "tool.execution",
  575. message: "patch verification failed: The last line of the patch must be '*** End Patch'",
  576. },
  577. })
  578. }),
  579. ),
  580. )
  581. it.live("rejects an empty patch", () =>
  582. withTempTool((_directory, registry) =>
  583. Effect.gen(function* () {
  584. for (const patchText of [
  585. "*** Begin Patch\n*** End Patch",
  586. " *** Begin Patch \n *** End Patch ",
  587. "<<EOF\n*** Begin Patch\n*** End Patch\nEOF",
  588. "*** Begin Patch\n*** Environment ID: remote\n*** End Patch",
  589. ]) {
  590. expect(yield* executeTool(registry, call(patchText))).toEqual({
  591. status: "error",
  592. error: { type: "tool.execution", message: "patch rejected: empty patch" },
  593. })
  594. }
  595. }),
  596. ),
  597. )
  598. it.live("rejects an invalid hunk header", () =>
  599. withTempTool((_directory, registry) =>
  600. Effect.gen(function* () {
  601. expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({
  602. status: "error",
  603. error: {
  604. type: "tool.execution",
  605. message:
  606. "patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
  607. },
  608. })
  609. }),
  610. ),
  611. )
  612. it.live("applies successive update operations to one file", () =>
  613. withTempTool((directory, registry) =>
  614. Effect.gen(function* () {
  615. const target = path.join(directory, "successive.txt")
  616. yield* Effect.promise(() => fs.writeFile(target, "a\nb\n"))
  617. yield* executeTool(
  618. registry,
  619. call(
  620. "*** Begin Patch\n*** Update File: successive.txt\n@@\n-a\n+A\n*** Update File: successive.txt\n@@\n-b\n+B\n*** End Patch",
  621. ),
  622. )
  623. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("A\nB\n")
  624. }),
  625. ),
  626. )
  627. it.live("does not invent a first-line diff for BOM files", () =>
  628. withTempTool((directory, registry) =>
  629. Effect.gen(function* () {
  630. const bom = "\uFEFF"
  631. const target = path.join(directory, "example.cs")
  632. yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
  633. formatFile = (file) =>
  634. Effect.promise(async () => {
  635. await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
  636. return true
  637. })
  638. const settled = yield* executeTool(
  639. registry,
  640. call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
  641. )
  642. expect(settled.status).toBe("completed")
  643. if (settled.status !== "completed") return
  644. const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output)
  645. expect(output.files[0]?.patch).not.toContain(bom)
  646. expect(output.files[0]?.patch).not.toContain("-using System;")
  647. expect(output.files[0]?.patch).not.toContain("+using System;")
  648. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
  649. `${bom}using System;\n\nclass Test {}\nclass Next {}\n`,
  650. )
  651. }),
  652. ),
  653. )
  654. it.live("rejects an update with missing context", () =>
  655. withTempTool((directory, registry) =>
  656. Effect.gen(function* () {
  657. const target = path.join(directory, "unchanged.txt")
  658. yield* Effect.promise(() => fs.writeFile(target, "line1\nline2\n"))
  659. expect(
  660. yield* executeTool(
  661. registry,
  662. call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
  663. ),
  664. ).toMatchObject({
  665. status: "error",
  666. error: {
  667. type: "tool.execution",
  668. message: "patch verification failed: Failed to find expected lines in unchanged.txt:\nmissing",
  669. },
  670. })
  671. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
  672. }),
  673. ),
  674. )
  675. it.live("rejects an update when the target file is missing", () =>
  676. withTempTool((directory, registry) =>
  677. Effect.gen(function* () {
  678. expect(
  679. yield* executeTool(
  680. registry,
  681. call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
  682. ),
  683. ).toMatchObject({
  684. status: "error",
  685. error: {
  686. message: expect.stringContaining(
  687. `patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
  688. ),
  689. },
  690. })
  691. }),
  692. ),
  693. )
  694. it.live("identifies a directory used as an update target", () =>
  695. withTempTool((directory, registry) =>
  696. Effect.gen(function* () {
  697. yield* Effect.promise(() => fs.mkdir(path.join(directory, "nested")))
  698. expect(
  699. yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
  700. ).toEqual({
  701. status: "error",
  702. error: {
  703. type: "tool.execution",
  704. message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
  705. },
  706. })
  707. }),
  708. ),
  709. )
  710. it.live("identifies a missing delete target", () =>
  711. withTempTool((_directory, registry) =>
  712. Effect.gen(function* () {
  713. expect(
  714. yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
  715. ).toEqual({
  716. status: "error",
  717. error: {
  718. type: "tool.execution",
  719. message: "patch verification failed: Failed to delete missing.txt: file does not exist",
  720. },
  721. })
  722. }),
  723. ),
  724. )
  725. it.live("reports the failing destination and filesystem error", () =>
  726. withTempTool((directory, registry) =>
  727. Effect.gen(function* () {
  728. yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
  729. failWriteTarget = "new.txt"
  730. expect(
  731. yield* executeTool(
  732. registry,
  733. call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch"),
  734. ),
  735. ).toEqual({
  736. status: "error",
  737. error: { type: "tool.execution", message: "Failed to write new.txt: forced write failure" },
  738. })
  739. expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
  740. expect(yield* exists(path.join(directory, "new.txt"))).toBe(false)
  741. }),
  742. ),
  743. )
  744. it.live("reports the successful prefix and filesystem error", () =>
  745. withTempTool((directory, registry) =>
  746. Effect.gen(function* () {
  747. failWriteTarget = "second.txt"
  748. expect(
  749. yield* executeTool(
  750. registry,
  751. call("*** Begin Patch\n*** Add File: first.txt\n+first\n*** Add File: second.txt\n+second\n*** End Patch"),
  752. ),
  753. ).toEqual({
  754. status: "error",
  755. error: {
  756. type: "tool.execution",
  757. message: "Failed to write second.txt: forced write failure. Completed before failure: first.txt",
  758. },
  759. })
  760. expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "first.txt"), "utf8"))).toBe("first\n")
  761. expect(yield* exists(path.join(directory, "second.txt"))).toBe(false)
  762. }),
  763. ),
  764. )
  765. it.live("reports a destination written before move removal fails", () =>
  766. withTempTool((directory, registry) =>
  767. Effect.gen(function* () {
  768. yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
  769. failRemoveErrorTarget = "old.txt"
  770. expect(
  771. yield* executeTool(
  772. registry,
  773. call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch"),
  774. ),
  775. ).toEqual({
  776. status: "error",
  777. error: {
  778. type: "tool.execution",
  779. message: "Wrote new.txt but failed to remove old.txt: forced remove failure",
  780. },
  781. })
  782. expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
  783. expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "new.txt"), "utf8"))).toBe("after\n")
  784. }),
  785. ),
  786. )
  787. it.live("approves an external directory before reading and requests edit permission afterward", () =>
  788. Effect.acquireUseRelease(
  789. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  790. ([active, outside]) => {
  791. reset()
  792. const repository = path.join(outside.path, "repository")
  793. const directory = path.join(repository, "nested")
  794. const target = path.join(directory, "external.txt")
  795. return Effect.promise(() =>
  796. Promise.all([
  797. fs.mkdir(path.join(repository, ".git"), { recursive: true }),
  798. fs.mkdir(directory, { recursive: true }).then(() => fs.writeFile(target, "before\n")),
  799. ]),
  800. ).pipe(
  801. Effect.andThen(
  802. withTool(active.path, (registry) =>
  803. Effect.gen(function* () {
  804. expect(
  805. yield* executeTool(
  806. registry,
  807. call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
  808. ),
  809. ).toMatchObject({ status: "completed" })
  810. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  811. expect(assertions[0]).toMatchObject({
  812. resources: [path.join(directory, "*").replaceAll("\\", "/")],
  813. save: [path.join(repository, "*").replaceAll("\\", "/")],
  814. metadata: {
  815. filepath: target,
  816. parentDir: directory,
  817. },
  818. })
  819. expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
  820. expect(readsBeforeEditApproval).toBe(1)
  821. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  822. }),
  823. ),
  824. ),
  825. )
  826. },
  827. ([active, outside]) =>
  828. Effect.promise(() =>
  829. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  830. ),
  831. ),
  832. )
  833. it.live("does not inspect an external file when external permission is denied", () =>
  834. Effect.acquireUseRelease(
  835. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  836. ([active, outside]) => {
  837. reset()
  838. denyAction = "external_directory"
  839. const target = path.join(outside.path, "external.txt")
  840. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  841. Effect.andThen(
  842. withTool(
  843. active.path,
  844. (registry) =>
  845. Effect.gen(function* () {
  846. expect(
  847. yield* executeTool(
  848. registry,
  849. call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
  850. ),
  851. ).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
  852. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  853. expect(readsBeforeEditApproval).toBe(0)
  854. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
  855. }),
  856. path.parse(active.path).root,
  857. ),
  858. ),
  859. )
  860. },
  861. ([active, outside]) =>
  862. Effect.promise(() =>
  863. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  864. ),
  865. ),
  866. )
  867. it.live("preserves edit permission rejection", () =>
  868. withTempTool((directory, registry) =>
  869. Effect.gen(function* () {
  870. const target = path.join(directory, "target.txt")
  871. yield* Effect.promise(() => fs.writeFile(target, "before\n"))
  872. denyAction = "edit"
  873. expect(
  874. yield* executeTool(
  875. registry,
  876. call("*** Begin Patch\n*** Update File: target.txt\n@@\n-before\n+after\n*** End Patch"),
  877. ),
  878. ).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
  879. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  880. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
  881. }),
  882. ),
  883. )
  884. it.live("treats a sibling path inside the project worktree as external to the Location", () =>
  885. Effect.acquireUseRelease(
  886. Effect.promise(() => tmpdir()),
  887. (tmp) => {
  888. reset()
  889. const active = path.join(tmp.path, "active")
  890. const target = path.join(tmp.path, "sibling.txt")
  891. return Effect.promise(() => Promise.all([fs.mkdir(active), fs.writeFile(target, "before\n")])).pipe(
  892. Effect.andThen(
  893. withTool(
  894. active,
  895. (registry) =>
  896. Effect.gen(function* () {
  897. expect(
  898. yield* executeTool(
  899. registry,
  900. call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
  901. ),
  902. ).toMatchObject({ status: "completed" })
  903. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  904. expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
  905. expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
  906. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  907. }),
  908. tmp.path,
  909. ),
  910. ),
  911. )
  912. },
  913. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  914. ),
  915. )
  916. it.live("follows an internal symlink to an external file without external permission", () =>
  917. Effect.acquireUseRelease(
  918. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  919. ([active, outside]) => {
  920. reset()
  921. if (process.platform === "win32") return Effect.void
  922. const target = path.join(outside.path, "external.txt")
  923. const link = path.join(active.path, "link.txt")
  924. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  925. Effect.andThen(Effect.promise(() => fs.symlink(target, link))),
  926. Effect.andThen(
  927. withTool(active.path, (registry) =>
  928. Effect.gen(function* () {
  929. expect(
  930. yield* executeTool(
  931. registry,
  932. call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
  933. ),
  934. ).toMatchObject({ status: "completed" })
  935. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  936. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  937. }),
  938. ),
  939. ),
  940. )
  941. },
  942. ([active, outside]) =>
  943. Effect.promise(() =>
  944. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  945. ),
  946. ),
  947. )
  948. it.live("approves a relative external target before reading and requests edit permission afterward", () =>
  949. Effect.acquireUseRelease(
  950. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  951. ([active, outside]) => {
  952. reset()
  953. const target = path.join(outside.path, "external.txt")
  954. const relative = path.relative(active.path, target)
  955. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  956. Effect.andThen(
  957. withTool(active.path, (registry) =>
  958. Effect.gen(function* () {
  959. expect(
  960. yield* executeTool(
  961. registry,
  962. call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`),
  963. ),
  964. ).toMatchObject({ status: "completed" })
  965. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  966. expect(readsBeforeEditApproval).toBe(1)
  967. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  968. }),
  969. ),
  970. ),
  971. )
  972. },
  973. ([active, outside]) =>
  974. Effect.promise(() =>
  975. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  976. ),
  977. ),
  978. )
  979. it.live("uses canonical external permissions and resources for a move destination", () =>
  980. Effect.acquireUseRelease(
  981. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  982. ([active, outside]) => {
  983. reset()
  984. const source = path.join(active.path, "source.txt")
  985. const destination = path.join(outside.path, "moved.txt")
  986. return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
  987. Effect.andThen(
  988. withTool(active.path, (registry) =>
  989. Effect.gen(function* () {
  990. const settled = yield* executeTool(
  991. registry,
  992. call(
  993. `*** Begin Patch\n*** Update File: source.txt\n*** Move to: ${destination}\n@@\n-before\n+after\n*** End Patch`,
  994. ),
  995. )
  996. expect(settled).toMatchObject({
  997. status: "completed",
  998. output: { applied: [{ resource: destination.replaceAll("\\", "/") }] },
  999. })
  1000. expect(assertions).toMatchObject([
  1001. {
  1002. action: "external_directory",
  1003. resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
  1004. save: [path.join(outside.path, "*").replaceAll("\\", "/")],
  1005. metadata: { filepath: destination, parentDir: outside.path },
  1006. },
  1007. {
  1008. action: "edit",
  1009. resources: ["source.txt", destination.replaceAll("\\", "/")],
  1010. },
  1011. ])
  1012. expect(yield* exists(source)).toBe(false)
  1013. expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
  1014. }),
  1015. ),
  1016. ),
  1017. )
  1018. },
  1019. ([active, outside]) =>
  1020. Effect.promise(() =>
  1021. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  1022. ),
  1023. ),
  1024. )
  1025. it.live("approves each external file under the same parent", () =>
  1026. Effect.acquireUseRelease(
  1027. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  1028. ([active, outside]) => {
  1029. reset()
  1030. const first = path.join(outside.path, "first.txt")
  1031. const second = path.join(outside.path, "second.txt")
  1032. return Effect.promise(() =>
  1033. Promise.all([fs.writeFile(first, "before\n"), fs.writeFile(second, "before\n")]),
  1034. ).pipe(
  1035. Effect.andThen(
  1036. withTool(active.path, (registry) =>
  1037. Effect.gen(function* () {
  1038. expect(
  1039. yield* executeTool(
  1040. registry,
  1041. call(
  1042. `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
  1043. ),
  1044. ),
  1045. ).toMatchObject({ status: "completed" })
  1046. expect(assertions.map((input) => input.action)).toEqual([
  1047. "external_directory",
  1048. "external_directory",
  1049. "edit",
  1050. ])
  1051. expect(assertions[0]?.resources).toEqual([
  1052. path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
  1053. ])
  1054. expect(assertions[1]?.resources).toEqual(assertions[0]?.resources)
  1055. }),
  1056. ),
  1057. ),
  1058. )
  1059. },
  1060. ([active, outside]) =>
  1061. Effect.promise(() =>
  1062. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  1063. ),
  1064. ),
  1065. )
  1066. it.live("rejects invalid later update before applying an earlier add", () =>
  1067. Effect.acquireUseRelease(
  1068. Effect.promise(() => tmpdir()),
  1069. (tmp) => {
  1070. reset()
  1071. return withTool(tmp.path, (registry) =>
  1072. Effect.gen(function* () {
  1073. expect(
  1074. yield* executeTool(
  1075. registry,
  1076. call(
  1077. "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
  1078. ),
  1079. ),
  1080. ).toMatchObject({
  1081. status: "error",
  1082. error: {
  1083. message: expect.stringContaining("patch verification failed: Failed to read file to update"),
  1084. },
  1085. })
  1086. expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
  1087. }),
  1088. )
  1089. },
  1090. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1091. ),
  1092. )
  1093. it.live("adds files by overwriting existing targets", () =>
  1094. Effect.acquireUseRelease(
  1095. Effect.promise(() => tmpdir()),
  1096. (tmp) => {
  1097. reset()
  1098. const target = path.join(tmp.path, "existing.txt")
  1099. return Effect.promise(() => fs.writeFile(target, "sentinel\n")).pipe(
  1100. Effect.andThen(
  1101. withTool(tmp.path, (registry) =>
  1102. Effect.gen(function* () {
  1103. expect(
  1104. yield* executeTool(
  1105. registry,
  1106. call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
  1107. ),
  1108. ).toMatchObject({ status: "completed" })
  1109. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
  1110. }),
  1111. ),
  1112. ),
  1113. )
  1114. },
  1115. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1116. ),
  1117. )
  1118. it.live("overwrites an add target that appears during permission approval", () =>
  1119. Effect.acquireUseRelease(
  1120. Effect.promise(() => tmpdir()),
  1121. (tmp) => {
  1122. reset()
  1123. const target = path.join(tmp.path, "appeared.txt")
  1124. afterEditApproval = () => Effect.promise(() => fs.writeFile(target, "winner\n")).pipe(Effect.orDie)
  1125. return withTool(tmp.path, (registry) =>
  1126. Effect.gen(function* () {
  1127. expect(
  1128. yield* executeTool(
  1129. registry,
  1130. call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
  1131. ),
  1132. ).toMatchObject({ status: "completed" })
  1133. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
  1134. }),
  1135. )
  1136. },
  1137. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1138. ),
  1139. )
  1140. it.live("preserves a later commit defect after earlier sequential applications", () =>
  1141. Effect.acquireUseRelease(
  1142. Effect.promise(() => tmpdir()),
  1143. (tmp) => {
  1144. reset()
  1145. const first = path.join(tmp.path, "first.txt")
  1146. const second = path.join(tmp.path, "second.txt")
  1147. failRemoveTarget = path.basename(second)
  1148. return Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])).pipe(
  1149. Effect.andThen(
  1150. withTool(tmp.path, (registry) =>
  1151. Effect.gen(function* () {
  1152. expect(
  1153. Exit.isFailure(
  1154. yield* executeTool(
  1155. registry,
  1156. call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
  1157. ).pipe(Effect.exit),
  1158. ),
  1159. ).toBe(true)
  1160. expect(yield* exists(first)).toBe(false)
  1161. expect(yield* exists(second)).toBe(true)
  1162. }),
  1163. ),
  1164. ),
  1165. )
  1166. },
  1167. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1168. ),
  1169. )
  1170. })