tool-patch.test.ts 40 KB

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