tool-write.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  8. import { FSUtil } from "@opencode-ai/util/fs-util"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  11. import { PermissionV2 } from "@opencode-ai/core/permission"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { SessionV2 } from "@opencode-ai/core/session"
  14. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  15. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  16. import { WriteTool } from "@opencode-ai/core/tool/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, settleTool, toolDefinitions } from "./lib/tool"
  22. const writeToolNode = makeLocationNode({
  23. name: "test/write-tool-plugin",
  24. layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
  25. deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, PermissionV2.node],
  26. })
  27. const sessionID = SessionV2.ID.make("ses_write_tool_test")
  28. const assertions: PermissionV2.AssertInput[] = []
  29. const writes: string[] = []
  30. let denyAction: string | undefined
  31. const permission = Layer.succeed(
  32. PermissionV2.Service,
  33. PermissionV2.Service.of({
  34. assert: (input) =>
  35. Effect.sync(() => assertions.push(input)).pipe(
  36. Effect.andThen(
  37. input.action === denyAction
  38. ? Effect.fail(
  39. new PermissionV2.BlockedError({
  40. rules: [],
  41. permission: input.action,
  42. resources: input.resources,
  43. }),
  44. )
  45. : Effect.void,
  46. ),
  47. ),
  48. ask: () => Effect.die("unused"),
  49. reply: () => Effect.die("unused"),
  50. get: () => Effect.die("unused"),
  51. forSession: () => Effect.die("unused"),
  52. list: () => Effect.die("unused"),
  53. }),
  54. )
  55. const reset = () => {
  56. assertions.length = 0
  57. writes.length = 0
  58. denyAction = undefined
  59. }
  60. const filesystem = Layer.effect(
  61. FSUtil.Service,
  62. Effect.gen(function* () {
  63. const fs = yield* FSUtil.Service
  64. return FSUtil.Service.of({
  65. ...fs,
  66. writeWithDirs: (target, content, mode) =>
  67. Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
  68. })
  69. }),
  70. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  71. const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
  72. const activeLocation = Layer.succeed(
  73. Location.Service,
  74. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  75. )
  76. return Effect.gen(function* () {
  77. return yield* body(yield* ToolRegistry.Service)
  78. }).pipe(
  79. Effect.provide(
  80. AppNodeBuilder.build(
  81. LayerNode.group([
  82. ToolRegistry.node,
  83. ToolRegistry.toolsNode,
  84. LocationMutation.node,
  85. FileMutation.node,
  86. writeToolNode,
  87. ]),
  88. [
  89. [FSUtil.node, filesystem],
  90. [Location.node, activeLocation],
  91. [PermissionV2.node, permission],
  92. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  93. ],
  94. ),
  95. ),
  96. )
  97. }
  98. const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
  99. sessionID,
  100. ...toolIdentity,
  101. call: { type: "tool-call" as const, id, name: "write", input },
  102. })
  103. const it = testEffect(Layer.empty)
  104. describe("WriteTool", () => {
  105. it.live("registers and creates a relative file through FileMutation once", () =>
  106. Effect.acquireUseRelease(
  107. Effect.promise(() => tmpdir()),
  108. (tmp) => {
  109. reset()
  110. return withTool(tmp.path, (registry) =>
  111. Effect.gen(function* () {
  112. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
  113. const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
  114. expect(settled).toEqual({
  115. result: { type: "text", value: "Created file successfully: src/new.txt" },
  116. output: {
  117. structured: {
  118. operation: "write",
  119. target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
  120. resource: "src/new.txt",
  121. existed: false,
  122. },
  123. content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
  124. },
  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(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
  131. }),
  132. )
  133. },
  134. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  135. ),
  136. )
  137. it.live("overwrites a relative existing file and reports that it wrote the file", () =>
  138. Effect.acquireUseRelease(
  139. Effect.promise(() => tmpdir()),
  140. (tmp) => {
  141. reset()
  142. return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
  143. Effect.andThen(
  144. withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
  145. ),
  146. Effect.andThen((settled) =>
  147. Effect.gen(function* () {
  148. expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
  149. expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
  150. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
  151. "after",
  152. )
  153. expect(writes).toHaveLength(1)
  154. }),
  155. ),
  156. )
  157. },
  158. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  159. ),
  160. )
  161. it.live("preserves exactly one BOM when overwriting existing files", () =>
  162. Effect.acquireUseRelease(
  163. Effect.promise(() => tmpdir()),
  164. (tmp) => {
  165. reset()
  166. const preserved = path.join(tmp.path, "preserved.txt")
  167. const deduplicated = path.join(tmp.path, "deduplicated.txt")
  168. return Effect.promise(() =>
  169. Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
  170. ).pipe(
  171. Effect.andThen(
  172. withTool(tmp.path, (registry) =>
  173. Effect.gen(function* () {
  174. yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
  175. yield* settleTool(
  176. registry,
  177. call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
  178. )
  179. expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
  180. expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
  181. }),
  182. ),
  183. ),
  184. )
  185. },
  186. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  187. ),
  188. )
  189. it.live("accepts an absolute file path inside the active Location", () =>
  190. Effect.acquireUseRelease(
  191. Effect.promise(() => tmpdir()),
  192. (tmp) => {
  193. reset()
  194. const target = path.join(tmp.path, "absolute.txt")
  195. return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
  196. Effect.andThen((result) =>
  197. Effect.gen(function* () {
  198. expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
  199. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  200. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
  201. }),
  202. ),
  203. )
  204. },
  205. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  206. ),
  207. )
  208. it.live("writes an external symlink target with only its in-location permission", () =>
  209. Effect.acquireUseRelease(
  210. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  211. ([active, outside]) => {
  212. reset()
  213. if (process.platform === "win32") return Effect.void
  214. const target = path.join(outside.path, "external.txt")
  215. const link = path.join(active.path, "link.txt")
  216. return Effect.promise(async () => {
  217. await fs.writeFile(target, "before")
  218. await fs.symlink(target, link)
  219. }).pipe(
  220. Effect.andThen(
  221. withTool(active.path, (registry) => executeTool(registry, call({ path: "link.txt", content: "after" }))),
  222. ),
  223. Effect.andThen((result) =>
  224. Effect.sync(() => {
  225. expect(result.type).toBe("text")
  226. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  227. expect(assertions[0]?.resources).toEqual(["link.txt"])
  228. }),
  229. ),
  230. Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
  231. Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
  232. )
  233. },
  234. ([active, outside]) =>
  235. Effect.promise(() =>
  236. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  237. ),
  238. ),
  239. )
  240. it.live("approves an explicit external absolute path before edit", () =>
  241. Effect.acquireUseRelease(
  242. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  243. ([active, outside]) => {
  244. reset()
  245. const target = path.join(outside.path, "external.txt")
  246. return withTool(active.path, (registry) =>
  247. settleTool(registry, call({ path: target, content: "external" })),
  248. ).pipe(
  249. Effect.andThen((settled) =>
  250. Effect.gen(function* () {
  251. const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
  252. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  253. expect(assertions[0]).toMatchObject({
  254. resources: [
  255. path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
  256. ],
  257. })
  258. expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
  259. expect(settled.output?.structured).toMatchObject({
  260. target: canonicalTarget,
  261. resource: canonicalTarget.replaceAll("\\", "/"),
  262. existed: false,
  263. })
  264. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
  265. expect(writes).toEqual([canonicalTarget])
  266. }),
  267. ),
  268. )
  269. },
  270. ([active, outside]) =>
  271. Effect.promise(() =>
  272. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  273. ),
  274. ),
  275. )
  276. it.live("saves external directory approval at the nearest project directory", () =>
  277. Effect.acquireUseRelease(
  278. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  279. ([active, outside]) => {
  280. reset()
  281. const repo = path.join(outside.path, "repo")
  282. const nested = path.join(repo, "packages", "app")
  283. const target = path.join(nested, "external.txt")
  284. return Effect.promise(() =>
  285. Promise.all([fs.mkdir(path.join(repo, ".git"), { recursive: true }), fs.mkdir(nested, { recursive: true })]),
  286. ).pipe(
  287. Effect.andThen(
  288. withTool(active.path, (registry) => executeTool(registry, call({ path: target, content: "external" }))),
  289. ),
  290. Effect.andThen(
  291. Effect.gen(function* () {
  292. const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
  293. const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
  294. expect(assertions[0]).toMatchObject({
  295. action: "external_directory",
  296. resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
  297. save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
  298. })
  299. }),
  300. ),
  301. )
  302. },
  303. ([active, outside]) =>
  304. Effect.promise(() =>
  305. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  306. ),
  307. ),
  308. )
  309. it.live("does not write when external_directory or edit approval is denied", () =>
  310. Effect.acquireUseRelease(
  311. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  312. ([active, outside]) =>
  313. Effect.gen(function* () {
  314. const external = path.join(outside.path, "denied.txt")
  315. reset()
  316. denyAction = "external_directory"
  317. expect(
  318. yield* withTool(active.path, (registry) =>
  319. executeTool(registry, call({ path: external, content: "blocked" })),
  320. ),
  321. ).toEqual({
  322. type: "error",
  323. value: `Unable to write ${external}`,
  324. })
  325. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  326. expect(writes).toEqual([])
  327. reset()
  328. denyAction = "edit"
  329. expect(
  330. yield* withTool(active.path, (registry) =>
  331. executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
  332. ),
  333. ).toEqual({
  334. type: "error",
  335. value: "Unable to write denied.txt",
  336. })
  337. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  338. expect(writes).toEqual([])
  339. }),
  340. ([active, outside]) =>
  341. Effect.promise(() =>
  342. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  343. ),
  344. ),
  345. )
  346. })