|
@@ -5,10 +5,10 @@ import { Effect, Exit, Layer, Schema } from "effect"
|
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
|
import { Environment } from "@opencode-ai/core/environment/index"
|
|
import { Environment } from "@opencode-ai/core/environment/index"
|
|
|
-import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
|
|
|
import { Formatter } from "@opencode-ai/core/formatter"
|
|
import { Formatter } from "@opencode-ai/core/formatter"
|
|
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
|
|
|
+import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
|
|
import { Permission } from "@opencode-ai/core/permission"
|
|
import { Permission } from "@opencode-ai/core/permission"
|
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
|
import { Session } from "@opencode-ai/core/session"
|
|
import { Session } from "@opencode-ai/core/session"
|
|
@@ -25,7 +25,15 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|
|
const patchToolNode = makeLocationNode({
|
|
const patchToolNode = makeLocationNode({
|
|
|
name: "test/patch-tool-plugin",
|
|
name: "test/patch-tool-plugin",
|
|
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
|
|
- deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
|
|
|
|
|
|
|
+ deps: [
|
|
|
|
|
+ Tool.node,
|
|
|
|
|
+ LocationMutation.node,
|
|
|
|
|
+ FileMutation.node,
|
|
|
|
|
+ Environment.node,
|
|
|
|
|
+ Formatter.node,
|
|
|
|
|
+ Location.node,
|
|
|
|
|
+ Permission.node,
|
|
|
|
|
+ ],
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const sessionID = Session.ID.make("ses_patch_tool_test")
|
|
const sessionID = Session.ID.make("ses_patch_tool_test")
|
|
@@ -91,7 +99,7 @@ const withTool = <A, E, R>(
|
|
|
return yield* body(yield* Tool.Service)
|
|
return yield* body(yield* Tool.Service)
|
|
|
}).pipe(
|
|
}).pipe(
|
|
|
Effect.provide(
|
|
Effect.provide(
|
|
|
- AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
|
|
|
|
|
|
+ AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
|
|
[
|
|
[
|
|
|
Environment.node,
|
|
Environment.node,
|
|
|
transformEnvironmentFiles(activeLocation, (files) => ({
|
|
transformEnvironmentFiles(activeLocation, (files) => ({
|
|
@@ -485,6 +493,42 @@ describe("PatchTool", () => {
|
|
|
),
|
|
),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ it.live("uses Location-relative resources for move targets in a nested Location", () =>
|
|
|
|
|
+ Effect.acquireUseRelease(
|
|
|
|
|
+ Effect.promise(() => tmpdir()),
|
|
|
|
|
+ (tmp) => {
|
|
|
|
|
+ reset()
|
|
|
|
|
+ const active = path.join(tmp.path, "nested", "location")
|
|
|
|
|
+ const source = path.join(active, "old.txt")
|
|
|
|
|
+ return Effect.promise(() =>
|
|
|
|
|
+ fs.mkdir(active, { recursive: true }).then(() => fs.writeFile(source, "before\n")),
|
|
|
|
|
+ ).pipe(
|
|
|
|
|
+ Effect.andThen(
|
|
|
|
|
+ withTool(
|
|
|
|
|
+ active,
|
|
|
|
|
+ (registry) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const settled = yield* executeTool(
|
|
|
|
|
+ registry,
|
|
|
|
|
+ call(
|
|
|
|
|
+ "*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(settled).toMatchObject({
|
|
|
|
|
+ status: "completed",
|
|
|
|
|
+ output: { applied: [{ resource: "moved.txt" }] },
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(assertions).toMatchObject([{ action: "edit", resources: ["old.txt", "moved.txt"] }])
|
|
|
|
|
+ }),
|
|
|
|
|
+ tmp.path,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
it.live("inserts lines with an insert-only hunk", () =>
|
|
it.live("inserts lines with an insert-only hunk", () =>
|
|
|
withTempTool((directory, registry) =>
|
|
withTempTool((directory, registry) =>
|
|
|
Effect.gen(function* () {
|
|
Effect.gen(function* () {
|
|
@@ -781,8 +825,15 @@ describe("PatchTool", () => {
|
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
|
([active, outside]) => {
|
|
([active, outside]) => {
|
|
|
reset()
|
|
reset()
|
|
|
- const target = path.join(outside.path, "external.txt")
|
|
|
|
|
- return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
|
|
|
|
|
|
+ const repository = path.join(outside.path, "repository")
|
|
|
|
|
+ const directory = path.join(repository, "nested")
|
|
|
|
|
+ const target = path.join(directory, "external.txt")
|
|
|
|
|
+ return Effect.promise(() =>
|
|
|
|
|
+ Promise.all([
|
|
|
|
|
+ fs.mkdir(path.join(repository, ".git"), { recursive: true }),
|
|
|
|
|
+ fs.mkdir(directory, { recursive: true }).then(() => fs.writeFile(target, "before\n")),
|
|
|
|
|
+ ]),
|
|
|
|
|
+ ).pipe(
|
|
|
Effect.andThen(
|
|
Effect.andThen(
|
|
|
withTool(active.path, (registry) =>
|
|
withTool(active.path, (registry) =>
|
|
|
Effect.gen(function* () {
|
|
Effect.gen(function* () {
|
|
@@ -793,6 +844,15 @@ describe("PatchTool", () => {
|
|
|
),
|
|
),
|
|
|
).toMatchObject({ status: "completed" })
|
|
).toMatchObject({ status: "completed" })
|
|
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
|
|
|
|
+ expect(assertions[0]).toMatchObject({
|
|
|
|
|
+ resources: [path.join(directory, "*").replaceAll("\\", "/")],
|
|
|
|
|
+ save: [path.join(repository, "*").replaceAll("\\", "/")],
|
|
|
|
|
+ metadata: {
|
|
|
|
|
+ filepath: target,
|
|
|
|
|
+ parentDir: directory,
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
|
|
|
expect(readsBeforeEditApproval).toBe(1)
|
|
expect(readsBeforeEditApproval).toBe(1)
|
|
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
|
|
}),
|
|
}),
|
|
@@ -860,7 +920,7 @@ describe("PatchTool", () => {
|
|
|
),
|
|
),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- it.live("treats a sibling path inside the project worktree as internal", () =>
|
|
|
|
|
|
|
+ it.live("treats a sibling path inside the project worktree as external to the Location", () =>
|
|
|
Effect.acquireUseRelease(
|
|
Effect.acquireUseRelease(
|
|
|
Effect.promise(() => tmpdir()),
|
|
Effect.promise(() => tmpdir()),
|
|
|
(tmp) => {
|
|
(tmp) => {
|
|
@@ -879,7 +939,9 @@ describe("PatchTool", () => {
|
|
|
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
|
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
|
|
),
|
|
),
|
|
|
).toMatchObject({ status: "completed" })
|
|
).toMatchObject({ status: "completed" })
|
|
|
- expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
|
|
|
|
|
|
+ expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
|
|
|
|
+ expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
|
|
|
|
|
+ expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
|
|
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
|
|
}),
|
|
}),
|
|
|
tmp.path,
|
|
tmp.path,
|
|
@@ -956,6 +1018,53 @@ describe("PatchTool", () => {
|
|
|
),
|
|
),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ it.live("uses canonical external permissions and resources for a move destination", () =>
|
|
|
|
|
+ Effect.acquireUseRelease(
|
|
|
|
|
+ Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
|
|
|
+ ([active, outside]) => {
|
|
|
|
|
+ reset()
|
|
|
|
|
+ const source = path.join(active.path, "source.txt")
|
|
|
|
|
+ const destination = path.join(outside.path, "moved.txt")
|
|
|
|
|
+ return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
|
|
|
|
|
+ Effect.andThen(
|
|
|
|
|
+ withTool(active.path, (registry) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const settled = yield* executeTool(
|
|
|
|
|
+ registry,
|
|
|
|
|
+ call(
|
|
|
|
|
+ `*** Begin Patch\n*** Update File: source.txt\n*** Move to: ${destination}\n@@\n-before\n+after\n*** End Patch`,
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(settled).toMatchObject({
|
|
|
|
|
+ status: "completed",
|
|
|
|
|
+ output: { applied: [{ resource: destination.replaceAll("\\", "/") }] },
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(assertions).toMatchObject([
|
|
|
|
|
+ {
|
|
|
|
|
+ action: "external_directory",
|
|
|
|
|
+ resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
|
|
|
|
+ save: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
|
|
|
|
+ metadata: { filepath: destination, parentDir: outside.path },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ action: "edit",
|
|
|
|
|
+ resources: ["source.txt", destination.replaceAll("\\", "/")],
|
|
|
|
|
+ },
|
|
|
|
|
+ ])
|
|
|
|
|
+ expect(yield* exists(source)).toBe(false)
|
|
|
|
|
+ expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ ([active, outside]) =>
|
|
|
|
|
+ Effect.promise(() =>
|
|
|
|
|
+ Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
it.live("approves each external file under the same parent", () =>
|
|
it.live("approves each external file under the same parent", () =>
|
|
|
Effect.acquireUseRelease(
|
|
Effect.acquireUseRelease(
|
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
@@ -983,9 +1092,7 @@ describe("PatchTool", () => {
|
|
|
"edit",
|
|
"edit",
|
|
|
])
|
|
])
|
|
|
expect(assertions[0]?.resources).toEqual([
|
|
expect(assertions[0]?.resources).toEqual([
|
|
|
- process.platform === "win32"
|
|
|
|
|
- ? FSUtil.normalizePathPattern(path.join(outside.path, "*"))
|
|
|
|
|
- : path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
|
|
|
|
|
|
+ path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
|
|
])
|
|
])
|
|
|
expect(assertions[1]?.resources).toEqual(assertions[0]?.resources)
|
|
expect(assertions[1]?.resources).toEqual(assertions[0]?.resources)
|
|
|
}),
|
|
}),
|