tool-write.test.ts 16 KB

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