chore: merge v2 into service channel config

This commit is contained in:
Dax Raad 2026-07-21 10:28:58 -04:00
commit e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.3",
"version": "1.18.4",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@ -16,7 +16,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"

View file

@ -52,7 +52,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
const layer = Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
@ -111,6 +111,7 @@ const layer = Layer.effect(
config,
location,
processes,
shell: options,
})
const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name)
@ -157,6 +158,7 @@ function evaluateTemplate(
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
},
) {
return Effect.gen(function* () {
@ -188,11 +190,12 @@ const evaluateShell = Effect.fnUntraced(function* (
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"))
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
const outputs = yield* Effect.forEach(
matches,
(match) => {
@ -240,8 +243,12 @@ const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer,
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
})
}
export const node = configured()

View file

@ -153,9 +153,16 @@ export interface Interface {
readonly entries: () => Effect.Effect<Entry[]>
}
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
const layer = Layer.effect(
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
@ -239,7 +246,7 @@ const layer = Layer.effect(
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered = locationIsGlobal
const discovered = locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
@ -288,14 +295,39 @@ const layer = Layer.effect(
Effect.map((entries) => entries.flat()),
)
const file = options?.file
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
Effect.map((config) => [
...(config ? [config] : []),
new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
]),
Effect.orDie,
)
: []
const content = options?.content
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.map(parseInfo),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
: []
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [
...claude,
...agents,
...(supplementary[0] ?? []),
...explicit,
...direct,
...supplementary.slice(1).flat(),
...(yield* loadWellknown().pipe(Effect.orDie)),
...content,
]
})
@ -394,8 +426,12 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
})
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
})
}
export const node = configured()

View file

@ -1,10 +1,9 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Flag } from "@opencode-ai/util/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "@opencode-ai/util/installation/version"
@ -17,6 +16,11 @@ export interface Interface {
db: DatabaseShape
}
export const Options = Schema.Struct({
path: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
const databaseLayer = Layer.effect(
@ -36,28 +40,25 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie),
)
export function layerFromPath(filename: string) {
return databaseLayer.pipe(Layer.provide(layer({ filename })))
export function layer(options?: Options) {
return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
if (options?.path) return provide(join(Global.Path.data, options.path))
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return provide(join(Global.Path.data, "opencode.db"))
return provide(
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
)
})
}
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
}
// Resolve the database path lazily so tests and embedders that set
// Flag.OPENCODE_DB after module evaluation still control the storage target.
export const node = makeGlobalNode({
service: Service,
layer: Layer.suspend(() => layerFromPath(path())),
deps: [],
})
export const node = configured({ path: ":memory:" })

View file

@ -162,7 +162,7 @@ const nativeLayer = (config: Config) =>
}),
)
const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
@ -171,9 +171,9 @@ const drizzleLayer = Layer.effect(
}),
)
export const layer = (config: Config) => {
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}

View file

@ -157,7 +157,7 @@ const nativeLayer = (config: Config) =>
}),
)
const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
@ -166,9 +166,9 @@ const drizzleLayer = Layer.effect(
}),
)
export const layer = (config: Config) => {
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}

View file

@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Scope } from "effect"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem"
@ -10,7 +10,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Flag } from "@opencode-ai/util/flag"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
@ -18,6 +17,11 @@ export interface Interface {
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
}
export const Options = Schema.Struct({
fff: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem/Search") {}
export const ripgrepLayer = Layer.effect(
@ -232,13 +236,18 @@ export const fffLayer = Layer.effect(
}),
)
const layer = Layer.unwrap(
export const layer = (options?: Options) => Layer.unwrap(
Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs ? fffLayer : ripgrepLayer
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
}
export const node = configured()

View file

@ -5,9 +5,8 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
import { Cause, Context, Effect, Layer, PubSub, Schema, Scope, Stream } from "effect"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Flag } from "@opencode-ai/util/flag"
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
@ -50,14 +49,19 @@ export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
}
export const Options = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
const layer = Layer.effect(
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {
const backend = getBackend()
const native = watcher()
if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
if (options?.enabled === false) {
return Service.of({ subscribe: () => Stream.empty })
}
@ -140,7 +144,11 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
}
export const node = configured()
function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,

View file

@ -3,7 +3,6 @@ export * as InstructionDiscovery from "./instruction-discovery"
import { Array, Context, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Flag } from "@opencode-ai/util/flag"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
@ -22,9 +21,14 @@ export interface Interface {
readonly load: () => Effect.Effect<Instructions.Instructions>
}
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionDiscovery") {}
const layer = Layer.effect(
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
@ -52,7 +56,7 @@ const layer = Layer.effect(
fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
yield* Effect.forEach(
Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
options?.project === false || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
@ -93,7 +97,15 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] })
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Global.node, Location.node],
})
}
export const node = configured()
function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")

View file

@ -4,7 +4,6 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { Global } from "@opencode-ai/util/global"
import { Flag } from "@opencode-ai/util/flag"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util"
@ -18,8 +17,6 @@ import { ProviderV2 } from "./provider"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
type Cost = {
readonly input: Money.USDPerMillionTokens
readonly output: Money.USDPerMillionTokens
@ -530,9 +527,17 @@ export interface Interface {
readonly refresh: (force?: boolean) => Effect.Effect<void>
}
export const Options = Schema.Struct({
url: Schema.optional(Schema.String),
file: Schema.optional(Schema.String),
fetch: Schema.optional(Schema.Boolean),
client: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const layer = Layer.effect(
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
@ -547,7 +552,9 @@ const layer = Layer.effect(
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const source = options?.url ?? "https://models.dev"
const fetch = options?.fetch ?? true
const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${options?.client ?? "cli"}`
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
@ -564,18 +571,18 @@ const layer = Layer.effect(
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", USER_AGENT),
HttpClientRequest.setHeader("User-Agent", userAgent),
http.execute,
Effect.flatMap((res) => res.text),
Effect.timeout("10 seconds"),
)
})
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
options?.file === undefined &&
error._tag === "FileSystemError" &&
error.method === "readJson"
) {
@ -609,7 +616,7 @@ const layer = Layer.effect(
if (fromDisk) return normalize(fromDisk)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return []
if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
Effect.gen(function* () {
@ -642,7 +649,7 @@ const layer = Layer.effect(
)
})
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
if (fetch && !process.argv.includes("--get-yargs-completions")) {
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
}
@ -651,6 +658,10 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [FSUtil.node, EventV2.node, httpClient] })
}
export const node = configured()
export * as ModelsDev from "./models-dev"

View file

@ -1,5 +1,26 @@
export * as Patch from "./patch"
import { Result, Schema } from "effect"
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
boundary: Schema.Literals(["first", "last"]),
}) {
override get message() {
return `The ${this.boundary} line of the patch must be '${this.boundary === "first" ? "*** Begin Patch" : "*** End Patch"}'`
}
}
export class InvalidHunkError extends Schema.TaggedErrorClass<InvalidHunkError>()("Patch.InvalidHunkError", {
line: Schema.String,
lineNumber: Schema.Number,
}) {
override get message() {
return `Invalid hunk at line ${this.lineNumber}: '${this.line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`
}
}
export type ParseError = BoundaryError | InvalidHunkError
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
@ -22,50 +43,69 @@ export interface FileUpdate {
readonly bom: boolean
}
export function parse(patchText: string): ReadonlyArray<Hunk> {
const lines = stripHeredoc(patchText.trim()).split("\n")
export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, ParseError> {
const lines = stripHeredoc(patchText.trim())
.split("\n")
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" }))
if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" }))
const hunks: Hunk[] = []
let index = begin + 1
while (index < end) {
const line = lines[index]!
if (line.startsWith("*** Add File:")) {
const path = line.slice("*** Add File:".length).trim()
if (!path) throw new Error("Invalid add file path")
const parsed = parseAdd(lines, index + 1)
const header = line.trim()
if (header.startsWith("*** Add File:")) {
const path = header.slice("*** Add File:".length).trim()
if (!path) {
index++
continue
}
const parsed = parseAdd(lines, index + 1, end)
hunks.push({ type: "add", path, contents: parsed.content })
index = parsed.next
continue
}
if (line.startsWith("*** Delete File:")) {
const path = line.slice("*** Delete File:".length).trim()
if (!path) throw new Error("Invalid delete file path")
if (header.startsWith("*** Delete File:")) {
const path = header.slice("*** Delete File:".length).trim()
if (!path) {
index++
continue
}
hunks.push({ type: "delete", path })
index++
continue
}
if (line.startsWith("*** Update File:")) {
const path = line.slice("*** Update File:".length).trim()
if (!path) throw new Error("Invalid update file path")
if (header.startsWith("*** Update File:")) {
const path = header.slice("*** Update File:".length).trim()
if (!path) {
index++
continue
}
let next = index + 1
let movePath: string | undefined
if (lines[next]?.startsWith("*** Move to:")) {
movePath = lines[next]!.slice("*** Move to:".length).trim()
if (!movePath) throw new Error("Invalid move file path")
next++
}
const parsed = parseUpdate(lines, next)
if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
const parsed = parseUpdate(lines, next, end)
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
index = parsed.next
continue
}
throw new Error(`Invalid patch line: ${line}`)
index++
}
return hunks
if (hunks.length === 0) {
const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "")
if (invalid !== -1) {
return Result.fail(
new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }),
)
}
}
return Result.succeed(hunks)
}
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
@ -85,43 +125,36 @@ export function joinBom(text: string, bom: boolean) {
return bom ? `\uFEFF${stripped}` : stripped
}
function parseAdd(lines: ReadonlyArray<string>, start: number) {
function parseAdd(lines: ReadonlyArray<string>, start: number, end: number) {
const content: string[] = []
let index = start
while (index < lines.length && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
content.push(lines[index]!.slice(1))
while (index < end && !lines[index]!.startsWith("***")) {
if (lines[index]!.startsWith("+")) content.push(lines[index]!.slice(1))
index++
}
return { content: content.join("\n"), next: index }
}
function parseUpdate(lines: ReadonlyArray<string>, start: number) {
function parseUpdate(lines: ReadonlyArray<string>, start: number, end: number) {
const chunks: UpdateFileChunk[] = []
let index = start
while (index < lines.length && !lines[index]!.startsWith("***")) {
while (index < end && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("@@")) {
throw new Error(`Invalid update file line: ${lines[index]}`)
index++
continue
}
const changeContext = lines[index]!.slice(2).trim() || undefined
const oldLines: string[] = []
const newLines: string[] = []
let endOfFile = false
index++
while (index < lines.length && !lines[index]!.startsWith("@@")) {
while (index < end && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) {
const line = lines[index]!
if (line === "*** End of File") {
endOfFile = true
index++
break
}
if (line.startsWith("***")) break
if (line.startsWith(" ")) {
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
else if (line.startsWith("+")) newLines.push(line.slice(1))
else throw new Error(`Invalid update chunk line: ${line}`)
index++
}
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })

View file

@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- Try to use patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:

View file

@ -24,8 +24,8 @@ If you notice unexpected changes in the worktree or staging area that you did no
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch.
- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.
- Always use patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with patch.
- Do not use Python to read/write files when a simple shell command or patch would suffice.
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.

View file

@ -89,7 +89,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
const layer = Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -164,7 +164,7 @@ const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"))
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@ -313,4 +313,8 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [EventV2.node, Location.node, Config.node] })
}
export const node = configured()

View file

@ -60,6 +60,7 @@ type Settings = {
}
type Dependencies = {
readonly headers?: SessionModelHeaders.Options
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@ -258,7 +259,7 @@ const make = (dependencies: Dependencies) => {
.stream(
LLM.request({
model: plan.model,
http: { headers: SessionModelHeaders.make(plan.session) },
http: { headers: SessionModelHeaders.make(plan.session, dependencies.headers) },
messages: [Message.user(plan.prompt)],
tools: [],
}),
@ -390,19 +391,23 @@ const make = (dependencies: Dependencies) => {
})
}
export const layer = Layer.effect(
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
return make({ events, llm, models, config: settings(yield* config.entries()) })
return make({ events, llm, models, config: settings(yield* config.entries()), headers: options })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
})
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
})
}
export const node = configured()

View file

@ -14,7 +14,7 @@ import { SessionRunnerModel } from "./runner/model"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const layer = Layer.effect(
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
SessionGenerate.Service,
Effect.gen(function* () {
const context = yield* SessionContext.Service
@ -49,7 +49,7 @@ const layer = Layer.effect(
return (yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session) },
http: { headers: SessionModelHeaders.make(selection.session, options) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
@ -62,8 +62,12 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
})
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({
service: SessionGenerate.Service,
layer: layer(options),
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
})
}
export const node = configured()

View file

@ -1,15 +1,23 @@
export * as SessionModelHeaders from "./model-headers"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { SessionSchema } from "./schema"
import { Schema } from "effect"
export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">) => ({
export const Options = Schema.Struct({
client: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export const make = (
session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">,
options?: Options,
) => ({
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": options?.client ?? "cli",
})

View file

@ -39,7 +39,7 @@ export interface Interface {
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionModelRequest") {}
const layer = Layer.effect(
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
@ -81,7 +81,7 @@ const layer = Layer.effect(
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session),
headers: SessionModelHeaders.make(session, options),
},
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
@ -112,8 +112,8 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, ToolRegistry.node],
})
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [PluginHooks.node, ToolRegistry.node] })
}
export const node = configured()

View file

@ -182,12 +182,11 @@ export const fromCatalogModel = (
credential?: Credential.Value,
dependencies: Dependencies = {},
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved =
credential?.type !== "key" || credential.metadata === undefined
? model
: produce(model, (draft) => {
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)

View file

@ -17,6 +17,7 @@ import { SessionUsage } from "./usage"
const MAX_LENGTH = 100
type Dependencies = {
readonly headers?: SessionModelHeaders.Options
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@ -66,7 +67,7 @@ const make = (dependencies: Dependencies) => {
.stream(
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session) },
http: { headers: SessionModelHeaders.make(session, dependencies.headers) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
@ -102,7 +103,7 @@ const make = (dependencies: Dependencies) => {
return { generateForFirstPrompt }
}
export const layer = Layer.effect(
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -110,15 +111,19 @@ export const layer = Layer.effect(
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
const title = make({ events, llm, agents, models })
const title = make({ events, llm, agents, models, headers: options })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
})
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
})
}
export const node = configured()

View file

@ -59,7 +59,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Shell") {}
export const layer = Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -168,7 +168,7 @@ export const layer = Layer.effect(
const id = Shell.ID.ascending()
const cwd = input.cwd ?? location.directory
const configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell)
const shell = ShellSelect.preferred(configShell, options)
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
@ -316,8 +316,12 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
})
}
export const node = configured()

View file

@ -5,7 +5,7 @@ import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout } from "node:timers/promises"
import { Flag } from "@opencode-ai/util/flag"
import { Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { which } from "../util/which"
@ -28,6 +28,11 @@ export type Item = {
acceptable: boolean
}
export const Options = Schema.Struct({
gitbash: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
@ -63,14 +68,14 @@ function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
function full(file: string, options?: Options) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
return which(shell) || shell
}
@ -86,8 +91,8 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
function resolve(file: string, options?: Options) {
const shell = full(file, options)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
@ -95,12 +100,12 @@ function resolve(file: string) {
return which(shell) ?? undefined
}
function win() {
function win(options?: Options) {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
.map((file) => full(file, options)),
),
)
}
@ -111,18 +116,18 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
const shell = resolve(file, options)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]
if (process.platform === "win32") return win(options)[0]
return fallback()
}
export function gitbash() {
export function gitbash(options?: Options) {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
if (options?.gitbash) return options.gitbash
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
@ -153,12 +158,12 @@ export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string): Item {
const item = full(file)
function info(file: string, options?: Options): Item {
const item = full(file, options)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
name: resolve(n, options) ? n : item,
acceptable: ok(item),
}
}
@ -175,8 +180,9 @@ export function args(file: string, command: string) {
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
export function preferred(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options)
if (options?.gitbash) return select(process.env.SHELL, options)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
@ -184,16 +190,17 @@ preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
export function acceptable(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options, { acceptable: true })
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
export async function list(options?: Options): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options) : await unix()
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
}

View file

@ -5,12 +5,13 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../location-mutation"
import { Location } from "../location"
import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import DESCRIPTION from "./patch.txt"
export const name = "patch"
@ -34,7 +35,7 @@ export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
[
"Applied patch sequentially:",
"Success. Updated the following files:",
...output.applied.map(
(item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`,
),
@ -42,24 +43,32 @@ export const toModelOutput = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly target: Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly target: Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: Target
})
interface Target {
readonly canonical: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
@ -68,8 +77,7 @@ export const Plugin = {
name,
Tool.withPermission(
Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
description: DESCRIPTION,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
@ -88,100 +96,184 @@ export const Plugin = {
messageID: context.messageID,
callID: context.callID,
}
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText),
catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
})
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks)
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { target } of targets) {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
),
)
if (hunks.length === 0) {
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
return yield* new ToolFailure({ message: "patch verification failed: no hunks found" })
}
for (const external of externalDirectories.values()) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const prepared: Prepared[] = []
for (const { hunk, target } of targets) {
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.canonical,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`
).replace(/^\uFEFF/, ""),
})
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target, before, after: "" })
const content = yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
}),
),
)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
return
}
const update = Patch.derive(hunk.path, hunk.chunks, original)
const previous = updates.get(target.canonical)
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
}),
),
),
)
}))
const before = original.replace(/^\uFEFF/, "")
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.canonical,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
moveTarget,
})
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error))))
}
const patchFiles = prepared.map(patchFile)
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,
})
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
target: change.target,
content:
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
yield* fs.writeWithDirs(
change.target.canonical,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
yield* fs.remove(change.target.canonical)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
if (change.moveTarget) {
yield* fs.writeWithDirs(change.moveTarget.canonical, change.content)
yield* fs.remove(change.target.canonical)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
yield* fs.writeWithDirs(change.target.canonical, change.content)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError((error) => fail(change.path, error))),
{ discard: true },
)
@ -199,7 +291,7 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
if (usePatch) {
delete event.tools.edit
delete event.tools.write
@ -212,17 +304,74 @@ export const Plugin = {
}
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
)
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
: diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
file: target,
patch,
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}
function trimDiff(diff: string) {
const lines = diff.split("\n")
const content = lines.filter(
(line) =>
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++"),
)
if (content.length === 0) return diff
const indent = content.reduce((result, line) => {
const value = line.slice(1)
if (value.trim().length === 0) return result
return Math.min(result, value.match(/^(\s*)/)?.[1].length ?? result)
}, Infinity)
if (indent === Infinity || indent === 0) return diff
return lines
.map((line) => {
if (
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++")
) {
return line[0] + line.slice(1 + indent)
}
return line
})
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string): Target {
const canonical =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
canonical,
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}

View file

@ -0,0 +1,33 @@
Use the `patch` tool to edit files. Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
Example patch:
```
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file

View file

@ -5,7 +5,6 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { truthy } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
@ -74,8 +73,13 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
enableExa:
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_ENABLE_EXA?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_EXA?.toLowerCase() ?? ""),
enableParallel:
["1", "true"].includes(process.env.OPENCODE_ENABLE_PARALLEL?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_PARALLEL?.toLowerCase() ?? ""),
exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY,
}),

View file

@ -70,6 +70,7 @@ function testLayer(
watcher?: Layer.Layer<Watcher.Service>,
credentialNode = emptyCredentialNode,
wellknownNode = emptyWellknownNode,
options?: Config.Options,
) {
const locationLayer = Layer.succeed(
Location.Service,
@ -81,6 +82,7 @@ function testLayer(
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Config.node, Config.configured(options)],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
[Credential.node, credentialNode],
@ -98,6 +100,90 @@ const provider = {
}
describe("Config", () => {
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const explicit = path.join(tmp.path, "custom.json")
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
const entries = yield* config.entries()
expect(
entries.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["global", "explicit", "project", "content"])
expect(Config.latest(entries, "shell")).toBe("content")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ file: explicit, content: JSON.stringify({ shell: "content" }) },
),
),
),
),
)
}),
),
)
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ project: false },
),
),
),
),
)
}),
),
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),

View file

@ -324,7 +324,7 @@ describe("DatabaseMigration", () => {
test("serializes concurrent embedded initialization for one database path", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "embedded.sqlite")
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
const layers = [Database.layer({ path: filename }), Database.layer({ path: filename })]
await Effect.runPromise(
Effect.all(

View file

@ -8,14 +8,11 @@ import { fileLogger } from "@opencode-ai/util/observability/logging"
import { resource } from "@opencode-ai/util/observability/otlp"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
afterEach(() => {
if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
else process.env.OPENCODE_CLIENT = opencodeClient
})
describe("resource", () => {
@ -39,16 +36,15 @@ describe("resource", () => {
})
test("keeps built-in attributes when env values conflict", () => {
process.env.OPENCODE_CLIENT = "cli"
process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
expect(resource().attributes).toMatchObject({
expect(resource("cli").attributes).toMatchObject({
"opencode.client": "cli",
"service.namespace": "anomalyco",
})
expect(resource().attributes["service.instance.id"]).not.toBe("override")
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
expect(resource("cli").attributes["service.instance.id"]).not.toBe("override")
expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
})
})
@ -66,7 +62,7 @@ test("falls back to local logging when OTLP initialization fails", async () => {
`
import { Effect } from "effect"
import { Observability } from "@opencode-ai/util/observability"
await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise)
await Effect.void.pipe(Effect.provide(Observability.layer()), Effect.scoped, Effect.runPromise)
`,
],
{

File diff suppressed because one or more lines are too long

View file

@ -20,8 +20,10 @@ const instructionLayer = (input: {
config: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
}) =>
AppNodeBuilder.build(InstructionDiscovery.node, [
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@ -242,15 +244,14 @@ describe("InstructionDiscovery", () => {
it.effect("honors the project instruction opt-out", () =>
Effect.gen(function* () {
const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG
let scanned = false
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
project: false,
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
@ -263,12 +264,6 @@ describe("InstructionDiscovery", () => {
),
}),
),
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG
else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous
}),
),
)
expect(scanned).toBe(false)

View file

@ -1,11 +1,10 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Flag } from "@opencode-ai/util/flag"
import { Global } from "@opencode-ai/util/global"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@ -14,22 +13,6 @@ import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
// cache themselves and silence the eager refresh fork. Save/restore around
// the suite — never leak the mutation to subsequent test files in the same
// bun process.
const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH
beforeAll(() => {
Flag.OPENCODE_MODELS_PATH = undefined
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
})
afterAll(() => {
Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH
Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
})
const cacheFile = path.join(Global.Path.cache, "models.json")
const fixture = {
@ -172,12 +155,13 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>) =>
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
]),
)
@ -243,17 +227,8 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
yield* writeCacheText("{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state))
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
const final = yield* Ref.get(state)

View file

@ -1,10 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Patch } from "@opencode-ai/core/patch"
import { Result } from "effect"
const parse = (input: string) => Result.getOrThrow(Patch.parse(input))
describe("Patch", () => {
test("parses add, update, and delete hunks", () => {
expect(
Patch.parse(
parse(
"*** Begin Patch\n*** Add File: add.txt\n+added\n*** Update File: update.txt\n@@ section\n-old\n+new\n*** Delete File: delete.txt\n*** End Patch",
),
).toEqual([
@ -19,18 +22,148 @@ describe("Patch", () => {
])
})
test("parses a file move", () => {
expect(
parse(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch",
),
).toEqual([
{
type: "update",
path: "old.txt",
movePath: "new.txt",
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("identifies the missing patch boundary", () => {
expect(() => parse("This is not a valid patch")).toThrow(
"The first line of the patch must be '*** Begin Patch'",
)
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
})
test("strips a heredoc wrapper", () => {
expect(Patch.parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
expect(parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("strips a heredoc wrapper without cat", () => {
expect(parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("parses a whitespace-padded hunk header", () => {
expect(parse("*** Begin Patch\n *** Update File: foo.txt\n@@\n-old\n+new\n*** End Patch")).toEqual([
{
type: "update",
path: "foo.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("parses leading and trailing whitespace around patch markers", () => {
expect(parse(" *** Begin Patch\n*** Update File: file.txt\n@@\n-one\n+two\n*** End Patch ")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["one"], newLines: ["two"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("parses whitespace on the inner sides of patch marker lines", () => {
expect(parse("*** Begin Patch \n*** Update File: file.txt\n@@\n-one\n+two\n *** End Patch")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["one"], newLines: ["two"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("strips one carriage return from CRLF patch lines", () => {
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("preserves an extra carriage return in CRLF patch lines", () => {
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old\r"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("derives fuzzy line updates while preserving BOM", () => {
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
expect(update).toEqual({ content: "new\n", bom: true })
expect(Patch.joinBom(update.content, update.bom)).toBe("\uFEFFnew\n")
})
test("derives multiple update chunks", () => {
expect(
Patch.derive(
"update.txt",
[
{ oldLines: ["line 2"], newLines: ["LINE 2"] },
{ oldLines: ["line 4"], newLines: ["LINE 4"] },
],
"line 1\nline 2\nline 3\nline 4\n",
).content,
).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
})
test("updates empty files and adds a trailing newline", () => {
expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe(
"First line\n",
)
expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe(
"new\n",
)
})
test("disambiguates updates with change context", () => {
expect(
Patch.derive(
"update.txt",
[{ oldLines: ["x=10"], newLines: ["x=11"], changeContext: "fn b" }],
"fn a\nx=10\nfn b\nx=10\n",
).content,
).toBe("fn a\nx=10\nfn b\nx=11\n")
})
test("matches leading, trailing, and Unicode punctuation differences", () => {
expect(Patch.derive("leading.txt", [{ oldLines: ["line"], newLines: ["next"] }], " line\n").content).toBe(
"next\n",
)
expect(Patch.derive("trailing.txt", [{ oldLines: ["line"], newLines: ["next"] }], "line \n").content).toBe(
"next\n",
)
expect(
Patch.derive('unicode.txt', [{ oldLines: ['He said "hello"'], newLines: ['He said "hi"'] }], 'He said “hello”\n')
.content,
).toBe('He said "hi"\n')
})
test("matches EOF-anchored chunks from the end", () => {
expect(
Patch.derive(
@ -41,28 +174,15 @@ describe("Patch", () => {
).toBe("marker\nmiddle\nmarker changed\nend\n")
})
test("parses the EOF marker inside update chunks", () => {
expect(
Patch.parse("*** Begin Patch\n*** Update File: update.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"),
).toEqual([
{
type: "update",
path: "update.txt",
movePath: undefined,
chunks: [{ oldLines: ["last"], newLines: ["end"], changeContext: undefined, endOfFile: true }],
},
test("matches V1 lenient parsing of malformed hunk bodies", () => {
expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
{ type: "add", path: "add.txt", contents: "" },
])
expect(parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
{ type: "update", path: "update.txt", movePath: undefined, chunks: [] },
])
expect(parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
{ type: "delete", path: "delete.txt" },
])
})
test("rejects malformed hunk bodies", () => {
expect(() => Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toThrow(
"Invalid add file line",
)
expect(() => Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toThrow(
"expected at least one @@ chunk",
)
expect(() => Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toThrow(
"Invalid patch line",
)
})
})

View file

@ -7,7 +7,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/util/flag"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@ -26,6 +25,8 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
it.effect("projects normalized models.dev snapshots into the catalog", () =>
@ -193,410 +194,361 @@ describe("ModelsDevPlugin", () => {
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
expect(yield* integrations.list()).toEqual([
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
),
)
expect(yield* integrations.list()).toEqual([
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
)
it.effect("converts reasoning options into settings variants", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-opus-4.7"),
)
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-toggle"),
)
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("amazon/nova-2-lite"),
)
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("gemini-2.5-flash"),
)
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("amazon--nova-lite"),
)
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4.7"))
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-toggle"))
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("amazon/nova-2-lite"))
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("gemini-2.5-flash"))
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("amazon--nova-lite"))
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")))),
)
})

View file

@ -56,7 +56,7 @@ const locations = Layer.effect(
() =>
// The test only needs the compaction location service used by SessionV2.compact.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
SessionCompaction.layer.pipe(
SessionCompaction.layer().pipe(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),

View file

@ -18,7 +18,6 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
@ -190,7 +189,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": "cli",
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")

View file

@ -460,7 +460,7 @@ describe("SessionV2.create", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
[[Database.node, targetDatabase]],

View file

@ -78,6 +78,25 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBeUndefined()
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
@ -637,6 +656,29 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
undefined,
{
loadAISDK: (runtime) =>
Effect.sync(() => {
expect(runtime.settings).not.toHaveProperty("apiKey")
return native
}),
},
)
}),
)
it.effect("reports whether a catalog model declares a provider package", () =>
Effect.sync(() => {
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)

View file

@ -22,7 +22,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
@ -3183,7 +3182,7 @@ describe("SessionRunnerLLM", () => {
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": "cli",
})
}),
)

View file

@ -17,7 +17,6 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
@ -153,7 +152,7 @@ it.effect("generates a title from the sole user message and renames the session"
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": "cli",
})
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)

View file

@ -23,13 +23,7 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
@ -99,13 +93,7 @@ describe("Snapshot", () => {
await fs.mkdir(location, { recursive: true })
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
const layer = snapshotLayer(tmp.path, location)
@ -168,14 +156,8 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
const capture = (directory: string) =>
@ -213,13 +195,7 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
yield* Effect.gen(function* () {
@ -251,3 +227,12 @@ function snapshotLayer(data: string, directory: string) {
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
}
async function initGit(directory: string, commit = false) {
await $`git init`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
if (!commit) return
await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
.cwd(directory)
.quiet()
}

View file

@ -1,13 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } 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 { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
@ -23,7 +21,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
deps: [ToolRegistry.toolsNode, FSUtil.node, Location.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_patch_tool_test")
@ -32,9 +30,6 @@ let denyAction: string | undefined
let failRemoveTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let blockRemoveTarget: string | undefined
let removeStarted: Deferred.Deferred<void> | undefined
let releaseRemove: Deferred.Deferred<void> | undefined
let afterEditApproval = (): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
@ -72,9 +67,6 @@ const reset = () => {
failRemoveTarget = undefined
readsBeforeEditApproval = 0
editApproved = false
blockRemoveTarget = undefined
removeStarted = undefined
releaseRemove = undefined
afterEditApproval = () => Effect.void
}
@ -90,21 +82,25 @@ const filesystem = Layer.effect(
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (blockRemoveTarget && path.basename(target) === blockRemoveTarget && removeStarted && releaseRemove)
return Deferred.succeed(removeStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseRemove)),
Effect.andThen(fs.remove(target, options)),
)
return fs.remove(target, options)
},
})
}),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const withTool = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
projectDirectory = directory,
) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
@ -114,8 +110,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
patchToolNode,
]),
[
@ -143,6 +137,15 @@ const exists = (target: string) =>
),
)
const it = testEffect(Layer.empty)
const withTempTool = <A, E, R>(body: (directory: string, registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) => body(tmp.path, registry))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
describe("PatchTool", () => {
it.live("registers and sequentially applies add, update, and delete hunks", () =>
@ -167,8 +170,9 @@ describe("PatchTool", () => {
)
expect(settled.result).toEqual({
type: "text",
value: "Applied patch sequentially:\nA nested/new.txt\nM update.txt\nD remove.txt",
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
})
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
expect(settled.output?.structured).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt" },
@ -194,15 +198,25 @@ describe("PatchTool", () => {
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 1,
deletions: 2,
patch: expect.stringContaining("-remove"),
},
],
})
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
{
sessionID,
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(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(2)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
"created\n",
)
@ -217,7 +231,7 @@ describe("PatchTool", () => {
),
)
it.live("rejects moves before applying any hunk", () =>
it.live("moves and updates a file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -234,9 +248,17 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([])
).toEqual({
type: "text",
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
})
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
"after\n",
)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "created.txt"), "utf8"))).toBe(
"created\n",
)
}),
),
),
@ -246,7 +268,427 @@ describe("PatchTool", () => {
),
)
it.live("approves an external directory and the batch before reading external update content", () =>
it.live("moves a file over an existing destination", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const source = path.join(tmp.path, "old.txt")
const destination = path.join(tmp.path, "nested", "moved.txt")
return Effect.promise(() =>
Promise.all([
fs.writeFile(source, "before\n"),
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
]),
).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toMatchObject({ type: "text" })
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("moves a symlink without deleting its target", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const target = path.join(directory, "target.txt")
const source = path.join(directory, "link.txt")
const moved = path.join(directory, "moved.txt")
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
yield* Effect.promise(() => fs.symlink(target, source))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: link.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
)
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n")
}),
),
)
it.live("includes move file info in structured output", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "old", "name.txt")
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
),
)
expect(settled.output?.structured).toMatchObject({
applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
files: [
{
file: "renamed/dir/name.txt",
status: "modified",
patch: expect.stringContaining("-old content\n+new content"),
},
],
})
}),
),
)
it.live("inserts lines with an insert-only hunk", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "insert-only.txt")
yield* Effect.promise(() => fs.writeFile(target, "alpha\nomega\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: insert-only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nbeta\nomega\n")
}),
),
)
it.live("updates an empty file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "empty.txt")
yield* Effect.promise(() => fs.writeFile(target, ""))
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: empty.txt\n@@\n+First line\n*** End Patch"))
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("First line\n")
}),
),
)
it.live("rejects deleting a directory", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
).toMatchObject({ type: "error" })
expect(yield* exists(path.join(directory, "dir"))).toBe(true)
}),
),
)
it.live("supports an end-of-file anchor", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "tail.txt")
yield* Effect.promise(() => fs.writeFile(target, "alpha\nlast\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nend\n")
}),
),
)
it.live("rejects a missing second chunk context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "two-chunks.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n"))
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch",
),
),
).toMatchObject({ type: "error" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
}),
),
)
it.live("requires patchText", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
}),
),
)
it.live("rejects invalid patch format", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
type: "error",
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
})
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
type: "error",
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
})
}),
),
)
it.live("rejects an empty patch", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
type: "error",
value: "patch rejected: empty patch",
})
}),
),
)
it.live("rejects an invalid hunk header", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
),
).toEqual({
type: "error",
value:
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
})
}),
),
)
it.live("applies multiple hunks to one file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "multi.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nB\nc\nD\n")
}),
),
)
it.live("applies successive update operations to one file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "successive.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: successive.txt\n@@\n-a\n+A\n*** Update File: successive.txt\n@@\n-b\n+B\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("A\nB\n")
}),
),
)
it.live("does not invent a first-line diff for BOM files", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch",
),
)
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(output.files[0]?.patch).not.toContain(bom)
expect(output.files[0]?.patch).not.toContain("-using System;")
expect(output.files[0]?.patch).not.toContain("+using System;")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
`${bom}using System;\n\nclass Test {}\nclass Next {}\n`,
)
}),
),
)
it.live("appends a trailing newline on update", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "no-newline.txt")
yield* Effect.promise(() => fs.writeFile(target, "no newline at end"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first line\nsecond line\n")
}),
),
)
it.live("disambiguates change context with an @@ header", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "context.txt")
yield* Effect.promise(() => fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
"fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n",
)
}),
),
)
it.live("parses a heredoc-wrapped patch", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* executeTool(
registry,
call("cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF"),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe(
"with cat\n",
)
}),
),
)
it.live("parses a heredoc-wrapped patch without cat", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* executeTool(
registry,
call("<<EOF\n*** Begin Patch\n*** Add File: heredoc.txt\n+without cat\n*** End Patch\nEOF"),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe(
"without cat\n",
)
}),
),
)
it.live("matches with trailing whitespace differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "trailing.txt")
yield* Effect.promise(() => fs.writeFile(target, "line1 \nline2\nline3 \n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: trailing.txt\n@@\n-line2\n+changed\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1 \nchanged\nline3 \n")
}),
),
)
it.live("matches with leading whitespace differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "leading.txt")
yield* Effect.promise(() => fs.writeFile(target, " line1\nline2\n line3\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: leading.txt\n@@\n-line2\n+changed\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(" line1\nchanged\n line3\n")
}),
),
)
it.live("matches with Unicode punctuation differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "unicode.txt")
yield* Effect.promise(() => fs.writeFile(target, "He said “hello”\nsome—dash\nend\n"))
yield* executeTool(
registry,
call(
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch',
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe('He said "hi"\nsome—dash\nend\n')
}),
),
)
it.live("rejects an update with missing context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "unchanged.txt")
yield* Effect.promise(() => fs.writeFile(target, "line1\nline2\n"))
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
),
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
}),
),
)
it.live("rejects an update when the target file is missing", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
),
).toMatchObject({
type: "error",
value: expect.stringContaining(
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
),
})
}),
),
)
it.live("identifies a directory used as an update target", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "nested")))
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
).toEqual({
type: "error",
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
})
}),
),
)
it.live("rejects a delete when the target file is missing", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
}),
),
)
it.live("approves an external directory before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@ -263,7 +705,7 @@ describe("PatchTool", () => {
),
).toMatchObject({ type: "text" })
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")
}),
),
@ -277,7 +719,106 @@ describe("PatchTool", () => {
),
)
it.live("approves a relative external target before reading update content", () =>
it.live("does not inspect an external file when external permission is denied", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "external.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(
active.path,
(registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "error" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
}),
path.parse(active.path).root,
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("treats a sibling path inside the project worktree as internal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const active = path.join(tmp.path, "active")
const target = path.join(tmp.path, "sibling.txt")
return Effect.promise(() => Promise.all([fs.mkdir(active), fs.writeFile(target, "before\n")])).pipe(
Effect.andThen(
withTool(
active,
(registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
tmp.path,
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("follows an internal symlink to an external file without external permission", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
if (process.platform === "win32") return Effect.void
const target = path.join(outside.path, "external.txt")
const link = path.join(active.path, "link.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(Effect.promise(() => fs.symlink(target, link))),
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves a relative external target before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@ -295,7 +836,7 @@ describe("PatchTool", () => {
),
).toMatchObject({ type: "text" })
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")
}),
),
@ -309,7 +850,7 @@ describe("PatchTool", () => {
),
)
it.live("approves one external directory scope for multiple files under the same parent", () =>
it.live("approves each external file under the same parent", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@ -330,10 +871,17 @@ describe("PatchTool", () => {
),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
expect(assertions.map((input) => input.action)).toEqual([
"external_directory",
"external_directory",
"edit",
])
expect(assertions[0]?.resources).toEqual([
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(outside.path, "*"))
: path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
])
expect(assertions[1]?.resources).toEqual(assertions[0]?.resources)
}),
),
),
@ -360,7 +908,10 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
).toMatchObject({
type: "error",
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
})
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
}),
)
@ -369,7 +920,7 @@ describe("PatchTool", () => {
),
)
it.live("rejects add hunks targeting an existing file without replacing it", () =>
it.live("adds files by overwriting existing targets", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -384,8 +935,8 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
).toMatchObject({ type: "text" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
),
),
@ -395,7 +946,7 @@ describe("PatchTool", () => {
),
)
it.live("rejects an add target that appears during permission approval", () =>
it.live("overwrites an add target that appears during permission approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -409,8 +960,8 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
).toMatchObject({ type: "text" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
)
},
@ -449,35 +1000,4 @@ describe("PatchTool", () => {
),
)
it.live("finishes the sequential commit phase when interrupted after the first mutation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const first = path.join(tmp.path, "first.txt")
const second = path.join(tmp.path, "second.txt")
blockRemoveTarget = path.basename(second)
return Effect.gen(function* () {
removeStarted = yield* Deferred.make<void>()
releaseRemove = yield* Deferred.make<void>()
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined)
yield* Fiber.join(interrupt)
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(false)
}),
)
})
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})