Compare commits

..
Author SHA1 Message Date
Kit Langton 82b13ca184 effect(util): make Process.run/text/lines return Effect
Migrate the run/text/lines helpers in util/process.ts to Effect-returning
functions that yield ChildProcessSpawner and use ChildProcess.make
internally. The legacy Promise-based behaviour stays available under
runPromise/textPromise/linesPromise for non-Effect callers; these are
thin wrappers over the original spawn() path so AbortSignal and timeout
semantics are preserved.

server.ts now runs Process.run/Process.text through a private
ManagedRuntime backed by CrossSpawnSpawner.defaultLayer and the shared
memoMap, since LSP.spawn callbacks execute inside Effect.promise blocks.

Other Process.run/text/lines call sites are renamed to the *Promise
variants and otherwise left untouched for follow-up migrations.
2026-05-12 16:31:38 -04:00
56 changed files with 827 additions and 1634 deletions
+7 -1
View File
@@ -240,7 +240,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
const status = createMemo(
() =>
sync.data.session_status[params.id ?? ""] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -208,9 +208,6 @@ export function createChildStoreManager(input: {
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id].type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
@@ -46,7 +46,6 @@ export type State = {
session_status: {
[sessionID: string]: SessionStatus
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
}
@@ -166,7 +166,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
})
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.session.id)
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
(message) =>
message.role === "assistant" &&
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
)
const status = sessionStore.session_status[props.session.id]
return (
pending !== undefined ||
status?.type === "busy" ||
status?.type === "retry" ||
(status !== undefined && status.type !== "idle")
)
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
@@ -305,7 +305,7 @@ export const SortableProject = (props: {
const isWorking = createMemo(() =>
dirs().some((directory) => {
const [store] = globalSync.child(directory, { bootstrap: false })
return Object.keys(store.session_status).some((id) => store.session_working(id))
return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry")
}),
)
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
+6 -1
View File
@@ -1496,7 +1496,12 @@ export default function Page() {
return out
})
const busy = (sessionID: string) => sync.data.session_working(sessionID)
const busy = (sessionID: string) => {
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
return (sync.data.message[sessionID] ?? []).some(
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
}
const queuedFollowups = createMemo(() => {
const id = params.id
@@ -57,7 +57,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked())
const status = createMemo(() => {
const id = params.id
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const busy = createMemo(() => status().type !== "idle")
const live = createMemo(() => busy() || blocked())
const [store, setStore] = createStore({
responding: undefined as string | undefined,
@@ -75,6 +75,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
const messages = () => {
const id = params.id
if (!id) return []
@@ -288,7 +290,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
if (sync.data.session_working(params.id ?? "")) {
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
+1 -5
View File
@@ -1,5 +1,4 @@
import { Config } from "@/config/config"
import { ConfigPermission } from "@/config/permission"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
@@ -118,10 +117,7 @@ export const layer = Layer.effect(
},
})
// Convert permission layers to rulesets and merge them
// Each layer's rules come after the previous, so later configs override earlier ones
const layers = ConfigPermission.toLayers(cfg.permission)
const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
+3 -3
View File
@@ -29,14 +29,14 @@ export const PrCommand = effectCmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
const checkout = yield* Effect.promise(() =>
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
Process.runPromise(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
)
if (checkout.code !== 0) {
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
}
const prInfoResult = yield* Effect.promise(() =>
Process.text(
Process.textPromise(
[
"gh",
"pr",
@@ -80,7 +80,7 @@ export const PrCommand = effectCmd({
UI.println(`Importing session...`)
const importResult = yield* Effect.promise(() =>
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
Process.textPromise(["opencode", "import", sessionUrl], { nothrow: true }),
)
if (importResult.code === 0) {
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
+1 -4
View File
@@ -124,7 +124,6 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* spinner.stop("Login successful")
@@ -157,7 +156,6 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* Prompt.log.success("Login successful")
@@ -193,11 +191,10 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
yield* put(saveProvider, {
type: "api",
key: result.key ?? apiKey,
...(Object.keys(merged).length ? { metadata: merged } : {}),
...metadata,
})
yield* Prompt.log.success("Login successful")
}
@@ -50,7 +50,7 @@ export async function read(): Promise<Content | undefined> {
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
await Process.runPromise(
[
"osascript",
"-e",
@@ -79,7 +79,7 @@ export async function read(): Promise<Content | undefined> {
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
const base64 = await Process.textPromise(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
@@ -91,11 +91,11 @@ export async function read(): Promise<Content | undefined> {
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
const wayland = await Process.runPromise(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
const x11 = await Process.runPromise(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
@@ -118,7 +118,7 @@ const getCopyMethod = lazy(async () => {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
await Process.runPromise(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
+1 -1
View File
@@ -192,7 +192,7 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
const result = await Process.runPromise(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
nothrow: true,
})
if (result.code !== 0) {
+6 -23
View File
@@ -55,16 +55,6 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
// Accumulate permission layers for later merging as rulesets.
// This preserves the ordering semantics: later rules override earlier rules.
// Each layer keeps the raw shape the user wrote on disk; consumers should use
// ConfigPermission.toLayers to normalise.
if (source.permission) {
merged.permission = [
...ConfigPermission.toLayers(target.permission),
...ConfigPermission.toLayers(source.permission),
]
}
return merged
}
@@ -238,12 +228,7 @@ export const Info = Schema.Struct({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(
Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]),
).annotate({
description:
"Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.",
}),
permission: Schema.optional(ConfigPermission.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
attachment: Schema.optional(ConfigAttachment.Info).annotate({
description: "Attachment processing configuration, including image size limits and resizing behavior",
@@ -276,10 +261,10 @@ export const Info = Schema.Struct({
}),
tail_turns: Schema.optional(NonNegativeInt).annotate({
description:
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
}),
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
@@ -719,12 +704,11 @@ export const layer = Layer.effect(
}
if (Flag.OPENCODE_PERMISSION) {
const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info
result.permission = [...ConfigPermission.toLayers(result.permission), envPermission]
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: ConfigPermission.Info = {}
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch") {
@@ -733,8 +717,7 @@ export const layer = Layer.effect(
}
perms[tool] = action
}
// Tools permissions come before other permissions (they can be overridden)
result.permission = [perms, ...ConfigPermission.toLayers(result.permission)]
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
+1 -1
View File
@@ -56,7 +56,7 @@ export async function readManagedPreferences() {
for (const plist of paths) {
if (!existsSync(plist)) continue
log.info("reading macOS managed preferences", { path: plist })
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
const result = await Process.runPromise(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
if (result.code !== 0) {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
@@ -56,11 +56,3 @@ export const Info = InputSchema.pipe(
).annotate({ identifier: "PermissionConfig" })
type _Info = Schema.Schema.Type<typeof InputObject>
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
// Top-level config accepts either a single permission object or an array of
// layered configs. Internal merging produces arrays; this helper normalises
// either shape into the array form expected by consumers.
export function toLayers(value: Info | Info[] | undefined): Info[] {
if (!value) return []
return Array.isArray(value) ? value : [value]
}
+2 -2
View File
@@ -221,7 +221,7 @@ export const rlang: Info = {
const air = which("air")
if (air == null) return false
const output = await Process.text([air, "--help"], { nothrow: true })
const output = await Process.textPromise([air, "--help"], { nothrow: true })
// Check for "Air: An R language server and formatter"
const firstLine = output.text.split("\n")[0]
@@ -239,7 +239,7 @@ export const uvformat: Info = {
if (await ruff.enabled(context)) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
const output = await Process.runPromise([uv, "format", "--help"], { nothrow: true })
if (output.code === 0) return [uv, "format", "--", "$FILE"]
return false
},
+1 -1
View File
@@ -47,7 +47,7 @@ export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
const p = await Process.runPromise([cmd, "--install-extension", "sst-dev.opencode"], {
nothrow: true,
})
const stdout = p.stdout.toString()
+27 -7
View File
@@ -5,6 +5,9 @@ import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Effect, ManagedRuntime } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Filesystem } from "@/util/filesystem"
import type { InstanceContext } from "../project/instance"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -21,8 +24,17 @@ const pathExists = async (p: string) =>
.stat(p)
.then(() => true)
.catch(() => false)
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
// Private runtime so the Effect-returning `Process.run` / `Process.text` can be
// invoked from this file's promise-based spawn callbacks. The `LSP` service
// layer in `lsp.ts` calls these spawn functions inside `Effect.promise(async
// () => ...)`, so a re-entry point is needed. Sharing `memoMap` keeps a single
// `ChildProcessSpawner` instance across the process.
const processRuntime = ManagedRuntime.make(CrossSpawnSpawner.defaultLayer, { memoMap })
const run = (cmd: string[], opts: Process.RunOptions = {}) =>
processRuntime.runPromise(Process.run(cmd, { ...opts, nothrow: true }))
const output = (cmd: string[], opts: Process.RunOptions = {}) =>
processRuntime.runPromise(Process.text(cmd, { ...opts, nothrow: true }))
export interface Handle {
process: ChildProcessWithoutNullStreams
@@ -188,8 +200,12 @@ export const ESLint: Info = {
await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
await processRuntime.runPromise(
Effect.gen(function* () {
yield* Process.run([npmCmd, "install"], { cwd: finalPath })
yield* Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
}),
)
log.info("installed VS Code ESLint server", { serverPath })
}
@@ -570,9 +586,13 @@ export const ElixirLS: Info = {
const cwd = path.join(Global.Path.bin, "elixir-ls-master")
const env = { MIX_ENV: "prod", ...process.env }
await Process.run(["mix", "deps.get"], { cwd, env })
await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
await processRuntime.runPromise(
Effect.gen(function* () {
yield* Process.run(["mix", "deps.get"], { cwd, env })
yield* Process.run(["mix", "compile"], { cwd, env })
yield* Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
}),
)
log.info(`installed elixir-ls`, {
path: elixirLsPath,
@@ -1,411 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createServer } from "http"
const log = Log.create({ service: "plugin.digitalocean" })
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
const DO_API_BASE = "https://api.digitalocean.com"
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
const OAUTH_PORT = 1456
const OAUTH_REDIRECT_PATH = "/auth/callback"
const OAUTH_TOKEN_PATH = "/auth/token"
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
const MAK_NAME_PREFIX = "opencode-oauth"
interface ImplicitTokenPayload {
access_token: string
expires_in: number
state: string
}
interface PendingOAuth {
state: string
resolve: (tokens: ImplicitTokenPayload) => void
reject: (error: Error) => void
}
interface ApiKeyInfo {
uuid: string
name: string
secret_key: string
}
interface RouterEntry {
name: string
uuid?: string
description?: string
}
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
function generateState(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
function redirectUri(): string {
return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
}
function buildAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
response_type: "token",
client_id: DO_OAUTH_CLIENT_ID,
redirect_uri: redirectUri(),
scope: "read write",
state,
})
return `${DO_AUTHORIZE_URL}?${params.toString()}`
}
const HTML_CALLBACK = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>OpenCode - DigitalOcean Authorization</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
.container { text-align: center; padding: 2rem; max-width: 32rem; }
h1 { color: #e8eef9; margin-bottom: 1rem; }
p { color: #9aa9c0; }
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Finishing sign-in...</h1>
<p id="msg">You can close this window once it says you're signed in.</p>
</div>
<script>
(async function() {
const params = new URLSearchParams((window.location.hash || "").slice(1))
const search = new URLSearchParams(window.location.search)
const error = params.get("error") || search.get("error")
const errorDescription = params.get("error_description") || search.get("error_description")
const titleEl = document.getElementById("title")
const msgEl = document.getElementById("msg")
try {
const body = error
? { error, error_description: errorDescription || "" }
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (error) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = errorDescription || error
msgEl.className = "error"
return
}
titleEl.textContent = "Authorization Successful"
msgEl.textContent = "You can close this window and return to OpenCode."
setTimeout(function () { window.close() }, 2000)
} catch (e) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = String(e && e.message ? e.message : e)
msgEl.className = "error"
}
})()
</script>
</body>
</html>`
async function startOAuthServer(): Promise<void> {
if (oauthServer) return
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_CALLBACK)
return
}
if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) {
const chunks: Buffer[] = []
req.on("data", (chunk: Buffer) => chunks.push(chunk))
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8")
let body: Record<string, string> = {}
try {
body = raw ? JSON.parse(raw) : {}
} catch {
body = {}
}
if (!pendingOAuth) {
res.writeHead(409, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "no_pending_oauth" }))
return
}
if (body.error) {
const message = body.error_description || body.error || "OAuth error"
pendingOAuth.reject(new Error(String(message)))
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ ok: true }))
return
}
if (!body.access_token) {
pendingOAuth.reject(new Error("Missing access_token in callback"))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "missing_access_token" }))
return
}
if (body.state !== pendingOAuth.state) {
pendingOAuth.reject(new Error("Invalid state - potential CSRF attack"))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "invalid_state" }))
return
}
const expires = parseInt(body.expires_in || "0", 10)
pendingOAuth.resolve({
access_token: body.access_token,
expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
state: body.state,
})
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ ok: true }))
})
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("digitalocean oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
}
function stopOAuthServer() {
if (!oauthServer) return
oauthServer.close(() => log.info("digitalocean oauth server stopped"))
oauthServer = undefined
}
function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
if (pendingOAuth) {
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
},
5 * 60 * 1000,
)
pendingOAuth = {
state,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${bearer}`,
"Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({ name }),
})
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
}
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
return data.api_key_info
}
async function listRouters(
bearer: string,
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
headers: {
Authorization: `Bearer ${bearer}`,
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
signal: AbortSignal.timeout(10_000),
}).catch(() => undefined)
if (!res) return { ok: false, status: 0 }
if (!res.ok) return { ok: false, status: res.status }
const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined
return { ok: true, routers: body?.model_routers ?? [] }
}
function routerModel(router: RouterEntry, providerID: string): Model {
const id = `router:${router.name}`
return {
id,
providerID,
name: router.name,
family: "digitalocean-inference-routers",
api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" },
status: "active",
headers: {},
options: {},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 128_000, output: 8_192 },
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {},
}
}
function parseRoutersJSON(raw: string | undefined): RouterEntry[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.flatMap((r) =>
r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [],
)
} catch {
return []
}
}
export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "digitalocean",
async models(provider, ctx) {
const baseModels = provider.models
if (ctx.auth?.type !== "api") return baseModels
const metadata = ctx.auth.metadata ?? {}
const oauthAccess = metadata["oauth_access"]
const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10)
const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10)
const cached = parseRoutersJSON(metadata["routers"])
let routers = cached
const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS
const bearerValid = oauthAccess && oauthExpires > Date.now()
if (bearerValid && stale) {
const result = await listRouters(oauthAccess)
if (result.ok) {
routers = result.routers
const updated: Record<string, string> = {
...metadata,
routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))),
routers_fetched_at: String(Date.now()),
}
await input.client.auth
.set({
path: { id: "digitalocean" },
body: { type: "api", key: ctx.auth.key, metadata: updated },
})
.catch((err) => log.warn("failed to persist refreshed routers", { error: err }))
} else if (result.status === 401 || result.status === 403) {
log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status })
} else if (result.status !== 0) {
log.warn("digitalocean router refresh failed", { status: result.status })
}
}
const merged: Record<string, Model> = { ...baseModels }
for (const router of routers) {
const id = `router:${router.name}`
if (merged[id]) continue
merged[id] = routerModel(router, "digitalocean")
}
return merged
},
},
auth: {
provider: "digitalocean",
methods: [
{
type: "oauth",
label: "Login with DigitalOcean",
async authorize() {
await startOAuthServer()
const state = generateState()
const callbackPromise = waitForOAuthCallback(state)
return {
url: buildAuthorizeUrl(state),
instructions:
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
method: "auto" as const,
async callback() {
try {
const tokens = await callbackPromise
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
const routerResult = await listRouters(tokens.access_token)
const routers = routerResult.ok ? routerResult.routers : []
if (!routerResult.ok) {
log.warn("digitalocean initial router fetch failed", { status: routerResult.status })
}
return {
type: "success" as const,
provider: "digitalocean",
key: apiKeyInfo.secret_key,
metadata: {
mak_uuid: apiKeyInfo.uuid,
mak_name: apiKeyInfo.name,
oauth_access: tokens.access_token,
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
routers: JSON.stringify(
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
),
routers_fetched_at: String(Date.now()),
},
}
} catch (err) {
log.error("digitalocean oauth callback failed", { error: err })
return { type: "failed" as const }
} finally {
stopOAuthServer()
}
},
}
},
},
{
type: "api",
label: "Paste Model Access Key",
},
],
},
}
}
-2
View File
@@ -19,7 +19,6 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -65,7 +64,6 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
-1
View File
@@ -197,7 +197,6 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
+36 -24
View File
@@ -79,12 +79,10 @@ Rules:
type Turn = {
start: number
end: number
id: MessageID
}
type Tail = {
start: number
id: MessageID
}
type CompletedCompaction = {
@@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
})
}
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
const source = input.tail
? "the conversation history above and the serialized recent conversation tail below"
: "the conversation history above"
const anchor = input.previousSummary
? [
"Update the anchored summary below using the conversation history above.",
`Update the anchored summary below using ${source}.`,
"Preserve still-true details, remove stale details, and merge in the new facts.",
"<previous-summary>",
input.previousSummary,
"</previous-summary>",
].join("\n")
: "Create a new anchored summary from the conversation history above."
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
: `Create a new anchored summary from ${source}.`
const tail = input.tail
? [
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
"<recent-conversation-tail>",
input.tail,
"</recent-conversation-tail>",
].join("\n")
: undefined
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
}
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
return messages.length ? JSON.stringify(messages, null, 2) : undefined
})
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
return (
input.cfg.compaction?.preserve_recent_tokens ??
@@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) {
result.push({
start: i,
end: messages.length,
id: msg.info.id,
})
}
for (let i = 0; i < result.length - 1; i++) {
@@ -177,7 +196,6 @@ function splitTurn(input: {
if (size > input.budget) continue
return {
start,
id: input.messages[start]!.info.id,
} satisfies Tail
}
return undefined
@@ -244,8 +262,7 @@ export const layer: Layer.Layer<
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
return Token.estimate(JSON.stringify(msgs))
return Token.estimate((yield* serialize(input)) ?? "")
})
const select = Effect.fn("SessionCompaction.select")(function* (input: {
@@ -254,10 +271,10 @@ export const layer: Layer.Layer<
model: Provider.Model
}) {
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
if (limit <= 0) return { head: input.messages, tail: [] }
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail_start_id: undefined }
if (!all.length) return { head: input.messages, tail: [] }
const recent = all.slice(-limit)
const sizes = yield* Effect.forEach(
recent,
@@ -276,7 +293,7 @@ export const layer: Layer.Layer<
const size = sizes[i]
if (total + size <= budget) {
total += size
keep = { start: turn.start, id: turn.id }
keep = { start: turn.start }
continue
}
const remaining = budget - total
@@ -292,10 +309,10 @@ export const layer: Layer.Layer<
break
}
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
if (!keep) return { head: input.messages, tail: [] }
return {
head: input.messages.slice(0, keep.start),
tail_start_id: keep.id,
tail: input.messages.slice(keep.start),
}
})
@@ -406,7 +423,10 @@ export const layer: Layer.Layer<
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const tailMessages = structuredClone(selected.tail)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
const tail = yield* serialize({ messages: tailMessages, model })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
@@ -473,13 +493,6 @@ export const layer: Layer.Layer<
return "stop"
}
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
yield* session.updatePart({
...compactionPart,
tail_start_id: selected.tail_start_id,
})
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
@@ -575,7 +588,6 @@ export const layer: Layer.Layer<
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
text: summary ?? "",
include: selected.tail_start_id,
})
}
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+3 -39
View File
@@ -772,12 +772,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (msg.info.summary && part.type !== "text") continue
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
type: "text",
text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
})
}
if (part.type === "step-start")
@@ -1003,53 +1004,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
export function filterCompacted(msgs: Iterable<WithParts>) {
const result = [] as WithParts[]
const completed = new Set<string>()
let retain: MessageID | undefined
for (const msg of msgs) {
result.push(msg)
if (retain) {
if (msg.info.id === retain) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id)) {
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
if (!part) continue
if (!part.tail_start_id) break
retain = part.tail_start_id
if (msg.info.id === retain) break
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
break
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
completed.add(msg.info.parentID)
}
result.reverse()
const compactionIndex = result.findLastIndex(
(msg) =>
msg.info.role === "user" &&
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
)
const compaction = result[compactionIndex]
const part = compaction?.parts.find(
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
)
const summaryIndex = compaction
? result.findIndex(
(msg, index) =>
index > compactionIndex &&
msg.info.role === "assistant" &&
msg.info.summary &&
msg.info.parentID === compaction.info.id,
)
: -1
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
return [
...result.slice(compactionIndex, summaryIndex + 1),
...result.slice(tailIndex, compactionIndex),
...result.slice(summaryIndex + 1),
]
}
return result
}
+1 -1
View File
@@ -1908,7 +1908,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const sh = Shell.preferred(cfg.shell)
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
shellMatches.map(async ([, cmd]) => (await Process.textPromise([cmd], { shell: sh, nothrow: true })).text),
),
)
let index = 0
+1 -3
View File
@@ -160,13 +160,11 @@ export const layer: Layer.Layer<
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const output = typeof result === "string" ? result : result.output
const metadata = typeof result === "string" ? {} : (result.metadata ?? {})
const attachments = typeof result === "string" ? undefined : result.attachments
const info = yield* agent.get(toolCtx.agent)
const out = yield* truncate.output(output, {}, info)
return {
title: typeof result === "string" ? "" : (result.title ?? ""),
title: "",
output: out.truncated ? out.content : output,
attachments,
metadata: {
...metadata,
truncated: out.truncated,
+2 -2
View File
@@ -7,11 +7,11 @@ export async function extractZip(zipPath: string, destDir: string) {
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
await Process.runPromise(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
await Process.runPromise(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
export * as Archive from "./archive"
+119 -34
View File
@@ -1,6 +1,8 @@
import { type ChildProcess } from "child_process"
import { type ChildProcess as NodeChildProcess } from "child_process"
import launch from "cross-spawn"
import { buffer } from "node:stream/consumers"
import { Effect, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { errorMessage } from "./error"
export type Stdio = "inherit" | "pipe" | "ignore"
@@ -53,7 +55,7 @@ export class RunFailedError extends Error {
}
}
export type Child = ChildProcess & { exited: Promise<number> }
export type Child = NodeChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], opts: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
@@ -110,8 +112,104 @@ export function spawn(cmd: string[], opts: Options = {}): Child {
return child
}
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const proc = spawn(cmd, {
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: NodeChildProcess) {
if (proc.exitCode !== null || proc.signalCode !== null) return
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await runPromise(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
const mergeEnv = (env: NodeJS.ProcessEnv | null | undefined): { env: Record<string, string>; extendEnv: boolean } => {
if (env === null) return { env: {}, extendEnv: false }
if (env === undefined) return { env: {}, extendEnv: true }
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) out[k] = v
}
return { env: out, extendEnv: true }
}
export const run = Effect.fn("Process.run")(function* (cmd: string[], opts: RunOptions = {}) {
if (cmd.length === 0) return yield* Effect.die(new Error("Command is required"))
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const { env, extendEnv } = mergeEnv(opts.env)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
env,
extendEnv,
shell: opts.shell,
stdin: opts.stdin ?? "ignore",
stdout: "pipe",
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all(
[Stream.mkUint8Array(handle.stdout), Stream.mkUint8Array(handle.stderr), handle.exitCode],
{ concurrency: 3 },
)
return {
code: exitCode as number,
stdout: Buffer.from(stdoutBytes),
stderr: Buffer.from(stderrBytes),
} satisfies Result
}),
).pipe(
Effect.catch((err) =>
opts.nothrow
? Effect.succeed({
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(errorMessage(err)),
} satisfies Result)
: Effect.die(err),
),
)
if (result.code === 0 || opts.nothrow) return result
return yield* Effect.die(new RunFailedError(cmd, result.code, result.stdout, result.stderr))
})
export const text = Effect.fn("Process.text")(function* (cmd: string[], opts: RunOptions = {}) {
const out = yield* run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
} satisfies TextResult
})
export const lines = Effect.fn("Process.lines")(function* (cmd: string[], opts: RunOptions = {}) {
const out = yield* text(cmd, opts)
return out.text.split(/\r?\n/).filter(Boolean)
})
// ---------------------------------------------------------------------------
// Promise-returning facades for legacy non-Effect callers.
//
// The new `run` / `text` / `lines` exports above return Effects. These
// wrappers preserve the original Promise-based shape (failing with
// `RunFailedError` on non-zero exit, etc.) and the legacy AbortSignal /
// timeout semantics by using `spawn(...)` directly.
//
// New code should yield the Effect versions. These wrappers exist only to
// avoid touching the remaining non-Effect call sites in this PR.
// ---------------------------------------------------------------------------
export function runPromise(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const spawnOpts = {
cwd: opts.cwd,
env: opts.env,
stdin: opts.stdin,
@@ -119,13 +217,16 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
abort: opts.abort,
kill: opts.kill,
timeout: opts.timeout,
stdout: "pipe",
stderr: "pipe",
})
stdout: "pipe" as const,
stderr: "pipe" as const,
}
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
// Preserve the legacy abort/timeout semantics by using `spawn(...)` directly
// rather than the Effect path (which lacks AbortSignal hooks today).
const proc = spawn(cmd, spawnOpts)
if (!proc.stdout || !proc.stderr) return Promise.reject(new Error("Process output not available"))
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
@@ -137,40 +238,24 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(errorMessage(err)),
}
} satisfies Result
})
.then((out) => {
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: ChildProcess) {
if (proc.exitCode !== null || proc.signalCode !== null) return
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
export async function textPromise(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await runPromise(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
export async function linesPromise(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await textPromise(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
export * as Process from "./process"
@@ -1,50 +1,58 @@
import { test, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Config } from "@/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "@/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
it.instance(
"agent color parsed from project config",
() =>
Effect.gen(function* () {
const cfg = yield* Config.Service.use((svc) => svc.get())
const writeConfig = (dir: string, agent: Config.Info["agent"]) =>
Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent,
}),
),
)
it.live("agent color parsed from project config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
build: { color: "#FFA500" },
plan: { color: "primary" },
})
yield* Effect.gen(function* () {
const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())))
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
}),
{
git: true,
config: {
agent: {
build: { color: "#FFA500" },
plan: { color: "primary" },
},
},
},
}).pipe(provideInstance(dir))
}),
)
it.instance(
"Agent.get includes color from config",
() =>
Effect.gen(function* () {
it.live("Agent.get includes color from config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
plan: { color: "#A855F7" },
build: { color: "accent" },
})
yield* Effect.gen(function* () {
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
expect(plan?.color).toBe("#A855F7")
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
expect(build?.color).toBe("accent")
}),
{
git: true,
config: {
agent: {
plan: { color: "#A855F7" },
build: { color: "accent" },
},
},
},
}).pipe(provideInstance(dir))
}),
)
test("Color.hexToAnsiBold converts valid hex to ANSI", () => {
+7 -171
View File
@@ -3,9 +3,7 @@ import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
import { ConfigPermission } from "@/config/permission"
import { ConfigParse } from "../../src/config/parse"
import { Permission } from "../../src/permission"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Instance } from "../../src/project/instance"
@@ -278,40 +276,6 @@ test("updates global config and omits empty shell key in json", async () => {
}
})
test("global config update preserves single-object permission shape on disk", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
shell: "bash",
permission: { bash: "ask" },
}),
)
},
})
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
try {
// Updating an unrelated key must not rewrite `permission` from object to array form.
await saveGlobal({ shell: "zsh" })
const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>(
path.join(tmp.path, "opencode.json"),
)
expect(written.shell).toBe("zsh")
expect(Array.isArray(written.permission)).toBe(false)
expect(written.permission).toEqual({ bash: "ask" })
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
test("updates global config and omits empty shell key in jsonc", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1749,10 +1713,7 @@ test("permission config preserves user key order", async () => {
directory: tmp.path,
fn: async () => {
const config = await load()
// load() goes through the merge pipeline, producing the layered array form
expect(config.permission).toHaveLength(1)
const perm = (config.permission as ConfigPermission.Info[])[0]
expect(Object.keys(perm)).toEqual([
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
"write",
@@ -1768,129 +1729,6 @@ test("permission config preserves user key order", async () => {
})
})
// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it
test("user top-level catchall overrides inherited bash rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "rm *": "deny" },
},
}),
)
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
"*": "ask",
bash: { "ls *": "allow" },
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const layers = ConfigPermission.toLayers(config.permission)
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
},
})
})
// No top-level catchall, so global bash "rm *" deny is preserved
test("inherited bash rules apply when no user top-level catchall", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "rm *": "deny" },
},
}),
)
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "ls *": "allow" },
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const layers = ConfigPermission.toLayers(config.permission)
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny")
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
},
})
})
// User's bash "*" catchall overrides global "rm *" deny
test("user bash catchall overrides inherited bash rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "rm *": "deny" },
},
}),
)
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "*": "ask", "ls *": "allow" },
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const layers = ConfigPermission.toLayers(config.permission)
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
// Non-bash permissions should use the top-level "*" rule
expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask")
},
})
})
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.schema(
Config.Info,
@@ -1904,8 +1742,7 @@ test("config parser preserves permission order while rejecting unknown top-level
"test",
)
// ConfigParse.schema preserves the raw shape the user wrote
expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"])
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
try {
ConfigParse.schema(Config.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
@@ -2742,12 +2579,11 @@ test("parseManagedPlist parses permission rules", async () => {
),
"test:mobileconfig",
)
const perm = config.permission as ConfigPermission.Info
expect(perm?.["*"]).toBe("ask")
expect(perm?.grep).toBe("allow")
expect(perm?.webfetch).toBe("ask")
expect(perm?.["~/.ssh/*"]).toBe("deny")
const bash = perm?.bash as Record<string, string>
expect(config.permission?.["*"]).toBe("ask")
expect(config.permission?.grep).toBe("allow")
expect(config.permission?.webfetch).toBe("ask")
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
const bash = config.permission?.bash as Record<string, string>
expect(bash?.["rm -rf *"]).toBe("deny")
expect(bash?.["curl *"]).toBe("deny")
})
+6 -19
View File
@@ -1,7 +1,6 @@
import { afterEach, describe, test, expect } from "bun:test"
import { Permission } from "../src/permission"
import { Config } from "@/config/config"
import { ConfigPermission } from "@/config/permission"
import { Instance } from "../src/project/instance"
import { WithInstance } from "../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
@@ -164,9 +163,7 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
@@ -191,9 +188,7 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
@@ -218,9 +213,7 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
@@ -247,9 +240,7 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -287,9 +278,7 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
@@ -320,9 +309,7 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -1,19 +1,15 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
function layer(directory: string, plugins: string[]) {
return ProviderAuth.layer.pipe(
@@ -41,15 +37,13 @@ function layer(directory: string, plugins: string[]) {
}
describe("plugin.auth-override", () => {
it.instance(
"user plugin overrides built-in github-copilot auth",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const fs = yield* AppFileSystem.Service
const pluginDir = path.join(tmp.directory, ".opencode", "plugin")
test("user plugin overrides built-in github-copilot auth", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
yield* fs.writeWithDirs(
await Bun.write(
path.join(pluginDir, "custom-copilot-auth.ts"),
[
"export default {",
@@ -67,26 +61,37 @@ describe("plugin.auth-override", () => {
"",
].join("\n"),
)
},
})
const plain = yield* tmpdirScoped({ git: true })
const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href
const methods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe(
Effect.provide(layer(tmp.directory, [plugin])),
)
const plainMethods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe(
Effect.provide(layer(plain, [])),
provideInstance(plain),
)
await using plain = await tmpdir()
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href
const [methods, plainMethods] = await Promise.all([
provideTestInstance({
directory: tmp.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))),
)
},
}),
{ git: true },
30000,
)
provideTestInstance({
directory: plain.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))),
)
},
}),
])
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000)
})
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
@@ -17,7 +17,7 @@ type Msg = {
}
function run(msg: Msg) {
return Process.run([process.execPath, worker, JSON.stringify(msg)], {
return Process.runPromise([process.execPath, worker, JSON.stringify(msg)], {
cwd: root,
nothrow: true,
})
+1 -1
View File
@@ -12,7 +12,7 @@ const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
function run(input: { file: string; spec: string; target: string; id: string }) {
return Process.run([process.execPath, worker, JSON.stringify(input)], {
return Process.runPromise([process.execPath, worker, JSON.stringify(input)], {
cwd: root,
nothrow: true,
})
@@ -1,16 +1,11 @@
import { afterEach, expect } from "bun:test"
import { afterEach, expect, test } from "bun:test"
import { existsSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
// InstanceBootstrap must run before any code touches the instance —
// originally tracked by PRs #25389 and #25449, now a permanent
@@ -24,64 +19,58 @@ afterEach(async () => {
await disposeAllInstances()
})
const bootstrapFixture = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const marker = path.join(dir, "config-hook-fired")
const pluginFile = path.join(dir, "plugin.ts")
yield* Effect.promise(() =>
Bun.write(
pluginFile,
[
`const MARKER = ${JSON.stringify(marker)}`,
"export default async () => ({",
" config: async () => {",
' await Bun.write(MARKER, "ran")',
" },",
"})",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
),
)
return { directory: dir, marker }
async function bootstrapFixture() {
return tmpdir({
init: async (dir) => {
const marker = path.join(dir, "config-hook-fired")
const pluginFile = path.join(dir, "plugin.ts")
await Bun.write(
pluginFile,
[
`const MARKER = ${JSON.stringify(marker)}`,
"export default async () => ({",
" config: async () => {",
' await Bun.write(MARKER, "ran")',
" },",
"})",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
)
return marker
},
})
}
test("WithInstance.provide runs InstanceBootstrap before fn", async () => {
await using tmp = await bootstrapFixture()
await WithInstance.provide({
directory: tmp.path,
fn: async () => "ok",
})
expect(existsSync(tmp.extra)).toBe(true)
})
it.live("InstanceStore.provide runs InstanceBootstrap before effect", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const store = yield* InstanceStore.Service
test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
await using tmp = await bootstrapFixture()
yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok"))
await cliBootstrap(tmp.path, async () => "ok")
expect(existsSync(tmp.marker)).toBe(true)
}),
)
expect(existsSync(tmp.extra)).toBe(true)
})
it.live("CLI bootstrap runs InstanceBootstrap before callback", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
await using tmp = await bootstrapFixture()
yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok"))
await InstanceRuntime.reloadInstance({ directory: tmp.path })
expect(existsSync(tmp.marker)).toBe(true)
}),
)
it.live("InstanceStore.reload runs InstanceBootstrap", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const store = yield* InstanceStore.Service
yield* store.reload({ directory: tmp.directory })
expect(existsSync(tmp.marker)).toBe(true)
}),
)
expect(existsSync(tmp.extra)).toBe(true)
})
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
@@ -8,14 +8,19 @@ import { ProjectID } from "../../src/project/schema"
import { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { $ } from "bun"
import { tmpdirScoped } from "../fixture/fixture"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
void Log.init({ print: false })
Log.init({ print: false })
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(Project.defaultLayer)),
)
}
function legacySessionID() {
// Global-session migration covers persisted IDs from before prefixed session IDs.
@@ -58,102 +63,91 @@ function ensureGlobal() {
}
describe("migrateFromGlobal", () => {
it.live("migrates global sessions on first project creation", () =>
Effect.gen(function* () {
// 1. Start with git init but no commits — creates "global" project row
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
const projects = yield* Project.Service
const { project: pre } = yield* projects.fromDirectory(tmp)
expect(pre.id).toBe(ProjectID.global)
test("migrates global sessions on first project creation", async () => {
// 1. Start with git init but no commits — creates "global" project row
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 3. Make a commit so the project gets a real ID
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
// 3. Make a commit so the project gets a real ID
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
const { project: real } = yield* projects.fromDirectory(tmp)
expect(real.id).not.toBe(ProjectID.global)
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
expect(real.id).not.toBe(ProjectID.global)
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(real.id)
}),
)
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(real.id)
})
it.live("migrates global sessions even when project row already exists", () =>
Effect.gen(function* () {
// 1. Create a repo with a commit — real project ID created immediately
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
test("migrates global sessions even when project row already exists", async () => {
// 1. Create a repo with a commit — real project ID created immediately
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
yield* Effect.sync(() => ensureGlobal())
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
ensureGlobal()
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
yield* projects.fromDirectory(tmp)
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(project.id)
}),
)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(project.id)
})
it.live("does not claim sessions with empty directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
test("does not claim sessions with empty directory", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
yield* Effect.sync(() => ensureGlobal())
ensureGlobal()
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global }))
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
seed({ id, dir: "", project: ProjectID.global })
yield* projects.fromDirectory(tmp)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectID.global)
}),
)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectID.global)
})
it.live("does not steal sessions from unrelated directories", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
test("does not steal sessions from unrelated directories", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
yield* Effect.sync(() => ensureGlobal())
ensureGlobal()
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global }))
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectID.global)
}),
)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectID.global)
})
})
@@ -1,132 +0,0 @@
import { test, expect, afterEach } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { Env } from "../../src/env"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { makeRuntime } from "../../src/effect/run-service"
const envRuntime = makeRuntime(Env.Service, Env.defaultLayer)
const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v))
async function list() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}),
)
}
const DIGITALOCEAN = ProviderID.make("digitalocean")
const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT
afterEach(() => {
if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT
else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent
})
function injectAuth(metadata: Record<string, string> | undefined) {
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
digitalocean: {
type: "api",
key: "sk_do_test",
...(metadata ? { metadata } : {}),
},
})
}
test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
const providers = await list()
expect(providers[DIGITALOCEAN]).toBeDefined()
expect(providers[DIGITALOCEAN].source).toBe("env")
const baseModel = Object.values(providers[DIGITALOCEAN].models)[0]
expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1")
expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible")
const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:"))
expect(routerEntries.length).toBe(0)
},
})
})
test("digitalocean provider.models surfaces cached routers from auth metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
injectAuth({
routers: JSON.stringify([
{ name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
{ name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
]),
routers_fetched_at: String(Date.now()),
oauth_access: "doo_v1_test",
oauth_expires: String(Date.now() + 60 * 60 * 1000),
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(models["router:my-router"]).toBeDefined()
expect(models["router:my-router"].api.id).toBe("router:my-router")
expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1")
expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(models["router:other-router"]).toBeDefined()
},
})
})
test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
injectAuth({
routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]),
routers_fetched_at: "0",
oauth_access: "doo_v1_expired",
oauth_expires: "1",
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(models["router:stale-router"]).toBeDefined()
},
})
})
test("digitalocean provider.models passes through base models when no auth metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(Object.keys(models).length).toBeGreaterThan(0)
expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0)
},
})
})
@@ -5,10 +5,11 @@
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
// strict `NonNegativeInt` schema then made every load of the message list
// fail to encode, killing Desktop boot for every user with such a row.
import { describe, expect } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { eq } from "drizzle-orm"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
@@ -16,66 +17,81 @@ import { MessageID, PartID } from "../../src/session/schema"
import * as Database from "@/storage/db"
import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const it = testEffect(Session.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function seedNegativeTokenSession() {
return Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
function seedNegativeTokenSession(directory: string) {
return Effect.promise(async () =>
WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
return info.id
})
return info.id
}).pipe(Effect.provide(Session.defaultLayer)),
),
}),
)
}
describe("messages endpoint tolerates legacy negative token counts", () => {
it.instance(
it.live(
"returns 200 even when a step-finish part has tokens.output < 0",
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
const test = yield* TestInstance
const sessionID = yield* seedNegativeTokenSession()
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
{ git: true, config: { formatter: false, lsp: false } },
Effect.acquireRelease(
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const sessionID = yield* seedNegativeTokenSession(tmp.path)
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
),
),
)
})
@@ -926,12 +926,12 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"persists tail_start_id for retained recent turns",
"does not persist tail_start_id for serialized recent turns",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
const keep = yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "third")
yield* createSummaryCompaction(session.id)
@@ -947,18 +947,18 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
)
itCompaction.instance(
"shrinks retained tail to fit preserve token budget",
"does not persist tail_start_id when shrinking serialized tail",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
yield* createUserMessage(session.id, "x".repeat(2_000))
const keep = yield* createUserMessage(session.id, "tiny")
yield* createUserMessage(session.id, "tiny")
yield* createSummaryCompaction(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
@@ -973,7 +973,7 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
)
@@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"falls back to full summary when retained tail media exceeds preserve token budget",
"serializes retained tail media as text in the summary input",
() => {
const stub = llm()
let captured = ""
@@ -1078,15 +1078,16 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("zzzz")
expect(captured).not.toContain("keep tail")
expect(captured).toContain("keep tail")
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
expect(filtered[1]?.info.role).toBe("assistant")
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
},
{ git: true },
@@ -1353,13 +1354,13 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"summarizes only the head while keeping recent tail out of summary input",
"summarizes the head while serializing recent tail into summary input",
() => {
const stub = llm()
let captured = ""
let captured: LLM.StreamInput["messages"] = []
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
captured = input.messages
}),
)
return Effect.gen(function* () {
@@ -1380,10 +1381,15 @@ describe("session.compaction.process", () => {
auto: false,
})
expect(captured).toContain("older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
const head = JSON.stringify(captured.slice(0, -1))
const prompt = JSON.stringify(captured.at(-1))
expect(head).toContain("older context")
expect(head).not.toContain("keep this turn")
expect(head).not.toContain("and this one too")
expect(prompt).toContain("keep this turn")
expect(prompt).toContain("and this one too")
expect(prompt).toContain("recent-conversation-tail")
expect(prompt).not.toContain("What did we do so far?")
}).pipe(withCompaction({ llm: stub.layer }))
},
{ git: true },
@@ -1431,7 +1437,7 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => {
const stub = llm()
stub.push(reply("summary one"))
stub.push(reply("summary two"))
@@ -1462,8 +1468,8 @@ describe("session.compaction.process", () => {
expect(ids).not.toContain(u1.id)
expect(ids).not.toContain(u2.id)
expect(ids).toContain(u3.id)
expect(ids).toContain(u4.id)
expect(ids).not.toContain(u3.id)
expect(ids).not.toContain(u4.id)
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
expect(
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
@@ -1472,7 +1478,7 @@ describe("session.compaction.process", () => {
})
itCompaction.instance(
"ignores previous summaries when sizing the retained tail",
"ignores previous summaries when sizing the serialized tail",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const test = yield* TestInstance
@@ -1511,7 +1517,7 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
)
})
@@ -650,7 +650,7 @@ describe("MessageV2.filterCompacted", () => {
),
)
it.instance("retains original tail when compaction stores tail_start_id", () =>
it.instance("ignores original tail when compaction stores tail_start_id", () =>
withSession(({ session, sessionID }) =>
Effect.gen(function* () {
const u1 = yield* addUser(sessionID, "first")
@@ -696,12 +696,12 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
}),
),
)
it.instance("fork remaps compaction tail_start_id for filterCompacted", () =>
it.instance("fork keeps legacy tail_start_id without replaying the tail", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const created = yield* session.create({})
@@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => {
})
const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id))
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
const forked = yield* session.fork({ sessionID: created.id })
const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id))
@@ -758,14 +758,14 @@ describe("MessageV2.filterCompacted", () => {
expect(tailPart?.type).toBe("compaction")
if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part")
expect(tailPart.tail_start_id).toBeDefined()
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true)
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false)
yield* session.remove(forked.id)
yield* session.remove(created.id)
}),
)
it.instance("retains an assistant tail when compaction starts inside a turn", () =>
it.instance("does not replay an assistant tail when compaction starts inside a turn", () =>
withSession(({ session, sessionID }) =>
Effect.gen(function* () {
const u1 = yield* addUser(sessionID, "first")
@@ -819,7 +819,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4])
}),
),
)
@@ -891,7 +891,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4])
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4])
}),
),
)
@@ -1,219 +1,250 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { MessageV2 } from "../../src/session/message-v2"
import { testEffect } from "../lib/effect"
const projectRoot = path.join(__dirname, "../..")
void Log.init({ print: false })
// Skip tests if no API key is available
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))
const live = hasApiKey ? it.instance : it.instance.skip
// Helper to run test within Instance context
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
return WithInstance.provide({
directory: projectRoot,
fn,
})
}
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
describe("StructuredOutput Integration", () => {
live(
test.skipIf(!hasApiKey)(
"produces structured output with simple schema",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any
expect(output.answer).toBe(4)
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
live(
"produces structured output with nested objects",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
required: ["answer"],
},
retryCount: 0,
},
required: ["company"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
const output = result.info.structured as any
expect(output.answer).toBe(4)
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
live(
test.skipIf(!hasApiKey)(
"produces structured output with nested objects",
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
},
required: ["company"],
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
test.skipIf(!hasApiKey)(
"works with text outputFormat (default)",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
live(
test.skipIf(!hasApiKey)(
"stores outputFormat on user message",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
},
required: ["result"],
},
retryCount: 3,
},
})
})
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
+16 -22
View File
@@ -1,6 +1,5 @@
import { describe, expect, beforeAll, afterAll } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { Discovery } from "../../src/skill/discovery"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
@@ -14,7 +13,7 @@ let downloadCount = 0
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer))
const it = testEffect(Discovery.defaultLayer)
beforeAll(async () => {
await rm(cacheDir, { recursive: true, force: true })
@@ -50,37 +49,36 @@ afterAll(async () => {
})
describe("Discovery.pull", () => {
const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) {
return yield* Discovery.Service.use((s) => s.pull(url))
})
it.live("downloads skills from cloudflare url", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(cacheDir)
const md = path.join(dir, "SKILL.md")
expect(yield* fsys.existsSafe(md)).toBe(true)
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
}
}),
)
it.live("url without trailing slash works", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(yield* fsys.existsSafe(md)).toBe(true)
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
}
}),
)
it.live("returns empty array for invalid url", () =>
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`)
const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`)
expect(dirs).toEqual([])
}),
)
@@ -88,23 +86,20 @@ describe("Discovery.pull", () => {
it.live("returns empty array for non-json response", () =>
Effect.gen(function* () {
// any url not explicitly handled in server returns 404 text "Not Found"
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`)
const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`)
expect(dirs).toEqual([])
}),
)
it.live("downloads reference files alongside SKILL.md", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
expect(agentsSdk).toBeDefined()
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true)
// agents-sdk has reference files per the index
const refDir = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })),
@@ -119,16 +114,15 @@ describe("Discovery.pull", () => {
// clear dir and downloadCount
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
downloadCount = 0
const discovery = yield* Discovery.Service
// first pull to populate cache
const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
const first = yield* pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
const firstCount = downloadCount
expect(firstCount).toBeGreaterThan(0)
// second pull should return same results from cache
const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
const second = yield* pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
@@ -1,15 +1,15 @@
import { afterEach, expect } from "bun:test"
import { $ } from "bun"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import fs from "fs/promises"
import path from "path"
import { Effect, Fiber, Layer } from "effect"
import { Effect, Fiber } from "effect"
import { Snapshot } from "../../src/snapshot"
import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer))
const it = testEffect(Snapshot.defaultLayer)
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
// with path.join (which produces \ on Windows) then normalizes back to /.
@@ -27,13 +27,17 @@ const exec = (cwd: string, command: string[]) =>
if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`)
})
const write = (file: string, content: string | Uint8Array) =>
AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file))
const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file))
const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir))
const rm = (file: string) =>
AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content))
const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
const exists = (file: string) =>
Effect.promise(() =>
fs
.access(file)
.then(() => true)
.catch(() => false),
)
const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true }))
const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
const unique = Math.random().toString(36).slice(2)
@@ -1,28 +1,4 @@
{
"digitalocean": {
"id": "digitalocean",
"env": ["DIGITALOCEAN_ACCESS_TOKEN"],
"npm": "@ai-sdk/openai-compatible",
"api": "https://inference.do-ai.run/v1",
"name": "DigitalOcean",
"doc": "https://docs.digitalocean.com/products/genai-platform/",
"models": {
"openai-gpt-oss-120b": {
"id": "openai-gpt-oss-120b",
"name": "GPT OSS 120B",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"release_date": "2025-08-05",
"last_updated": "2025-08-05",
"modalities": { "input": ["text"], "output": ["text"] },
"open_weights": false,
"cost": { "input": 0.35, "output": 0.75 },
"limit": { "context": 128000, "output": 16384 }
}
}
},
"ollama-cloud": {
"id": "ollama-cloud",
"env": ["OLLAMA_API_KEY"],
@@ -5,7 +5,6 @@ import { pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Tool } from "@/tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -30,7 +29,6 @@ import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
const node = CrossSpawnSpawner.defaultLayer
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
@@ -195,51 +193,6 @@ describe("tool.registry", () => {
}),
)
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "image.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'image tool',",
" args: {},",
" execute: async () => ({",
" output: 'here is an image',",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" }),",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
if (!loaded) throw new Error("custom image tool was not loaded")
const agents = yield* Agent.Service
const result = yield* loaded.execute({}, {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
agent: (yield* agents.defaultInfo()).name,
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
} satisfies Tool.Context)
expect(result.output).toBe("here is an image")
expect(result.attachments).toEqual([
{ type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" },
])
}),
)
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -224,7 +224,7 @@ describe("Truncate", () => {
)
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
const out = await Process.runPromise([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
cwd: ROOT,
})
+8 -8
View File
@@ -10,19 +10,19 @@ function node(script: string) {
describe("util.process", () => {
test("captures stdout and stderr", async () => {
const out = await Process.run(node('process.stdout.write("out");process.stderr.write("err")'))
const out = await Process.runPromise(node('process.stdout.write("out");process.stderr.write("err")'))
expect(out.code).toBe(0)
expect(out.stdout.toString()).toBe("out")
expect(out.stderr.toString()).toBe("err")
})
test("returns code when nothrow is enabled", async () => {
const out = await Process.run(node("process.exit(7)"), { nothrow: true })
const out = await Process.runPromise(node("process.exit(7)"), { nothrow: true })
expect(out.code).toBe(7)
})
test("throws RunFailedError on non-zero exit", async () => {
const err = await Process.run(node('process.stderr.write("bad");process.exit(3)')).catch((error) => error)
const err = await Process.runPromise(node('process.stderr.write("bad");process.exit(3)')).catch((error) => error)
expect(err).toBeInstanceOf(Process.RunFailedError)
if (!(err instanceof Process.RunFailedError)) throw err
expect(err.code).toBe(3)
@@ -34,7 +34,7 @@ describe("util.process", () => {
const started = Date.now()
setTimeout(() => abort.abort(), 25)
const out = await Process.run(node("setInterval(() => {}, 1000)"), {
const out = await Process.runPromise(node("setInterval(() => {}, 1000)"), {
abort: abort.signal,
nothrow: true,
})
@@ -50,7 +50,7 @@ describe("util.process", () => {
const started = Date.now()
setTimeout(() => abort.abort(), 25)
const out = await Process.run(node('process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'), {
const out = await Process.runPromise(node('process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'), {
abort: abort.signal,
nothrow: true,
timeout: 25,
@@ -62,14 +62,14 @@ describe("util.process", () => {
test("uses cwd when spawning commands", async () => {
await using tmp = await tmpdir()
const out = await Process.run(node("process.stdout.write(process.cwd())"), {
const out = await Process.runPromise(node("process.stdout.write(process.cwd())"), {
cwd: tmp.path,
})
expect(out.stdout.toString()).toBe(tmp.path)
})
test("merges environment overrides", async () => {
const out = await Process.run(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
const out = await Process.runPromise(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
env: {
OPENCODE_TEST: "set",
},
@@ -80,7 +80,7 @@ describe("util.process", () => {
test("uses shell in run on Windows", async () => {
if (process.platform !== "win32") return
const out = await Process.run(["set", "OPENCODE_TEST_SHELL"], {
const out = await Process.runPromise(["set", "OPENCODE_TEST_SHELL"], {
shell: true,
env: {
OPENCODE_TEST_SHELL: "ok",
+4 -4
View File
@@ -8,9 +8,10 @@ import type {
UserMessage,
Message,
Part,
Auth,
Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2"
import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2"
import type { BunShell } from "./shell.js"
import { type ToolDefinition } from "./tool.js"
@@ -152,7 +153,6 @@ export type AuthHook = {
type: "success"
key: string
provider?: string
metadata?: Record<string, string>
}
| {
type: "failed"
@@ -177,7 +177,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & (
accountId?: string
enterpriseUrl?: string
}
| { key: string; metadata?: Record<string, string> }
| { key: string }
))
| {
type: "failed"
@@ -198,7 +198,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & (
accountId?: string
enterpriseUrl?: string
}
| { key: string; metadata?: Record<string, string> }
| { key: string }
))
| {
type: "failed"
+1 -15
View File
@@ -27,21 +27,7 @@ type AskInput = {
metadata: { [key: string]: any }
}
export type ToolAttachment = {
type: "file"
mime: string
url: string
filename?: string
}
export type ToolResult =
| string
| {
title?: string
output: string
metadata?: { [key: string]: any }
attachments?: ToolAttachment[]
}
export type ToolResult = string | { output: string; metadata?: { [key: string]: any } }
export function tool<Args extends z.ZodRawShape>(input: {
description: string
-3
View File
@@ -1666,9 +1666,6 @@ export type OAuth = {
export type ApiAuth = {
type: "api"
key: string
metadata?: {
[key: string]: string
}
}
export type WellKnownAuth = {
+1 -4
View File
@@ -1263,10 +1263,7 @@ export type Config = {
}
instructions?: Array<string>
layout?: LayoutConfig
/**
* Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.
*/
permission?: PermissionConfig | Array<PermissionConfig>
permission?: PermissionConfig
tools?: {
[key: string]: boolean
}
+1 -12
View File
@@ -12407,18 +12407,7 @@
"$ref": "#/components/schemas/LayoutConfig"
},
"permission": {
"anyOf": [
{
"$ref": "#/components/schemas/PermissionConfig"
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/PermissionConfig"
}
}
],
"description": "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones."
"$ref": "#/components/schemas/PermissionConfig"
},
"tools": {
"type": "object",
@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="-14 -14 100.8 100.8" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<polygon fill-rule="evenodd" points="36.4 58.7 22.4 58.7 22.4 44.6 22.4 44.6 36.4 44.6 36.4 44.6 36.4 58.7"/>
<polygon fill-rule="evenodd" points="22.4 69.5 11.6 69.5 11.6 69.5 11.6 58.7 22.4 58.7 22.4 69.5"/>
<polygon fill-rule="evenodd" points="11.6 58.7 2.5 58.7 2.5 58.7 2.5 49.6 2.5 49.6 11.5 49.6 11.6 49.6 11.6 58.7"/>
<path d="M36.4,0C16.3,0,0,16.3,0,36.4h14.1c0-12.3,10-22.3,22.3-22.3s22.3,10,22.3,22.3-10,22.3-22.3,22.3h0v14.1h0c20.1,0,36.4-16.3,36.4-36.4S56.5,0,36.4,0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 613 B

@@ -854,20 +854,6 @@
d="M79.01 5.863c-4.066 0-6.511 2.92-6.511 6.535 0 3.635 2.445 6.555 6.511 6.555 4.046 0 6.512-2.92 6.512-6.555s-2.466-6.535-6.512-6.535Zm0 10.968c-2.633 0-4.172-1.933-4.172-4.433s1.539-4.455 4.172-4.455c2.635 0 4.151 1.933 4.151 4.434 0 2.521-1.516 4.454-4.15 4.454Zm14.393 2.096c3.393 0 5.542-1.808 5.837-4.539h-2.36c-.316 1.555-1.517 2.437-3.477 2.437-2.423 0-3.878-1.68-3.878-4.433 0-2.774 1.476-4.434 3.878-4.434 1.96 0 3.14.862 3.477 2.5h2.36c-.295-2.773-2.444-4.622-5.837-4.622-3.856 0-6.217 2.669-6.217 6.535 0 3.887 2.36 6.556 6.217 6.556Zm-29.543-.311h2.36v-6.01c0-2.752 1.348-4.244 3.772-4.244h2.276V6.177h-2.255c-2.128 0-3.288.735-3.898 2.605l-.443-.063.527-2.542h-2.36v12.439h.02Zm-24.445-7.332c.106-2.101 1.517-3.53 3.793-3.53 2.276 0 3.646 1.345 3.646 3.53h-7.439Zm9.778.4c0-3.426-2.381-5.821-5.943-5.821-3.73 0-6.174 2.563-6.174 6.535 0 4.013 2.423 6.555 6.28 6.555 2.929 0 5.247-1.597 5.669-3.887h-2.36c-.507 1.156-1.666 1.828-3.31 1.828-2.38 0-3.877-1.408-3.94-3.803h9.694c.042-.588.084-.861.084-1.408Zm5.69 6.932h1.939l5.5-12.44h-2.529L56 15.99l-.316.021-3.793-9.833h-2.508l5.5 12.439ZM32.23 12.35c0-.882-.359-1.701-.99-2.437a8.594 8.594 0 0 1-1.497 1.093c.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.281.021-14.118-2.059-14.118-4.811 0-.463.168-.925.505-1.345a8.13 8.13 0 0 1-1.475-1.093c-.632.736-.99 1.555-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.87.021 16.078-2.5 16.078-6.535Zm-3.351 1.534c-.906-.462-1.96-.861-3.16-1.197-1.37.378-2.909.672-4.553.861 2.318.294 4.341.778 5.9 1.408.76-.336 1.37-.693 1.813-1.072Zm-17.849-.357a31.902 31.902 0 0 1-4.467-.84c-1.18.336-2.255.735-3.16 1.197.42.379 1.01.715 1.748 1.05 1.539-.63 3.52-1.113 5.88-1.407Zm21.2-6.808c0-4.013-7.207-6.534-16.079-6.534C7.26.185.051 2.706.051 6.719c0 4.035 7.208 6.535 16.1 6.535 8.872.021 16.079-2.5 16.079-6.535Zm-1.94 0c0 2.732-5.836 4.812-14.139 4.812-8.302.021-14.14-2.06-14.14-4.812 0-2.731 5.838-4.811 14.14-4.811 7.86 0 14.14 2.08 14.14 4.811Zm-3.223 2.564c.758-.336 1.37-.694 1.812-1.072-2.95-1.513-7.544-2.353-12.728-2.353s-9.799.84-12.728 2.353c.422.378 1.012.715 1.75 1.05 2.507-1.05 6.363-1.68 10.978-1.68 4.404 0 8.324.651 10.916 1.702ZM1.042 15.628c-.632.736-.99 1.534-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.892 0 16.099-2.521 16.099-6.534 0-.883-.359-1.702-.99-2.438-.422.4-.907.757-1.497 1.093.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.302 0-14.14-2.08-14.14-4.811 0-.463.17-.925.506-1.345a10.73 10.73 0 0 1-1.475-1.093Z"
></path>
</symbol>
<symbol viewBox="-14 -14 100.8 100.8" fill="currentColor" id="digitalocean">
<polygon
fill-rule="evenodd"
points="36.4 58.7 22.4 58.7 22.4 44.6 22.4 44.6 36.4 44.6 36.4 44.6 36.4 58.7"
></polygon>
<polygon fill-rule="evenodd" points="22.4 69.5 11.6 69.5 11.6 69.5 11.6 58.7 22.4 58.7 22.4 69.5"></polygon>
<polygon
fill-rule="evenodd"
points="11.6 58.7 2.5 58.7 2.5 58.7 2.5 49.6 2.5 49.6 11.5 49.6 11.6 49.6 11.6 58.7"
></polygon>
<path
d="M36.4,0C16.3,0,0,16.3,0,36.4h14.1c0-12.3,10-22.3,22.3-22.3s22.3,10,22.3,22.3-10,22.3-22.3,22.3h0v14.1h0c20.1,0,36.4-16.3,36.4-36.4S56.5,0,36.4,0Z"
></path>
</symbol>
<symbol viewBox="0 0 40 40" id="deepseek">
<path
d="M35.6638 9.91965C35.3251 9.75432 35.1785 10.0703 34.9811 10.2316C34.9131 10.2836 34.8558 10.3516 34.7985 10.413C34.3025 10.9423 33.7238 11.289 32.9678 11.2476C31.8625 11.1863 30.9186 11.533 30.0839 12.3783C29.9066 11.3356 29.3173 10.7143 28.4213 10.3143C27.9519 10.1063 27.4773 9.89965 27.148 9.44766C26.9186 9.12633 26.856 8.76767 26.7413 8.41568C26.668 8.20235 26.5946 7.98502 26.3506 7.94902C26.084 7.90769 25.98 8.13035 25.876 8.31702C25.4587 9.07967 25.2973 9.91965 25.3133 10.7703C25.3493 12.6849 26.1573 14.2102 27.764 15.2942C27.9466 15.4182 27.9933 15.5435 27.9359 15.7249C27.8266 16.0982 27.696 16.4609 27.5813 16.8355C27.508 17.0742 27.3986 17.1248 27.1426 17.0222C26.2777 16.6504 25.4919 16.1164 24.828 15.4489C23.6854 14.3449 22.6534 13.1263 21.3654 12.1716C21.067 11.9511 20.7606 11.7416 20.4468 11.5436C19.1335 10.2676 20.6201 9.21967 20.9641 9.09567C21.3241 8.965 21.0881 8.51968 19.9254 8.52501C18.7628 8.53035 17.6988 8.91834 16.3428 9.43699C16.1413 9.51421 15.934 9.57529 15.7229 9.61966C14.4557 9.38091 13.1598 9.33506 11.8789 9.48366C9.36565 9.76365 7.35902 10.953 5.88305 12.9809C4.10975 15.4182 3.69243 18.1888 4.20308 21.0768C4.74041 24.122 6.29504 26.6433 8.683 28.6139C11.1603 30.6579 14.0122 31.6592 17.2668 31.4672C19.2428 31.3539 21.4441 31.0886 23.9254 28.9873C24.552 29.2993 25.208 29.4233 26.2986 29.5166C27.1386 29.5953 27.9466 29.4766 28.5719 29.3459C29.5519 29.1379 29.4839 28.23 29.1306 28.0646C26.2573 26.726 26.888 27.2713 26.3133 26.83C27.7746 25.102 29.9746 23.3074 30.8359 17.4928C30.9026 17.0302 30.8452 16.7395 30.8359 16.3662C30.8306 16.1395 30.8826 16.0502 31.1426 16.0249C31.8639 15.95 32.5637 15.7349 33.2025 15.3915C35.0638 14.3742 35.8158 12.7049 35.9931 10.7023C36.0198 10.3956 35.9878 10.081 35.6638 9.91965ZM19.4414 27.9433C16.6562 25.754 15.3055 25.0327 14.7482 25.0634C14.2256 25.0954 14.3202 25.6913 14.4349 26.0807C14.5549 26.4647 14.7109 26.7286 14.9295 27.066C15.0815 27.2886 15.1855 27.6206 14.7789 27.87C13.8816 28.4246 12.3229 27.6833 12.2496 27.6473C10.435 26.578 8.91632 25.1673 7.84834 23.2381C6.81637 21.3808 6.21638 19.3888 6.11771 17.2622C6.09105 16.7475 6.24171 16.5662 6.7537 16.4729C7.42583 16.3442 8.11451 16.3267 8.79233 16.4209C11.6349 16.8368 14.0536 18.1075 16.0828 20.1194C17.2402 21.2661 18.1161 22.6354 19.0188 23.974C19.9788 25.3953 21.0108 26.75 22.3254 27.8593C22.7894 28.2486 23.1587 28.5446 23.5134 28.7619C22.4441 28.8819 20.6601 28.9086 19.4414 27.9433ZM20.7748 19.3568C20.7745 19.2906 20.7904 19.2253 20.8211 19.1666C20.8517 19.1078 20.8962 19.0575 20.9507 19.0198C21.0052 18.9821 21.068 18.9583 21.1337 18.9503C21.1995 18.9424 21.2662 18.9505 21.3281 18.9741C21.407 19.0024 21.475 19.0546 21.5228 19.1235C21.5706 19.1923 21.5958 19.2743 21.5947 19.3581C21.5949 19.4123 21.5843 19.4659 21.5636 19.5159C21.5428 19.5659 21.5123 19.6113 21.4738 19.6494C21.4354 19.6875 21.3897 19.7176 21.3395 19.7378C21.2893 19.7581 21.2356 19.7682 21.1814 19.7675C21.1277 19.7676 21.0745 19.7571 21.0248 19.7365C20.9752 19.7158 20.9302 19.6855 20.8925 19.6473C20.8548 19.609 20.825 19.5636 20.805 19.5138C20.785 19.4639 20.7739 19.4105 20.7748 19.3568ZM24.9213 21.4848C24.6547 21.5928 24.3893 21.6861 24.1347 21.6981C23.7516 21.7114 23.3756 21.5918 23.0707 21.3594C22.7054 21.0528 22.4441 20.8821 22.3347 20.3488C22.297 20.0881 22.3042 19.823 22.3561 19.5648C22.4494 19.1288 22.3454 18.8488 22.0374 18.5955C21.7881 18.3875 21.4694 18.3302 21.1201 18.3302C21.0005 18.3232 20.8843 18.2875 20.7814 18.2262C20.6348 18.1542 20.5148 17.9728 20.6294 17.7488C20.6668 17.6768 20.8428 17.5008 20.8854 17.4688C21.3601 17.1995 21.9081 17.2875 22.4134 17.4902C22.8827 17.6822 23.2374 18.0342 23.748 18.5328C24.2694 19.1341 24.364 19.3008 24.6613 19.7515C24.896 20.1048 25.1093 20.4674 25.2547 20.8821C25.344 21.1421 25.2293 21.3541 24.9213 21.4848Z"

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 275 KiB

@@ -78,7 +78,6 @@ export const iconNames = [
"fireworks-ai",
"fastrouter",
"evroc",
"digitalocean",
"deepseek",
"deepinfra",
"cortecs",
@@ -721,88 +721,6 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
---
### DigitalOcean
DigitalOcean's [Inference Engine](https://docs.digitalocean.com/products/inference/) provides access to open models like GPT-OSS, Llama, Qwen, and DeepSeek, plus custom [Inference Routers](https://docs.digitalocean.com/products/genai-platform/concepts/inference-routers/) that route each request to the cheapest, fastest, or best-fit model for a task.
OpenCode supports two authentication methods:
- **OAuth (Recommended)** — Sign in to your DigitalOcean account; OpenCode auto-creates a Model Access Key and discovers your available Models & Inference Routers.
- **Model Access Key** — Paste an existing key from the DigitalOcean console.
#### OAuth (Recommended)
1. Run the `/connect` command and search for **DigitalOcean**.
```txt
/connect
```
2. Select **Login with DigitalOcean**.
```txt
┌ Select auth method
│ Login with DigitalOcean
│ Paste Model Access Key
```
3. Your browser opens to authorize OpenCode. Sign in and approve.
:::note
OpenCode creates a Model Access Key named `opencode-oauth-<timestamp>` in your DigitalOcean account. You can rotate or revoke it from the **Model Access Keys** page in the "Manage" section of the DigitalOcean console under Inference.
:::
4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection.
```txt
/models
```
5. To pick up newly created Inference Routers, re-run `/connect` and select **DigitalOcean** again.
#### Using a Model Access Key
If you'd rather paste a key directly:
1. Head over to the **Manage** page in the Inference section of the [DigitalOcean console](https://cloud.digitalocean.com/) and create a new key.
2. Run the `/connect` command and select **DigitalOcean**, then **Paste Model Access Key**.
```txt
┌ Enter your DigitalOcean Model Access Key
└ enter
```
:::note
Inference Routers are not auto-discovered with this method. To surface them in the model picker, sign in via OAuth instead.
:::
3. Run the `/models` command to select a model.
```txt
/models
```
#### Environment Variable
Alternatively, set your Model Access Key as an environment variable.
```bash frame="none"
export DIGITALOCEAN_ACCESS_TOKEN=your-model-access-key
```
#### Inference Routers
Inference Routers let you define a routing policy across multiple models — picking the cheapest, fastest, or most appropriate model per request based on the task. After OAuth, OpenCode surfaces each router as `router:<router-name>` in the model picker.
Selecting a router model is a drop-in replacement for any other model — OpenCode forwards your request and DigitalOcean picks the underlying model based on your router's policy. Learn more about [Inference Routers](https://docs.digitalocean.com/products/inference/how-to/use-inference-router/)
---
### FrogBot
1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key.