tool-patch.test.ts 42 KB

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