tool-write.test.ts 15 KB

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