tool-patch.test.ts 39 KB

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