tool-write.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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. const sessionID = SessionV2.ID.make("ses_write_tool_test")
  19. const assertions: PermissionV2.AssertInput[] = []
  20. const writes: string[] = []
  21. let denyAction: string | undefined
  22. let afterAssertion = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
  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
  30. ? Effect.fail(new PermissionV2.DeniedError({ rules: [] }))
  31. : afterAssertion(input),
  32. ),
  33. ),
  34. ask: () => Effect.die("unused"),
  35. reply: () => Effect.die("unused"),
  36. get: () => Effect.die("unused"),
  37. forSession: () => Effect.die("unused"),
  38. list: () => Effect.die("unused"),
  39. }),
  40. )
  41. const reset = () => {
  42. assertions.length = 0
  43. writes.length = 0
  44. denyAction = undefined
  45. afterAssertion = () => Effect.void
  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(FSUtil.defaultLayer))
  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. const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
  64. const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
  65. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  66. const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(planning), Layer.provide(commits))
  67. return Effect.gen(function* () {
  68. return yield* body(yield* ToolRegistry.Service)
  69. }).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, write)))
  70. }
  71. const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
  72. sessionID,
  73. call: { type: "tool-call" as const, id, name: "write", input },
  74. })
  75. const it = testEffect(Layer.empty)
  76. describe("WriteTool", () => {
  77. it.live("registers and creates a relative file through FileMutation once", () =>
  78. Effect.acquireUseRelease(
  79. Effect.promise(() => tmpdir()),
  80. (tmp) => {
  81. reset()
  82. return withTool(tmp.path, (registry) =>
  83. Effect.gen(function* () {
  84. expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"])
  85. const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" }))
  86. expect(settled).toEqual({
  87. result: { type: "text", value: "Created file successfully: src/new.txt" },
  88. output: {
  89. structured: {
  90. operation: "write",
  91. target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
  92. resource: "src/new.txt",
  93. existed: false,
  94. },
  95. content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
  96. },
  97. })
  98. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
  99. "created",
  100. )
  101. expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
  102. expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
  103. }),
  104. )
  105. },
  106. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  107. ),
  108. )
  109. it.live("overwrites a relative existing file and reports that it wrote the file", () =>
  110. Effect.acquireUseRelease(
  111. Effect.promise(() => tmpdir()),
  112. (tmp) => {
  113. reset()
  114. return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
  115. Effect.andThen(
  116. withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))),
  117. ),
  118. Effect.andThen((settled) =>
  119. Effect.gen(function* () {
  120. expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
  121. expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
  122. expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
  123. "after",
  124. )
  125. expect(writes).toHaveLength(1)
  126. }),
  127. ),
  128. )
  129. },
  130. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  131. ),
  132. )
  133. it.live("preserves exactly one BOM when overwriting existing files", () =>
  134. Effect.acquireUseRelease(
  135. Effect.promise(() => tmpdir()),
  136. (tmp) => {
  137. reset()
  138. const preserved = path.join(tmp.path, "preserved.txt")
  139. const deduplicated = path.join(tmp.path, "deduplicated.txt")
  140. return Effect.promise(() =>
  141. Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
  142. ).pipe(
  143. Effect.andThen(
  144. withTool(tmp.path, (registry) =>
  145. Effect.gen(function* () {
  146. yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved"))
  147. yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"))
  148. expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
  149. expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
  150. }),
  151. ),
  152. ),
  153. )
  154. },
  155. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  156. ),
  157. )
  158. it.live("accepts an absolute file path inside the active Location", () =>
  159. Effect.acquireUseRelease(
  160. Effect.promise(() => tmpdir()),
  161. (tmp) => {
  162. reset()
  163. const target = path.join(tmp.path, "absolute.txt")
  164. return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe(
  165. Effect.andThen((result) =>
  166. Effect.gen(function* () {
  167. expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
  168. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  169. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
  170. }),
  171. ),
  172. )
  173. },
  174. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  175. ),
  176. )
  177. it.live("approves an explicit external absolute path before edit", () =>
  178. Effect.acquireUseRelease(
  179. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  180. ([active, outside]) => {
  181. reset()
  182. const target = path.join(outside.path, "external.txt")
  183. return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe(
  184. Effect.andThen((settled) =>
  185. Effect.gen(function* () {
  186. const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
  187. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  188. expect(assertions[0]).toMatchObject({
  189. resources: [
  190. path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
  191. ],
  192. })
  193. expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
  194. expect(settled.output?.structured).toMatchObject({
  195. target: canonicalTarget,
  196. resource: canonicalTarget.replaceAll("\\", "/"),
  197. existed: false,
  198. })
  199. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
  200. expect(writes).toEqual([canonicalTarget])
  201. }),
  202. ),
  203. )
  204. },
  205. ([active, outside]) =>
  206. Effect.promise(() =>
  207. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  208. ),
  209. ),
  210. )
  211. it.live("does not write when external_directory or edit approval is denied", () =>
  212. Effect.acquireUseRelease(
  213. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  214. ([active, outside]) =>
  215. Effect.gen(function* () {
  216. const external = path.join(outside.path, "denied.txt")
  217. reset()
  218. denyAction = "external_directory"
  219. expect(
  220. yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))),
  221. ).toEqual({
  222. type: "error",
  223. value: `Unable to write ${external}`,
  224. })
  225. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  226. expect(writes).toEqual([])
  227. reset()
  228. denyAction = "edit"
  229. expect(
  230. yield* withTool(active.path, (registry) =>
  231. registry.execute(call({ path: "denied.txt", content: "blocked" })),
  232. ),
  233. ).toEqual({
  234. type: "error",
  235. value: "Unable to write denied.txt",
  236. })
  237. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  238. expect(writes).toEqual([])
  239. }),
  240. ([active, outside]) =>
  241. Effect.promise(() =>
  242. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  243. ),
  244. ),
  245. )
  246. if (process.platform !== "win32") {
  247. it.live("delegates post-approval revalidation to FileMutation before writing", () =>
  248. Effect.acquireUseRelease(
  249. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  250. ([active, outside]) => {
  251. reset()
  252. const parent = path.join(active.path, "parent")
  253. afterAssertion = (input) =>
  254. input.action === "edit"
  255. ? Effect.promise(async () => {
  256. await fs.rmdir(parent)
  257. await fs.symlink(outside.path, parent)
  258. })
  259. : Effect.void
  260. return Effect.promise(() => fs.mkdir(parent)).pipe(
  261. Effect.andThen(
  262. withTool(active.path, (registry) =>
  263. registry.execute(call({ path: "parent/escape.txt", content: "blocked" })),
  264. ),
  265. ),
  266. Effect.andThen((result) =>
  267. Effect.gen(function* () {
  268. expect(result).toEqual({ type: "error", value: "Unable to write parent/escape.txt" })
  269. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  270. expect(writes).toEqual([])
  271. expect(
  272. yield* Effect.promise(() =>
  273. fs.stat(path.join(outside.path, "escape.txt")).then(
  274. () => true,
  275. () => false,
  276. ),
  277. ),
  278. ).toBe(false)
  279. }),
  280. ),
  281. )
  282. },
  283. ([active, outside]) =>
  284. Effect.promise(() =>
  285. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  286. ),
  287. ),
  288. )
  289. }
  290. })
  291. test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
  292. const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
  293. const definition = await Effect.runPromise(
  294. withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
  295. )
  296. const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
  297. expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
  298. expect(source).toContain(
  299. "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
  300. )
  301. for (const todo of [
  302. "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
  303. "Add formatter integration after V2 formatter runtime exists.",
  304. "Publish watcher/file-edit events after V2 watcher integration exists.",
  305. "Add snapshots / undo after design exists.",
  306. "Add LSP notification and diagnostics after V2 LSP runtime exists.",
  307. ]) {
  308. expect(source).toContain(`TODO: ${todo}`)
  309. }
  310. })