tool-patch.test.ts 33 KB

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