Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton 2a8ee9c965 refactor(tui): fade long session branch details 2026-08-08 20:50:04 -04:00
Kit Langton d0bec49ec2 fix(tui): keep session branch metadata subdued 2026-08-08 20:43:05 -04:00
Kit Langton 433fb4711f feat(tui): show session branches in vertical tabs 2026-08-08 20:39:42 -04:00
opencode-agent[bot] e8f215bfbc chore: generate 2026-08-09 00:29:22 +00:00
opencode-agent[bot] 445af9ce70 docs: fix install command rendering (#41340)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-08 20:28:07 -04:00
17 changed files with 682 additions and 571 deletions
+65 -6
View File
@@ -1,5 +1,7 @@
import type { AgentSideConnection, PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
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 { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
@@ -26,8 +28,9 @@ export async function replyPermission(input: {
}) {
const toolName = input.tool?.name ?? input.event.data.action
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 title = permissionTitle(toolName, toolInput, input.event.data.resources)
const title = permissionTitle(toolName, toolInput, previews)
const result = await input.connection
.requestPermission({
sessionId: input.clientSessionID ?? input.sessionID,
@@ -41,7 +44,8 @@ export async function replyPermission(input: {
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
})
@@ -90,8 +94,54 @@ export async function syncEditedFiles(input: {
)
}
function permissionTitle(toolName: string, input: ToolInput, resources: ReadonlyArray<string>) {
if (toToolKind(toolName) === "edit" && resources.length > 1) return `${resources.length} files`
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const tool = toolName.toLocaleLowerCase()
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()) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
@@ -107,7 +157,7 @@ function permissionTitle(toolName: string, input: ToolInput, resources: Readonly
case "write":
case "patch":
case "apply_patch":
return filePath(input)
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
default:
return undefined
}
@@ -118,12 +168,21 @@ function permissionLocations(
input: ToolInput,
resources: ReadonlyArray<string>,
cwd: string,
previews: ReadonlyArray<ToolCallContent>,
): 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)
if (locations.length > 0) return locations
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) {
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
}
@@ -211,7 +211,7 @@ describe("acp permission behavior", () => {
}
})
test("authorizes edit resources and syncs the completed file", async () => {
test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts")
await fs.writeFile(file, "before")
@@ -240,7 +240,6 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
resources: ["file.ts"],
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
)
@@ -279,8 +278,8 @@ describe("acp permission behavior", () => {
title: "file.ts",
kind: "edit",
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" }])
} finally {
await fixture.stop()
@@ -288,7 +287,7 @@ describe("acp permission behavior", () => {
}
})
test("authorizes and syncs each file in a patch", async () => {
test("previews and syncs each file in a patch", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
@@ -331,7 +330,6 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
resources: ["first.ts", "second.ts"],
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
)
@@ -373,8 +371,11 @@ describe("acp permission behavior", () => {
title: "2 files",
kind: "edit",
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([
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
@@ -555,7 +556,6 @@ function permissionAsked(
id: string,
input: {
readonly action?: string
readonly resources?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
@@ -564,7 +564,7 @@ function permissionAsked(
id,
sessionID,
action: input.action ?? "shell",
resources: [...(input.resources ?? ["*"])],
resources: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
+41 -30
View File
@@ -48,13 +48,15 @@ export const readText = Effect.fn("FileMutation.readText")(function* (files: Fil
return Bom.decodeBytes((yield* files.read(target)).bytes)
})
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")((files: Files, target: string, bom: boolean) =>
Effect.gen(function* () {
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
if (synced.bytes) yield* files.write(target, synced.bytes)
return synced.text
}).pipe(Effect.uninterruptible),
)
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
files: Files,
target: string,
bom: boolean,
) {
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
if (synced.bytes) yield* files.write(target, synced.bytes)
return synced.text
})
/** Share transaction locks across Location graphs that address the same file. */
const transactionLocks = KeyedMutex.makeUnsafe<string>()
@@ -68,10 +70,15 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withLock: Interface["withLock"] = (targets) => (effect) =>
[...new Set(targets.map(FSUtil.resolve))]
.sort()
.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 => ({
operation: "write",
@@ -81,32 +88,36 @@ const layer = Layer.effect(
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
Effect.gen(function* () {
const existed = yield* environment.files.stat(input.target.absolute).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
yield* environment.files.write(
input.target.absolute,
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
)
return writeResult(input.target, existed)
}).pipe(Effect.uninterruptible),
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* environment.files.stat(input.target.absolute).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
yield* environment.files.write(
input.target.absolute,
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
)
return writeResult(input.target, existed)
}),
),
)
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
Effect.map((result) => result.bytes),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
)
yield* environment.files.write(
input.target.absolute,
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
)
return writeResult(input.target, current !== undefined)
}).pipe(Effect.uninterruptible),
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
Effect.map((result) => result.bytes),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
)
yield* environment.files.write(
input.target.absolute,
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ withLock, write, writeTextPreservingBom })
+61 -55
View File
@@ -11,9 +11,11 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -85,7 +87,7 @@ const findLineOccurrences = (content: string, search: string) => {
if (
!actual.every(
(item, lineIndex) =>
normalizeForMatch(item.text.trimEnd()) === normalizeForMatch(expected[lineIndex].trimEnd()),
normalizeForMatch(item.text.trimEnd()) === normalizeForMatch(expected[lineIndex]!.trimEnd()),
)
)
return []
@@ -112,6 +114,7 @@ export const Plugin = {
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -151,69 +154,72 @@ export const Plugin = {
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({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
return yield* fileMutation.withLock([target.absolute])(
Effect.gen(function* () {
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,
)
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
}),
)
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(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+187 -175
View File
@@ -3,7 +3,7 @@ export * as PatchTool from "./patch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Schema } from "effect"
import { Effect, Result, Schema } from "effect"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -93,6 +93,12 @@ export const Plugin = {
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
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 completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
@@ -112,196 +118,202 @@ export const Plugin = {
if (hunks.length === 0) {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const plans = hunks.map((hunk) => ({
hunk,
target: resolveTarget(location, hunk.path),
moveTarget:
hunk.type === "update" && hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined,
}))
const targets = plans.flatMap((plan) => [plan.target, ...(plan.moveTarget ? [plan.moveTarget] : [])])
for (const target of targets) {
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
const prepared: Prepared[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
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({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
return yield* mutation.withLock(targets.map((target) => target.absolute))(
Effect.gen(function* () {
const prepared: Prepared[] = []
const updates = new Map<string, string>()
for (const plan of plans) {
const hunk = plan.hunk
const target = plan.target
yield* Effect.gen(function* () {
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)}`,
}),
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),
),
)
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)}` }),
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.absolute,
})
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 }),
),
)
}
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 }
}),
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 }
}).pipe(
mutation.withLock(lockTargets),
Effect.map((output) => ({
output,
content: toModelOutput(output),
+14 -10
View File
@@ -9,11 +9,13 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
export const name = "write"
@@ -75,24 +77,26 @@ export const Plugin = {
agent: context.agent,
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({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
return yield* fileMutation.withLock([target.absolute])(
Effect.gen(function* () {
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result
}),
)
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
+85
View File
@@ -110,6 +110,49 @@ 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", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -160,4 +203,46 @@ describe("FileMutation", () => {
}).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) })
}
+55 -67
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
@@ -40,7 +40,6 @@ const assertions: Permission.AssertInput[] = []
const writes: string[] = []
let reads = 0
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 formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
@@ -49,7 +48,6 @@ const permission = Layer.succeed(
Permission.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction
? Effect.fail(
@@ -79,7 +77,6 @@ const reset = () => {
writes.length = 0
reads = 0
denyAction = undefined
afterPermission = () => Effect.void
afterRead = () => Effect.void
formatFile = () => Effect.succeed(false)
}
@@ -177,7 +174,17 @@ describe("EditTool", () => {
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toBeUndefined()
expect(assertions[0]?.metadata).toMatchObject({
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))])
}),
),
@@ -342,7 +349,7 @@ describe("EditTool", () => {
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(reads).toBe(0)
expect(reads).toBe(1)
expect(writes).toEqual([])
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
}),
@@ -379,7 +386,7 @@ describe("EditTool", () => {
})
expect(missing).toEqual(matching)
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
expect(reads).toBe(0)
expect(reads).toBe(2)
expect(writes).toEqual([])
}),
),
@@ -636,77 +643,58 @@ describe("EditTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(target, "one\ntwo\n"))
const firstRead = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondApproved = yield* Deferred.make<void>()
afterRead = () =>
reads === 1
? Deferred.succeed(firstRead, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Effect.void
afterPermission = (input) =>
input.source?.id === "call-edit-two"
? Deferred.succeed(secondApproved, undefined).pipe(Effect.asVoid)
: Effect.void
const first = yield* withTool(tmp.path, (registry) =>
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.all(
[
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
),
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
),
],
{ concurrency: "unbounded" },
),
),
).pipe(Effect.forkChild)
yield* Deferred.await(firstRead)
const second = yield* withTool(tmp.path, (registry) =>
executeTool(
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")
})
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("validates current content after permission succeeds", () =>
it.live("applies the edit when content changes after matching", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
const permissionReached = yield* Deferred.make<void>()
const releasePermission = yield* Deferred.make<void>()
afterPermission = (input) =>
input.action === "edit"
? Deferred.succeed(permissionReached, undefined).pipe(Effect.andThen(Deferred.await(releasePermission)))
: Effect.void
const edit = yield* withTool(tmp.path, (registry) =>
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
).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")
})
afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void)
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toMatchObject({ status: "completed", output: { replacements: 1 } })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
expect(writes).toEqual([target])
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
+45 -78
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
import { Effect, Exit, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
@@ -33,11 +33,9 @@ let denyAction: string | undefined
let failRemoveTarget: string | undefined
let failRemoveErrorTarget: string | undefined
let failWriteTarget: string | undefined
let reads = 0
let readsBeforeEditApproval = 0
let editApproved = false
let afterEditApproval = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
let afterEditApproval = (): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
@@ -48,7 +46,7 @@ const permission = Layer.succeed(
assertions.push(input)
if (input.action === "edit") editApproved = true
}).pipe(
Effect.andThen(input.action === "edit" ? Effect.suspend(() => afterEditApproval(input)) : Effect.void),
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
Effect.andThen(
input.action === denyAction
? Effect.fail(
@@ -79,11 +77,9 @@ const reset = () => {
failRemoveTarget = undefined
failRemoveErrorTarget = undefined
failWriteTarget = undefined
reads = 0
readsBeforeEditApproval = 0
editApproved = false
afterEditApproval = () => Effect.void
afterRead = () => Effect.void
formatFile = () => Effect.succeed(false)
}
@@ -108,12 +104,8 @@ const withTool = <A, E, R>(
transformEnvironmentFiles(activeLocation, (files) => ({
read: (target, range) =>
Effect.sync(() => {
reads++
if (!editApproved) readsBeforeEditApproval++
}).pipe(
Effect.andThen(files.read(target, range)),
Effect.tap((result) => Effect.suspend(() => afterRead(target, result.bytes))),
),
}).pipe(Effect.andThen(files.read(target, range))),
remove: (target) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget)
return Effect.die("forced remove failure")
@@ -227,10 +219,14 @@ describe("PatchTool", () => {
action: "edit",
resources: ["nested/new.txt", "update.txt", "remove.txt"],
save: ["*"],
metadata: {
filepath: "nested/new.txt, update.txt, remove.txt",
diff: expect.stringContaining("Index:"),
files: expect.any(Array),
},
},
])
expect(assertions[0]?.metadata).toBeUndefined()
expect(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(2)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
"created\n",
)
@@ -271,69 +267,40 @@ describe("PatchTool", () => {
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(target, "one\ntwo\n"))
const firstRead = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondApproved = yield* Deferred.make<void>()
afterRead = () =>
reads === 1
? Deferred.succeed(firstRead, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Effect.void
afterEditApproval = (input) =>
input.source?.id === "call-patch-two"
? Deferred.succeed(secondApproved, undefined).pipe(Effect.asVoid)
: Effect.void
const first = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch", "call-patch-one"),
).pipe(Effect.forkChild)
yield* Deferred.await(firstRead)
const second = yield* executeTool(
registry,
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)
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")
})
afterEditApproval = () =>
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
Effect.all(
[
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
"call-patch-one",
),
),
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
"call-patch-two",
),
),
],
{ concurrency: "unbounded" },
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "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", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
@@ -816,7 +783,7 @@ describe("PatchTool", () => {
),
)
it.live("approves external-directory and edit access before reading", () =>
it.live("approves an external directory before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@@ -833,7 +800,7 @@ describe("PatchTool", () => {
),
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
@@ -964,7 +931,7 @@ describe("PatchTool", () => {
),
)
it.live("approves a relative external target before reading", () =>
it.live("approves a relative external target before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@@ -982,7 +949,7 @@ describe("PatchTool", () => {
),
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
+25 -131
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -13,7 +13,6 @@ import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
import { WriteTool } from "@opencode-ai/core/tool/plugin/write"
import { transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
@@ -28,26 +27,10 @@ const writeToolNode = makeLocationNode({
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
})
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
Location.node,
Permission.node,
],
})
const sessionID = Session.ID.make("ses_write_tool_test")
const assertions: Permission.AssertInput[] = []
const writes: string[] = []
let reads = 0
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
let denyAction: string | undefined
const permission = Layer.succeed(
@@ -55,7 +38,6 @@ const permission = Layer.succeed(
Permission.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction
? Effect.fail(
@@ -83,17 +65,11 @@ const formatter = Layer.mock(Formatter.Service, {
const reset = () => {
assertions.length = 0
writes.length = 0
reads = 0
formatFile = () => Effect.succeed(false)
afterPermission = () => Effect.void
denyAction = undefined
}
const withTool = <A, E, R>(
directory: string,
body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
options?: { edit?: boolean },
) => {
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
@@ -103,18 +79,11 @@ const withTool = <A, E, R>(
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
Tool.node,
LocationMutation.node,
FileMutation.node,
writeToolNode,
...(options?.edit ? [editToolNode] : []),
]),
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[
[
Environment.node,
transformEnvironmentFiles(activeLocation, (files) => ({
read: (target, range) => Effect.sync(() => reads++).pipe(Effect.andThen(files.read(target, range))),
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
})),
@@ -134,12 +103,6 @@ const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
call: { type: "tool-call" as const, id, name: "write", input },
})
const editCall = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "edit", input },
})
const it = testEffect(Layer.empty)
describe("WriteTool", () => {
@@ -166,7 +129,17 @@ describe("WriteTool", () => {
"created",
)
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toBeUndefined()
expect(assertions[0]?.metadata).toMatchObject({
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")])
}),
)
@@ -214,7 +187,17 @@ describe("WriteTool", () => {
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(assertions[0]?.metadata).toBeUndefined()
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "existing.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before[\s\S]*\+after/),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@@ -429,93 +412,4 @@ 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]()),
),
)
})
+18 -3
View File
@@ -10,6 +10,7 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -148,10 +149,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
if (tab === NEW_SESSION_TAB) return "Start a new session"
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
const projectLabel = projectName(project(), value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default)
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(() => stringWidth(detail()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -184,6 +190,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) => {
if (!detailFades() || index < visibleDetailParts().length - FADE_WIDTH) return detailColor()
const position = index - (visibleDetailParts().length - FADE_WIDTH)
return tint(detailColor(), pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -360,7 +371,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
{detail()}
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
</box>
</box>
@@ -13,6 +13,16 @@ export function sessionTabShortcutLabel(index: number) {
return "·"
}
export function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) {
if (!current || current === defaultBranch) return undefined
return current
}
export function sessionTabDetail(project: string, current: string | undefined, defaultBranch: string | undefined) {
const branch = sessionTabBranch(current, defaultBranch)
return branch && project ? `${project}:${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+15 -4
View File
@@ -157,9 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -171,8 +171,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (client.connection.status() !== "connected") return
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.split("\n")
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [`${location.directory}\n${location.workspaceID ?? ""}`, location]),
)
await Promise.allSettled(Array.from(locations.values(), (location) => data.location.vcs.sync(location)))
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
+1
View File
@@ -38,6 +38,7 @@ export function permissionPresentation(
title: `Edit ${formatPath(file)}`,
lines: [],
diff,
patch: diff ? undefined : text(input.patchText) || undefined,
file,
}
}
@@ -11,11 +11,25 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabBranch,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("shows only non-default session branches", () => {
expect(sessionTabBranch("main", "main")).toBeUndefined()
expect(sessionTabBranch("feature/sidebar", "main")).toBe("feature/sidebar")
expect(sessionTabBranch("feature/sidebar", undefined)).toBe("feature/sidebar")
expect(sessionTabBranch(undefined, "main")).toBeUndefined()
})
test("separates the project and branch with a colon", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main")).toBe("opencode:feature/sidebar")
expect(sessionTabDetail("opencode", "main", "main")).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
@@ -27,7 +27,14 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -44,7 +51,16 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -54,7 +70,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory },
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -104,6 +120,7 @@ async function renderSessionTabs(
route,
data,
sessions,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -134,6 +151,21 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")
@@ -152,7 +152,7 @@ describe("run permission shared", () => {
})
})
test("uses the resource display when an edit has no generated diff", () => {
test("uses source patch text 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 request = req({
action: "edit",
@@ -171,11 +171,13 @@ describe("run permission shared", () => {
expect(permissionInfo(request)).toMatchObject({
title: "Edit src/index.ts",
diff: undefined,
patch,
})
expect(permissionInfo(request, undefined, true)).toMatchObject({
title: "Edit src/index.ts",
lines: [],
lines: [patch],
diff: undefined,
patch: undefined,
})
})