tool-write.test.ts 16 KB

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