tool-patch.test.ts 42 KB

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