tool-write.test.ts 12 KB

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