|
|
@@ -1,7 +1,7 @@
|
|
|
import fs from "fs/promises"
|
|
|
import path from "path"
|
|
|
import { describe, expect } from "bun:test"
|
|
|
-import { Effect, Layer } from "effect"
|
|
|
+import { Deferred, Effect, Fiber, Layer } from "effect"
|
|
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
|
|
import { Formatter } from "@opencode-ai/core/formatter"
|
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
|
@@ -13,6 +13,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
|
import { Session } from "@opencode-ai/core/session"
|
|
|
import { Tool } from "@opencode-ai/core/tool"
|
|
|
+import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
|
|
|
import { WriteTool } from "@opencode-ai/core/tool/plugin/write"
|
|
|
import { transformEnvironmentFiles } from "./fixture/environment"
|
|
|
import { location } from "./fixture/location"
|
|
|
@@ -27,10 +28,25 @@ const writeToolNode = makeLocationNode({
|
|
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
|
|
})
|
|
|
|
|
|
+const editToolNode = makeLocationNode({
|
|
|
+ name: "test/edit-tool-plugin",
|
|
|
+ layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
|
|
+ deps: [
|
|
|
+ Tool.node,
|
|
|
+ LocationMutation.node,
|
|
|
+ FileMutation.node,
|
|
|
+ Environment.node,
|
|
|
+ Formatter.node,
|
|
|
+ Location.node,
|
|
|
+ Permission.node,
|
|
|
+ ],
|
|
|
+})
|
|
|
+
|
|
|
const sessionID = Session.ID.make("ses_write_tool_test")
|
|
|
const assertions: Permission.AssertInput[] = []
|
|
|
const writes: string[] = []
|
|
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
|
|
+let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
|
|
let denyAction: string | undefined
|
|
|
|
|
|
const permission = Layer.succeed(
|
|
|
@@ -38,6 +54,7 @@ const permission = Layer.succeed(
|
|
|
Permission.Service.of({
|
|
|
assert: (input) =>
|
|
|
Effect.sync(() => assertions.push(input)).pipe(
|
|
|
+ Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
|
|
Effect.andThen(
|
|
|
input.action === denyAction
|
|
|
? Effect.fail(
|
|
|
@@ -66,6 +83,7 @@ const reset = () => {
|
|
|
assertions.length = 0
|
|
|
writes.length = 0
|
|
|
formatFile = () => Effect.succeed(false)
|
|
|
+ afterPermission = () => Effect.void
|
|
|
denyAction = undefined
|
|
|
}
|
|
|
|
|
|
@@ -97,12 +115,40 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+const withMutationTools = <A, E, R>(directory: string, 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* () {
|
|
|
+ return yield* body(yield* Tool.Service)
|
|
|
+ }).pipe(
|
|
|
+ Effect.provide(
|
|
|
+ AppNodeBuilder.build(
|
|
|
+ LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode, editToolNode]),
|
|
|
+ [
|
|
|
+ [Environment.node, environment],
|
|
|
+ [Location.node, activeLocation],
|
|
|
+ [Formatter.node, formatter],
|
|
|
+ [Permission.node, permission],
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
|
|
|
sessionID,
|
|
|
...toolIdentity,
|
|
|
call: { type: "tool-call" as const, id, name: "write", input },
|
|
|
})
|
|
|
|
|
|
+const editCall = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
|
|
|
+ sessionID,
|
|
|
+ ...toolIdentity,
|
|
|
+ call: { type: "tool-call" as const, id, name: "edit", input },
|
|
|
+})
|
|
|
+
|
|
|
const it = testEffect(Layer.empty)
|
|
|
|
|
|
describe("WriteTool", () => {
|
|
|
@@ -412,4 +458,131 @@ describe("WriteTool", () => {
|
|
|
),
|
|
|
),
|
|
|
)
|
|
|
+
|
|
|
+ it.live("serializes write and edit transactions across Location service instances", () =>
|
|
|
+ Effect.acquireUseRelease(
|
|
|
+ Effect.promise(() => tmpdir()),
|
|
|
+ (tmp) => {
|
|
|
+ reset()
|
|
|
+ const target = path.join(tmp.path, "shared.txt")
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ yield* Effect.promise(() => fs.writeFile(target, "initial"))
|
|
|
+ const formatting = yield* Deferred.make<void>()
|
|
|
+ const releaseFormatting = yield* Deferred.make<void>()
|
|
|
+ const editApproved = yield* Deferred.make<void>()
|
|
|
+ let formats = 0
|
|
|
+ formatFile = () =>
|
|
|
+ ++formats === 1
|
|
|
+ ? Deferred.succeed(formatting, undefined).pipe(
|
|
|
+ Effect.andThen(Deferred.await(releaseFormatting)),
|
|
|
+ Effect.as(false),
|
|
|
+ )
|
|
|
+ : Effect.succeed(false)
|
|
|
+ afterPermission = (input) =>
|
|
|
+ input.source?.id === "call-serialized-edit" && input.action === "edit"
|
|
|
+ ? Deferred.succeed(editApproved, undefined).pipe(Effect.asVoid)
|
|
|
+ : Effect.void
|
|
|
+
|
|
|
+ const write = yield* withMutationTools(tmp.path, (registry) =>
|
|
|
+ executeTool(registry, call({ path: "shared.txt", content: "before" }, "call-serialized-write")),
|
|
|
+ ).pipe(Effect.forkChild)
|
|
|
+ yield* Deferred.await(formatting)
|
|
|
+ const edit = yield* withMutationTools(tmp.path, (registry) =>
|
|
|
+ executeTool(
|
|
|
+ registry,
|
|
|
+ editCall({ path: "shared.txt", oldString: "before", newString: "after" }, "call-serialized-edit"),
|
|
|
+ ),
|
|
|
+ ).pipe(Effect.forkChild)
|
|
|
+ yield* Effect.yieldNow
|
|
|
+ expect(yield* Deferred.isDone(editApproved)).toBe(false)
|
|
|
+
|
|
|
+ yield* Deferred.succeed(releaseFormatting, undefined)
|
|
|
+ expect((yield* Fiber.join(write)).status).toBe("completed")
|
|
|
+ expect((yield* Fiber.join(edit)).status).toBe("completed")
|
|
|
+ expect(yield* Deferred.isDone(editApproved)).toBe(true)
|
|
|
+ expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
|
|
|
+ })
|
|
|
+ },
|
|
|
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("serializes complete write transactions across Location service instances", () =>
|
|
|
+ Effect.acquireUseRelease(
|
|
|
+ Effect.promise(() => tmpdir()),
|
|
|
+ (tmp) => {
|
|
|
+ reset()
|
|
|
+ const target = path.join(tmp.path, "shared.txt")
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ const formatting = yield* Deferred.make<void>()
|
|
|
+ const releaseFormatting = yield* Deferred.make<void>()
|
|
|
+ const secondApproved = yield* Deferred.make<void>()
|
|
|
+ let formats = 0
|
|
|
+ formatFile = () =>
|
|
|
+ ++formats === 1
|
|
|
+ ? Deferred.succeed(formatting, undefined).pipe(
|
|
|
+ Effect.andThen(Deferred.await(releaseFormatting)),
|
|
|
+ Effect.as(false),
|
|
|
+ )
|
|
|
+ : Effect.succeed(false)
|
|
|
+ afterPermission = (input) =>
|
|
|
+ input.source?.id === "call-second-write" && input.action === "edit"
|
|
|
+ ? Deferred.succeed(secondApproved, undefined).pipe(Effect.asVoid)
|
|
|
+ : Effect.void
|
|
|
+
|
|
|
+ const first = yield* withTool(tmp.path, (registry) =>
|
|
|
+ executeTool(registry, call({ path: "shared.txt", content: "first" }, "call-first-write")),
|
|
|
+ ).pipe(Effect.forkChild)
|
|
|
+ yield* Deferred.await(formatting)
|
|
|
+ const second = yield* withTool(tmp.path, (registry) =>
|
|
|
+ executeTool(registry, call({ path: "shared.txt", content: "second" }, "call-second-write")),
|
|
|
+ ).pipe(Effect.forkChild)
|
|
|
+ yield* Effect.yieldNow
|
|
|
+ expect(yield* Deferred.isDone(secondApproved)).toBe(false)
|
|
|
+
|
|
|
+ yield* Deferred.succeed(releaseFormatting, undefined)
|
|
|
+ expect((yield* Fiber.join(first)).status).toBe("completed")
|
|
|
+ expect((yield* Fiber.join(second)).status).toBe("completed")
|
|
|
+ expect(yield* Deferred.isDone(secondApproved)).toBe(true)
|
|
|
+ expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("second")
|
|
|
+ })
|
|
|
+ },
|
|
|
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("allows complete write transactions for unrelated paths to run concurrently", () =>
|
|
|
+ Effect.acquireUseRelease(
|
|
|
+ Effect.promise(() => tmpdir()),
|
|
|
+ (tmp) => {
|
|
|
+ reset()
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ const formatting = yield* Deferred.make<void>()
|
|
|
+ const releaseFormatting = yield* Deferred.make<void>()
|
|
|
+ let formats = 0
|
|
|
+ formatFile = () =>
|
|
|
+ ++formats === 1
|
|
|
+ ? Deferred.succeed(formatting, undefined).pipe(
|
|
|
+ Effect.andThen(Deferred.await(releaseFormatting)),
|
|
|
+ Effect.as(false),
|
|
|
+ )
|
|
|
+ : Effect.succeed(false)
|
|
|
+
|
|
|
+ const first = yield* withTool(tmp.path, (registry) =>
|
|
|
+ executeTool(registry, call({ path: "first.txt", content: "first" }, "call-first-path")),
|
|
|
+ ).pipe(Effect.forkChild)
|
|
|
+ yield* Deferred.await(formatting)
|
|
|
+ const second = yield* withTool(tmp.path, (registry) =>
|
|
|
+ executeTool(registry, call({ path: "second.txt", content: "second" }, "call-second-path")),
|
|
|
+ ).pipe(Effect.forkChild)
|
|
|
+
|
|
|
+ expect((yield* Fiber.join(second)).status).toBe("completed")
|
|
|
+ expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "second.txt"), "utf8"))).toBe("second")
|
|
|
+ yield* Deferred.succeed(releaseFormatting, undefined)
|
|
|
+ expect((yield* Fiber.join(first)).status).toBe("completed")
|
|
|
+ })
|
|
|
+ },
|
|
|
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
+ ),
|
|
|
+ )
|
|
|
})
|