Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403import fs from "fs/promises"import path from "path"import { describe, expect } from "bun:test"import { Effect, Layer } from "effect"import { FileMutation } from "@opencode/core/file-mutation"import { Formatter } from "@opencode/core/formatter"import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"import { LayerNode } from "@opencode/util/effect/layer-node"import { Environment } from "@opencode/core/environment/index"import { Location } from "@opencode/core/location"import { FileAccess } from "@opencode/core/file-access"import { Permission } from "@opencode/core/permission"import { AbsolutePath } from "@opencode/core/schema"import { Session } from "@opencode/core/session"import { Tool } from "@opencode/core/tool"import { WriteTool } from "@opencode/core/tool/plugin/write"import { transformEnvironmentFiles } from "./fixture/environment"import { location } from "./fixture/location"import { tmpdir, withTempDir } from "./fixture/tmpdir"import { makeLocationNode } from "@opencode/util/effect/app-node"import { testEffect } from "./lib/effect"import { permissionLayer } from "./lib/permission"import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],})
const sessionID = Session.ID.make("ses_write_tool_test")const makeWriteFixture = () => { const fixture: { assertions: Permission.AssertInput[] writes: string[] denyAction?: string formatFile: (target: string) => Effect.Effect<boolean> } = { assertions: [], writes: [], formatFile: () => Effect.succeed(false), }
const permission = permissionLayer({ assert: (input) => Effect.sync(() => fixture.assertions.push(input)).pipe( Effect.andThen( input.action === fixture.denyAction ? Effect.fail( new Permission.BlockedError({ rules: [], permission: input.action, resources: input.resources, }), ) : Effect.void, ), ), })
const formatter = Layer.mock(Formatter.Service, { file: (target) => fixture.formatFile(target), })
return Object.assign(fixture, { permission, formatter })}
const withTool = <A, E, R>( directory: string, fixture: ReturnType<typeof makeWriteFixture>, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) return Effect.gen(function* () { const registry = yield* Tool.Service return yield* body(registry) }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [ Environment.node.replace( transformEnvironmentFiles((files) => ({ write: (target, content) => Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), })), ), Location.node.replace(activeLocation), Formatter.node.replace(fixture.formatter), Permission.node.replace(fixture.permission), ]), ), )}
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", () => withTempDir((tmp) => { const fixture = makeWriteFixture() return withTool(tmp.path, fixture, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"]) const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" })) expect(settled).toEqual({ status: "completed", output: { 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(fixture.assertions).toMatchObject([ { sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }, ]) expect(fixture.assertions[0]?.metadata).toMatchObject({ files: [ { file: "src/new.txt", status: "added", additions: 1, deletions: 0, patch: expect.stringContaining("+created"), }, ], }) expect(fixture.writes).toEqual([ path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), ]) }), ) }), )
it.live("formats the committed file", () => withTempDir((tmp) => { const fixture = makeWriteFixture() const target = path.join(tmp.path, "formatted.txt") fixture.formatFile = (file) => Effect.promise(async () => { await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase()) return true }) return withTool(tmp.path, fixture, (registry) => Effect.gen(function* () { expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({ status: "completed", }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME") }), ) }), )
it.live("overwrites a relative existing file and reports that it wrote the file", () => withTempDir((tmp) => { const fixture = makeWriteFixture() return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( Effect.andThen( withTool(tmp.path, fixture, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" })), ), ), Effect.andThen((settled) => Effect.gen(function* () { expect(settled.status).toBe("completed") if (settled.status !== "completed") return expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }]) expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true }) expect(fixture.assertions[0]?.metadata).toMatchObject({ files: [ { file: "existing.txt", status: "modified", additions: 1, deletions: 1, patch: expect.stringMatching(/-before[\s\S]*\+after/), }, ], }) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe("after") expect(fixture.writes).toHaveLength(1) }), ), ) }), )
it.live("preserves exactly one BOM when overwriting existing files", () => withTempDir((tmp) => { const fixture = makeWriteFixture() const preserved = path.join(tmp.path, "preserved.txt") const deduplicated = path.join(tmp.path, "deduplicated.txt") fixture.formatFile = (target) => Effect.promise(async () => { await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`) return true }) return Effect.promise(() => Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]), ).pipe( Effect.andThen( withTool(tmp.path, fixture, (registry) => Effect.gen(function* () { yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) yield* executeTool( 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") }), ), ), ) }), )
it.live("accepts an absolute file path inside the active Location", () => withTempDir((tmp) => { const fixture = makeWriteFixture() const target = path.join(tmp.path, "absolute.txt") return withTool(tmp.path, fixture, (registry) => executeTool(registry, call({ path: target, content: "inside" })), ).pipe( Effect.andThen((result) => Effect.gen(function* () { expect(result).toMatchObject({ status: "completed", content: [{ type: "text", text: "Created file successfully: absolute.txt" }], }) expect(fixture.assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside") }), ), ) }), )
it.live("writes an external symlink target with only its in-location permission", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { const fixture = makeWriteFixture() if (process.platform === "win32") return Effect.void const target = path.join(outside.path, "external.txt") const link = path.join(active.path, "link.txt") return Effect.promise(async () => { await fs.writeFile(target, "before") await fs.symlink(target, link) }).pipe( Effect.andThen( withTool(active.path, fixture, (registry) => executeTool(registry, call({ path: "link.txt", content: "after" })), ), ), Effect.andThen((result) => Effect.sync(() => { expect(result.status).toBe("completed") expect(fixture.assertions.map((input) => input.action)).toEqual(["edit"]) expect(fixture.assertions[0]?.resources).toEqual(["link.txt"]) }), ), Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))), ) }, ([active, outside]) => Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), )
it.live("approves an explicit external absolute path before edit", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { const fixture = makeWriteFixture() const target = path.join(outside.path, "external.txt") return withTool(active.path, fixture, (registry) => executeTool(registry, call({ path: target, content: "external" })), ).pipe( Effect.andThen((settled) => Effect.gen(function* () { const absoluteTarget = target expect(fixture.assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(fixture.assertions[0]).toMatchObject({ resources: [path.join(outside.path, "*").replaceAll("\\", "/")], }) expect(fixture.assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"], }) expect(settled).toMatchObject({ status: "completed", output: { target: absoluteTarget, resource: absoluteTarget.replaceAll("\\", "/"), existed: false, }, }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external") expect(fixture.writes).toEqual([absoluteTarget]) }), ), ) }, ([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]) => { const fixture = makeWriteFixture() 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, fixture, (registry) => executeTool(registry, call({ path: target, content: "external" })), ), ), Effect.andThen( Effect.gen(function* () { expect(fixture.assertions[0]).toMatchObject({ action: "external_directory", resources: [path.join(nested, "*").replaceAll("\\", "/")], save: [path.join(repo, "*").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") const fixture = makeWriteFixture() fixture.denyAction = "external_directory" expect( yield* withTool(active.path, fixture, (registry) => executeTool(registry, call({ path: external, content: "blocked" })), ), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: external_directory" }, }) expect(fixture.assertions.map((input) => input.action)).toEqual(["external_directory"]) expect(fixture.writes).toEqual([])
const deniedEdit = makeWriteFixture() deniedEdit.denyAction = "edit" expect( yield* withTool(active.path, deniedEdit, (registry) => executeTool(registry, call({ path: "denied.txt", content: "blocked" })), ), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: edit" }, }) expect(deniedEdit.assertions.map((input) => input.action)).toEqual(["edit"]) expect(deniedEdit.writes).toEqual([]) }), ([active, outside]) => Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), )})