mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5291d5ff5 | |||
| ea1c3b9a97 | |||
| e7bd4f17c0 |
@@ -1,7 +1,5 @@
|
|||||||
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection, PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { Patch } from "@opencode-ai/util/patch"
|
|
||||||
import { Result } from "effect"
|
|
||||||
import { isAbsolute, resolve } from "node:path"
|
import { isAbsolute, resolve } from "node:path"
|
||||||
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
|
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
|
||||||
|
|
||||||
@@ -28,9 +26,8 @@ export async function replyPermission(input: {
|
|||||||
}) {
|
}) {
|
||||||
const toolName = input.tool?.name ?? input.event.data.action
|
const toolName = input.tool?.name ?? input.event.data.action
|
||||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
|
||||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||||
const title = permissionTitle(toolName, toolInput, previews)
|
const title = permissionTitle(toolName, toolInput, input.event.data.resources)
|
||||||
const result = await input.connection
|
const result = await input.connection
|
||||||
.requestPermission({
|
.requestPermission({
|
||||||
sessionId: input.clientSessionID ?? input.sessionID,
|
sessionId: input.clientSessionID ?? input.sessionID,
|
||||||
@@ -44,8 +41,7 @@ export async function replyPermission(input: {
|
|||||||
},
|
},
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}),
|
}),
|
||||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
|
||||||
...(previews.length > 0 ? { content: previews } : {}),
|
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
})
|
})
|
||||||
@@ -94,54 +90,8 @@ export async function syncEditedFiles(input: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
function permissionTitle(toolName: string, input: ToolInput, resources: ReadonlyArray<string>) {
|
||||||
const tool = toolName.toLocaleLowerCase()
|
if (toToolKind(toolName) === "edit" && resources.length > 1) return `${resources.length} files`
|
||||||
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
|
|
||||||
const path = filePath(input)
|
|
||||||
if (!path) return []
|
|
||||||
const oldText = await readText(path, cwd)
|
|
||||||
if (tool === "write") {
|
|
||||||
const content = stringValue(input.content)
|
|
||||||
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
|
|
||||||
}
|
|
||||||
if (tool !== "edit") return []
|
|
||||||
const oldString = stringValue(input.oldString)
|
|
||||||
const newString = stringValue(input.newString)
|
|
||||||
if (oldString === undefined || newString === undefined) return []
|
|
||||||
const newText =
|
|
||||||
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
|
|
||||||
return [{ type: "diff", path, oldText, newText }]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
|
||||||
const patchText = stringValue(input.patchText)
|
|
||||||
if (!patchText) return []
|
|
||||||
try {
|
|
||||||
const parsed = Patch.parse(patchText)
|
|
||||||
if (Result.isFailure(parsed)) return []
|
|
||||||
return await Promise.all(
|
|
||||||
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
|
|
||||||
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
|
|
||||||
if (hunk.type === "add") {
|
|
||||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
|
||||||
return { type: "diff", path: hunk.path, oldText, newText }
|
|
||||||
}
|
|
||||||
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
|
|
||||||
return {
|
|
||||||
type: "diff",
|
|
||||||
path: hunk.movePath ?? hunk.path,
|
|
||||||
oldText,
|
|
||||||
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
|
||||||
if (previews.length > 1) return `${previews.length} files`
|
|
||||||
switch (toolName.toLocaleLowerCase()) {
|
switch (toolName.toLocaleLowerCase()) {
|
||||||
case "external_directory":
|
case "external_directory":
|
||||||
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
||||||
@@ -157,7 +107,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
|||||||
case "write":
|
case "write":
|
||||||
case "patch":
|
case "patch":
|
||||||
case "apply_patch":
|
case "apply_patch":
|
||||||
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
|
return filePath(input)
|
||||||
default:
|
default:
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -168,21 +118,12 @@ function permissionLocations(
|
|||||||
input: ToolInput,
|
input: ToolInput,
|
||||||
resources: ReadonlyArray<string>,
|
resources: ReadonlyArray<string>,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
previews: ReadonlyArray<ToolCallContent>,
|
|
||||||
): ToolCallLocation[] {
|
): ToolCallLocation[] {
|
||||||
const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
|
|
||||||
if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
|
|
||||||
const locations = toLocations(toolName, input, cwd)
|
const locations = toLocations(toolName, input, cwd)
|
||||||
if (locations.length > 0) return locations
|
if (locations.length > 0) return locations
|
||||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
|
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function readText(path: string, cwd: string) {
|
|
||||||
return Bun.file(resolvePath(path, cwd))
|
|
||||||
.text()
|
|
||||||
.catch(() => "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function filePath(input: ToolInput) {
|
function filePath(input: ToolInput) {
|
||||||
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
|
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ describe("acp permission behavior", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("previews edits during approval and syncs the completed file", async () => {
|
test("authorizes edit resources and syncs the completed file", async () => {
|
||||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||||
const file = path.join(cwd, "file.ts")
|
const file = path.join(cwd, "file.ts")
|
||||||
await fs.writeFile(file, "before")
|
await fs.writeFile(file, "before")
|
||||||
@@ -240,6 +240,7 @@ describe("acp permission behavior", () => {
|
|||||||
send(
|
send(
|
||||||
permissionAsked("ses_edit", "perm_edit", {
|
permissionAsked("ses_edit", "perm_edit", {
|
||||||
action: "edit",
|
action: "edit",
|
||||||
|
resources: ["file.ts"],
|
||||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -278,8 +279,8 @@ describe("acp permission behavior", () => {
|
|||||||
title: "file.ts",
|
title: "file.ts",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
locations: [{ path: "file.ts" }],
|
locations: [{ path: "file.ts" }],
|
||||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
|
||||||
})
|
})
|
||||||
|
expect(permissionRequests[0]?.toolCall.content).toBeUndefined()
|
||||||
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
|
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.stop()
|
await fixture.stop()
|
||||||
@@ -287,7 +288,7 @@ describe("acp permission behavior", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("previews and syncs each file in a patch", async () => {
|
test("authorizes and syncs each file in a patch", async () => {
|
||||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
|
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
|
||||||
@@ -330,6 +331,7 @@ describe("acp permission behavior", () => {
|
|||||||
send(
|
send(
|
||||||
permissionAsked("ses_patch", "perm_patch", {
|
permissionAsked("ses_patch", "perm_patch", {
|
||||||
action: "edit",
|
action: "edit",
|
||||||
|
resources: ["first.ts", "second.ts"],
|
||||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -371,11 +373,8 @@ describe("acp permission behavior", () => {
|
|||||||
title: "2 files",
|
title: "2 files",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||||
content: [
|
|
||||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
|
||||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
expect(permissionRequests[0]?.toolCall.content).toBeUndefined()
|
||||||
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
||||||
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
|
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
|
||||||
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
|
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
|
||||||
@@ -556,6 +555,7 @@ function permissionAsked(
|
|||||||
id: string,
|
id: string,
|
||||||
input: {
|
input: {
|
||||||
readonly action?: string
|
readonly action?: string
|
||||||
|
readonly resources?: ReadonlyArray<string>
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
} = {},
|
} = {},
|
||||||
@@ -564,7 +564,7 @@ function permissionAsked(
|
|||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
action: input.action ?? "shell",
|
action: input.action ?? "shell",
|
||||||
resources: ["*"],
|
resources: [...(input.resources ?? ["*"])],
|
||||||
metadata: input.metadata ?? { command: "printf hello" },
|
metadata: input.metadata ?? { command: "printf hello" },
|
||||||
...(input.source ? { source: input.source } : {}),
|
...(input.source ? { source: input.source } : {}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,15 +48,13 @@ export const readText = Effect.fn("FileMutation.readText")(function* (files: Fil
|
|||||||
return Bom.decodeBytes((yield* files.read(target)).bytes)
|
return Bom.decodeBytes((yield* files.read(target)).bytes)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")((files: Files, target: string, bom: boolean) =>
|
||||||
files: Files,
|
Effect.gen(function* () {
|
||||||
target: string,
|
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
|
||||||
bom: boolean,
|
if (synced.bytes) yield* files.write(target, synced.bytes)
|
||||||
) {
|
return synced.text
|
||||||
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
|
}).pipe(Effect.uninterruptible),
|
||||||
if (synced.bytes) yield* files.write(target, synced.bytes)
|
)
|
||||||
return synced.text
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Share transaction locks across Location graphs that address the same file. */
|
/** Share transaction locks across Location graphs that address the same file. */
|
||||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||||
@@ -70,15 +68,10 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const environment = yield* Environment.Service
|
const environment = yield* Environment.Service
|
||||||
const locks = KeyedMutex.makeUnsafe<string>()
|
|
||||||
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
||||||
[...new Set(targets.map(FSUtil.resolve))]
|
[...new Set(targets.map(FSUtil.resolve))]
|
||||||
.sort()
|
.sort()
|
||||||
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
||||||
const withTargetLock =
|
|
||||||
(target: Target) =>
|
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
||||||
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
|
||||||
|
|
||||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
@@ -88,36 +81,32 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const existed = yield* environment.files.stat(input.target.absolute).pipe(
|
||||||
const existed = yield* environment.files.stat(input.target.absolute).pipe(
|
Effect.as(true),
|
||||||
Effect.as(true),
|
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
|
||||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
|
)
|
||||||
)
|
yield* environment.files.write(
|
||||||
yield* environment.files.write(
|
input.target.absolute,
|
||||||
input.target.absolute,
|
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
|
||||||
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
|
)
|
||||||
)
|
return writeResult(input.target, existed)
|
||||||
return writeResult(input.target, existed)
|
}).pipe(Effect.uninterruptible),
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||||
withTargetLock(input.target)(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const next = Bom.split(input.content)
|
||||||
const next = Bom.split(input.content)
|
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
|
||||||
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
|
Effect.map((result) => result.bytes),
|
||||||
Effect.map((result) => result.bytes),
|
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
)
|
||||||
)
|
yield* environment.files.write(
|
||||||
yield* environment.files.write(
|
input.target.absolute,
|
||||||
input.target.absolute,
|
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
|
||||||
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
|
)
|
||||||
)
|
return writeResult(input.target, current !== undefined)
|
||||||
return writeResult(input.target, current !== undefined)
|
}).pipe(Effect.uninterruptible),
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Service.of({ withLock, write, writeTextPreservingBom })
|
return Service.of({ withLock, write, writeTextPreservingBom })
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
|
||||||
import { Environment } from "../../environment"
|
import { Environment } from "../../environment"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
import { Location } from "../../location"
|
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { fileDiff } from "./file-diff"
|
import { fileDiff } from "./file-diff"
|
||||||
@@ -87,7 +85,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
|||||||
if (
|
if (
|
||||||
!actual.every(
|
!actual.every(
|
||||||
(item, lineIndex) =>
|
(item, lineIndex) =>
|
||||||
normalizeForMatch(item.text.trimEnd()) === normalizeForMatch(expected[lineIndex]!.trimEnd()),
|
normalizeForMatch(item.text.trimEnd()) === normalizeForMatch(expected[lineIndex].trimEnd()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
@@ -114,7 +112,6 @@ export const Plugin = {
|
|||||||
const fileMutation = yield* FileMutation.Service
|
const fileMutation = yield* FileMutation.Service
|
||||||
const environment = yield* Environment.Service
|
const environment = yield* Environment.Service
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
const location = yield* Location.Service
|
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
@@ -154,72 +151,69 @@ export const Plugin = {
|
|||||||
source: permissionSource,
|
source: permissionSource,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
|
||||||
Effect.catchTag("Environment.NotFound", () =>
|
|
||||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
|
||||||
),
|
|
||||||
Effect.catchTag("Environment.WrongKind", (error) =>
|
|
||||||
error.actual === "directory"
|
|
||||||
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
|
|
||||||
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const source = original.text
|
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
|
||||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
|
||||||
const exact = findOccurrences(source, oldString)
|
|
||||||
// These one-to-one mappings preserve offsets into the original source.
|
|
||||||
const unicode =
|
|
||||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
|
||||||
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
|
||||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
|
||||||
const replacements = matches.length
|
|
||||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
|
||||||
.toReversed()
|
|
||||||
.reduce(
|
|
||||||
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
const preview =
|
|
||||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
|
||||||
? fileDiff(target.resource, source, replaced)
|
|
||||||
: undefined
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
resources: [target.resource],
|
resources: [target.resource],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
metadata: preview ? { files: [preview] } : undefined,
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: permissionSource,
|
source: permissionSource,
|
||||||
})
|
})
|
||||||
if (replacements === 0) {
|
return yield* fileMutation.withLock([target.absolute])(
|
||||||
return yield* new ToolFailure({
|
Effect.gen(function* () {
|
||||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||||
})
|
Effect.catchTag("Environment.NotFound", () =>
|
||||||
}
|
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||||
if (replacements > 1 && input.replaceAll !== true) {
|
),
|
||||||
return yield* new ToolFailure({
|
Effect.catchTag("Environment.WrongKind", (error) =>
|
||||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
error.actual === "directory"
|
||||||
})
|
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
|
||||||
}
|
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
|
||||||
const replacementBom = replaced.startsWith("\uFEFF")
|
),
|
||||||
const result = yield* fileMutation.write({
|
)
|
||||||
target,
|
const source = original.text
|
||||||
content: Bom.join(replaced, original.bom || replacementBom),
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
})
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
const bom = original.bom || replacementBom
|
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
const formatted = (yield* formatter.file(target.absolute))
|
const exact = findOccurrences(source, oldString)
|
||||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
// These one-to-one mappings preserve offsets into the original source.
|
||||||
: (yield* FileMutation.readText(environment.files, target.absolute)).text
|
const unicode =
|
||||||
return {
|
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||||
files: [fileDiff(result.resource, source, formatted)],
|
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||||
replacements,
|
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||||
} satisfies Output
|
const replacements = matches.length
|
||||||
|
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||||
|
.toReversed()
|
||||||
|
.reduce(
|
||||||
|
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
if (replacements === 0) {
|
||||||
|
return yield* new ToolFailure({
|
||||||
|
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (replacements > 1 && input.replaceAll !== true) {
|
||||||
|
return yield* new ToolFailure({
|
||||||
|
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const replacementBom = replaced.startsWith("\uFEFF")
|
||||||
|
const result = yield* fileMutation.write({
|
||||||
|
target,
|
||||||
|
content: Bom.join(replaced, original.bom || replacementBom),
|
||||||
|
})
|
||||||
|
const bom = original.bom || replacementBom
|
||||||
|
const formatted = (yield* formatter.file(target.absolute))
|
||||||
|
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||||
|
: (yield* FileMutation.readText(environment.files, target.absolute)).text
|
||||||
|
return {
|
||||||
|
files: [fileDiff(result.resource, source, formatted)],
|
||||||
|
replacements,
|
||||||
|
} satisfies Output
|
||||||
|
}),
|
||||||
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
fileMutation.withLock([path.resolve(location.directory, input.path)]),
|
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as PatchTool from "./patch"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { Effect, Result, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
@@ -93,12 +93,6 @@ export const Plugin = {
|
|||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
const parsed = Patch.parse(input.patchText)
|
const parsed = Patch.parse(input.patchText)
|
||||||
const lockTargets = Result.isSuccess(parsed)
|
|
||||||
? parsed.success.flatMap((hunk) => [
|
|
||||||
path.resolve(location.directory, hunk.path),
|
|
||||||
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
|
|
||||||
])
|
|
||||||
: []
|
|
||||||
const fail = (operation: string, error: unknown) => {
|
const fail = (operation: string, error: unknown) => {
|
||||||
const completed = applied.map((item) => item.resource).join(", ")
|
const completed = applied.map((item) => item.resource).join(", ")
|
||||||
return new ToolFailure({
|
return new ToolFailure({
|
||||||
@@ -118,202 +112,196 @@ export const Plugin = {
|
|||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||||
}
|
}
|
||||||
const prepared: Prepared[] = []
|
const plans = hunks.map((hunk) => ({
|
||||||
const targets: Target[] = []
|
hunk,
|
||||||
const updates = new Map<string, string>()
|
target: resolveTarget(location, hunk.path),
|
||||||
for (const hunk of hunks) {
|
moveTarget:
|
||||||
yield* Effect.gen(function* () {
|
hunk.type === "update" && hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined,
|
||||||
const target = resolveTarget(location, hunk.path)
|
}))
|
||||||
targets.push(target)
|
const targets = plans.flatMap((plan) => [plan.target, ...(plan.moveTarget ? [plan.moveTarget] : [])])
|
||||||
if (target.externalDirectory) {
|
for (const target of targets) {
|
||||||
yield* permission.assert({
|
if (target.externalDirectory) {
|
||||||
action: "external_directory",
|
yield* permission.assert({
|
||||||
resources: [target.externalDirectory.resource],
|
action: "external_directory",
|
||||||
save: [target.externalDirectory.resource],
|
resources: [target.externalDirectory.resource],
|
||||||
metadata: {
|
save: [target.externalDirectory.resource],
|
||||||
filepath: target.absolute,
|
metadata: {
|
||||||
parentDir: target.externalDirectory.directory,
|
filepath: target.absolute,
|
||||||
},
|
parentDir: target.externalDirectory.directory,
|
||||||
sessionID: context.sessionID,
|
},
|
||||||
agent: context.agent,
|
sessionID: context.sessionID,
|
||||||
source,
|
agent: context.agent,
|
||||||
})
|
source,
|
||||||
}
|
|
||||||
if (hunk.type === "add") {
|
|
||||||
const content =
|
|
||||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
|
||||||
prepared.push({
|
|
||||||
...hunk,
|
|
||||||
target,
|
|
||||||
content,
|
|
||||||
before: "",
|
|
||||||
after: Bom.split(content).text,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hunk.type === "delete") {
|
|
||||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const previous = updates.get(target.absolute)
|
|
||||||
const original =
|
|
||||||
previous ??
|
|
||||||
(yield* Effect.gen(function* () {
|
|
||||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Bom.join(content.text, content.bom)
|
|
||||||
}))
|
|
||||||
const before = Bom.split(original).text
|
|
||||||
const update = yield* Effect.try({
|
|
||||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
|
||||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
|
||||||
})
|
})
|
||||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
}
|
||||||
if (moveTarget) targets.push(moveTarget)
|
|
||||||
if (moveTarget?.externalDirectory) {
|
|
||||||
yield* permission.assert({
|
|
||||||
action: "external_directory",
|
|
||||||
resources: [moveTarget.externalDirectory.resource],
|
|
||||||
save: [moveTarget.externalDirectory.resource],
|
|
||||||
metadata: {
|
|
||||||
filepath: moveTarget.absolute,
|
|
||||||
parentDir: moveTarget.externalDirectory.directory,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
prepared.push({
|
|
||||||
...hunk,
|
|
||||||
target,
|
|
||||||
content: Patch.joinBom(update.content, update.bom),
|
|
||||||
before,
|
|
||||||
after: update.content,
|
|
||||||
moveTarget,
|
|
||||||
})
|
|
||||||
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof ToolFailure
|
|
||||||
? error
|
|
||||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchFiles = prepared.map((change) => patchFile(change))
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
resources: [...new Set(targets.map((target) => target.resource))],
|
resources: [...new Set(targets.map((target) => target.resource))],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
metadata: {
|
|
||||||
filepath: targets.map((target) => target.resource).join(", "),
|
|
||||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
|
||||||
files: patchFiles,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.forEach(
|
return yield* mutation.withLock(targets.map((target) => target.absolute))(
|
||||||
prepared,
|
Effect.gen(function* () {
|
||||||
(change) =>
|
const prepared: Prepared[] = []
|
||||||
Effect.gen(function* () {
|
const updates = new Map<string, string>()
|
||||||
if (change.type === "add") {
|
for (const plan of plans) {
|
||||||
yield* environment.files
|
const hunk = plan.hunk
|
||||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
const target = plan.target
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
yield* Effect.gen(function* () {
|
||||||
applied.push({
|
if (hunk.type === "add") {
|
||||||
type: change.type,
|
const content =
|
||||||
resource: change.target.resource,
|
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||||
target: change.target.absolute,
|
prepared.push({
|
||||||
})
|
...hunk,
|
||||||
return
|
target,
|
||||||
}
|
content,
|
||||||
if (change.type === "delete") {
|
before: "",
|
||||||
yield* environment.files
|
after: Bom.split(content).text,
|
||||||
.remove(change.target.absolute)
|
})
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
return
|
||||||
applied.push({
|
}
|
||||||
type: change.type,
|
if (hunk.type === "delete") {
|
||||||
resource: change.target.resource,
|
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||||
target: change.target.absolute,
|
Effect.mapError(
|
||||||
})
|
(error) =>
|
||||||
return
|
new ToolFailure({
|
||||||
}
|
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||||
if (change.moveTarget) {
|
}),
|
||||||
const moveTarget = change.moveTarget
|
|
||||||
yield* environment.files
|
|
||||||
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
|
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
|
||||||
yield* environment.files
|
|
||||||
.remove(change.target.absolute)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
applied.push({
|
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||||
type: change.type,
|
return
|
||||||
resource: change.moveTarget.resource,
|
}
|
||||||
target: change.moveTarget.absolute,
|
const previous = updates.get(target.absolute)
|
||||||
})
|
const original =
|
||||||
return
|
previous ??
|
||||||
}
|
(yield* Effect.gen(function* () {
|
||||||
yield* environment.files
|
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
Effect.mapError(
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
(error) =>
|
||||||
applied.push({
|
new ToolFailure({
|
||||||
type: change.type,
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||||
resource: change.target.resource,
|
}),
|
||||||
target: change.target.absolute,
|
),
|
||||||
})
|
|
||||||
}),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
const formatted = new Map<string, string>()
|
|
||||||
yield* Effect.forEach(
|
|
||||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
|
||||||
(target) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* FileMutation.readText(environment.files, target).pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
|
||||||
)
|
|
||||||
formatted.set(
|
|
||||||
target,
|
|
||||||
(yield* formatter.file(target))
|
|
||||||
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
|
||||||
)
|
)
|
||||||
: current.text,
|
return Bom.join(content.text, content.bom)
|
||||||
|
}))
|
||||||
|
const before = Bom.split(original).text
|
||||||
|
const update = yield* Effect.try({
|
||||||
|
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||||
|
catch: (error) =>
|
||||||
|
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||||
|
})
|
||||||
|
const moveTarget = plan.moveTarget
|
||||||
|
prepared.push({
|
||||||
|
...hunk,
|
||||||
|
target,
|
||||||
|
content: Patch.joinBom(update.content, update.bom),
|
||||||
|
before,
|
||||||
|
after: update.content,
|
||||||
|
moveTarget,
|
||||||
|
})
|
||||||
|
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
||||||
|
}).pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ToolFailure
|
||||||
|
? error
|
||||||
|
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}),
|
}
|
||||||
{ discard: true },
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
prepared,
|
||||||
|
(change) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (change.type === "add") {
|
||||||
|
yield* environment.files
|
||||||
|
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.target.resource,
|
||||||
|
target: change.target.absolute,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (change.type === "delete") {
|
||||||
|
yield* environment.files
|
||||||
|
.remove(change.target.absolute)
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.target.resource,
|
||||||
|
target: change.target.absolute,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (change.moveTarget) {
|
||||||
|
const moveTarget = change.moveTarget
|
||||||
|
yield* environment.files
|
||||||
|
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||||
|
yield* environment.files
|
||||||
|
.remove(change.target.absolute)
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
fail(
|
||||||
|
`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`,
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.moveTarget.resource,
|
||||||
|
target: change.moveTarget.absolute,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* environment.files
|
||||||
|
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.target.resource,
|
||||||
|
target: change.target.absolute,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
const formatted = new Map<string, string>()
|
||||||
|
yield* Effect.forEach(
|
||||||
|
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||||
|
(target) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const current = yield* FileMutation.readText(environment.files, target).pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||||
|
)
|
||||||
|
formatted.set(
|
||||||
|
target,
|
||||||
|
(yield* formatter.file(target))
|
||||||
|
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||||
|
)
|
||||||
|
: current.text,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
const files = yield* Effect.forEach(prepared, (change) => {
|
||||||
|
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||||
|
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||||
|
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
||||||
|
})
|
||||||
|
return { applied, files }
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const files = yield* Effect.forEach(prepared, (change) => {
|
|
||||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
|
||||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
|
||||||
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
|
||||||
})
|
|
||||||
return { applied, files }
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
mutation.withLock(lockTargets),
|
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: toModelOutput(output),
|
content: toModelOutput(output),
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ export * as WriteTool from "./write"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
|
||||||
import { Environment } from "../../environment"
|
import { Environment } from "../../environment"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { fileDiff } from "./file-diff"
|
|
||||||
|
|
||||||
export const name = "write"
|
export const name = "write"
|
||||||
|
|
||||||
@@ -77,26 +75,24 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
|
||||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
|
||||||
)
|
|
||||||
const next = Bom.split(input.content)
|
|
||||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
resources: [target.resource],
|
resources: [target.resource],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
metadata: { files: [preview] },
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
return yield* fileMutation.withLock([target.absolute])(
|
||||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
Effect.gen(function* () {
|
||||||
if (yield* formatter.file(target.absolute)) {
|
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
||||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
||||||
}
|
if (yield* formatter.file(target.absolute)) {
|
||||||
return result
|
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
|
|||||||
@@ -110,49 +110,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("serializes concurrent writes to the same absolute target", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "shared.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
|
||||||
const releaseFirst = yield* Deferred.make<void>()
|
|
||||||
const secondStarted = yield* Deferred.make<void>()
|
|
||||||
let writes = 0
|
|
||||||
const filesystem = instrumentWrites((write) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
writes++
|
|
||||||
if (writes === 1) {
|
|
||||||
yield* Deferred.succeed(firstStarted, undefined)
|
|
||||||
yield* Deferred.await(releaseFirst)
|
|
||||||
} else {
|
|
||||||
yield* Deferred.succeed(secondStarted, undefined)
|
|
||||||
}
|
|
||||||
yield* write
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const mutation = yield* LocationMutation.Service
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
|
||||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
|
||||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(firstStarted)
|
|
||||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
|
||||||
|
|
||||||
yield* Deferred.succeed(releaseFirst, undefined)
|
|
||||||
yield* Deferred.await(secondStarted)
|
|
||||||
yield* Fiber.join(first)
|
|
||||||
yield* Fiber.join(second)
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
|
|
||||||
}).pipe(provide(directory, filesystem))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("shares transaction locks across Location service instances", () =>
|
it.live("shares transaction locks across Location service instances", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -203,46 +160,4 @@ describe("FileMutation", () => {
|
|||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
|
||||||
const releaseFirst = yield* Deferred.make<void>()
|
|
||||||
const secondFinished = yield* Deferred.make<void>()
|
|
||||||
const secondPath = path.join(directory, "second.txt")
|
|
||||||
let writes = 0
|
|
||||||
const filesystem = instrumentWrites((write) =>
|
|
||||||
++writes === 1
|
|
||||||
? Deferred.succeed(firstStarted, undefined).pipe(
|
|
||||||
Effect.andThen(Deferred.await(releaseFirst)),
|
|
||||||
Effect.andThen(write),
|
|
||||||
)
|
|
||||||
: write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const mutation = yield* LocationMutation.Service
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
|
||||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
|
||||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(firstStarted)
|
|
||||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(secondFinished)
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
|
|
||||||
|
|
||||||
yield* Deferred.succeed(releaseFirst, undefined)
|
|
||||||
yield* Fiber.join(first)
|
|
||||||
yield* Fiber.join(second)
|
|
||||||
}).pipe(provide(directory, filesystem))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function instrumentWrites(
|
|
||||||
run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>,
|
|
||||||
): EnvironmentFilesTransform {
|
|
||||||
return (files) => ({ write: (target, content) => run(files.write(target, content), target) })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Deferred, Effect, Fiber, Layer } 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"
|
import { Environment } from "@opencode-ai/core/environment"
|
||||||
@@ -40,6 +40,7 @@ const assertions: Permission.AssertInput[] = []
|
|||||||
const writes: string[] = []
|
const writes: string[] = []
|
||||||
let reads = 0
|
let reads = 0
|
||||||
let denyAction: string | undefined
|
let denyAction: string | undefined
|
||||||
|
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
||||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ const permission = Layer.succeed(
|
|||||||
Permission.Service.of({
|
Permission.Service.of({
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
|
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction
|
input.action === denyAction
|
||||||
? Effect.fail(
|
? Effect.fail(
|
||||||
@@ -77,6 +79,7 @@ const reset = () => {
|
|||||||
writes.length = 0
|
writes.length = 0
|
||||||
reads = 0
|
reads = 0
|
||||||
denyAction = undefined
|
denyAction = undefined
|
||||||
|
afterPermission = () => Effect.void
|
||||||
afterRead = () => Effect.void
|
afterRead = () => Effect.void
|
||||||
formatFile = () => Effect.succeed(false)
|
formatFile = () => Effect.succeed(false)
|
||||||
}
|
}
|
||||||
@@ -174,17 +177,7 @@ describe("EditTool", () => {
|
|||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||||
expect(assertions[0]?.metadata).toMatchObject({
|
expect(assertions[0]?.metadata).toBeUndefined()
|
||||||
files: [
|
|
||||||
{
|
|
||||||
file: "hello.txt",
|
|
||||||
status: "modified",
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
patch: expect.stringContaining("-before\n+after"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -349,7 +342,7 @@ describe("EditTool", () => {
|
|||||||
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
||||||
})
|
})
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||||
expect(reads).toBe(1)
|
expect(reads).toBe(0)
|
||||||
expect(writes).toEqual([])
|
expect(writes).toEqual([])
|
||||||
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
|
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
|
||||||
}),
|
}),
|
||||||
@@ -386,7 +379,7 @@ describe("EditTool", () => {
|
|||||||
})
|
})
|
||||||
expect(missing).toEqual(matching)
|
expect(missing).toEqual(matching)
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
|
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
|
||||||
expect(reads).toBe(2)
|
expect(reads).toBe(0)
|
||||||
expect(writes).toEqual([])
|
expect(writes).toEqual([])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -643,58 +636,77 @@ describe("EditTool", () => {
|
|||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const target = path.join(tmp.path, "concurrent.txt")
|
const target = path.join(tmp.path, "concurrent.txt")
|
||||||
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
|
return Effect.gen(function* () {
|
||||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
yield* Effect.promise(() => fs.writeFile(target, "one\ntwo\n"))
|
||||||
Effect.andThen(
|
const firstRead = yield* Deferred.make<void>()
|
||||||
withTool(tmp.path, (registry) =>
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
Effect.all(
|
const secondApproved = yield* Deferred.make<void>()
|
||||||
[
|
afterRead = () =>
|
||||||
executeTool(
|
reads === 1
|
||||||
registry,
|
? Deferred.succeed(firstRead, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
||||||
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
: Effect.void
|
||||||
),
|
afterPermission = (input) =>
|
||||||
executeTool(
|
input.source?.id === "call-edit-two"
|
||||||
registry,
|
? Deferred.succeed(secondApproved, undefined).pipe(Effect.asVoid)
|
||||||
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
: Effect.void
|
||||||
),
|
|
||||||
],
|
const first = yield* withTool(tmp.path, (registry) =>
|
||||||
{ concurrency: "unbounded" },
|
executeTool(
|
||||||
),
|
registry,
|
||||||
|
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
||||||
),
|
),
|
||||||
),
|
).pipe(Effect.forkChild)
|
||||||
Effect.andThen((results) =>
|
yield* Deferred.await(firstRead)
|
||||||
Effect.gen(function* () {
|
const second = yield* withTool(tmp.path, (registry) =>
|
||||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
executeTool(
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
registry,
|
||||||
}),
|
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
||||||
),
|
),
|
||||||
)
|
).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(secondApproved)
|
||||||
|
expect(reads).toBe(1)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
|
expect((yield* Fiber.join(first)).status).toBe("completed")
|
||||||
|
expect((yield* Fiber.join(second)).status).toBe("completed")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||||
|
})
|
||||||
},
|
},
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("applies the edit when content changes after matching", () =>
|
it.live("validates current content after permission succeeds", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const target = path.join(tmp.path, "concurrent.txt")
|
const target = path.join(tmp.path, "concurrent.txt")
|
||||||
afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void)
|
return Effect.gen(function* () {
|
||||||
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
|
||||||
Effect.andThen(
|
const permissionReached = yield* Deferred.make<void>()
|
||||||
withTool(tmp.path, (registry) =>
|
const releasePermission = yield* Deferred.make<void>()
|
||||||
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
|
afterPermission = (input) =>
|
||||||
),
|
input.action === "edit"
|
||||||
),
|
? Deferred.succeed(permissionReached, undefined).pipe(Effect.andThen(Deferred.await(releasePermission)))
|
||||||
Effect.andThen((result) =>
|
: Effect.void
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(result).toMatchObject({ status: "completed", output: { replacements: 1 } })
|
const edit = yield* withTool(tmp.path, (registry) =>
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
|
||||||
expect(writes).toEqual([target])
|
).pipe(Effect.forkChild)
|
||||||
}),
|
yield* Deferred.await(permissionReached)
|
||||||
),
|
expect(reads).toBe(0)
|
||||||
)
|
yield* Effect.promise(() => fs.writeFile(target, "newer\n"))
|
||||||
|
yield* Deferred.succeed(releasePermission, undefined)
|
||||||
|
|
||||||
|
expect(yield* Fiber.join(edit)).toMatchObject({
|
||||||
|
status: "error",
|
||||||
|
error: { message: expect.stringContaining("Could not find oldString") },
|
||||||
|
})
|
||||||
|
expect(reads).toBe(1)
|
||||||
|
expect(writes).toEqual([])
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
|
||||||
|
})
|
||||||
},
|
},
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer, Schema } from "effect"
|
import { Deferred, Effect, Exit, Fiber, 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"
|
import { Environment } from "@opencode-ai/core/environment"
|
||||||
@@ -33,9 +33,11 @@ let denyAction: string | undefined
|
|||||||
let failRemoveTarget: string | undefined
|
let failRemoveTarget: string | undefined
|
||||||
let failRemoveErrorTarget: string | undefined
|
let failRemoveErrorTarget: string | undefined
|
||||||
let failWriteTarget: string | undefined
|
let failWriteTarget: string | undefined
|
||||||
|
let reads = 0
|
||||||
let readsBeforeEditApproval = 0
|
let readsBeforeEditApproval = 0
|
||||||
let editApproved = false
|
let editApproved = false
|
||||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
let afterEditApproval = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
||||||
|
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||||
|
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
@@ -46,7 +48,7 @@ const permission = Layer.succeed(
|
|||||||
assertions.push(input)
|
assertions.push(input)
|
||||||
if (input.action === "edit") editApproved = true
|
if (input.action === "edit") editApproved = true
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
Effect.andThen(input.action === "edit" ? Effect.suspend(() => afterEditApproval(input)) : Effect.void),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction
|
input.action === denyAction
|
||||||
? Effect.fail(
|
? Effect.fail(
|
||||||
@@ -77,9 +79,11 @@ const reset = () => {
|
|||||||
failRemoveTarget = undefined
|
failRemoveTarget = undefined
|
||||||
failRemoveErrorTarget = undefined
|
failRemoveErrorTarget = undefined
|
||||||
failWriteTarget = undefined
|
failWriteTarget = undefined
|
||||||
|
reads = 0
|
||||||
readsBeforeEditApproval = 0
|
readsBeforeEditApproval = 0
|
||||||
editApproved = false
|
editApproved = false
|
||||||
afterEditApproval = () => Effect.void
|
afterEditApproval = () => Effect.void
|
||||||
|
afterRead = () => Effect.void
|
||||||
formatFile = () => Effect.succeed(false)
|
formatFile = () => Effect.succeed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +108,12 @@ const withTool = <A, E, R>(
|
|||||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||||
read: (target, range) =>
|
read: (target, range) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
reads++
|
||||||
if (!editApproved) readsBeforeEditApproval++
|
if (!editApproved) readsBeforeEditApproval++
|
||||||
}).pipe(Effect.andThen(files.read(target, range))),
|
}).pipe(
|
||||||
|
Effect.andThen(files.read(target, range)),
|
||||||
|
Effect.tap((result) => Effect.suspend(() => afterRead(target, result.bytes))),
|
||||||
|
),
|
||||||
remove: (target) => {
|
remove: (target) => {
|
||||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget)
|
if (failRemoveTarget && path.basename(target) === failRemoveTarget)
|
||||||
return Effect.die("forced remove failure")
|
return Effect.die("forced remove failure")
|
||||||
@@ -219,14 +227,10 @@ describe("PatchTool", () => {
|
|||||||
action: "edit",
|
action: "edit",
|
||||||
resources: ["nested/new.txt", "update.txt", "remove.txt"],
|
resources: ["nested/new.txt", "update.txt", "remove.txt"],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
metadata: {
|
|
||||||
filepath: "nested/new.txt, update.txt, remove.txt",
|
|
||||||
diff: expect.stringContaining("Index:"),
|
|
||||||
files: expect.any(Array),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(readsBeforeEditApproval).toBe(2)
|
expect(assertions[0]?.metadata).toBeUndefined()
|
||||||
|
expect(readsBeforeEditApproval).toBe(0)
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
|
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
|
||||||
"created\n",
|
"created\n",
|
||||||
)
|
)
|
||||||
@@ -267,40 +271,69 @@ describe("PatchTool", () => {
|
|||||||
it.live("serializes concurrent patch transactions", () =>
|
it.live("serializes concurrent patch transactions", () =>
|
||||||
withTempTool((directory, registry) => {
|
withTempTool((directory, registry) => {
|
||||||
const target = path.join(directory, "concurrent.txt")
|
const target = path.join(directory, "concurrent.txt")
|
||||||
afterEditApproval = () =>
|
return Effect.gen(function* () {
|
||||||
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
|
yield* Effect.promise(() => fs.writeFile(target, "one\ntwo\n"))
|
||||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
const firstRead = yield* Deferred.make<void>()
|
||||||
Effect.andThen(
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
Effect.all(
|
const secondApproved = yield* Deferred.make<void>()
|
||||||
[
|
afterRead = () =>
|
||||||
executeTool(
|
reads === 1
|
||||||
registry,
|
? Deferred.succeed(firstRead, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
||||||
call(
|
: Effect.void
|
||||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
|
afterEditApproval = (input) =>
|
||||||
"call-patch-one",
|
input.source?.id === "call-patch-two"
|
||||||
),
|
? Deferred.succeed(secondApproved, undefined).pipe(Effect.asVoid)
|
||||||
),
|
: Effect.void
|
||||||
executeTool(
|
|
||||||
registry,
|
const first = yield* executeTool(
|
||||||
call(
|
registry,
|
||||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
|
call("*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch", "call-patch-one"),
|
||||||
"call-patch-two",
|
).pipe(Effect.forkChild)
|
||||||
),
|
yield* Deferred.await(firstRead)
|
||||||
),
|
const second = yield* executeTool(
|
||||||
],
|
registry,
|
||||||
{ concurrency: "unbounded" },
|
call("*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch", "call-patch-two"),
|
||||||
),
|
).pipe(Effect.forkChild)
|
||||||
),
|
yield* Deferred.await(secondApproved)
|
||||||
Effect.andThen((results) =>
|
expect(reads).toBe(1)
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
expect((yield* Fiber.join(first)).status).toBe("completed")
|
||||||
}),
|
expect((yield* Fiber.join(second)).status).toBe("completed")
|
||||||
),
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||||
)
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("validates patch context after permission succeeds", () =>
|
||||||
|
withTempTool((directory, registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = path.join(directory, "current.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
|
||||||
|
const permissionReached = yield* Deferred.make<void>()
|
||||||
|
const releasePermission = yield* Deferred.make<void>()
|
||||||
|
afterEditApproval = () =>
|
||||||
|
Deferred.succeed(permissionReached, undefined).pipe(Effect.andThen(Deferred.await(releasePermission)))
|
||||||
|
|
||||||
|
const patch = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call("*** Begin Patch\n*** Update File: current.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||||
|
).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(permissionReached)
|
||||||
|
expect(reads).toBe(0)
|
||||||
|
yield* Effect.promise(() => fs.writeFile(target, "newer\n"))
|
||||||
|
yield* Deferred.succeed(releasePermission, undefined)
|
||||||
|
|
||||||
|
expect(yield* Fiber.join(patch)).toMatchObject({
|
||||||
|
status: "error",
|
||||||
|
error: { message: expect.stringContaining("Failed to find expected lines") },
|
||||||
|
})
|
||||||
|
expect(reads).toBe(1)
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("returns file diffs for final formatted content", () =>
|
it.live("returns file diffs for final formatted content", () =>
|
||||||
withTempTool((directory, registry) => {
|
withTempTool((directory, registry) => {
|
||||||
const target = path.join(directory, "formatted.txt")
|
const target = path.join(directory, "formatted.txt")
|
||||||
@@ -783,7 +816,7 @@ describe("PatchTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("approves an external directory before reading and requests edit permission afterward", () =>
|
it.live("approves external-directory and edit access before reading", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
([active, outside]) => {
|
([active, outside]) => {
|
||||||
@@ -800,7 +833,7 @@ 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(readsBeforeEditApproval).toBe(1)
|
expect(readsBeforeEditApproval).toBe(0)
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -931,7 +964,7 @@ describe("PatchTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("approves a relative external target before reading and requests edit permission afterward", () =>
|
it.live("approves a relative external target before reading", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
([active, outside]) => {
|
([active, outside]) => {
|
||||||
@@ -949,7 +982,7 @@ 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(readsBeforeEditApproval).toBe(1)
|
expect(readsBeforeEditApproval).toBe(0)
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
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 { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
import { Formatter } from "@opencode-ai/core/formatter"
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
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 { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
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 { WriteTool } from "@opencode-ai/core/tool/plugin/write"
|
||||||
import { transformEnvironmentFiles } from "./fixture/environment"
|
import { transformEnvironmentFiles } from "./fixture/environment"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
@@ -27,10 +28,26 @@ const writeToolNode = makeLocationNode({
|
|||||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
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 sessionID = Session.ID.make("ses_write_tool_test")
|
||||||
const assertions: Permission.AssertInput[] = []
|
const assertions: Permission.AssertInput[] = []
|
||||||
const writes: string[] = []
|
const writes: string[] = []
|
||||||
|
let reads = 0
|
||||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||||
|
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
||||||
let denyAction: string | undefined
|
let denyAction: string | undefined
|
||||||
|
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
@@ -38,6 +55,7 @@ const permission = Layer.succeed(
|
|||||||
Permission.Service.of({
|
Permission.Service.of({
|
||||||
assert: (input) =>
|
assert: (input) =>
|
||||||
Effect.sync(() => assertions.push(input)).pipe(
|
Effect.sync(() => assertions.push(input)).pipe(
|
||||||
|
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
input.action === denyAction
|
input.action === denyAction
|
||||||
? Effect.fail(
|
? Effect.fail(
|
||||||
@@ -65,11 +83,17 @@ const formatter = Layer.mock(Formatter.Service, {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
writes.length = 0
|
writes.length = 0
|
||||||
|
reads = 0
|
||||||
formatFile = () => Effect.succeed(false)
|
formatFile = () => Effect.succeed(false)
|
||||||
|
afterPermission = () => Effect.void
|
||||||
denyAction = undefined
|
denyAction = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
const withTool = <A, E, R>(
|
||||||
|
directory: string,
|
||||||
|
body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
|
||||||
|
options?: { edit?: boolean },
|
||||||
|
) => {
|
||||||
const activeLocation = Layer.succeed(
|
const activeLocation = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||||
@@ -79,11 +103,18 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
LayerNode.group([
|
||||||
|
Tool.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
FileMutation.node,
|
||||||
|
writeToolNode,
|
||||||
|
...(options?.edit ? [editToolNode] : []),
|
||||||
|
]),
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
Environment.node,
|
Environment.node,
|
||||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||||
|
read: (target, range) => Effect.sync(() => reads++).pipe(Effect.andThen(files.read(target, range))),
|
||||||
write: (target, content) =>
|
write: (target, content) =>
|
||||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
|
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
|
||||||
})),
|
})),
|
||||||
@@ -103,6 +134,12 @@ const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
|
|||||||
call: { type: "tool-call" as const, id, name: "write", input },
|
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)
|
const it = testEffect(Layer.empty)
|
||||||
|
|
||||||
describe("WriteTool", () => {
|
describe("WriteTool", () => {
|
||||||
@@ -129,17 +166,7 @@ describe("WriteTool", () => {
|
|||||||
"created",
|
"created",
|
||||||
)
|
)
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
|
||||||
expect(assertions[0]?.metadata).toMatchObject({
|
expect(assertions[0]?.metadata).toBeUndefined()
|
||||||
files: [
|
|
||||||
{
|
|
||||||
file: "src/new.txt",
|
|
||||||
status: "added",
|
|
||||||
additions: 1,
|
|
||||||
deletions: 0,
|
|
||||||
patch: expect.stringContaining("+created"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
|
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -187,17 +214,7 @@ describe("WriteTool", () => {
|
|||||||
if (settled.status !== "completed") return
|
if (settled.status !== "completed") return
|
||||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||||
expect(assertions[0]?.metadata).toMatchObject({
|
expect(assertions[0]?.metadata).toBeUndefined()
|
||||||
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(
|
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||||
"after",
|
"after",
|
||||||
)
|
)
|
||||||
@@ -412,4 +429,93 @@ describe("WriteTool", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("authorizes an edit while a write holds the same-path execution lock", () =>
|
||||||
|
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* withTool(
|
||||||
|
tmp.path,
|
||||||
|
(registry) =>
|
||||||
|
executeTool(registry, call({ path: "shared.txt", content: "before" }, "call-serialized-write")),
|
||||||
|
{ edit: true },
|
||||||
|
).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(formatting)
|
||||||
|
const edit = yield* withTool(
|
||||||
|
tmp.path,
|
||||||
|
(registry) =>
|
||||||
|
executeTool(
|
||||||
|
registry,
|
||||||
|
editCall({ path: "shared.txt", oldString: "before", newString: "after" }, "call-serialized-edit"),
|
||||||
|
),
|
||||||
|
{ edit: true },
|
||||||
|
).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(editApproved)
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before")
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseFormatting, undefined)
|
||||||
|
expect((yield* Fiber.join(write)).status).toBe("completed")
|
||||||
|
expect((yield* Fiber.join(edit)).status).toBe("completed")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("does not hold the execution lock while waiting for permission", () =>
|
||||||
|
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 firstAsked = yield* Deferred.make<void>()
|
||||||
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
|
afterPermission = (input) =>
|
||||||
|
input.source?.id === "call-waiting-write" && input.action === "edit"
|
||||||
|
? Deferred.succeed(firstAsked, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
||||||
|
: Effect.void
|
||||||
|
|
||||||
|
const first = yield* withTool(tmp.path, (registry) =>
|
||||||
|
executeTool(registry, call({ path: "shared.txt", content: "first" }, "call-waiting-write")),
|
||||||
|
).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(firstAsked)
|
||||||
|
expect(reads).toBe(0)
|
||||||
|
|
||||||
|
const second = yield* withTool(tmp.path, (registry) =>
|
||||||
|
executeTool(registry, call({ path: "shared.txt", content: "second" }, "call-approved-write")),
|
||||||
|
)
|
||||||
|
expect(second.status).toBe("completed")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("second")
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
|
expect((yield* Fiber.join(first)).status).toBe("completed")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function permissionPresentation(
|
|||||||
title: `Edit ${formatPath(file)}`,
|
title: `Edit ${formatPath(file)}`,
|
||||||
lines: [],
|
lines: [],
|
||||||
diff,
|
diff,
|
||||||
patch: diff ? undefined : text(input.patchText) || undefined,
|
|
||||||
file,
|
file,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ describe("run permission shared", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses source patch text when an edit has no generated diff", () => {
|
test("uses the resource display when an edit has no generated diff", () => {
|
||||||
const patch = '*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+const arrow = "→"\n*** End Patch'
|
const patch = '*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+const arrow = "→"\n*** End Patch'
|
||||||
const request = req({
|
const request = req({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
@@ -171,13 +171,11 @@ describe("run permission shared", () => {
|
|||||||
expect(permissionInfo(request)).toMatchObject({
|
expect(permissionInfo(request)).toMatchObject({
|
||||||
title: "Edit src/index.ts",
|
title: "Edit src/index.ts",
|
||||||
diff: undefined,
|
diff: undefined,
|
||||||
patch,
|
|
||||||
})
|
})
|
||||||
expect(permissionInfo(request, undefined, true)).toMatchObject({
|
expect(permissionInfo(request, undefined, true)).toMatchObject({
|
||||||
title: "Edit src/index.ts",
|
title: "Edit src/index.ts",
|
||||||
lines: [patch],
|
lines: [],
|
||||||
diff: undefined,
|
diff: undefined,
|
||||||
patch: undefined,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user