tool-write.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { fileURLToPath } from "url"
  4. import { describe, expect, test } from "bun:test"
  5. import { Effect, Layer } from "effect"
  6. import { FileMutation } from "@opencode-ai/core/file-mutation"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  9. import { FSUtil } from "@opencode-ai/core/fs-util"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  12. import { PermissionV2 } from "@opencode-ai/core/permission"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { SessionV2 } from "@opencode-ai/core/session"
  15. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  16. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  17. import { WriteTool } from "@opencode-ai/core/tool/write"
  18. import { location } from "./fixture/location"
  19. import { tmpdir } from "./fixture/tmpdir"
  20. import { testEffect } from "./lib/effect"
  21. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  22. const sessionID = SessionV2.ID.make("ses_write_tool_test")
  23. const assertions: PermissionV2.AssertInput[] = []
  24. const writes: string[] = []
  25. let denyAction: string | undefined
  26. const permission = Layer.succeed(
  27. PermissionV2.Service,
  28. PermissionV2.Service.of({
  29. assert: (input) =>
  30. Effect.sync(() => assertions.push(input)).pipe(
  31. Effect.andThen(
  32. input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
  33. ),
  34. ),
  35. ask: () => Effect.die("unused"),
  36. reply: () => Effect.die("unused"),
  37. get: () => Effect.die("unused"),
  38. forSession: () => Effect.die("unused"),
  39. list: () => Effect.die("unused"),
  40. }),
  41. )
  42. const reset = () => {
  43. assertions.length = 0
  44. writes.length = 0
  45. denyAction = undefined
  46. }
  47. const filesystem = Layer.effect(
  48. FSUtil.Service,
  49. Effect.gen(function* () {
  50. const fs = yield* FSUtil.Service
  51. return FSUtil.Service.of({
  52. ...fs,
  53. writeWithDirs: (target, content, mode) =>
  54. Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
  55. })
  56. }),
  57. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  58. const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
  59. const activeLocation = Layer.succeed(
  60. Location.Service,
  61. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  62. )
  63. return Effect.gen(function* () {
  64. return yield* body(yield* ToolRegistry.Service)
  65. }).pipe(
  66. Effect.provide(
  67. AppNodeBuilder.build(
  68. LayerNode.group([
  69. ToolRegistry.node,
  70. ToolRegistry.toolsNode,
  71. LocationMutation.node,
  72. FileMutation.node,
  73. WriteTool.node,
  74. ]),
  75. [
  76. [FSUtil.node, filesystem],
  77. [Location.node, activeLocation],
  78. [PermissionV2.node, permission],
  79. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  80. ],
  81. ),
  82. ),
  83. )
  84. }
  85. const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
  86. sessionID,
  87. ...toolIdentity,
  88. call: { type: "tool-call" as const, id, name: "write", input },
  89. })
  90. const it = testEffect(Layer.empty)
  91. describe("WriteTool", () => {
  92. it.live("registers and creates a relative file through FileMutation once", () =>
  93. Effect.acquireUseRelease(
  94. Effect.promise(() => tmpdir()),
  95. (tmp) => {
  96. reset()
  97. return withTool(tmp.path, (registry) =>
  98. Effect.gen(function* () {
  99. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
  100. const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
  101. expect(settled).toEqual({
  102. result: { type: "text", value: "Created file successfully: src/new.txt" },
  103. output: {
  104. structured: {
  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. })
  113. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
  114. "created",
  115. )
  116. expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
  117. expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
  118. }),
  119. )
  120. },
  121. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  122. ),
  123. )
  124. it.live("overwrites a relative existing file and reports that it wrote the file", () =>
  125. Effect.acquireUseRelease(
  126. Effect.promise(() => tmpdir()),
  127. (tmp) => {
  128. reset()
  129. return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
  130. Effect.andThen(
  131. withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
  132. ),
  133. Effect.andThen((settled) =>
  134. Effect.gen(function* () {
  135. expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
  136. expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
  137. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
  138. "after",
  139. )
  140. expect(writes).toHaveLength(1)
  141. }),
  142. ),
  143. )
  144. },
  145. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  146. ),
  147. )
  148. it.live("preserves exactly one BOM when overwriting existing files", () =>
  149. Effect.acquireUseRelease(
  150. Effect.promise(() => tmpdir()),
  151. (tmp) => {
  152. reset()
  153. const preserved = path.join(tmp.path, "preserved.txt")
  154. const deduplicated = path.join(tmp.path, "deduplicated.txt")
  155. return Effect.promise(() =>
  156. Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
  157. ).pipe(
  158. Effect.andThen(
  159. withTool(tmp.path, (registry) =>
  160. Effect.gen(function* () {
  161. yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
  162. yield* settleTool(
  163. registry,
  164. call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
  165. )
  166. expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
  167. expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
  168. }),
  169. ),
  170. ),
  171. )
  172. },
  173. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  174. ),
  175. )
  176. it.live("accepts an absolute file path inside the active Location", () =>
  177. Effect.acquireUseRelease(
  178. Effect.promise(() => tmpdir()),
  179. (tmp) => {
  180. reset()
  181. const target = path.join(tmp.path, "absolute.txt")
  182. return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
  183. Effect.andThen((result) =>
  184. Effect.gen(function* () {
  185. expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
  186. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  187. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
  188. }),
  189. ),
  190. )
  191. },
  192. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  193. ),
  194. )
  195. it.live("approves an explicit external absolute path before edit", () =>
  196. Effect.acquireUseRelease(
  197. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  198. ([active, outside]) => {
  199. reset()
  200. const target = path.join(outside.path, "external.txt")
  201. return withTool(active.path, (registry) =>
  202. settleTool(registry, call({ path: target, content: "external" })),
  203. ).pipe(
  204. Effect.andThen((settled) =>
  205. Effect.gen(function* () {
  206. const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
  207. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  208. expect(assertions[0]).toMatchObject({
  209. resources: [
  210. path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
  211. ],
  212. })
  213. expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
  214. expect(settled.output?.structured).toMatchObject({
  215. target: canonicalTarget,
  216. resource: canonicalTarget.replaceAll("\\", "/"),
  217. existed: false,
  218. })
  219. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
  220. expect(writes).toEqual([canonicalTarget])
  221. }),
  222. ),
  223. )
  224. },
  225. ([active, outside]) =>
  226. Effect.promise(() =>
  227. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  228. ),
  229. ),
  230. )
  231. it.live("does not write when external_directory or edit approval is denied", () =>
  232. Effect.acquireUseRelease(
  233. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  234. ([active, outside]) =>
  235. Effect.gen(function* () {
  236. const external = path.join(outside.path, "denied.txt")
  237. reset()
  238. denyAction = "external_directory"
  239. expect(
  240. yield* withTool(active.path, (registry) =>
  241. executeTool(registry, call({ path: external, content: "blocked" })),
  242. ),
  243. ).toEqual({
  244. type: "error",
  245. value: `Unable to write ${external}`,
  246. })
  247. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  248. expect(writes).toEqual([])
  249. reset()
  250. denyAction = "edit"
  251. expect(
  252. yield* withTool(active.path, (registry) =>
  253. executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
  254. ),
  255. ).toEqual({
  256. type: "error",
  257. value: "Unable to write denied.txt",
  258. })
  259. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  260. expect(writes).toEqual([])
  261. }),
  262. ([active, outside]) =>
  263. Effect.promise(() =>
  264. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  265. ),
  266. ),
  267. )
  268. })
  269. test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
  270. const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
  271. const definition = await Effect.runPromise(
  272. withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
  273. )
  274. const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
  275. expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
  276. expect(source).toContain(
  277. "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
  278. )
  279. for (const todo of [
  280. "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
  281. "Add formatter integration after V2 formatter runtime exists.",
  282. "Publish watcher/file-edit events after V2 watcher integration exists.",
  283. "Add snapshots / undo after design exists.",
  284. "Add LSP notification and diagnostics after V2 LSP runtime exists.",
  285. ]) {
  286. expect(source).toContain(`TODO: ${todo}`)
  287. }
  288. })