tool-write.test.ts 16 KB

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