tool-patch.test.ts 39 KB

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