Compare commits

..
Author SHA1 Message Date
opencode-agent[bot] cd9d06c1ca chore: update nix node_modules hashes 2026-09-07 11:16:05 +00:00
86 changed files with 687 additions and 3321 deletions
+1 -1
View File
@@ -671,7 +671,7 @@ export const dict = {
"session.tab.add": "Add tab",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",
"session.panel.reviewAndFiles": "Review, files, and browser",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
+1 -1
View File
@@ -111,7 +111,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem[/\\]watcher-binding\.ts$/ }, () => ({
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
loader: "js",
}))
-52
View File
@@ -72,30 +72,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
},
}),
Spec.make("uninstall", {
description: "Uninstall OpenCode and remove its user files",
params: {
keepConfig: Flag.boolean("keep-config").pipe(
Flag.withAlias("c"),
Flag.withDescription("Keep configuration files"),
Flag.withDefault(false),
),
keepData: Flag.boolean("keep-data").pipe(
Flag.withAlias("d"),
Flag.withDescription("Keep session data and snapshots"),
Flag.withDefault(false),
),
dryRun: Flag.boolean("dry-run").pipe(
Flag.withDescription("Show what would be removed without removing it"),
Flag.withDefault(false),
),
force: Flag.boolean("force").pipe(
Flag.withAlias("f"),
Flag.withDescription("Skip confirmation prompts"),
Flag.withDefault(false),
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -385,34 +361,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
...PermissionParams,
},
}),
Spec.make("session", {
description: "Manage sessions",
commands: [
Spec.make("list", {
description: "List top-level sessions in the current project, newest first",
params: {
...ServerParams,
maxCount: Flag.integer("max-count").pipe(
Flag.withAlias("n"),
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
Flag.withDescription("Limit to N most recent sessions (default: 100)"),
Flag.optional,
),
format: Flag.choice("format", ["table", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("table"),
),
},
}),
Spec.make("delete", {
description: "Delete a session and its child sessions",
params: {
...ServerParams,
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
},
}),
],
}),
Spec.make("service", {
description: "Manage the background server",
commands: [
@@ -44,10 +44,6 @@ export default Runtime.handler(
)
const output = yield* Effect.promise(() => response.text())
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
if (!response.ok) {
process.stderr.write(`HTTP ${response.status} ${response.statusText}${EOL}`)
process.exitCode = 1
}
}),
)
@@ -1,34 +0,0 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
const handler = Effect.fn("cli.session.delete")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.delete>,
) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
yield* Effect.tryPromise({
try: (signal) => client.session.remove({ sessionID: input.sessionID }, { signal }),
catch: (cause) => cause,
})
process.stdout.write(`Session ${input.sessionID} deleted${EOL}`)
})
export default Runtime.handler(Commands.commands.session.commands.delete, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -1,113 +0,0 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option, Stream } from "effect"
import { EOL } from "node:os"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
const handler = Effect.fn("cli.session.list")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.list>,
) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
const location = yield* Effect.tryPromise({
try: (signal) => client.location.get({ location: { directory: process.cwd() } }, { signal }),
catch: (cause) => cause,
})
const page = yield* Effect.tryPromise({
try: (signal) =>
client.session.list(
{
project: location.project.id,
parentID: null,
order: "desc",
limit: Option.getOrElse(input.maxCount, () => 100),
},
{ signal },
),
catch: (cause) => cause,
})
if (input.format === "table" && page.data.length === 0) return
const output =
(input.format === "json"
? JSON.stringify(
page.data.map((session) => ({
id: session.id,
title: session.title,
updated: session.time.updated,
created: session.time.created,
projectId: session.projectID,
directory: session.location.directory,
})),
null,
2,
)
: formatTable(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
process.stdout.write(output, (error) => (error ? reject(error) : resolve()))
}),
)
if (!process.stdout.isTTY || Option.isSome(input.maxCount) || input.format === "json") {
yield* write
return
}
const { AppProcess } = yield* Effect.promise(() => import("@opencode-ai/util/process"))
const { LayerNode } = yield* Effect.promise(() => import("@opencode-ai/util/effect/layer-node"))
const { ChildProcess } = yield* Effect.promise(() => import("effect/unstable/process"))
yield* Effect.gen(function* () {
const processService = yield* AppProcess.Service
const pager = yield* processService
.spawn(
ChildProcess.make(
process.platform === "win32" ? "cmd" : "less",
process.platform === "win32" ? ["/c", "more"] : ["-R", "-S"],
{
stdin: Stream.make(new TextEncoder().encode(output)),
stdout: "inherit",
stderr: "inherit",
},
),
)
.pipe(Effect.option)
if (Option.isNone(pager)) {
yield* write
return
}
yield* pager.value.exitCode
}).pipe(Effect.provide(LayerNode.compile(AppProcess.node)))
})
export default Runtime.handler(Commands.commands.session.commands.list, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
}
@@ -1,114 +0,0 @@
import { confirm, intro, log, outro, spinner } from "@clack/prompts"
import { Service } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Installation } from "../../services/installation"
import { ServerConnection } from "../../services/server-connection"
import { ServiceConfig } from "../../services/service-config"
import { handlePromptErrors, prompt, requireInteractive } from "../../ui/prompt"
export default Runtime.handler(
Commands.commands.uninstall,
Effect.fn("cli.uninstall")(function* (input) {
intro("Uninstall OpenCode")
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const installation = yield* Installation.make()
const method = yield* installation.method()
log.info(`Installation method: ${method ?? "unknown"}`)
if (!method && !input.dryRun)
return yield* Effect.fail(
new Error(
"Could not detect the installation method. Uninstall OpenCode with your package manager or remove its binary manually.",
),
)
const removal =
method && method !== "curl" && installation.installedPackage
? { method, command: Installation.uninstallCommand(method, installation.installedPackage) }
: undefined
const directories = [
{ path: global.data, label: "Data", keep: input.keepData },
{ path: global.cache, label: "Cache", keep: false },
{ path: global.config, label: "Config", keep: input.keepConfig },
{ path: global.state, label: "State", keep: false },
]
const shell = method === "curl" ? yield* installation.shellChanges() : []
log.message("Uninstall plan:")
log.info("Stop the local background service")
yield* Effect.forEach(
directories,
(directory) =>
fs
.exists(directory.path)
.pipe(
Effect.flatMap((exists) =>
exists
? Effect.sync(() =>
log.info(`${directory.keep ? "Keep" : "Remove"} ${directory.label}: ${directory.path}`),
)
: Effect.void,
),
),
{ discard: true },
)
shell.forEach((change) => log.info(`Remove installer PATH entry: ${change.path}`))
if (removal) log.info(`Package: ${removal.command.join(" ")}`)
if (method === "curl") log.info(`Binary (manual removal): ${process.execPath}`)
if (input.dryRun) {
outro("Dry run - no changes made")
return undefined
}
if (!input.force) {
yield* requireInteractive("Pass --force to uninstall without an interactive terminal, or --dry-run to preview.")
if (!(yield* prompt(() => confirm({ message: "Are you sure you want to uninstall?", initialValue: false })))) {
outro("Cancelled")
return undefined
}
}
const progress = spinner()
yield* Effect.gen(function* () {
progress.start("Stopping background service...")
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
progress.stop("Background service stopped")
if (removal) {
progress.start(`Running ${removal.command.join(" ")}...`)
yield* installation.uninstall(removal.method)
progress.stop("Package removed")
}
yield* Effect.forEach(
shell,
(change) =>
Effect.gen(function* () {
progress.start(`Cleaning ${change.path}...`)
yield* fs.writeFileString(change.path, change.content)
progress.stop(`Cleaned ${change.path}`)
}),
{ discard: true },
)
yield* Effect.forEach(
directories.filter((directory) => !directory.keep),
(directory) =>
Effect.gen(function* () {
progress.start(`Removing ${directory.label}...`)
yield* fs.remove(directory.path, { recursive: true, force: true })
progress.stop(`Removed ${directory.label}`)
}),
{ discard: true },
)
}).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Uninstall failed", 1))))
if (method === "curl") {
log.message("To finish removing the binary, run:")
log.info(Installation.binaryRemovalCommand())
}
outro("Done")
return undefined
}, handlePromptErrors),
)
+23 -17
View File
@@ -6,16 +6,6 @@ import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
type Requirements =
| FileSystem.FileSystem
| Global.Service
| Npm.Service
| AppProcess.Service
| Updater.Service
| Config.Service
| Scope.Scope
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -24,11 +14,29 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Requirements>
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<
void,
unknown,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Requirements>
default: (
input: Input<Node>,
) => Effect.Effect<
void,
any,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Requirements>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
@@ -59,14 +67,12 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases)
result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
}
if (value.$) {
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases)
result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
}
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
}
-5
View File
@@ -18,7 +18,6 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
uninstall: () => import("./commands/handlers/uninstall"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
@@ -54,10 +53,6 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
session: {
list: () => import("./commands/handlers/session/list"),
delete: () => import("./commands/handlers/session/delete"),
},
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
-169
View File
@@ -1,169 +0,0 @@
export * as Installation from "./installation"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Duration, Effect, FileSystem, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "node:path"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type ShellChange = {
readonly path: string
readonly content: string
}
export interface Interface {
readonly installedPackage: string | undefined
readonly method: () => Effect.Effect<Method | undefined>
readonly uninstall: (method: Exclude<Method, "curl">) => Effect.Effect<void, Error>
readonly shellChanges: () => Effect.Effect<ReadonlyArray<ShellChange>>
}
const Manifest = Schema.fromJsonString(
Schema.Struct({
name: Schema.String,
bin: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
)
export const make = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Manifest)))
return Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable)
? manifest.name
: undefined
}).pipe(Effect.orElseSucceed(() => undefined))
const run = Effect.fnUntraced(
function* (command: ReadonlyArray<string>, timeout: Duration.Input = "10 seconds") {
const result = yield* appProcess.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
},
Effect.catch((error) =>
Effect.succeed({
code: 1,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}),
),
)
const method = Effect.fn("cli.installation.method")(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return undefined
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const uninstall = Effect.fn("cli.installation.uninstall")(function* (method: Exclude<Method, "curl">) {
if (!installedPackage) return yield* Effect.fail(new Error("Could not identify the installed OpenCode package"))
const result = yield* run(uninstallCommand(method, installedPackage), "5 minutes")
if (result.code !== 0)
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`))
return undefined
})
const shellChanges = Effect.fn("cli.installation.shell-changes")(function* () {
const bin = path.join(global.home, ".opencode", "bin")
const files = yield* fs.readDirectory(bin).pipe(Effect.orElseSucceed(() => []))
// V1 and other installed binaries still need this shared PATH entry.
if (files.some((name) => name !== path.basename(process.execPath))) return []
const shell = path.basename(process.env.SHELL || "bash")
const entry = shell === "fish" ? `fish_add_path ${bin}` : `export PATH=${bin}:$PATH`
const changes = yield* Effect.forEach(shellConfigFiles(shell, global.home), (file) =>
fs.readFileString(file).pipe(
Effect.flatMap((content) => {
const updated = removeShellEntry(content, entry)
return Effect.succeed(updated === undefined ? undefined : { path: file, content: updated })
}),
Effect.orElseSucceed(() => undefined),
),
)
return changes.filter((change) => change !== undefined)
})
return { installedPackage, method, uninstall, shellChanges } satisfies Interface
})
export function uninstallCommand(method: Exclude<Method, "curl">, name: string): [string, ...string[]] {
const commands: Record<Exclude<Method, "curl">, [string, ...string[]]> = {
npm: ["npm", "uninstall", "--global", name],
pnpm: ["pnpm", "remove", "--global", name],
bun: ["bun", "remove", "--global", name],
yarn: ["yarn", "global", "remove", name],
}
return commands[method]
}
export function binaryRemovalCommand(executable = process.execPath) {
return process.platform === "win32"
? `Remove-Item -LiteralPath '${executable.replaceAll("'", "''")}'`
: `rm -- '${executable.replaceAll("'", "'\\''")}'`
}
function shellConfigFiles(shell: string, home: string) {
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
const zsh = process.env.ZDOTDIR || home
const candidates: Record<string, string[]> = {
fish: [path.join(home, ".config", "fish", "config.fish")],
zsh: [
path.join(zsh, ".zshrc"),
path.join(zsh, ".zshenv"),
path.join(xdg, "zsh", ".zshrc"),
path.join(xdg, "zsh", ".zshenv"),
],
bash: [
path.join(home, ".bashrc"),
path.join(home, ".bash_profile"),
path.join(home, ".profile"),
path.join(xdg, "bash", ".bashrc"),
path.join(xdg, "bash", ".bash_profile"),
],
ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
sh: [path.join(home, ".ashrc"), path.join(home, ".profile")],
}
return [...new Set(candidates[shell] ?? [])]
}
function removeShellEntry(content: string, entry: string) {
const newline = content.includes("\r\n") ? "\r\n" : "\n"
const lines = content.split(/\r?\n/)
const marker = lines.findIndex((line, index) => line === "# opencode" && lines[index + 1] === entry)
if (marker === -1) return undefined
const start = marker > 0 && lines[marker - 1] === "" ? marker - 1 : marker
return [...lines.slice(0, start), ...lines.slice(marker + 2)].join(newline)
}
+34 -6
View File
@@ -6,10 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { Installation } from "./installation"
export const methods = Installation.methods
export type Method = Installation.Method
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
@@ -57,10 +56,17 @@ const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installation = yield* Installation.make()
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = installation.installedPackage
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -89,7 +95,29 @@ const make = Effect.gen(function* () {
)
})
const method = installation.method
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
-14
View File
@@ -970,20 +970,6 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly providerContext?:
| {
readonly version: 1
readonly provenance: {
readonly providerID: Provider.ID
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: Schema.Json
}
| undefined
readonly text: string
readonly recent: string
}
+46 -101
View File
@@ -138,15 +138,6 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionProviderContextProvenance = {
providerID: string
provider: string
modelID: string
route: string
protocol: string
endpoint: string
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -192,8 +183,6 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ModelCapabilities = {
tools: boolean
input: Array<string>
@@ -212,6 +201,18 @@ export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type FormWhen = {
key: string
op: "eq" | "neq"
@@ -511,6 +512,19 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -525,8 +539,6 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
id: string
sessionID: string
@@ -1333,6 +1345,23 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -1352,19 +1381,6 @@ export type ModelCompatibility = {
requireAssistantAfterTool?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1726,37 +1742,10 @@ export type SessionMessageToolStateError = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
providerContext?: SessionProviderContext
text: string
recent: string
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionForked = {
id: string
@@ -1835,7 +1824,6 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -2011,7 +1999,6 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
canonical?: string
name?: string
env?: Array<string>
@@ -2021,7 +2008,6 @@ export type ConfigEntry =
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
modelID?: string
family?: string
name?: string
@@ -2099,11 +2085,6 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -3099,18 +3080,6 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3390,18 +3359,6 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3681,18 +3638,6 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
-1
View File
@@ -76,7 +76,6 @@ const layer = Layer.effect(
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
compaction: model.compaction ?? provider.compaction,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
+5 -2
View File
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission.js"
import type { FileAccess } from "../../file-access.js"
import type { LocationMutation } from "../../location-mutation.js"
import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js"
import { AbsolutePath } from "../../schema.js"
@@ -27,7 +27,10 @@ const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Info)
type PathAction = FileAccess.ExternalDirectoryAuthorization["action"] | typeof ReadTool.name | typeof EditTool.name
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
@@ -57,7 +57,6 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -77,7 +76,6 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
-173
View File
@@ -1,173 +0,0 @@
export * as FileAccess from "./file-access.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Array, Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
import type { SessionErrors } from "./session/error.js"
import type { Tool } from "./tool.js"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
readonly directory: AbsolutePath
readonly resource: string
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
readonly absolute: AbsolutePath
/** Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
export type Invocation = Pick<Tool.Context, "sessionID" | "agent" | "messageID" | "id">
export interface ReadOptions {
/** A target already authorized by this invocation, used for filename recovery. */
readonly siblingOf: Target
}
export interface Interface {
/** Resolve a lexical path and its permission resources, without requesting approval. */
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
/** Approve external directories in one batch, preserving first-seen resource order. */
readonly authorizeExternal: (
targets: readonly Target[],
context: Invocation,
metadata?: Permission.AssertInput["metadata"],
) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
/** Resolve a read target and obtain external-directory approval before read approval. */
readonly authorizeRead: (
file: string,
context: Invocation,
options?: ReadOptions,
) => Effect.Effect<Target, FSUtil.Error | Error | SessionErrors.NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileAccess") {}
/** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
normalized === "~"
? home
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
const slash = (value: string) => value.replaceAll("\\", "/")
const invocation = (context: Invocation) => ({
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool" as const, messageID: context.messageID, id: context.id },
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory,
resource: slash(path.join(directory, "*")),
save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")),
},
} satisfies Target
})
const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* (
targets: readonly Target[],
context: Invocation,
metadata?: Permission.AssertInput["metadata"],
) {
const external = Array.dedupeWith(
targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])),
(left, right) => left.resource === right.resource,
)
if (external.length === 0) return
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
...(metadata === undefined ? {} : { metadata }),
...invocation(context),
})
})
const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* (
file: string,
context: Invocation,
options?: ReadOptions,
) {
const target = yield* resolve({ path: file, kind: options ? "file" : undefined })
const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute)
// Filename recovery shares the directory approval, but checks the recovered file's own read rules.
if (!sibling) yield* authorizeExternal([target], context)
yield* permission.assert({
action: "read",
resources: [target.resource],
save: ["*"],
...invocation(context),
})
return target
})
return Service.of({ resolve, authorizeExternal, authorizeRead })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
+4 -2
View File
@@ -7,9 +7,11 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment/index.js"
import type { Files } from "./environment/index.js"
import type { FileAccess } from "./file-access.js"
export type Target = Pick<FileAccess.Target, "absolute" | "resource">
export interface Target {
readonly absolute: string
readonly resource: string
}
export interface WriteInput {
readonly target: Target
-1
View File
@@ -42,7 +42,6 @@ export const layer = Layer.effect(
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
(error) => {
const mapped: Error = input.model
+2 -2
View File
@@ -17,7 +17,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { FileAccess } from "./file-access.js"
import { LocationMutation } from "./location-mutation.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
@@ -82,7 +82,7 @@ const nodes = [
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
FileAccess.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
Mcp.node,
+130 -3
View File
@@ -1,3 +1,130 @@
/** @deprecated Use FileAccess for path resolution and authorization. */
export { FileAccess as LocationMutation } from "./file-access.js"
export * from "./file-access.js"
export * as LocationMutation from "./location-mutation.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. A leading `~` expands to
* the home directory; other relative paths resolve from the active Location.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
readonly directory: string
/** `external_directory` permission resource. */
readonly resource: string
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
/** Absolute lexical path. */
readonly absolute: string
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
export interface Interface {
/**
* Resolve a path and derive its permission resources. A leading `~` expands
* to the home directory; other relative paths resolve from the Location.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
normalized === "~"
? home
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/")
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node],
})
+1 -39
View File
@@ -1,12 +1,6 @@
export * as McpOAuth from "./oauth.js"
import {
auth,
discoverOAuthServerInfo,
parseErrorResponse,
type OAuthClientProvider,
type OAuthServerInfo,
} from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import { Cause, Deferred, Effect } from "effect"
@@ -16,12 +10,6 @@ import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
/**
* opencode's OAuth Client ID Metadata Document. Authorization servers that support CIMD accept this URL as the
* client_id and fetch it to learn our name and redirect URIs, so no per-server dynamic registration is needed.
*/
export const CLIENT_METADATA_URL = "https://opencode.ai/oauth/opencode/client.json"
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
Effect.gen(function* () {
@@ -91,10 +79,6 @@ export interface Options {
readonly state?: string
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
readonly client?: { readonly id: string; readonly secret?: string }
/** Use opencode's Client ID Metadata Document as the client_id instead of registering dynamically. */
readonly clientMetadataUrl?: string
/** Pre-fetched authorization server discovery so the SDK does not repeat it. */
readonly discovery?: OAuthServerInfo
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
@@ -111,8 +95,6 @@ export const provider = (options: Options): OAuthClientProvider => {
const client = options.client
return {
redirectUrl: options.redirectUrl,
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(options.discovery ? { discoveryState: () => options.discovery } : {}),
clientMetadata: {
redirect_uris: [options.redirectUrl],
client_name: "opencode",
@@ -270,32 +252,12 @@ export const authorize = (input: {
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
// Discover the authorization server up front so we can decide how to identify ourselves. CIMD only works
// when the server advertises it, accepts public clients (our document declares no client secret), and the
// redirect is our own loopback URL (a user-configured redirect_uri is not in the published document).
// A configured client_id is pre-registered and always wins.
const discovery = yield* Effect.tryPromise({
try: () => discoverOAuthServerInfo(input.config.url, { fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
!oauth?.client_id &&
!oauth?.redirect_uri &&
discovery.authorizationServerMetadata?.client_id_metadata_document_supported === true &&
(discovery.authorizationServerMetadata.token_endpoint_auth_methods_supported?.includes("none") ?? false)
yield* Effect.logInfo("mcp oauth client registration selected", {
...fields,
registration: oauth?.client_id ? "static" : cimd ? "cimd" : "dcr",
})
let authorizationUrl: URL | undefined
const oauthProvider = provider({
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
scope: oauth?.scope,
state,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
clientMetadataUrl: cimd ? CLIENT_METADATA_URL : undefined,
discovery,
onRedirect: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
+1 -29
View File
@@ -52,24 +52,10 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError<Unresol
}
}
export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCompactionError>()(
"SessionRunnerModel.UnsupportedCompactionError",
{
providerID: Provider.ID,
modelID: ID,
route: Schema.String,
},
) {
override get message() {
return `Provider compaction is not supported by ${this.providerID}/${this.modelID} (${this.route})`
}
}
export type Error =
| VariantUnavailableError
| UnsupportedPackageError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
| Integration.AuthorizationError
export interface Resolved {
@@ -83,8 +69,6 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
}
export interface Interface {
@@ -131,20 +115,9 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<
LanguageModel,
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
> =>
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
)
}),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
@@ -323,7 +296,6 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
}
})
return Service.of({
+3 -3
View File
@@ -31,7 +31,6 @@ import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileAccess } from "../file-access.js"
import { FileMutation } from "../file-mutation.js"
import { Formatter } from "../formatter.js"
import { Form } from "../form.js"
@@ -45,6 +44,7 @@ import { Integration } from "../integration.js"
import { Job } from "../job.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
@@ -103,7 +103,6 @@ const services = [
Credential.Service,
Bus.Service,
Environment.Service,
FileAccess.Service,
FileMutation.Service,
Formatter.Service,
LocationWatcherPolicy.Service,
@@ -117,6 +116,7 @@ const services = [
Job.Service,
KV.Service,
Location.Service,
LocationMutation.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
@@ -152,7 +152,6 @@ export const requirements = LayerNode.group([
Credential.node,
Bus.node,
Environment.node,
FileAccess.node,
FileMutation.node,
Formatter.node,
LocationWatcherPolicy.node,
@@ -166,6 +165,7 @@ export const requirements = LayerNode.group([
Job.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Mcp.node,
Npm.node,
@@ -263,7 +263,6 @@ export const OpenAIPlugin = define({
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
})
for (const model of item.models.values()) {
+83 -231
View File
@@ -15,15 +15,12 @@ import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import { SessionHistory } from "./history.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionProviderContext } from "./provider-context.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionRunnerRetry } from "./runner/retry.js"
import { SessionSchema } from "./schema.js"
@@ -96,8 +93,6 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
/** Known overflow must recover from durable history, not submit the overflowing native window again. */
readonly overflow?: boolean
}
type RequiredInput = {
@@ -129,10 +124,7 @@ type ExecuteInput = AutoInput & {
}
export type Outcome =
| (Pick<SessionMessage.CompactionCompleted, "status"> & {
/** Consumes the logical step's one overflow rebuild even when the native attempt overflowed first. */
readonly recoveredOverflow?: boolean
})
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface extends State.Transformable<Editor> {
@@ -144,14 +136,14 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
const hasInputUsage = (message: SessionMessage.Info) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0
export const estimateTokens = (input: RequiredInput) => {
const index = input.messages.findLastIndex(hasInputUsage)
const index = input.messages.findLastIndex(
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
@@ -207,32 +199,6 @@ const estimatePart = (part: ContentPart): number => {
)
}
/** Keep whole, real user messages, never synthetic guidance or half an attachment/tool exchange. */
export const retainUsers = (
messages: readonly SessionMessage.Info[],
model: Pick<SessionRunnerModel.Resolved, "ref" | "capabilities">,
keepTokens: number,
) => {
const users = SessionModelRequest.boundImages(
SessionModelRequest.unsupportedParts(
toLLMMessages(
messages.filter((message) => message.type === "user").map((message) => ({ ...message, skills: undefined })),
model.ref,
),
model.capabilities,
),
)
let tokens = 0
let start = users.length
for (let index = users.length - 1; index >= 0; index--) {
const size = users[index].content.reduce((sum, part) => sum + estimatePart(part), 0)
if (tokens + size > keepTokens) break
tokens += size
start = index
}
return users.slice(start)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
@@ -370,7 +336,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const db = (yield* Database.Service).db
const state = State.create<Settings, Editor>({
name: "session-compaction",
@@ -392,154 +357,6 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const started = (input: ExecuteInput, recent: string) =>
input.started
? Effect.void
: bus.publish(SessionEvent.Compaction.Started, {
sessionID: input.context.session.id,
reason: input.reason,
recent,
inputID: input.inputID,
})
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
const interrupted = (input: ExecuteInput) =>
input.reason === "auto"
? failed({
sessionID: input.context.session.id,
reason: input.reason,
inputID: input.inputID,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}).pipe(Effect.asVoid)
: Effect.void
const compactionRequest = (
input: ExecuteInput,
messages: readonly SessionMessage.Info[],
prompt: Message[],
webSocket?: "session",
) => {
const context = input.context
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages,
})
return input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
...prompt,
],
},
webSocket,
})
}
/** The durable transcript since the last local summary, re-expanding every native window. */
const original = (sessionID: SessionSchema.ID) => SessionHistory.load(db, sessionID, "local").pipe(Effect.orDie)
const recoverLocally = (input: ExecuteInput) =>
original(input.context.session.id).pipe(
Effect.flatMap((messages) => execute({ ...input, context: { ...input.context, messages } })),
)
const executeProvider = Effect.fn("SessionCompaction.executeProvider")(function* (input: ExecuteInput) {
const context = input.context
const reject = (message: string) =>
failed({
sessionID: context.session.id,
reason: input.reason,
inputID: input.inputID,
error: { type: "provider.unsupported-operation", message },
})
const prepared = yield* compactionRequest(input, context.messages, [], "session")
const request = prepared.request
const provenance = SessionProviderContext.provenance(context.model)
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
// History is selected before request hooks. Until that interface can select on the final route,
// require routing in the catalog; never install a checkpoint that the next request would skip.
if (
!SessionProviderContext.compatible(
provenance,
SessionProviderContext.provenance({ model: request.model, ref: context.model.ref }),
)
)
return yield* reject(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
})
yield* started(input, "")
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
// Transient provider failures retry like any other request; only a known automatic overflow permits
// local recovery, and nothing is installed until the provider returns a checkpoint.
const result = yield* restore(
Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
const result = yield* llm
.compact(request, { ...prepared.options, mechanism: "trigger" })
.pipe(transient)
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
}
if (LLMClient.canCompact(request))
return yield* llm
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
.pipe(transient)
// Model resolution admits provider policies only for routes with a compaction operation.
return yield* Effect.die(
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
)
}),
)
if (result.usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
sessionID: context.session.id,
source: "compaction" as const,
...SessionUsage.record(result.usage, context.model.cost),
})
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
text: "",
recent: "",
providerContext: SessionProviderContext.encode(provenance, result.replacement),
})
return { status: "completed" as const }
}),
).pipe(
Effect.onInterrupt(() => interrupted(input)),
Effect.catchTag(
"AI.Error",
(cause): Effect.Effect<Outcome> =>
input.reason === "auto" && isContextOverflowFailure(cause)
? recoverLocally({ ...input, started: true }).pipe(
Effect.map((result) =>
result.status === "completed" ? { ...result, recoveredOverflow: true } : result,
),
)
: failed({
sessionID: context.session.id,
reason: input.reason,
inputID: input.inputID,
error: toSessionError(cause),
}),
),
)
})
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
const context = input.context
const history = splitHistory(context.messages, state.get().tokens)
@@ -550,7 +367,13 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
yield* started(input, history.recent)
if (!input.started)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: context.session.id,
reason: input.reason,
recent: history.recent,
inputID: input.inputID,
})
const chunks: string[] = []
let failure: SessionError.Error | undefined
@@ -565,19 +388,37 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const prepared = yield* compactionRequest(input, history.messages, [
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
])
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages: history.messages,
})
const prepared = yield* input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
],
},
})
const retry = yield* SessionRunnerRetry.policy(context.session.id)
// Both requests share the retry allowance; rejected output never enters the reminder request.
for (const request of [
prepared.request,
LLMRequest.update(prepared.request, {
@@ -638,13 +479,42 @@ export const layer = Layer.effect(
}
return Effect.void
}),
transient,
Effect.retry({
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* retry({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
retry: SessionRunnerRetry.isRetryable(cause),
})
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
input.reason === "auto"
? failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
@@ -674,24 +544,13 @@ export const layer = Layer.effect(
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
return yield* executeProvider(request)
})
const compact = (input: AutoInput) => execute({ ...input, reason: "auto" })
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
// Native usage describes the compaction operation, not the replacement's size. Wait for
// a primary response to anchor the new window, including after restart or new admission.
if (
input.messages.findLastIndex(hasInputUsage) < input.messages.findLastIndex(SessionProviderContext.isCheckpoint)
)
return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
@@ -700,12 +559,7 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -724,17 +578,15 @@ export const layer = Layer.effect(
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (context) => {
const request = {
onSuccess: (context) =>
execute({
context,
instructionUpdate: context.instructionUpdate,
prepare: input.prepare,
reason: "manual" as const,
reason: "manual",
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
},
}),
}),
)
})
@@ -752,5 +604,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Database.node, llmClient],
deps: [Bus.node, llmClient],
})
+1 -7
View File
@@ -18,7 +18,6 @@ import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
import { InstructionEntry } from "./instruction-entry.js"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
@@ -157,12 +156,7 @@ const layer = Layer.effect(
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(
db,
selection.session.id,
selection.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
agent: selection.agent,
+1 -7
View File
@@ -9,7 +9,6 @@ import type { Instructions } from "../instructions/index.js"
import { SessionContext } from "./context.js"
import type { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import type { SessionSchema } from "./schema.js"
@@ -30,12 +29,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
const context = yield* SessionContext.Service
const selection = yield* context.select(input.session.id)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(
database.db,
selection.session.id,
selection.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
model,
+9 -63
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -6,30 +6,13 @@ import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { Instructions } from "../instructions/index.js"
import { InstructionState } from "./instruction-state.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionMessageTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
/**
* Which completed compactions bound a history read. Local summaries always do. Native
* windows do for model-neutral readers (`latest`), never for the original transcript
* (`local`), and only when the target model can replay them (a provenance).
*/
export type Boundary = "latest" | "local" | SessionProviderContext.Provenance
const replayable = (message: SessionMessage.Info, boundary: Boundary) =>
!SessionProviderContext.isCheckpoint(message) ||
boundary === "latest" ||
(boundary !== "local" && SessionProviderContext.compatible(message.providerContext.provenance, boundary))
export const latestCompaction = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -38,19 +21,6 @@ export const latestCompaction = Effect.fnUntraced(function* (
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
boundary === "latest"
? undefined
: or(
sql`json_extract(${SessionMessageTable.data}, '$.providerContext') is null`,
boundary === "local"
? undefined
: and(
...Object.entries(boundary).map(
([key, value]) =>
sql`json_extract(${SessionMessageTable.data}, ${`$.providerContext.provenance.${key}`}) = ${value}`,
),
),
),
),
)
.orderBy(desc(SessionMessageTable.seq))
@@ -61,11 +31,6 @@ export const latestCompaction = Effect.fnUntraced(function* (
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
: Effect.void,
),
Effect.mapError(
() =>
new MessageDecodeError({
@@ -75,12 +40,8 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
),
)
const messageEntries = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
const compaction = yield* latestCompaction(db, sessionID, boundary)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const rows = yield* db
.select()
.from(SessionMessageTable)
@@ -93,38 +54,24 @@ const messageEntries = Effect.fnUntraced(function* (
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const entries = yield* Effect.forEach(rows, (row) =>
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
// Re-expansion may cross a native checkpoint whose completion already advanced the instruction
// epoch: the baseline supersedes the chronological updates before it. Forks seed their baseline
// at sequence 0 but retain parent sequences, so the copied checkpoint still retires them.
const native = entries.findLast((entry) => SessionProviderContext.isCheckpoint(entry.message))
// Skipped native checkpoints are not textual summaries. Their original transcript remains available.
return entries.filter(
(entry) =>
!(entry.message.type === "system" && native && entry.seq < native.seq) && replayable(entry.message, boundary),
)
})
export const load = Effect.fn("SessionHistory.load")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
return (yield* messageEntries(db, sessionID, boundary)).map((entry) => entry.message)
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.List,
boundary: Boundary,
) {
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID, boundary)
const messages = yield* messageEntries(db, sessionID)
return {
initial: yield* InstructionState.initial(db, sessionID, instructions),
entries: messages,
@@ -138,13 +85,12 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.List,
boundary: Boundary,
) {
const observed = yield* Instructions.read(instructions)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID, boundary)
const messages = yield* messageEntries(db, sessionID)
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
const unsettled = messages.findIndex(
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
@@ -413,7 +413,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
})
return
@@ -428,7 +427,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
time: { created },
}),
@@ -15,7 +15,6 @@ import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionSystemPrompt } from "./system-prompt.js"
@@ -346,21 +345,6 @@ export const layer = Layer.effect(
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
}),
)
// History selects native windows against the catalog route before hooks run. A newly installed
// routing hook must not send an existing opaque window to another deployment; `prepare` has no
// error channel, so like hook failures this surfaces as a defect.
const selected = SessionProviderContext.provenance(resolved)
if (
selected &&
!SessionProviderContext.compatible(
selected,
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
) &&
request.messages.some((message) => message.content.some((part) => part.type === "compaction"))
)
return yield* Effect.die(
new Error("Provider context is incompatible with the route selected by model request hooks"),
)
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
@@ -1,69 +0,0 @@
export * as SessionProviderContext from "./provider-context.js"
import { Message } from "@opencode-ai/ai"
import { SessionProviderContext } from "@opencode-ai/schema/session-provider-context"
import { Schema } from "effect"
import { isDeepStrictEqual } from "node:util"
import { Hash } from "@opencode-ai/util/hash"
import type { SessionMessage } from "./message.js"
import type { SessionRunnerModel } from "./runner/model.js"
export type Provenance = SessionProviderContext.Provenance
export const Info = SessionProviderContext.Info
export type Info = SessionProviderContext.Info
const messages = Schema.toCodecJson(Schema.Array(Message))
/** No guessed endpoints. Dynamic URL builders cannot establish a durable deployment identity here. */
export function provenance(resolved: Pick<SessionRunnerModel.Resolved, "model" | "ref">): Provenance | undefined {
const model = resolved.model
const endpoint = model.route.endpoint
if (!endpoint.baseURL || typeof endpoint.path !== "string") return undefined
return {
providerID: resolved.ref.providerID,
provider: model.provider,
modelID: model.id,
route: model.route.id,
protocol: model.route.protocol,
endpoint: Hash.sha256(
JSON.stringify([
endpoint.baseURL,
endpoint.path,
Object.entries(endpoint.query ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
]),
),
}
}
export const compatible = (source: Provenance, target: Provenance | undefined) =>
target !== undefined && isDeepStrictEqual(source, target)
/** A completed compaction that installed a native replacement window instead of a local summary. */
export const isCheckpoint = (
message: SessionMessage.Info,
): message is SessionMessage.CompactionCompleted & { readonly providerContext: Info } =>
message.type === "compaction" && message.status === "completed" && message.providerContext !== undefined
/** Stores the canonical replacement, not a local summary or transport continuation.
* Provider and attachment metadata can contain optional undefined entries. Use JSON's
* omission semantics, while preserving canonical binary media as equivalent base64.
*/
export const encode = (provenance: Provenance, replacement: ReadonlyArray<Message>): Info => ({
version: 1,
provenance,
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(
JSON.stringify(
replacement.map((message) => ({
...message,
content: message.content.map((part) =>
part.type === "media" && part.data instanceof Uint8Array
? { ...part, data: Buffer.from(part.data).toString("base64") }
: part,
),
})),
),
),
})
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
+4 -13
View File
@@ -11,7 +11,6 @@ import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionHistory } from "../history.js"
import { SessionProviderContext } from "../provider-context.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
@@ -114,12 +113,7 @@ const layer = Layer.effect(
const selected = yield* context.select(session.id)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(
db,
session.id,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
return {
session: selected.session,
agent: selected.agent,
@@ -212,9 +206,8 @@ const layer = Layer.effect(
prepare: context.prepare,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const result = yield* compaction.compact(compactionInput)
if (result.status !== "completed") return yield* new StepFailedError({ error: result.error })
if (result.recoveredOverflow) recoverOverflow = false
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
continue
}
@@ -257,9 +250,7 @@ const layer = Layer.effect(
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction
.compact({ ...compactionInput, overflow: true })
.pipe(Effect.map((result) => result.status === "completed"))
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
@@ -35,8 +35,6 @@ export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
@@ -59,7 +57,6 @@ export const resolved = (
readonly variant?: Model.VariantID
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Provider.Compaction
},
): Resolved => ({
model,
@@ -71,7 +68,6 @@ export const resolved = (
capabilities: options.capabilities,
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
})
const layer = Layer.effect(
+1 -20
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError, isContextOverflowFailure } from "@opencode-ai/ai"
import { AIError } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -10,7 +10,6 @@ import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { toSessionError } from "../to-session-error.js"
interface Input {
readonly cause: AIError
@@ -107,24 +106,6 @@ export const policy = (sessionID: SessionSchema.ID) =>
})
})
/**
* Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the
* session retry hook adjust each decision. Context overflow is never transient: callers recover it.
*/
export const transient =
(decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) =>
<A, R>(effect: Effect.Effect<A, AIError, R>) =>
Effect.retry(effect, {
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) })
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
})
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const decide = yield* policy(sessionID)
@@ -3,7 +3,6 @@ import type { Model } from "@opencode-ai/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import { SessionMessage } from "../message.js"
import { SessionProviderContext } from "../provider-context.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
@@ -275,9 +274,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
return assistant(message, model, providerMetadataKey)
case "compaction":
if (message.status !== "completed") return []
// History selection only keeps native windows the target model can replay.
if (SessionProviderContext.isCheckpoint(message))
return [...SessionProviderContext.decode(message.providerContext)]
return [
Message.make({
id: message.id,
+1 -1
View File
@@ -172,7 +172,7 @@ const layer = Layer.effect(
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID, "latest")),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()
@@ -46,8 +46,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
return { type: "provider.unsupported-operation", message: cause.message }
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
+13 -5
View File
@@ -15,7 +15,7 @@ import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { Location } from "../../location.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -109,7 +109,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -143,8 +143,16 @@ export const Plugin = {
})
}
const target = yield* access.resolve({ path: input.path, kind: "file" })
yield* access.authorizeExternal([target], context)
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
@@ -210,7 +218,7 @@ export const Plugin = {
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([FileAccess.resolvePath(location.directory, input.path)]),
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+11 -4
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
import { Permission } from "../../permission.js"
@@ -48,7 +48,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -63,8 +63,15 @@ export const Plugin = {
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* access.resolve({ path: searchPath ?? ".", kind: "directory" })
yield* access.authorizeExternal([target], context)
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
+10 -4
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
@@ -67,7 +67,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -82,8 +82,14 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* access.resolve({ path: input.path ?? "." })
yield* access.authorizeExternal([target], context)
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
+18 -12
View File
@@ -9,7 +9,7 @@ import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
import { FileMutation } from "../../file-mutation.js"
import { Location } from "../../location.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission.js"
import DESCRIPTION from "../patch.txt"
@@ -45,29 +45,29 @@ export const toModelContent = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: FileAccess.Target
readonly target: LocationMutation.Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: FileAccess.Target
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: FileAccess.Target
readonly target: LocationMutation.Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: FileAccess.Target
readonly moveTarget?: LocationMutation.Target
})
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
const environment = yield* Environment.Service
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
@@ -86,9 +86,9 @@ export const Plugin = {
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
FileAccess.resolvePath(location.directory, hunk.path),
LocationMutation.resolvePath(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath
? [FileAccess.resolvePath(location.directory, hunk.movePath)]
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
: []),
])
: []
@@ -114,11 +114,17 @@ export const Plugin = {
const prepared: Prepared[] = []
const updates = new Map<string, string>()
const resolveTarget = Effect.fnUntraced(function* (value: string) {
const target = yield* access.resolve({ path: value, kind: "file" })
const target = yield* mutation.resolve({ path: value, kind: "file" })
if (!target.externalDirectory) return target
yield* access.authorizeExternal([target], context, {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
return target
})
+34 -6
View File
@@ -6,7 +6,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js"
import { ReadToolFileSystem } from "../read-filesystem.js"
@@ -30,7 +31,8 @@ export const Plugin = {
id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
const reader = yield* ReadToolFileSystem.Service
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
@@ -46,13 +48,37 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const read = (target: FileAccess.Target) =>
reader.read(target.absolute, target.resource, {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
Effect.gen(function* () {
if (target.externalDirectory && authorizeExternal)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
})
const read = (target: LocationMutation.Target) =>
reader.read(AbsolutePath.make(target.absolute), target.resource, {
offset: input.offset,
limit: input.limit,
})
const requested = yield* access.authorizeRead(input.path, context)
const requested = yield* mutation.resolve({ path: input.path })
yield* authorize(requested)
const result = yield* read(requested).pipe(
Effect.map((content) => ({ content, target: requested, path: input.path })),
Effect.catchIf(
@@ -63,7 +89,9 @@ export const Plugin = {
Effect.orElseSucceed(() => undefined),
)
if (!alternate) return yield* missing(input.path, requested.absolute)
const target = yield* access.authorizeRead(alternate, context, { siblingOf: requested })
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
// The candidate is a sibling under the external directory already approved above.
yield* authorize(target, false)
const content = yield* read(target).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
+18 -6
View File
@@ -8,7 +8,7 @@ import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
import { Job } from "../../job.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { NonNegativeInt } from "../../schema.js"
import { Session } from "../../session.js"
@@ -104,7 +104,7 @@ export const Plugin = {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const compatibleShell = shellSelect.resolve({ priority: "compat" })
@@ -117,18 +117,30 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" })
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
invocation.cwd = target.absolute
const timeout = invocation.timeout
const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
access.resolve({
path: FileAccess.resolvePath(target.absolute, directory),
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
kind: "directory",
}),
)
yield* access.authorizeExternal([target, ...directories], context)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
+11 -4
View File
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { FileAccess } from "../../file-access.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -46,7 +46,7 @@ export const toModelContent = (output: Output) =>
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -68,8 +68,15 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* access.resolve({ path: input.path, kind: "file" })
yield* access.authorizeExternal([target], context)
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
)
+1 -1
View File
@@ -25,7 +25,7 @@ const InputObject = Schema.StructWithRest(
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
question: Schema.optional(Action),
webfetch: Schema.optional(Rule),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
-3
View File
@@ -1342,7 +1342,6 @@ describe("Config", () => {
bash: "ask",
edit: { "*.md": "allow", "*": "deny" },
question: "deny",
webfetch: { "*": "ask", "https://en.wikipedia.org/*": "allow" },
},
agent: {
reviewer: {
@@ -1421,8 +1420,6 @@ describe("Config", () => {
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "https://en.wikipedia.org/*", effect: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toMatchObject({
system: "Review changes.",
@@ -32,54 +32,6 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "@opencode-ai/ai/providers/openai/responses",
compaction: { mode: "provider", threshold: 120_000 },
models: {
native: {},
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode-ai/ai/providers/openai/chat" },
unsupported: { package: "@opencode-ai/ai/providers/openai/chat" },
},
},
default: { package: "@opencode-ai/ai/providers/openai/chat", models: { chat: {} } },
},
}),
}),
])
const native = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("native")))
const local = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
expect(yield* ModelResolver.fromCatalogModel(unsupported).pipe(Effect.flip)).toMatchObject({
_tag: "SessionRunnerModel.UnsupportedCompactionError",
message: "Provider compaction is not supported by custom/unsupported (openai-chat)",
})
}),
)
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
-178
View File
@@ -1,178 +0,0 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempLocationLayer } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { toolIdentity } from "./lib/tool"
const invocation = {
...toolIdentity,
sessionID: Session.ID.make("ses_file_access"),
id: Tool.CallID.make("call-read"),
}
const slash = (file: string) => file.replaceAll("\\", "/")
function provide(requests: Permission.AssertInput[], denied?: string) {
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [
Location.node.replace(tempLocationLayer),
Permission.node.replace(
permissionLayer({
assert: (input) =>
Effect.gen(function* () {
requests.push(input)
if (input.action === denied)
yield* new Permission.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
})
}),
}),
),
]),
)
}
describe("FileAccess.authorizeRead", () => {
it.live("returns an absolute target and preserves invocation identity on the read assertion", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const location = yield* Location.Service
const target = yield* access.authorizeRead("src/../README.md", invocation)
const absolute: AbsolutePath = target.absolute
expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md")))
expect(target.externalDirectory).toBeUndefined()
expect(requests).toEqual([
{
action: "read",
resources: ["README.md"],
save: ["*"],
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
},
])
}).pipe(provide(requests))
})
it.live("authorizes an external directory before the file's read rules", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.authorizeRead("../notes.txt", invocation)
expect(requests).toMatchObject([
{ action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] },
{ action: "read", resources: [slash(target.absolute)] },
])
for (const request of requests) {
expect(request).toMatchObject({
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
})
}
}).pipe(provide(requests))
})
for (const action of ["external_directory", "read"]) {
it.live(`propagates ${action} denial without continuing authorization`, () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip)
expect(error).toBeInstanceOf(Permission.BlockedError)
expect(requests.map((request) => request.action)).toEqual(
action === "external_directory" ? ["external_directory"] : ["external_directory", "read"],
)
}).pipe(provide(requests, action))
})
}
it.live("reuses a sibling's directory approval only for the supplied recovery call", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const requested = yield* access.authorizeRead("../report final.txt", invocation)
const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested })
yield* access.authorizeRead("../notes.txt", invocation)
expect(requests.map((request) => request.action)).toEqual([
"external_directory",
"read",
"read",
"external_directory",
"read",
])
expect(requests[2].resources).toEqual([slash(recovered.absolute)])
}).pipe(provide(requests))
})
it.live("checks the external directory for a target that is not a sibling", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const requested = yield* access.authorizeRead("README.md", invocation)
yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested })
expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"])
}).pipe(provide(requests))
})
it.live("batches external resources in first-seen order and preserves broader repository saves", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const external = yield* tmpdirScoped()
const git = path.join(external.path, "git")
const hg = path.join(external.path, "hg")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(git, ".git"), { recursive: true })
await fs.mkdir(path.join(git, "nested"))
await fs.mkdir(path.join(hg, ".hg"), { recursive: true })
await fs.mkdir(path.join(hg, "nested"))
})
const access = yield* FileAccess.Service
const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" })
const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" })
const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" })
const internal = yield* access.resolve({ path: "README.md" })
const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) }
yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata)
expect(requests).toEqual([
{
action: "external_directory",
resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))],
save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))],
metadata,
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
},
])
yield* access.authorizeExternal([internal], invocation)
expect(requests).toHaveLength(1)
yield* access.authorizeExternal([second], invocation)
expect(requests).toHaveLength(2)
expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))])
expect(Object.hasOwn(requests[1], "metadata")).toBe(false)
}).pipe(provide(requests))
})
})
+30 -30
View File
@@ -7,14 +7,12 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
import { withTempDir } from "./fixture/tmpdir"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
const activeLocation = Layer.succeed(
@@ -22,22 +20,27 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([FileAccess.node, FileMutation.node]), [
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
Location.node.replace(activeLocation),
Permission.node.replace(permissionLayer()),
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
]),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("FileMutation", () => {
it.live("writes an existing internal file and returns a stable result", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "hello.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write",
@@ -51,10 +54,9 @@ describe("FileMutation", () => {
)
it.live("writes a prospective internal file and creates parent directories", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.resolve({
const target = yield* (yield* LocationMutation.Service).resolve({
path: path.join("src", "nested", "hello.txt"),
})
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
@@ -71,13 +73,12 @@ describe("FileMutation", () => {
)
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const preservedPath = path.join(directory, "preserved.txt")
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
const access = yield* FileAccess.Service
const preserved = yield* access.resolve({ path: "preserved.txt" })
const created = yield* access.resolve({ path: "created.txt" })
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
@@ -90,12 +91,11 @@ describe("FileMutation", () => {
)
it.live("writes an explicitly resolved external target", () =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt")
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
expect(result).toEqual({
@@ -111,7 +111,7 @@ describe("FileMutation", () => {
)
it.live("serializes concurrent writes to the same absolute target", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
@@ -133,10 +133,10 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const firstPlan = yield* access.resolve({ path: "shared.txt" })
const secondPlan = yield* access.resolve({ path: "shared.txt" })
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
@@ -154,7 +154,7 @@ describe("FileMutation", () => {
)
it.live("shares transaction locks across Location service instances", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -183,7 +183,7 @@ describe("FileMutation", () => {
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -205,7 +205,7 @@ describe("FileMutation", () => {
)
it.live("allows distinct absolute targets to proceed independently", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -222,10 +222,10 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const access = yield* FileAccess.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const firstPlan = yield* access.resolve({ path: "first.txt" })
const secondPlan = yield* access.resolve({ path: "second.txt" })
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
@@ -4,20 +4,17 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { tmpdirScoped, withTempDir } from "./fixture/tmpdir"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(FileAccess.node, {
LayerNode.compile(LocationMutation.node, {
replacements: [
Permission.node.replace(permissionLayer()),
Location.node.replace(
Layer.succeed(
Location.Service,
@@ -34,14 +31,21 @@ function provide(directory: string, projectDirectory = directory) {
)
}
describe("FileAccess.resolve", () => {
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationMutation", () => {
it.live("resolves an active relative existing file target", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "hello.txt" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -53,11 +57,11 @@ describe("FileAccess.resolve", () => {
)
it.live("resolves an active relative prospective file target", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: path.join("src", "new.txt") })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -67,10 +71,10 @@ describe("FileAccess.resolve", () => {
)
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../outside.txt" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -85,12 +89,11 @@ describe("FileAccess.resolve", () => {
)
it.live("allows a relative path outside the Location but inside the project worktree", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const active = path.join(directory, "packages", "opencode")
yield* Effect.promise(() => fs.mkdir(active, { recursive: true }))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../../README.md" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" })
expect(target).toMatchObject({
absolute: path.join(directory, "README.md"),
resource: "../../README.md",
@@ -101,34 +104,37 @@ describe("FileAccess.resolve", () => {
)
it.live("does not treat a filesystem-root project sentinel as an internal boundary", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../outside.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
expect(target.externalDirectory).toBeDefined()
}).pipe(provide(directory, path.parse(directory).root)),
),
)
it.live("resolves a prospective target below an external symlink lexically", () =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
if (process.platform === "win32") return
const outside = yield* tmpdirScoped()
yield* Effect.promise(() => fs.symlink(outside.path, path.join(directory, "escape")))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: path.join("escape", "new.txt") })
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
})
expect(target.externalDirectory).toBeUndefined()
}).pipe(provide(directory)),
),
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory))
}),
)
it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
@@ -136,8 +142,8 @@ describe("FileAccess.resolve", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
const access = yield* FileAccess.Service
expect(yield* access.resolve({ path: "linked/new.txt" })).toMatchObject({
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -146,11 +152,11 @@ describe("FileAccess.resolve", () => {
)
it.live("accepts an explicit absolute in-location target without external approval", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -161,12 +167,12 @@ describe("FileAccess.resolve", () => {
)
it.live("requires external-directory authorization for an explicit external absolute target", () =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -182,26 +188,26 @@ describe("FileAccess.resolve", () => {
)
it.live("resolves an existing external file target", () =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(AbsolutePath.make(outside))
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
),
),
)
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: outside, kind: "file" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -212,12 +218,12 @@ describe("FileAccess.resolve", () => {
)
it.live("authorizes prospective external descendants at their lexical parent", () =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -228,18 +234,19 @@ describe("FileAccess.resolve", () => {
),
)
test("ignores unknown path input fields", () => {
expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
})
})
test("expands a leading tilde against the home directory", () => {
const home = path.resolve("/Users/aiden")
expect(FileAccess.resolvePath("/project", "~", home)).toBe(home)
expect(FileAccess.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(FileAccess.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(FileAccess.resolvePath("/project", "~\\notes.md", home)).toBe(
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
process.platform === "win32" ? path.resolve(home, "notes.md") : path.resolve("/project", "~\\notes.md"),
)
})
@@ -250,16 +257,16 @@ describe("FileAccess.resolve", () => {
["/cygdrive/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
["/mnt/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
])("normalizes Windows shell drive path %s before resolution", (input, windows) => {
expect(FileAccess.resolvePath("/project", input)).toBe(
expect(LocationMutation.resolvePath("/project", input)).toBe(
process.platform === "win32" ? path.resolve(windows) : path.resolve(input),
)
})
it.live("resolves a tilde path as an external home target", () =>
withTempDir(({ path: directory }) =>
withTmp((directory) =>
Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "~/notes.md" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -275,8 +282,8 @@ describe("FileAccess.resolve", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "~/notes.md" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
-85
View File
@@ -164,89 +164,4 @@ describe("MCP OAuth", () => {
test("rejects an invalid redirect URL", async () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
describe("client registration", () => {
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
const authorizationServer = (metadata: Record<string, unknown>) => {
const registrations: unknown[] = []
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
registration_endpoint: `${url.origin}/register`,
response_types_supported: ["code"],
...metadata,
})
if (request.method === "POST" && url.pathname === "/register") {
registrations.push(await request.json())
return Response.json({ client_id: "registered", redirect_uris: [] })
}
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "access", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
return { server, registrations, tokenRequests }
}
const start = (server: ReturnType<typeof Bun.serve>, oauth?: ConfigMCP.OAuth) =>
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({ type: "remote", url: server.url.href, ...(oauth ? { oauth } : {}) }),
methodID: Integration.MethodID.make("oauth"),
})
return { authorization, url: new URL(authorization.url) }
})
const cimd = { client_id_metadata_document_supported: true, token_endpoint_auth_methods_supported: ["none"] }
test("uses the client metadata document when the server supports public CIMD clients", async () => {
const { server, registrations, tokenRequests } = authorizationServer(cimd)
const credential = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { authorization, url } = yield* start(server)
expect(url.searchParams.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
const redirect = new URL(url.searchParams.get("redirect_uri")!)
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", url.searchParams.get("state")!)
yield* Effect.promise(() => fetch(redirect))
return yield* authorization.callback
}),
),
).finally(() => server.stop(true))
expect(registrations).toHaveLength(0)
expect(tokenRequests[0]?.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
expect(McpOAuth.clientFromCredential(credential)).toEqual({ client_id: McpOAuth.CLIENT_METADATA_URL })
})
test("registers dynamically when the server does not accept public clients", async () => {
const { server, registrations } = authorizationServer({
client_id_metadata_document_supported: true,
token_endpoint_auth_methods_supported: ["client_secret_post"],
})
const { url } = await Effect.runPromise(Effect.scoped(start(server))).finally(() => server.stop(true))
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(1)
})
test("registers dynamically when a custom redirect_uri is configured", async () => {
const { server, registrations } = authorizationServer(cimd)
const { url } = await Effect.runPromise(
Effect.scoped(start(server, { redirect_uri: "http://127.0.0.1:0/custom" })),
).finally(() => server.stop(true))
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(1)
})
})
})
@@ -145,11 +145,7 @@ describe("OpenAIPlugin", () => {
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({
originator: "opencode",
"chatgpt-account-id": "acct_123",
"x-codex-beta-features": "remote_compaction_v2",
})
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.hasHttpHooks).toBe(false)
@@ -210,8 +206,6 @@ describe("OpenAIPlugin", () => {
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.capabilities.responsesWebsockets).toBe(true)
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
@@ -197,20 +197,6 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
@@ -42,7 +42,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
FileAccess.node,
LocationMutation.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -64,7 +64,7 @@ const testLayer = AppNodeBuilder.build(
Session.node,
Location.node,
FSUtil.node,
FileAccess.node,
LocationMutation.node,
ReadToolFileSystem.node,
readToolNode,
Tool.node,
@@ -1,437 +0,0 @@
import { expect, test } from "bun:test"
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionHistory } from "@opencode-ai/core/session/history"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionInbox.node,
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
PluginHooks.node,
llmClient,
]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)
const setup = Effect.fnUntraced(function* (endpoint = false) {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
const store = yield* SessionStore.Service
const compaction = yield* SessionCompaction.Service
const requests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
const blocked = Deferred.makeUnsafe<void>()
const hanging = Promise.withResolvers<Response>()
const state = { failure: false, flaky: false, hang: false, overflow: false, localFailure: false, calls: 0 }
const bodies: Record<string, unknown>[] = []
const headers: Headers[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
state.calls++
headers.push(request.headers)
bodies.push(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)))(
await request.text(),
),
)
if (state.hang) {
Deferred.doneUnsafe(blocked, Effect.void)
return hanging.promise
}
// Persistent failures opt out of retries so the schedule's backoff stays out of these tests.
if (state.failure || state.flaky) {
const retry = state.flaky
state.flaky = false
return Response.json(
{ error: { message: "fixture rate limit", type: "rate_limit_error" } },
{ status: 429, headers: retry ? {} : { "x-should-retry": "false" } },
)
}
const trigger = JSON.stringify(bodies.at(-1)).includes("compaction_trigger")
if (state.overflow && (trigger || state.localFailure))
return Response.json(
{
error: {
message: "Your input exceeds the context window",
code: "context_length_exceeded",
type: "invalid_request_error",
},
},
{ status: 400 },
)
const checkpoint = {
type: "compaction",
id: `cmp_${state.calls}`,
encrypted_content: `encrypted_${state.calls}`,
}
if (new URL(request.url).pathname.endsWith("/compact"))
return Response.json({
id: "compact_endpoint",
object: "response.compaction",
output: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "endpoint retained" }] },
checkpoint,
],
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
})
const output = trigger ? [checkpoint] : []
const summary = state.overflow
? [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "summary", role: "assistant", content: [] },
},
{
type: "response.output_text.delta",
item_id: "summary",
output_index: 0,
content_index: 0,
delta: "## Objective\n- Recovered locally",
},
]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
: ""
return new Response(
`${summary}data: ${JSON.stringify({
type: "response.completed",
response: {
id: `resp_${state.calls}`,
status: "completed",
output,
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) =>
Effect.sync(() => {
hanging.resolve(new Response("cancelled"))
void server.stop(true)
}),
)
const native = OpenAI.configure({ apiKey: "fixture", baseURL: server.url.toString() }).responses("gpt-5.4-mini")
const model = SessionRunnerModel.resolved(
endpoint
? LanguageModel.update(native, {
route: native.route.with({ compact: { endpoint: native.route.compact.endpoint } }),
})
: native,
{
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { mode: "provider" },
},
)
const sessionID = SessionSchema.ID.create()
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "native-compaction",
version: "test",
})
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die("Missing fixture session")
const instructions = Instructions.make({
key: Instructions.Key.make("test/native"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.succeed("Current instructions"),
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
yield* InstructionState.prepare(db, bus, instructions, sessionID)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-test-hook"] = event.kind
}),
)
yield* hooks.register("session", "http.request", (event) =>
Effect.sync(() => event.request.headers.set("x-http-hook", event.kind)),
)
const prompt = Effect.fnUntraced(function* (text: string, synthetic = false) {
const id = SessionMessage.ID.create()
yield* inbox.admit({
id,
sessionID,
item: { type: synthetic ? "synthetic" : "user", payload: { text }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
})
const load = Effect.gen(function* () {
const history = yield* SessionHistory.preview(
db,
sessionID,
instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session,
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("Compaction must never dispatch tools"),
},
}
})
const compact = Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: SessionMessage.ID.create(),
resolveContext: () => load,
prepare: requests.prepare,
})
})
const checkpoint = Effect.gen(function* () {
const messages = (yield* load).messages
const last = messages.findLast((message) => message.type === "compaction" && message.status === "completed")
if (last?.type !== "compaction" || last.status !== "completed" || !last.providerContext)
return yield* Effect.die("Missing native checkpoint")
expect(last.summary).toBe("")
expect(last.recent).toBe("")
return last.providerContext
})
return {
compact,
automatic: Effect.gen(function* () {
return yield* compaction.compact({ context: yield* load, prepare: requests.prepare })
}),
checkpoint,
prompt,
load,
requests,
bodies,
headers,
state,
blocked,
sessionID,
store,
hooks,
model,
}
})
it.live(
"manual trigger persists and continues, retains earlier users repeatedly, and preserves context on failure/cancellation",
() =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("First real user request")
yield* fixture.prompt("Synthetic context, not a user request", true)
expect(yield* fixture.compact).toEqual({ status: "completed" })
const first = yield* fixture.checkpoint
expect(SessionProviderContext.decode(first).map((message) => message.role)).toEqual(["user", "assistant"])
expect(JSON.stringify(first.messages)).not.toContain("Synthetic context")
expect(fixture.bodies[0]).toMatchObject({
input: expect.arrayContaining([{ type: "compaction_trigger" }]),
tools: [expect.objectContaining({ name: "read" })],
})
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
expect(fixture.headers[0]?.get("x-test-hook")).toBe("compaction")
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
yield* fixture.prompt("Second real user request")
const context = yield* fixture.load
const prepared = yield* fixture.requests.prepare({
kind: "primary",
scope: { session: context.session, model: context.model, agentID: context.agent.id, tools: context.tools },
transcript: SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }),
})
const client = yield* LLMClient.Service
yield* client.generate(prepared.request, prepared.options)
expect(JSON.stringify(fixture.bodies[1])).toContain("encrypted_1")
expect(JSON.stringify(fixture.bodies[1])).toContain("Current instructions")
expect(JSON.stringify(fixture.bodies[1])).toContain("Second real user request")
expect(yield* fixture.compact).toEqual({ status: "completed" })
const second = yield* fixture.checkpoint
expect(
SessionProviderContext.decode(second)
.filter((message) => message.role === "user")
.map((message) => message.content),
).toEqual([[Message.text("First real user request")], [Message.text("Second real user request")]])
expect(JSON.stringify(second.messages)).not.toContain("encrypted_1")
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 40, output: 8 } })
// Nothing new since the checkpoint is not compactable, exactly like a fresh local summary.
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "compaction.unavailable" } })
yield* fixture.prompt("Third real user request")
fixture.state.failure = true
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(4)
expect(yield* fixture.checkpoint).toEqual(second)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.compact.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.checkpoint).toEqual(second)
// A transient provider failure retries under the shared session policy and its plugin hook.
fixture.state.hang = false
const retries: PluginHooks.Domains["session"]["retry"][] = []
yield* fixture.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
retries.push(event)
event.decision = { retry: true, delay: 0 }
}),
)
fixture.state.flaky = true
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(7)
expect(retries).toMatchObject([
{
agent: "compaction",
attempt: 2,
error: { type: "provider.rate-limit" },
decision: { retry: true, delay: 0 },
},
])
expect(
SessionProviderContext.decode(yield* fixture.checkpoint).filter((message) => message.role === "user"),
).toHaveLength(3)
}),
15000,
)
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
Effect.gen(function* () {
const fixture = yield* setup(true)
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(yield* fixture.automatic).toEqual({ status: "completed" })
const replacement = SessionProviderContext.decode(yield* fixture.checkpoint)
expect(replacement[0]?.content).toEqual([Message.text("endpoint retained")])
expect(JSON.stringify(replacement)).not.toContain("Original user")
expect(fixture.state.calls).toBe(2)
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
}),
)
it.live("only known automatic native overflow falls back locally and failed recovery retains the checkpoint", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original durable request")
expect(yield* fixture.compact).toEqual({ status: "completed" })
const installed = yield* fixture.checkpoint
yield* fixture.prompt("Recent request")
fixture.state.failure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(2)
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.automatic.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.interrupted" },
})
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.hang = false
fixture.state.overflow = true
fixture.state.localFailure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed" })
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.checkpoint).toEqual(installed)
expect(JSON.stringify(fixture.bodies[4])).toContain("Original durable request")
expect(JSON.stringify(fixture.bodies[4])).not.toContain("encrypted_1")
fixture.state.localFailure = false
expect(yield* fixture.automatic).toEqual({ status: "completed", recoveredOverflow: true })
expect(fixture.state.calls).toBe(7)
expect((yield* fixture.load).messages).toContainEqual(
expect.objectContaining({ type: "compaction", summary: "## Objective\n- Recovered locally" }),
)
}),
)
it.live("rejects request-hook route rewrites before provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original user")
yield* fixture.hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.baseURL = "https://another.example/v1"
}),
)
expect(yield* fixture.compact).toMatchObject({
status: "failed",
error: { type: "provider.unsupported-operation" },
})
expect(fixture.state.calls).toBe(0)
}),
)
test("retained user budget counts attachments and drops whole oldest messages", () => {
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
const user = (text: string) =>
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text,
time: { created: DateTime.makeUnsafe(0) },
})
const newest = {
...user("x".repeat(63_000 * 4)),
files: [{ mime: "image/png", data: "aGVsbG8=", source: { type: "inline" as const } }],
}
expect(SessionCompaction.retainUsers([user("old"), newest], model, 64_000)).toEqual([])
expect(
SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
).toHaveLength(1)
})
@@ -1,303 +0,0 @@
import { expect, test } from "bun:test"
import { CompactionPart, LanguageModel, Message, ToolCallPart } from "@opencode-ai/ai"
import { OpenAIResponses } from "@opencode-ai/ai/protocols"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionHistory } from "@opencode-ai/core/session/history"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Model } from "@opencode-ai/schema/model"
import { asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 128_000, output: 4096 },
},
)
const target = SessionProviderContext.provenance(model)
if (!target) throw new Error("Fixture must have a concrete endpoint")
const replacement = [
Message.user("retained request"),
Message.assistant(
CompactionPart.make({ provider: model.model.provider, encrypted: "opaque-checkpoint", id: "cp_1" }),
),
]
const providerContext = SessionProviderContext.encode(target, replacement)
const sessionID = SessionSchema.ID.make("ses_provider_context")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)
const setup = Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "provider-context",
version: "test",
})
const state = { value: "initial instructions" }
const instructions = Instructions.make({
key: Instructions.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => state.value),
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
const prepare = InstructionState.prepare(database.db, bus, instructions, sessionID)
const prompt = Effect.fnUntraced(function* (text: string) {
const id = SessionMessage.ID.create()
yield* inbox.admit({ id, sessionID, item: { type: "user", payload: { text }, delivery: "steer" } })
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
return id
})
const compact = (context?: SessionProviderContext.Info) =>
bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: context ? "" : "local summary",
recent: "",
providerContext: context,
})
const load = (boundary: SessionHistory.Boundary) =>
SessionHistory.entriesForRunner(database.db, sessionID, instructions, boundary)
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
})
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
const messages = [
...replacement,
Message.assistant(ToolCallPart.make({ id: "call_1", name: "read", input: { path: "file" } })),
Message.tool({ id: "call_1", name: "read", result: { text: "result" } }),
Message.user({ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) }),
]
const context = SessionProviderContext.encode(providerContext.provenance, messages)
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(SessionProviderContext.Info))(JSON.stringify(context))
const decoded = SessionProviderContext.decode(stored)
expect(decoded.slice(0, -1)).toEqual(messages.slice(0, -1))
expect(decoded.at(-1)?.content).toEqual([{ type: "media", mediaType: "image/png", data: "AQID" }])
const optionalMetadata = SessionProviderContext.encode(providerContext.provenance, [
Message.make({
role: "user",
content: [
{ type: "text", text: "attachment", metadata: { attachment: { name: undefined, source: { type: "inline" } } } },
],
providerMetadata: { openai: { itemId: undefined, type: "message", status: undefined, phase: undefined } },
}),
])
expect(SessionProviderContext.decode(optionalMetadata)[0]).toMatchObject({
providerMetadata: { openai: { type: "message" } },
content: [{ metadata: { attachment: { source: { type: "inline" } } } }],
})
expect(() =>
SessionProviderContext.decode({
...context,
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai" }] }],
}),
).toThrow()
})
test("compatibility uses the actual deployment and endpoint rather than a catalog alias or variant", () => {
expect(
SessionProviderContext.compatible(
providerContext.provenance,
SessionProviderContext.provenance({
...model,
ref: { ...model.ref, id: Model.ID.make("alias"), variant: Model.VariantID.make("high") },
}),
),
).toBe(true)
for (const changed of [
{ ...model, model: LanguageModel.update(model.model, { id: "other-deployment" }) },
{
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { baseURL: "https://another.example/v1?api-key=secret" } }),
}),
},
{ ...model, model: LanguageModel.update(model.model, { route: model.model.route.with({ id: "other-route" }) }) },
])
expect(
SessionProviderContext.compatible(providerContext.provenance, SessionProviderContext.provenance(changed)),
).toBe(false)
const privateEndpoint = SessionProviderContext.provenance({
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { baseURL: "https://user:secret@example.com/v1?api-key=secret" } }),
}),
})
expect(JSON.stringify(privateEndpoint)).not.toContain("secret")
expect(
SessionProviderContext.provenance({
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { path: () => "/dynamic" } }),
}),
}),
).toBeUndefined()
expect(SessionProviderContext.compatible(providerContext.provenance, undefined)).toBe(false)
})
it.effect(
"advances the native instruction epoch and omits superseded chronological updates after durable replay and provider switches",
() =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("original request")
s.state.value = "changed instructions"
yield* s.prepare
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
const completed = yield* s.compact(providerContext)
s.state.value = "newest instructions"
yield* s.prepare
yield* s.prompt("continue")
const verify = Effect.gen(function* () {
expect(
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toMatchObject({
epoch_start: completed.durable.seq,
initial_values: { "test/context": Instructions.hash("changed instructions") },
current_values: { "test/context": Instructions.hash("newest instructions") },
})
const native = yield* s.load(target)
expect(native.initial).toBe("changed instructions")
expect(
toLLMMessages(
native.entries.map((entry) => entry.message),
model.ref,
),
).toEqual([
...replacement,
Message.system("newest instructions"),
expect.objectContaining({ role: "user", content: [Message.text("continue")] }),
])
for (const incompatible of [
"local" as const,
{ ...providerContext.provenance, modelID: "other" },
{ ...providerContext.provenance, provider: "other" },
]) {
const expanded = yield* s.load(incompatible)
expect(expanded.initial).toBe("changed instructions")
expect(
toLLMMessages(
expanded.entries.map((entry) => entry.message),
model.ref,
).map((message) => message.content),
).toEqual([
[Message.text("original request")],
[Message.text("newest instructions")],
[Message.text("continue")],
])
}
const preview = yield* SessionHistory.preview(s.db, sessionID, s.instructions, target)
expect(preview.initial).toBe("changed instructions")
expect(preview.messages).toEqual(native.entries.map((entry) => entry.message))
const store = yield* SessionStore.Service
expect((yield* store.messages({ sessionID })).map((message) => message.type)).toEqual([
"user",
"system",
"compaction",
"system",
"user",
])
})
yield* verify
const recorded = yield* s.db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
expect(recorded.filter((event) => event.data.providerContext !== undefined)).toHaveLength(1)
yield* s.bus.remove(sessionID)
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
for (const event of recorded)
yield* s.bus.replay({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
})
yield* verify
}),
)
it.effect("falls back to an earlier compatible native or local checkpoint", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("before local")
s.state.value = "local baseline"
yield* s.prepare
yield* s.compact()
yield* s.prompt("after local")
yield* s.compact(providerContext)
yield* s.prompt("after native")
s.state.value = "new native baseline"
yield* s.prepare
yield* s.compact({ ...providerContext, provenance: { ...providerContext.provenance, modelID: "other" } })
s.state.value = "post-epoch update"
yield* s.prepare
const native = yield* s.load(target)
expect(native.initial).toBe("new native baseline")
expect(native.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "system"])
expect(native.entries[0]?.message).toMatchObject({ providerContext })
expect(
toLLMMessages(
native.entries.map((entry) => entry.message),
model.ref,
).filter((message) => message.role === "system"),
).toEqual([Message.system("post-epoch update")])
const local = yield* s.load("local")
expect(local.initial).toBe("new native baseline")
expect(local.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "user", "system"])
expect(local.entries[0]?.message).toMatchObject({ summary: "local summary" })
}),
)
it.effect("rejects malformed persisted native windows instead of silently dropping them", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.compact({ ...providerContext, messages: [{ role: "invalid", content: [] }] })
expect(yield* SessionHistory.load(s.db, sessionID, target).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageDecodeError",
})
const store = yield* SessionStore.Service
expect(yield* store.context(sessionID).pipe(Effect.flip)).toMatchObject({ _tag: "Session.MessageDecodeError" })
}),
)
+1 -174
View File
@@ -1,8 +1,6 @@
import { describe, expect, test } from "bun:test"
import {
AIError,
CompactionPart,
CompactionCheckpointResponse,
HttpContext,
LLMEvent,
LLMRequest,
@@ -42,7 +40,6 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -195,7 +192,7 @@ test("does not apply an ineligible tier without base pricing", () => {
).toBe(Money.USD.zero)
})
const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"]) => {
const makeRunnerState = () => {
let toolBarrier: ToolBarrier | undefined
const releaseTools = (barrier: ToolBarrier) =>
Effect.sync(() => {
@@ -203,7 +200,6 @@ const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"])
}).pipe(Effect.andThen(Deferred.succeed(barrier.release, undefined)), Effect.asVoid)
return {
currentModel: model,
compaction,
modelResolveHook: Effect.void,
systemBaseline: "Initial context",
systemRemoved: false,
@@ -323,7 +319,6 @@ const layer = Layer.unwrap(
cost: [],
limit: modelLimits.get(String(selected.id)) ?? defaultModelLimit,
variant: session.model?.variant,
compaction: state.compaction,
})
}),
),
@@ -1440,92 +1435,6 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.inbox).toEqual([])
})
scenario(
"restores installed native context with auto disabled and preserves it across fork and revert",
function* (s) {
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => editor.configure({ auto: false }))
yield* s.runPrompt("Original request")
s.systemBaseline = "Checkpoint instructions"
yield* s.runPrompt("Before checkpoint")
const target = SessionProviderContext.provenance({
model: s.currentModel,
ref: Model.Ref.make({
id: Model.ID.make(s.currentModel.id),
providerID: Provider.ID.make(s.currentModel.provider),
}),
})
if (!target) throw new Error("Expected concrete fixture endpoint")
const replacement = [
Message.assistant(CompactionPart.make({ provider: s.currentModel.provider, encrypted: "checkpoint" })),
]
const providerContext = SessionProviderContext.encode(target, replacement)
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: "",
recent: "",
providerContext,
})
const checkpoint = (yield* s.messages).find((message) => message.type === "compaction")
if (!checkpoint) throw new Error("Expected checkpoint")
s.systemBaseline = "Newest instructions"
const after = yield* s.runPrompt("After checkpoint")
const continued = s.requests.at(-1)
if (!continued) throw new Error("Expected continuation request")
expect(continued.messages[0]).toEqual(replacement[0])
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
expect(systemTexts(continued)).toEqual(["Newest instructions"])
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[0])
expect(s.requests.at(-1)?.system.map((part) => part.text)).toContain("Newest instructions")
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
Message.system("Newest instructions"),
])
expect(
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
).toMatchObject({ providerContext })
const original = s.currentModel
s.currentModel = LanguageModel.update(original, { id: "different-deployment" })
yield* s.session.prompt({ sessionID: forked.id, text: "Switched fork", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
Message.system("Newest instructions"),
])
s.currentModel = original
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: checkpoint.id })
yield* s.runPrompt("After revert")
expect(
s.requests
.at(-1)
?.messages.flatMap((message) => message.content)
.some((part) => part.type === "compaction"),
).toBe(false)
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
expect(
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
).toMatchObject({ providerContext })
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.baseURL = "https://another-deployment.example/v1"
}),
)
const before = s.requests.length
yield* s.session.prompt({ sessionID: forked.id, text: "Changed route", resume: false })
expect(yield* s.session.resume(forked.id).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
expect(s.requests).toHaveLength(before)
},
)
scenario("seeds a fork with the parent's newest instruction values", function* (s) {
yield* s.runPrompt("First")
s.systemBaseline = "Changed context"
@@ -2769,88 +2678,6 @@ describe("SessionRunnerLLM", () => {
})
})
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
agent.steps = 2
}),
)
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("First real request")
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
responseID: `resp_${encrypted}`,
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted },
})
yield* s.llm.push(
checkpoint("first"),
TestLLM.tool("echo-native", "echo", { text: "continue" }),
TestLLM.text("No usage yet", "no-usage"),
)
yield* s.runPrompt("Second real request")
expect(s.requests).toHaveLength(4)
expect(s.requests[2].toolChoice).not.toEqual({ type: "none" })
expect(s.executions).toEqual(["continue"])
expect(JSON.stringify(s.requests[2].messages)).toContain("first")
const installed = (yield* s.messages).filter((message) => message.type === "compaction")
expect(installed).toMatchObject([{ status: "completed", reason: "auto", providerContext: { version: 1 } }])
// New input without a post-checkpoint usage anchor must not retrigger compaction.
yield* s.llm.push(TestLLM.textWithUsage("Measured", "measured", 10_000))
yield* s.runPrompt("Third real request")
expect(s.requests).toHaveLength(5)
yield* s.llm.push(checkpoint("second"), TestLLM.textWithUsage("Continued", "continued", 10_000))
yield* s.runPrompt("Fourth real request")
expect(s.requests).toHaveLength(7)
expect(userTexts(s.requests[6])).toEqual([
"First real request",
"Second real request",
"Third real request",
"Fourth real request",
])
expect(JSON.stringify(s.requests[6].messages)).not.toContain('"encrypted":"first"')
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => editor.configure({ auto: false }))
yield* replaySessionProjection(sessionID)
yield* s.llm.push(TestLLM.text("Disabled auto still replays", "disabled"))
yield* s.runPrompt("Fifth real request")
expect(s.requests).toHaveLength(8)
expect(JSON.stringify(s.requests[7].messages)).toContain('"encrypted":"second"')
})
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
CompactionCheckpointResponse.make({
responseID: "resp_native",
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted: "native-window" },
}),
TestLLM.text("After native", "after-native"),
)
yield* s.runPrompt("Before native checkpoint")
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered original history", "local-recovery"),
TestLLM.text("Recovered", "recovered"),
)
yield* s.runPrompt("Overflow request")
expect(s.requests).toHaveLength(3)
expect(JSON.stringify(s.requests[0].messages)).toContain("native-window")
expect(JSON.stringify(s.requests[1].messages)).not.toContain("native-window")
expect(userTexts(s.requests[1])).toContain("Original durable request")
expect(userTexts(s.requests[1]).at(-1)).toBe(SessionCompaction.buildPrompt(false))
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recovered original history" },
{ type: "assistant" },
])
})
scenario("does not compact immediately when the advertised output limit fills the context", function* (s) {
s.currentModel = fullOutputModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500))
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
FileAccess.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -91,7 +91,7 @@ const withTool = <A, E, R>(
return yield* body(registry)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, editToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
read: (target, range) =>
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [
Tool.node,
FileAccess.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
read: (target, range) =>
+66 -73
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, Result } from "effect"
import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -12,7 +12,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { Tool } from "@opencode-ai/core/tool"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
@@ -30,7 +30,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
FileAccess.node,
LocationMutation.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -47,7 +47,7 @@ const readCalls: {
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: AbsolutePath[] = []
let readDefect: unknown
let resolveFailure: unknown
let directoryEntries: string[] = []
let directoryEntryDetails: Environment.DirEntry[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
@@ -69,7 +69,7 @@ const reader = Layer.succeed(
},
read: (input, resource, page = {}) => {
readCalls.push({ input, page })
if (readDefect !== undefined) return Effect.die(readDefect)
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readOverride) return readOverride(input, resource, page)
if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult)
@@ -77,14 +77,13 @@ const reader = Layer.succeed(
}),
)
let allow = true
let deniedResource: string | undefined
const permission = permissionLayer({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(
Effect.andThen(
allow && !input.resources.some((resource) => resource === deniedResource)
allow
? Effect.void
: Effect.fail(
new Permission.BlockedError({
@@ -113,6 +112,30 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
)
const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
const directory = path.dirname(absolute)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
absolute,
resource,
externalDirectory: external
? {
action: "external_directory" as const,
directory,
resource: externalResource,
save: externalResource,
}
: undefined,
})
},
}),
)
const unavailableImage = Layer.mock(Image.Service, {
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
})
@@ -123,6 +146,7 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
Permission.node.replace(permission),
Config.node.replace(config),
Image.node.replace(imageLayer),
LocationMutation.node.replace(mutation),
FSUtil.node.replace(testFileSystem),
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
@@ -141,8 +165,7 @@ describe("ReadTool", () => {
readCalls.length = 0
listCalls.length = 0
allow = true
deniedResource = undefined
readDefect = undefined
resolveFailure = undefined
directoryEntries = []
directoryEntryDetails = []
readResult = {
@@ -597,21 +620,18 @@ describe("ReadTool", () => {
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
readDefect = new Error("unexpected")
resolveFailure = new Error("unexpected")
const registry = yield* Tool.Service
const exit = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
}).pipe(Effect.exit)
expect(Result.getOrThrow(Exit.findDefect(exit))).toBe(readDefect)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
page: { offset: undefined, limit: undefined },
},
])
expect(
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
}).pipe(Effect.exit),
),
).toBe(true)
}),
)
@@ -701,57 +721,6 @@ describe("ReadTool", () => {
}),
)
it.effect("recovers an external filename without repeating directory approval", () =>
Effect.gen(function* () {
const directory = path.join(path.parse(process.cwd()).root, "external-read")
const requested = path.join(directory, "report final.txt")
const recovered = path.join(directory, "report\u202ffinal.txt")
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
readOverride = (input) =>
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-external-recovery", name: "read", input: { path: requested } },
}),
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{ action: "external_directory", resources: [path.join(directory, "*").replaceAll("\\", "/")] },
{ action: "read", resources: [requested.replaceAll("\\", "/")] },
{ action: "read", resources: [recovered.replaceAll("\\", "/")] },
])
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested), AbsolutePath.make(recovered)])
}),
)
it.effect("does not read a recovered filename denied by its own read rules", () =>
Effect.gen(function* () {
const requested = path.join(process.cwd(), "report final.txt")
const recovered = path.join(process.cwd(), "report\u202ffinal.txt")
deniedResource = path.basename(recovered)
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
readOverride = (input) =>
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-recovery", name: "read", input: { path: requested } },
}),
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
expect(assertions).toMatchObject([
{ action: "read", resources: [path.basename(requested)] },
{ action: "read", resources: [path.basename(recovered)] },
])
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested)])
}),
)
it.effect("does not recover ambiguous files", () =>
Effect.gen(function* () {
const requested = "report final.txt"
@@ -891,6 +860,30 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
resolveFailure = new Error("missing")
expect(
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
}),
)
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.TextPage({
+3 -3
View File
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
+2 -2
View File
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -131,7 +131,7 @@ const shellPluginSupervisor = makeLocationNode({
deps: [
Config.node,
Environment.node,
FileAccess.node,
LocationMutation.node,
Permission.node,
Session.node,
Job.node,
+3 -3
View File
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -79,7 +79,7 @@ const withTool = <A, E, R>(
return yield* body(registry)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
write: (target, content) =>
-1
View File
@@ -353,7 +353,6 @@ export interface DialogSelectOption<Value> {
readonly title: string
readonly value: Value
readonly description?: string
readonly footer?: string
readonly category?: string
readonly disabled?: boolean
}
-85
View File
@@ -14384,9 +14384,6 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14492,9 +14489,6 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16438,9 +16432,6 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17374,36 +17365,6 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17426,9 +17387,6 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18397,9 +18355,6 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18948,46 +18903,6 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
+9 -7
View File
@@ -246,13 +246,15 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.remove",
summary: "Delete session",
description: "Delete a session and its child sessions.",
}),
),
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.remove",
summary: "Delete session",
description: "Delete a session and its child sessions.",
}),
),
)
.add(
HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", {
-2
View File
@@ -41,7 +41,6 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -59,7 +58,6 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
-1
View File
@@ -106,7 +106,6 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+1 -8
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, PositiveInt, statics } from "./schema.js"
import { optional, statics } from "./schema.js"
export const ID = Schema.String.pipe(
Schema.brand("Provider.ID"),
@@ -28,12 +28,6 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
@@ -58,7 +52,6 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
-1
View File
@@ -587,7 +587,6 @@ export namespace Compaction {
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
text: Schema.String,
recent: Schema.String,
},
-2
View File
@@ -1,7 +1,6 @@
export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
import { Location } from "./location.js"
@@ -255,7 +254,6 @@ export const CompactionCompleted = Schema.Struct({
providerState: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
providerContext: SessionProviderContext.Info.pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
@@ -1,24 +0,0 @@
export * as SessionProviderContext from "./session-provider-context.js"
import { Schema } from "effect"
import { Provider } from "./provider.js"
/** Exact producing model/deployment and route identity, never credentials or a connection ID. */
export interface Provenance extends Schema.Schema.Type<typeof Provenance> {}
export const Provenance = Schema.Struct({
providerID: Provider.ID,
provider: Schema.String,
modelID: Schema.String,
route: Schema.String,
protocol: Schema.String,
/** Digest of the configured endpoint; raw URLs and query values are not persisted. */
endpoint: Schema.String,
}).annotate({ identifier: "Session.ProviderContext.Provenance" })
/** Core validates the versioned canonical AI Message[] payload on installation and replay. */
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
version: Schema.Literal(1),
provenance: Provenance,
messages: Schema.Json,
}).annotate({ identifier: "Session.ProviderContext" })
-19
View File
@@ -56,25 +56,6 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
const model = Model.Info.default(Provider.ID.make("custom"), Model.ID.make("gpt-5.6"))
@@ -48,25 +48,3 @@ test("failed steps only override the assistant finish for content filters", () =
})
expect(() => decode({ ...input, finish: "stop" })).toThrow()
})
test("provider compaction context is optional, versioned and JSON-only", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
const encode = Schema.encodeSync(SessionEvent.Compaction.Ended.data)
const local = { sessionID: "ses_context", reason: "manual" as const, text: "summary", recent: "" }
expect(encode({ ...decode(local), providerContext: undefined })).toEqual(local)
const providerContext = {
version: 1 as const,
provenance: {
providerID: "openai",
provider: "openai",
modelID: "deployment",
route: "responses",
protocol: "responses",
endpoint: "digest",
},
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai", encrypted: "opaque" }] }],
}
expect(encode(decode({ ...local, providerContext }))).toEqual({ ...local, providerContext })
expect(() => decode({ ...local, providerContext: { ...providerContext, version: 2 } })).toThrow()
expect(() => decode({ ...local, providerContext: { ...providerContext, messages: [() => "invalid"] } })).toThrow()
})
@@ -388,13 +388,7 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
return (
<div data-component="session-compaction-message">
<div class="py-2">
<TimelineSeparator
label={i18n.t(
props.message.status === "completed" && props.message.providerContext
? "ui.messagePart.providerCompaction"
: "ui.messagePart.compaction",
)}
/>
<TimelineSeparator label={i18n.t("ui.messagePart.compaction")} />
</div>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
+1 -5
View File
@@ -2113,11 +2113,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>
{props.message.status === "completed" && props.message.providerContext
? "Provider compaction"
: "Compaction"}
</text>
<text fg={color()}>Compaction</text>
<Show when={cancelled()}>
<text fg={color()}>· cancelled</text>
</Show>
+2 -2
View File
@@ -135,7 +135,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
() => props.current,
(current) => {
if (props.focusCurrent === false) return
if (current !== undefined) {
if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
@@ -309,7 +309,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
scrollAfterLayout(true, option.value)
return
}
if (current === undefined || props.focusCurrent === false) return
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
@@ -82,10 +82,10 @@ async function renderSelect(
return app
}
async function mountSelect<T>(
async function mountSelect(
root: string,
initial: DialogSelectOption<T>[],
current?: T,
initial: DialogSelectOption<string>[],
current?: string,
focusCurrent?: boolean,
select?: { flat?: boolean },
) {
@@ -108,16 +108,13 @@ async function mountSelect<T>(
import("../../../src/ui/toast"),
])
const selected: T[] = []
const moved: T[] = []
let replaceOptions!: (options: DialogSelectOption<T>[]) => void
let replaceCurrent!: (value: T | undefined) => void
const selected: string[] = []
const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
function Harness() {
const [options, setOptions] = createSignal(initial)
replaceOptions = setOptions
const [value, setCurrent] = createSignal(current)
replaceCurrent = (value) => setCurrent(() => value)
function Fixture() {
const dialog = useDialog()
@@ -126,7 +123,7 @@ async function mountSelect<T>(
<DialogSelect
title="Mutable options"
options={options()}
current={value()}
current={current}
focusCurrent={focusCurrent}
flat={select?.flat}
onMove={(option) => moved.push(option.value)}
@@ -158,7 +155,7 @@ async function mountSelect<T>(
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, replaceCurrent, selected }
return { app, moved, replaceOptions, selected }
}
test("budgets option content for constrained and full-width large dialogs", () => {
@@ -445,58 +442,6 @@ test("keeps the current option selected when options reorder", async () => {
}
})
test.each([false, 0, "", null, "current", undefined])("focuses current %p when it changes", async (current) => {
await using tmp = await tmpdir()
const select = await mountSelect<string | typeof current>(
tmp.path,
[
{ title: "First", value: "first" },
{ title: "Current", value: current === undefined ? "current" : current },
],
"first",
)
try {
select.replaceCurrent(current)
await select.app.waitForVisualIdle()
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual([current === undefined ? "first" : current])
} finally {
select.app.renderer.destroy()
}
})
test.each([false, 0, "", null, "current", undefined])(
"restores current %p after clearing a filter",
async (current) => {
await using tmp = await tmpdir()
const select = await mountSelect<string | typeof current>(
tmp.path,
[
...Array.from({ length: 6 }, (_, index) => ({ title: `Item-${index}`, value: `item-${index}` })),
{ title: "Current", value: current === undefined ? "current" : current },
],
current,
)
try {
await select.app.mockInput.typeText("Item-0")
await select.app.waitForFrame((frame) => frame.includes("Item-0") && !frame.includes("Item-1"))
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitForVisualIdle()
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual([current === undefined ? "item-0" : current])
expect(select.app.captureCharFrame().includes("Current")).toBe(current !== undefined)
} finally {
select.app.renderer.destroy()
}
},
)
test("shows no-match and still closes after a flat filter goes empty", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { MouseButton } from "@opentui/core"
import { expect, setSystemTime, test } from "bun:test"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
@@ -182,21 +182,16 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
// Keep click timing independent of renderer delays on busy CI runners.
setSystemTime(new Date(1_000))
await app.mockMouse.doubleClick(5, 0)
expect(promoted).toEqual([])
setSystemTime(new Date(2_000))
await app.mockMouse.click(40, 0)
expect(active()).toBe("second")
expect(promoted).toEqual([])
setSystemTime(new Date(2_100))
await app.mockMouse.click(40, 0)
expect(promoted).toEqual(["second"])
} finally {
setSystemTime()
app.renderer.destroy()
}
})
-1
View File
@@ -104,7 +104,6 @@ const source = {
"ui.messagePart.review.title": "Review your answers",
"ui.messagePart.questions.dismissed": "Questions dismissed",
"ui.messagePart.compaction": "Session compacted",
"ui.messagePart.providerCompaction": "Session compacted by provider",
"ui.messagePart.context.details": "Details",
"ui.messagePart.context.read.one": "{{count}} read",
"ui.messagePart.context.read.other": "{{count}} reads",
@@ -83,10 +83,6 @@ For most permissions, you can use an object to apply different actions based on
"edit": {
"*": "deny",
"packages/web/src/content/docs/*.mdx": "allow"
},
"webfetch": {
"*": "ask",
"https://en.wikipedia.org/*": "allow"
}
}
}
@@ -163,7 +159,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards:
- `lsp` — running LSP queries (currently non-granular)
- `question` — asking the user questions during execution
- `webfetch` — fetching a URL (matches the URL)
- `websearch` — web search
- `websearch` — web search (matches the query)
- `external_directory` — triggered when a tool touches paths outside the project working directory
- `doom_loop` — triggered when the same tool call repeats 3 times with identical input
-85
View File
@@ -14384,9 +14384,6 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14492,9 +14489,6 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16438,9 +16432,6 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17374,36 +17365,6 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17426,9 +17387,6 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18397,9 +18355,6 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18948,46 +18903,6 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
-85
View File
@@ -14384,9 +14384,6 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14492,9 +14489,6 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16438,9 +16432,6 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17374,36 +17365,6 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17426,9 +17387,6 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18397,9 +18355,6 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18948,46 +18903,6 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
+8 -63
View File
@@ -18,19 +18,18 @@ the size of the final system prompt, messages, and advertised tools. It starts
compaction when:
```text
estimated tokens >= min(input limit - buffer, context limit - max(output reserve, buffer))
estimated tokens > context limit - max(requested output tokens, buffer)
```
The estimate uses the latest model response's input usage plus output and newer
content. Without usage, it estimates text, media, instructions, and tools locally.
The output reserve is capped at 32,000 tokens; an absent input limit does not
constrain the ceiling. Successful compaction rebuilds the request without promoting
input again or spending another agent step.
The estimate is approximate: V2 JSON-serializes the request and assumes four
characters per token. When compaction succeeds, V2 rebuilds the request from
the new checkpoint and retries the step without promoting the input again.
V2 also recognizes provider errors classified as context overflow. If an
overflow occurs before the provider produces assistant output or other retry
evidence, V2 can compact and retry that step once. This recovery is attempted
only when `auto` is enabled. A second overflow after recovery is returned as an error.
even when `auto` is `false`; `auto` controls only the preflight size check. A
second overflow after recovery is returned as an error.
## Manual compaction
@@ -76,60 +75,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
preserves more recent detail but leaves less room for future work. Larger
`buffer` triggers preflight compaction earlier.
## Provider compaction
By default, compaction generates a local text summary. To use the selected
provider's native compaction operation for automatic and manual requests, set a
provider policy. An individual model's policy replaces the entire provider policy:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"compaction": { "mode": "provider", "threshold": 120000 },
"models": {
"gpt-5.4-mini": { "compaction": { "mode": "provider" } },
"gpt-4.1": { "compaction": { "mode": "local" } },
},
},
},
}
```
- `threshold` is an optional positive integer in provider mode. Omit it to use the
selected model's usable input ceiling above. A configured threshold is clamped
to that ceiling. In this example, `gpt-5.4-mini` uses its own ceiling, not 120,000.
- Scheduling uses the normal safe session step boundaries, not in-band provider
context management. `compaction.auto: false` disables all new automatic work.
- After installing a native checkpoint, automatic checks wait for a fresh model
usage anchor. Encrypted checkpoint bytes are not a meaningful token count.
- OpenAI Responses uses a streamed compaction trigger when the route supports it.
Endpoint-only routes use their standalone compaction endpoint. Deployment/model
support can vary. Transient provider failures retry under the same session retry
policy and plugin hook as other requests; nothing is installed until a checkpoint
is returned.
- A known automatic context overflow uses local recovery over the durable original
history, re-expanding native checkpoints. This applies both to ordinary model
calls and native compaction rejection. If local recovery fails, the prior
checkpoint remains intact and the error surfaces. Authentication, rate limits,
cancellation, and other failures do not trigger local fallback. Manual native
compaction also surfaces errors without fallback.
- Unsupported routes are rejected during model resolution. Configure custom
endpoints through provider/model `settings.baseURL`, not a `model.request` hook;
native compaction rejects endpoint rewrites by that hook.
- Trigger checkpoints retain whole, real user messages and attachments up to the
`compaction.tokens` budget, including users retained across earlier native
compactions. Synthetic guidance is not retained as user input. Endpoint results
are stored as the provider returned them. Neither path fabricates a text summary.
Keep `threshold` comfortably above `tokens` plus the system prompt and tools, or
every step will compact again as soon as the next response reports usage.
- Successful native checkpoints advance the instruction epoch and are replayed
only with a matching provider, model, protocol, and endpoint. Switching to an
incompatible route reuses an earlier compatible checkpoint or retained transcript.
Disabling automatic compaction does not remove an installed checkpoint.
## Local checkpoint contents
## Checkpoint contents
V2 uses the session's selected agent, model, and variant to generate the summary.
The request reuses the normal instructions, tool definitions, and structured
@@ -173,8 +119,7 @@ read. See [Instructions](/instructions) for source ordering and update behavior.
## Current limitations
- Compaction requires a resolvable model. Automatic scheduling needs a positive
catalog context limit; manual and overflow recovery do not.
- Compaction requires a resolvable model with a positive catalog context limit.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
its output allowance, the model returns no summary, or the provider fails.
+1 -23
View File
@@ -354,29 +354,7 @@ Control automatic context compaction and how much recent context it preserves.
}
```
Local summaries remain the default. Opt into native provider compaction for both
automatic and manual requests with a provider or model policy:
```jsonc
{
"providers": {
"openai": {
"compaction": { "mode": "provider", "threshold": 120000 },
"models": {
"gpt-4.1": { "compaction": { "mode": "local" } },
},
},
},
}
```
A model policy replaces the whole provider policy. The optional positive integer
`threshold` defaults to the selected model's usable input budget and cannot exceed
its safe ceiling. Provider checkpoints keep recent user messages within the same
`compaction.tokens` budget that local summaries use for their retained tail.
Top-level `compaction.auto: false` disables new automatic compaction without
discarding installed checkpoints. See the [compaction guide](/compaction) for
budgeting and overflow recovery.
See the [compaction guide](/compaction) for automatic context management.
### Session warming