tool-write.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer } from "effect"
  5. import { FileMutation } from "@opencode-ai/core/file-mutation"
  6. import { Formatter } from "@opencode-ai/core/formatter"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { Environment } from "@opencode-ai/core/environment/index"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  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 { WriteTool } from "@opencode-ai/core/tool/plugin/write"
  17. import { transformEnvironmentFiles } from "./fixture/environment"
  18. import { location } from "./fixture/location"
  19. import { tmpdir } from "./fixture/tmpdir"
  20. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  21. import { testEffect } from "./lib/effect"
  22. import { permissionLayer } from "./lib/permission"
  23. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  24. const writeToolNode = makeLocationNode({
  25. name: "test/write-tool-plugin",
  26. layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
  27. deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
  28. })
  29. const sessionID = Session.ID.make("ses_write_tool_test")
  30. const assertions: Permission.AssertInput[] = []
  31. const writes: string[] = []
  32. let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
  33. let denyAction: string | undefined
  34. const permission = permissionLayer({
  35. assert: (input) =>
  36. Effect.sync(() => assertions.push(input)).pipe(
  37. Effect.andThen(
  38. input.action === denyAction
  39. ? Effect.fail(
  40. new Permission.BlockedError({
  41. rules: [],
  42. permission: input.action,
  43. resources: input.resources,
  44. }),
  45. )
  46. : Effect.void,
  47. ),
  48. ),
  49. })
  50. const formatter = Layer.mock(Formatter.Service, {
  51. file: (target) => formatFile(target),
  52. })
  53. const reset = () => {
  54. assertions.length = 0
  55. writes.length = 0
  56. formatFile = () => Effect.succeed(false)
  57. denyAction = undefined
  58. }
  59. const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
  60. const activeLocation = Layer.succeed(
  61. Location.Service,
  62. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  63. )
  64. return Effect.gen(function* () {
  65. return yield* body(yield* Tool.Service)
  66. }).pipe(
  67. Effect.provide(
  68. AppNodeBuilder.build(
  69. LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
  70. [
  71. [
  72. Environment.node,
  73. transformEnvironmentFiles(activeLocation, (files) => ({
  74. write: (target, content) =>
  75. Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
  76. })),
  77. ],
  78. [Location.node, activeLocation],
  79. [Formatter.node, formatter],
  80. [Permission.node, permission],
  81. ],
  82. ),
  83. ),
  84. )
  85. }
  86. const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
  87. sessionID,
  88. ...toolIdentity,
  89. call: { type: "tool-call" as const, id, name: "write", input },
  90. })
  91. const it = testEffect(Layer.empty)
  92. describe("WriteTool", () => {
  93. it.live("registers and creates a relative file through FileMutation once", () =>
  94. Effect.acquireUseRelease(
  95. Effect.promise(() => tmpdir()),
  96. (tmp) => {
  97. reset()
  98. return withTool(tmp.path, (registry) =>
  99. Effect.gen(function* () {
  100. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
  101. const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
  102. expect(settled).toEqual({
  103. status: "completed",
  104. output: {
  105. operation: "write",
  106. target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
  107. resource: "src/new.txt",
  108. existed: false,
  109. },
  110. content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
  111. })
  112. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
  113. "created",
  114. )
  115. expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
  116. expect(assertions[0]?.metadata).toMatchObject({
  117. files: [
  118. {
  119. file: "src/new.txt",
  120. status: "added",
  121. additions: 1,
  122. deletions: 0,
  123. patch: expect.stringContaining("+created"),
  124. },
  125. ],
  126. })
  127. expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
  128. }),
  129. )
  130. },
  131. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  132. ),
  133. )
  134. it.live("formats the committed file", () =>
  135. Effect.acquireUseRelease(
  136. Effect.promise(() => tmpdir()),
  137. (tmp) => {
  138. reset()
  139. const target = path.join(tmp.path, "formatted.txt")
  140. formatFile = (file) =>
  141. Effect.promise(async () => {
  142. await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
  143. return true
  144. })
  145. return withTool(tmp.path, (registry) =>
  146. Effect.gen(function* () {
  147. expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
  148. status: "completed",
  149. })
  150. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
  151. }),
  152. )
  153. },
  154. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  155. ),
  156. )
  157. it.live("overwrites a relative existing file and reports that it wrote the file", () =>
  158. Effect.acquireUseRelease(
  159. Effect.promise(() => tmpdir()),
  160. (tmp) => {
  161. reset()
  162. return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
  163. Effect.andThen(
  164. withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))),
  165. ),
  166. Effect.andThen((settled) =>
  167. Effect.gen(function* () {
  168. expect(settled.status).toBe("completed")
  169. if (settled.status !== "completed") return
  170. expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
  171. expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
  172. expect(assertions[0]?.metadata).toMatchObject({
  173. files: [
  174. {
  175. file: "existing.txt",
  176. status: "modified",
  177. additions: 1,
  178. deletions: 1,
  179. patch: expect.stringMatching(/-before[\s\S]*\+after/),
  180. },
  181. ],
  182. })
  183. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
  184. "after",
  185. )
  186. expect(writes).toHaveLength(1)
  187. }),
  188. ),
  189. )
  190. },
  191. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  192. ),
  193. )
  194. it.live("preserves exactly one BOM when overwriting existing files", () =>
  195. Effect.acquireUseRelease(
  196. Effect.promise(() => tmpdir()),
  197. (tmp) => {
  198. reset()
  199. const preserved = path.join(tmp.path, "preserved.txt")
  200. const deduplicated = path.join(tmp.path, "deduplicated.txt")
  201. formatFile = (target) =>
  202. Effect.promise(async () => {
  203. await fs.writeFile(
  204. target,
  205. `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
  206. )
  207. return true
  208. })
  209. return Effect.promise(() =>
  210. Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
  211. ).pipe(
  212. Effect.andThen(
  213. withTool(tmp.path, (registry) =>
  214. Effect.gen(function* () {
  215. yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
  216. yield* executeTool(
  217. registry,
  218. call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
  219. )
  220. expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
  221. expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
  222. }),
  223. ),
  224. ),
  225. )
  226. },
  227. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  228. ),
  229. )
  230. it.live("accepts an absolute file path inside the active Location", () =>
  231. Effect.acquireUseRelease(
  232. Effect.promise(() => tmpdir()),
  233. (tmp) => {
  234. reset()
  235. const target = path.join(tmp.path, "absolute.txt")
  236. return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
  237. Effect.andThen((result) =>
  238. Effect.gen(function* () {
  239. expect(result).toMatchObject({
  240. status: "completed",
  241. content: [{ type: "text", text: "Created file successfully: absolute.txt" }],
  242. })
  243. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  244. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
  245. }),
  246. ),
  247. )
  248. },
  249. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  250. ),
  251. )
  252. it.live("writes an external symlink target with only its in-location permission", () =>
  253. Effect.acquireUseRelease(
  254. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  255. ([active, outside]) => {
  256. reset()
  257. if (process.platform === "win32") return Effect.void
  258. const target = path.join(outside.path, "external.txt")
  259. const link = path.join(active.path, "link.txt")
  260. return Effect.promise(async () => {
  261. await fs.writeFile(target, "before")
  262. await fs.symlink(target, link)
  263. }).pipe(
  264. Effect.andThen(
  265. withTool(active.path, (registry) => executeTool(registry, call({ path: "link.txt", content: "after" }))),
  266. ),
  267. Effect.andThen((result) =>
  268. Effect.sync(() => {
  269. expect(result.status).toBe("completed")
  270. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  271. expect(assertions[0]?.resources).toEqual(["link.txt"])
  272. }),
  273. ),
  274. Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
  275. Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
  276. )
  277. },
  278. ([active, outside]) =>
  279. Effect.promise(() =>
  280. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  281. ),
  282. ),
  283. )
  284. it.live("approves an explicit external absolute path before edit", () =>
  285. Effect.acquireUseRelease(
  286. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  287. ([active, outside]) => {
  288. reset()
  289. const target = path.join(outside.path, "external.txt")
  290. return withTool(active.path, (registry) =>
  291. executeTool(registry, call({ path: target, content: "external" })),
  292. ).pipe(
  293. Effect.andThen((settled) =>
  294. Effect.gen(function* () {
  295. const absoluteTarget = target
  296. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  297. expect(assertions[0]).toMatchObject({
  298. resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
  299. })
  300. expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
  301. expect(settled).toMatchObject({
  302. status: "completed",
  303. output: {
  304. target: absoluteTarget,
  305. resource: absoluteTarget.replaceAll("\\", "/"),
  306. existed: false,
  307. },
  308. })
  309. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
  310. expect(writes).toEqual([absoluteTarget])
  311. }),
  312. ),
  313. )
  314. },
  315. ([active, outside]) =>
  316. Effect.promise(() =>
  317. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  318. ),
  319. ),
  320. )
  321. it.live("saves external directory approval at the nearest project directory", () =>
  322. Effect.acquireUseRelease(
  323. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  324. ([active, outside]) => {
  325. reset()
  326. const repo = path.join(outside.path, "repo")
  327. const nested = path.join(repo, "packages", "app")
  328. const target = path.join(nested, "external.txt")
  329. return Effect.promise(() =>
  330. Promise.all([fs.mkdir(path.join(repo, ".git"), { recursive: true }), fs.mkdir(nested, { recursive: true })]),
  331. ).pipe(
  332. Effect.andThen(
  333. withTool(active.path, (registry) => executeTool(registry, call({ path: target, content: "external" }))),
  334. ),
  335. Effect.andThen(
  336. Effect.gen(function* () {
  337. expect(assertions[0]).toMatchObject({
  338. action: "external_directory",
  339. resources: [path.join(nested, "*").replaceAll("\\", "/")],
  340. save: [path.join(repo, "*").replaceAll("\\", "/")],
  341. })
  342. }),
  343. ),
  344. )
  345. },
  346. ([active, outside]) =>
  347. Effect.promise(() =>
  348. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  349. ),
  350. ),
  351. )
  352. it.live("does not write when external_directory or edit approval is denied", () =>
  353. Effect.acquireUseRelease(
  354. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  355. ([active, outside]) =>
  356. Effect.gen(function* () {
  357. const external = path.join(outside.path, "denied.txt")
  358. reset()
  359. denyAction = "external_directory"
  360. expect(
  361. yield* withTool(active.path, (registry) =>
  362. executeTool(registry, call({ path: external, content: "blocked" })),
  363. ),
  364. ).toEqual({
  365. status: "error",
  366. error: { type: "permission.rejected", message: "Permission denied: external_directory" },
  367. })
  368. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  369. expect(writes).toEqual([])
  370. reset()
  371. denyAction = "edit"
  372. expect(
  373. yield* withTool(active.path, (registry) =>
  374. executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
  375. ),
  376. ).toEqual({
  377. status: "error",
  378. error: { type: "permission.rejected", message: "Permission denied: edit" },
  379. })
  380. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  381. expect(writes).toEqual([])
  382. }),
  383. ([active, outside]) =>
  384. Effect.promise(() =>
  385. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  386. ),
  387. ),
  388. )
  389. })