import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { WriteTool } from "@opencode-ai/core/tool/write" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, PermissionV2.node], }) const sessionID = SessionV2.ID.make("ses_write_tool_test") const assertions: PermissionV2.AssertInput[] = [] const writes: string[] = [] let denyAction: string | undefined const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen( input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void, ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), forSession: () => Effect.die("unused"), list: () => Effect.die("unused"), }), ) const reset = () => { assertions.length = 0 writes.length = 0 denyAction = undefined } const filesystem = Layer.effect( FSUtil.Service, Effect.gen(function* () { const fs = yield* FSUtil.Service return FSUtil.Service.of({ ...fs, writeWithDirs: (target, content, mode) => Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), }) }), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) }).pipe( Effect.provide( AppNodeBuilder.build( LayerNode.group([ ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, writeToolNode, ]), [ [FSUtil.node, filesystem], [Location.node, activeLocation], [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ], ), ), ) } const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "write", input }, }) const it = testEffect(Layer.empty) describe("WriteTool", () => { it.live("registers and creates a relative file through FileMutation once", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() return withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"]) const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" })) expect(settled).toEqual({ result: { type: "text", value: "Created file successfully: src/new.txt" }, output: { structured: { operation: "write", target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), resource: "src/new.txt", existed: false, }, content: [{ type: "text", text: "Created file successfully: src/new.txt" }], }, }) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe( "created", ) expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }]) expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")]) }), ) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) it.live("overwrites a relative existing file and reports that it wrote the file", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( Effect.andThen( withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))), ), Effect.andThen((settled) => Effect.gen(function* () { expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" }) expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true }) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe( "after", ) expect(writes).toHaveLength(1) }), ), ) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) it.live("preserves exactly one BOM when overwriting existing files", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() const preserved = path.join(tmp.path, "preserved.txt") const deduplicated = path.join(tmp.path, "deduplicated.txt") return Effect.promise(() => Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]), ).pipe( Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) yield* settleTool( registry, call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"), ) expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter") }), ), ), ) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) it.live("accepts an absolute file path inside the active Location", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() const target = path.join(tmp.path, "absolute.txt") return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe( Effect.andThen((result) => Effect.gen(function* () { expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside") }), ), ) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) it.live("approves an explicit external absolute path before edit", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { reset() const target = path.join(outside.path, "external.txt") return withTool(active.path, (registry) => settleTool(registry, call({ path: target, content: "external" })), ).pipe( Effect.andThen((settled) => Effect.gen(function* () { const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt") expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(assertions[0]).toMatchObject({ resources: [ path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"), ], }) expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] }) expect(settled.output?.structured).toMatchObject({ target: canonicalTarget, resource: canonicalTarget.replaceAll("\\", "/"), existed: false, }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external") expect(writes).toEqual([canonicalTarget]) }), ), ) }, ([active, outside]) => Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), ) it.live("saves external directory approval at the nearest project directory", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { reset() const repo = path.join(outside.path, "repo") const nested = path.join(repo, "packages", "app") const target = path.join(nested, "external.txt") return Effect.promise(() => Promise.all([fs.mkdir(path.join(repo, ".git"), { recursive: true }), fs.mkdir(nested, { recursive: true })]), ).pipe( Effect.andThen( withTool(active.path, (registry) => executeTool(registry, call({ path: target, content: "external" }))), ), Effect.andThen( Effect.gen(function* () { const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo)) const canonicalNested = yield* Effect.promise(() => fs.realpath(nested)) expect(assertions[0]).toMatchObject({ action: "external_directory", resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")], save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")], }) }), ), ) }, ([active, outside]) => Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), ) it.live("does not write when external_directory or edit approval is denied", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => Effect.gen(function* () { const external = path.join(outside.path, "denied.txt") reset() denyAction = "external_directory" expect( yield* withTool(active.path, (registry) => executeTool(registry, call({ path: external, content: "blocked" })), ), ).toEqual({ type: "error", value: `Unable to write ${external}`, }) expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) expect(writes).toEqual([]) reset() denyAction = "edit" expect( yield* withTool(active.path, (registry) => executeTool(registry, call({ path: "denied.txt", content: "blocked" })), ), ).toEqual({ type: "error", value: "Unable to write denied.txt", }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(writes).toEqual([]) }), ([active, outside]) => Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), ) }) test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => { const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") const definition = await Effect.runPromise( withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)), ) const schema = definition[0]?.inputSchema as { readonly properties?: Record } expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) expect(source).toContain( "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", "Add formatter integration after V2 formatter runtime exists.", "Publish watcher/file-edit events after V2 watcher integration exists.", "Add snapshots / undo after design exists.", "Add LSP notification and diagnostics after V2 LSP runtime exists.", ]) { expect(source).toContain(`TODO: ${todo}`) } })