Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton f5291d5ff5 fix(core): authorize mutations before locking 2026-08-08 20:20:03 -04:00
Kit Langton ea1c3b9a97 test(core): simplify mutation lock coverage 2026-08-08 20:20:03 -04:00
Kit Langton e7bd4f17c0 fix(core): unify file mutation transaction locks 2026-08-08 20:20:03 -04:00
12 changed files with 561 additions and 590 deletions
+6 -65
View File
@@ -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 } : {}),
}) })
+30 -41
View File
@@ -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 })
+55 -61
View File
@@ -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"})`,
+174 -186
View File
@@ -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),
+10 -14
View File
@@ -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 })),
-85
View File
@@ -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) })
}
+67 -55
View File
@@ -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]()),
), ),
+78 -45
View File
@@ -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")
}), }),
), ),
+131 -25
View File
@@ -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]()),
),
)
}) })
-1
View File
@@ -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,
}) })
}) })