mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9d06c1ca |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}))
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] })
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,6 @@ export const layer = Layer.effect(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
"SessionRunnerModel.UnsupportedCompactionError",
|
||||
],
|
||||
(error) => {
|
||||
const mapped: Error = input.model
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"})`,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
+72
-65
@@ -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",
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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" })
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user